pax_global_header00006660000000000000000000000064152527163160014522gustar00rootroot0000000000000052 comment=ee985bf444db4f47bb5bed37bfb6914d7d48d242 scrapinghub-andi-04df919/000077500000000000000000000000001525271631600153025ustar00rootroot00000000000000scrapinghub-andi-04df919/.git-blame-ignore-revs000066400000000000000000000001241525271631600213770ustar00rootroot00000000000000# applying pre-commit hooks to the project edccf5367a5ffa0de44fe9dd75271a0495c424d2 scrapinghub-andi-04df919/.github/000077500000000000000000000000001525271631600166425ustar00rootroot00000000000000scrapinghub-andi-04df919/.github/dependabot.yml000066400000000000000000000003331525271631600214710ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: github-actions directory: "/" schedule: interval: monthly groups: github-actions: patterns: - "*" cooldown: default-days: 7 scrapinghub-andi-04df919/.github/workflows/000077500000000000000000000000001525271631600206775ustar00rootroot00000000000000scrapinghub-andi-04df919/.github/workflows/publish.yml000066400000000000000000000025221525271631600230710ustar00rootroot00000000000000name: publish permissions: contents: read on: push: tags: - "[0-9]+.[0-9]+.[0-9]+" jobs: build: name: Build distribution runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.14" - name: Install pypa/build run: python3 -m pip install build --user - name: Build a binary wheel and a source tarball run: python3 -m build - name: Store the distribution packages uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: python-package-distributions path: dist/ publish: name: Publish to PyPI needs: - build runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/andi permissions: id-token: write steps: - name: Download all the dists uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: python-package-distributions path: dist/ - name: Publish distribution to PyPI uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 scrapinghub-andi-04df919/.github/workflows/test.yml000066400000000000000000000043131525271631600224020ustar00rootroot00000000000000name: tests permissions: contents: read on: push: branches: [ master ] pull_request: branches: [ master ] jobs: test: runs-on: ubuntu-latest permissions: contents: read id-token: write # OIDC authentication with Codecov strategy: fail-fast: false matrix: python-version: ['3.10', '3.11', '3.12', '3.13', '3.14', '3.15'] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} allow-prereleases: true - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install tox - name: tox run: | tox -e py - name: Upload coverage report uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: use_oidc: true - name: Upload test results if: ${{ !cancelled() }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: report_type: test_results use_oidc: true check: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ['3.14'] tox-job: ["mypy", "twinecheck"] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 scrapinghub-andi-04df919/.gitignore000066400000000000000000000003011525271631600172640ustar00rootroot00000000000000# Python *.pyc *.pyo /build/ /dist/ *.egg-info # Mac OS *.DS_Store # IDE /.idea/ /.vscode/ .mypy_cache/ .cache/ .tox/ .pytest_cache/ .coverage coverage.xml *.junit.xml htmlcov/ .env/ .venv/ scrapinghub-andi-04df919/.pre-commit-config.yaml000066400000000000000000000007661525271631600215740ustar00rootroot00000000000000repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.16.7 hooks: - id: ruff-check args: [ --fix ] - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/rhysd/actionlint rev: v1.7.12 hooks: - id: actionlint - repo: https://github.com/zizmorcore/zizmor-pre-commit rev: v1.30.1 hooks: - id: zizmor args: [--no-progress, --fix] scrapinghub-andi-04df919/CHANGES.rst000066400000000000000000000035531525271631600171120ustar00rootroot00000000000000Changes ======= 0.9.0 (2026-09-17) ------------------ * Switched the license from BSD-3 to Apache 2.0. * Added support for Python 3.15. * Dropped support for Python 3.9. * Added support for ``functools.partial()`` callables. * Improved support for ``X | Y`` annotations. * Improved support for unesolvable annotations. * Type hint improvements. * Linting and CI improvements. 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. scrapinghub-andi-04df919/LICENSE000066400000000000000000000261351525271631600163160ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. scrapinghub-andi-04df919/README.rst000066400000000000000000000524271525271631600170030ustar00rootroot00000000000000==== 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 Apache 2.0. Installation ============ :: pip install andi andi requires Python >= 3.10. 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 scrapinghub-andi-04df919/andi/000077500000000000000000000000001525271631600162155ustar00rootroot00000000000000scrapinghub-andi-04df919/andi/__init__.py000066400000000000000000000003231525271631600203240ustar00rootroot00000000000000from .andi import CustomBuilder, Plan, Step, inspect, plan from .errors import NonProvidableError __all__ = [ "CustomBuilder", "NonProvidableError", "Plan", "Step", "inspect", "plan", ] scrapinghub-andi-04df919/andi/andi.py000066400000000000000000000545361525271631600175170ustar00rootroot00000000000000from collections import OrderedDict, defaultdict from collections.abc import Callable, Container, Mapping, MutableMapping from dataclasses import dataclass from inspect import Parameter from typing import Any, TypeAlias from andi.errors import ( CyclicDependencyErrCase, ErrCase, LackingAnnotationErrCase, NonInjectableOrExternalErrCase, NonProvidableError, _class_or_func_str, ) from andi.typeutils import ( PlanCallable, _signature, get_callable_func_obj, get_globalns, get_partial_bound_args, get_type_hints_with_extras, get_unannotated_params, get_union_args, is_union, strip_annotated, ) def inspect(class_or_func: PlanCallable) -> dict[str, list[Any]]: """ 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 * a ``functools.partial`` wrapping any of the above - in this case the arguments already bound by the partial are excluded The elements of the returned lists are the objects produced by ``typing.get_type_hints(..., include_extras=True)``, with ``Union`` / ``Optional`` annotations flattened into their members. They are therefore not necessarily ``type`` objects: an ``Annotated[X, ...]`` annotation, for example, is kept as-is. """ func = get_callable_func_obj(class_or_func) globalns = get_globalns(func) try: annotations = get_type_hints_with_extras(func, globalns) except NameError as exception: # Postponed annotations are evaluated here rather than where they are # written, so the traceback alone does not tell which signature is at # fault. raise NameError( f"Could not resolve the type annotations of " f"{_class_or_func_str(class_or_func)}: {exception}" ) from exception 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) for name in get_partial_bound_args(class_or_func): annotations.pop(name, None) res: dict[str, list[Any]] = {} 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: PlanCallable) -> 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: TypeAlias = Container[Any] | Callable[[Any], bool] class KwargsSpec(dict[str, PlanCallable]): """ 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[PlanCallable, 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()} @dataclass(frozen=True) class CustomBuilder: result_class_or_fn: PlanCallable factory: PlanCallable PlanKey: TypeAlias = PlanCallable | CustomBuilder Step: TypeAlias = tuple[PlanKey, 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 | CustomBuilder, KwargsSpec]``. Note that ``KwargsSpec`` is almost a ``dict[str, Callable]`` so each step in the plan corresponds to (callable_or_custom_builder, (argument_name -> callable_to_build_the_argument)). The first element of a step is usually the class/function to invoke, but it can also be a :class:`CustomBuilder` when the step is built through a ``custom_builder_fn`` (see ``plan``). Consumers iterating over the plan must handle that case, e.g. with ``isinstance(fn_or_cls, CustomBuilder)``. ``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: Any, full_final_kwargs: bool = False, **kwargs: Any): 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[PlanCallable, 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: TypeAlias = Callable[[Any], PlanCallable | None] CustomBuilderFn: TypeAlias = Callable[[Any], PlanCallable | None] def plan( class_or_func: PlanCallable, *, is_injectable: ContainerOrCallableType, externally_provided: ContainerOrCallableType | None = None, full_final_kwargs: bool = False, overrides: OverrideFn | None = None, recursive_overrides: bool = False, custom_builder_fn: CustomBuilderFn = 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, or a :class:`CustomBuilder` when ``custom_builder_fn`` provides a factory for that 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) if isinstance(fn_or_cls, CustomBuilder): instances[fn_or_cls.result_class_or_fn] = fn_or_cls.factory(**kwargs) else: 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: ... kwargs = kwargs_spec.kwargs(instances) ... if isinstance(fn_or_cls, CustomBuilder): ... instances[fn_or_cls.result_class_or_fn] = fn_or_cls.factory(**kwargs) ... else: ... instances[fn_or_cls] = fn_or_cls(**kwargs) ... 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 def _plan( class_or_func: PlanCallable, *, is_injectable: Callable[[Any], bool], externally_provided: Callable[[Any], bool], full_final_kwargs: bool, dependency_stack: list[PlanKey] | None = None, overrides: OverrideFn, recursive_overrides: bool = False, custom_builder_fn: CustomBuilderFn = lambda _: None, custom_builder_result: PlanCallable | None = None, ) -> tuple[Plan, list[ErrCase]]: dependency_stack = dependency_stack or [] is_root_call = not dependency_stack # For better code reading plan_od: MutableMapping[PlanKey, KwargsSpec] = OrderedDict() type_for_arg = KwargsSpec() if externally_provided(strip_annotated(class_or_func)): return Plan([(class_or_func, KwargsSpec())], full_final_kwargs=True), [] plan_key: PlanKey if not custom_builder_result: plan_key = class_or_func 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: dict[str, list[ErrCase]] = defaultdict(list) non_injectable_errs: dict[str, list[ErrCase]] = defaultdict(list) for argname, types in arguments.items(): sel_cls, arg_overrides = _select_type( types, is_injectable, externally_provided, overrides, recursive_overrides, custom_builder_fn=custom_builder_fn, ) if sel_cls is not None: errors: list[ErrCase] = [] 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: err_case: ErrCase if not types: err_case = LackingAnnotationErrCase(argname, class_or_func) 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: list[Any], is_injectable: Callable[[Any], bool], externally_provided: Callable[[Any], bool], overrides: OverrideFn, recursive_overrides: bool, *, custom_builder_fn: CustomBuilderFn = lambda _: None, ) -> tuple[PlanCallable | None, 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( # noqa: PLW2901 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: Any) -> PlanCallable | None: return None def _may_override( class_or_func: Any, overrides: OverrideFn, recursive_overrides: bool ) -> tuple[PlanCallable, 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: ContainerOrCallableType | None, ) -> Callable[[Any], 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 scrapinghub-andi-04df919/andi/errors.py000066400000000000000000000057241525271631600201130ustar00rootroot00000000000000from collections.abc import Mapping, Sequence from typing import Any, NamedTuple, TypeAlias from andi.typeutils import PlanCallable class NonProvidableError(TypeError): """Raised when a type is not providable""" def __init__( self, class_or_func: PlanCallable, errors_per_argument: Mapping[str, Sequence["ErrCase"]], ) -> None: self.class_or_func = class_or_func self.errors_per_argument = errors_per_argument super().__init__(_exception_msg(class_or_func, errors_per_argument)) class CyclicDependencyErrCase(NamedTuple): class_or_func: PlanCallable dependency_stack: list[Any] class NonInjectableOrExternalErrCase(NamedTuple): argname: str class_or_func: PlanCallable types: list[Any] class LackingAnnotationErrCase(NamedTuple): argname: str class_or_func: PlanCallable ErrCase: TypeAlias = ( CyclicDependencyErrCase | NonInjectableOrExternalErrCase | LackingAnnotationErrCase ) def _class_or_func_str(class_or_func: PlanCallable) -> str: init_str = ".__init__()" if isinstance(class_or_func, type) else "" return f"{class_or_func}{init_str}" def _cyclic_dependency_error( class_or_func: PlanCallable, dependency_stack: list[Any] ) -> str: return ( f"Cyclic dependency found. Dependency graph: " f"{' -> '.join(map(str, [*dependency_stack, class_or_func]))}" ) def _no_injectable_or_external_error( argname: str, class_or_func: PlanCallable, types: list[Any] ) -> str: return ( f"Any of {types} types are required " f"for argument '{argname}' " f"in '{_class_or_func_str(class_or_func)}' " f"but none of them is injectable or externally providable" ) def _argument_lacking_annotation_error( argname: str, class_or_func: PlanCallable ) -> str: return ( f"Parameter '{argname}' is lacking annotations in " f"'{_class_or_func_str(class_or_func)}'" ) def _exception_msg( class_or_func: PlanCallable, arg_errors: Mapping[str, Sequence[ErrCase]], ) -> str: 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 += f"'{arg}' in '{_class_or_func_str(class_or_func)}'. " msg += "Causes:" for err_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 AssertionError( # noqa: TRY004 "Unexpected type of error. This is a bug." ) msg += f"\n {err_idx}. {err_msg}" return msg scrapinghub-andi-04df919/andi/py.typed000066400000000000000000000000001525271631600177020ustar00rootroot00000000000000scrapinghub-andi-04df919/andi/typeutils.py000066400000000000000000000152101525271631600206300ustar00rootroot00000000000000import functools import inspect import sys import types from collections.abc import Callable, Container from typing import ( Annotated, Any, TypeAlias, Union, cast, get_args, get_origin, get_type_hints, ) # A callable that andi can inspect the dependencies of and that can be invoked # to build a value. This alias is added for clarity. PlanCallable: TypeAlias = Callable[..., Any] def is_union(tp: Any) -> bool: """Return True if a passed type is a typing.Union or a ``X | Y`` union. >>> is_union(Union[int, str]) True >>> is_union(int | str) True >>> is_union(str) False >>> is_union(Union) False >>> is_union(list[str]) False """ if isinstance(tp, types.UnionType): return True return hasattr(tp, "__origin__") and tp.__origin__ is Union def get_type_hints_with_extras(obj: Any, *args: Any, **kwargs: Any) -> dict[str, Any]: """ Like get_type_hints, but sets include_extras=True """ kwargs["include_extras"] = True return get_type_hints(obj, *args, **kwargs) if sys.version_info >= (3, 14): from annotationlib import Format def _signature(obj: Callable[..., Any]) -> inspect.Signature: # Only parameter kinds and default values are read from the returned # signature. A best-effort annotation format is requested so that # annotations that cannot be resolved at run time do not raise. return inspect.signature(obj, annotation_format=Format.FORWARDREF) else: def _signature(obj: Callable[..., Any]) -> inspect.Signature: return inspect.signature(obj) def get_union_args(tp: Any) -> list[Any]: """Return a list of typing.Union args.""" return list(tp.__args__) def issubclass_safe(cls: Any, bases: Any) -> 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: Callable[..., Any], annotations: Container[str] ) -> 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 _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[..., Any]) -> dict[str, Any]: """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[..., Any]) -> dict[str, Any]: """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 = nsobj.__wrapped__ return getattr(nsobj, "__globals__", {}) def _get_globalns_for_attrs(func: Callable[..., Any]) -> dict[str, Any]: """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__) # 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: PlanCallable) -> Callable[..., Any]: """ 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(f"{class_or_func!r} is not callable") if isinstance(class_or_func, type): # see https://github.com/python/typing/discussions/1331 return cast("Callable[..., Any]", class_or_func.__init__) # type: ignore[misc] # 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): return get_callable_func_obj(class_or_func.func) if hasattr(class_or_func, "__call__"): # noqa: B004 return cast("Callable[..., Any]", class_or_func.__call__) # not sure how to trigger it raise TypeError(f"Unexpected callable object {class_or_func!r}") def get_partial_bound_args(class_or_func: Any) -> set[str]: """Return the names of the parameters already filled in by ``functools.partial``, following nested partials.""" names: set[str] = set() while isinstance(class_or_func, functools.partial): params = list(inspect.signature(class_or_func.func).parameters) names.update(params[: len(class_or_func.args)]) names.update(class_or_func.keywords) class_or_func = class_or_func.func return names def is_typing_annotated(o: Any) -> bool: """Return True if the input is typing.Annotated""" return get_origin(o) is Annotated def strip_annotated(o: Any) -> Any: """Return the underlying type for Annotated, the input itself otherwise.""" if is_typing_annotated(o): return get_args(o)[0] return o scrapinghub-andi-04df919/codecov.yml000066400000000000000000000001201525271631600174400ustar00rootroot00000000000000comment: layout: "header, diff, tree" coverage: status: project: false scrapinghub-andi-04df919/pyproject.toml000066400000000000000000000101501525271631600202130ustar00rootroot00000000000000[build-system] requires = ["hatchling>=1.27.0"] build-backend = "hatchling.build" [project] name = "andi" version = "0.9.0" description = "Library for annotation-based dependency injection" readme = "README.rst" license = "Apache-2.0" 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.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.15", ] requires-python = ">=3.10" [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] strict = true [tool.ruff.lint] extend-select = [ # flake8-builtins "A", # flake8-async "ASYNC", # flake8-bugbear "B", # flake8-comprehensions "C4", # flake8-commas "COM", # pydocstyle "D", # pycodestyle "E4", "E7", "E9", # Pyflakes "F", # flake8-future-annotations "FA", # flynt "FLY", # refurb "FURB", # isort "I", # flake8-implicit-str-concat "ISC", # flake8-logging "LOG", # Perflint "PERF", # pygrep-hooks "PGH", # flake8-pie "PIE", # pylint "PL", # flake8-pytest-style "PT", # flake8-use-pathlib "PTH", # flake8-pyi "PYI", # flake8-quotes "Q", # flake8-return "RET", # flake8-raise "RSE", # Ruff-specific rules "RUF", # flake8-bandit "S", # flake8-simplify "SIM", # flake8-slots "SLOT", # flake8-debugger "T10", # flake8-type-checking "TC", # flake8-tidy-imports "TID", # pyupgrade "UP", # pycodestyle warnings "W", # flake8-2020 "YTT", ] ignore = [ # Trailing comma missing "COM812", # Missing docstring in public module "D100", # Missing docstring in public class "D101", # Missing docstring in public method "D102", # Missing docstring in public function "D103", # Missing docstring in public package "D104", # Missing docstring in magic method "D105", # Missing docstring in public nested class "D106", # Missing docstring in __init__ "D107", # One-line docstring should fit on one line with quotes "D200", # No blank lines allowed after function docstring "D202", # 1 blank line required between summary line and description "D205", # Multi-line docstring closing quotes should be on a separate line "D209", # First line should end with a period "D400", # First line should be in imperative mood; try rephrasing "D401", # First line should not be the function's "signature" "D402", # First word of the first line should be properly capitalized "D403", # No blank lines allowed between a section header and its content "D412", # Too many return statements "PLR0911", # Too many branches "PLR0912", # Too many arguments in function definition "PLR0913", # Too many statements "PLR0915", # Magic value used in comparison "PLR2004", # String contains ambiguous {}. "RUF001", # Docstring contains ambiguous {}. "RUF002", # Comment contains ambiguous {}. "RUF003", # Use of `assert` detected "S101", ] [tool.ruff.lint.isort] split-on-trailing-comma = false [tool.ruff.lint.pydocstyle] convention = "pep257" scrapinghub-andi-04df919/tests/000077500000000000000000000000001525271631600164445ustar00rootroot00000000000000scrapinghub-andi-04df919/tests/__init__.py000066400000000000000000000000001525271631600205430ustar00rootroot00000000000000scrapinghub-andi-04df919/tests/test_attrs_support.py000066400000000000000000000006471525271631600230150ustar00rootroot00000000000000import andi from tests.types import A_36, B_36 from tests.types_pep563 import A_37, B_37 def test_attrs_str_type_annotations() -> None: 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() -> None: assert andi.inspect(B_37.__init__) == {"a": [A_37]} assert andi.inspect(A_37.__init__) == {"b": [B_37]} scrapinghub-andi-04df919/tests/test_dataclasses_support.py000066400000000000000000000010001525271631600241270ustar00rootroot00000000000000import andi from tests.types import ADCnp, BDCnp from tests.types_pep563 import ADC, BDC, ADCStrRef def test_dataclasses() -> None: assert andi.inspect(BDCnp.__init__) == {"a": [ADCnp]} def test_dataclasses_forward_refs() -> None: 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() -> None: assert andi.inspect(ADCStrRef.__init__) == {"b": [BDC]} scrapinghub-andi-04df919/tests/test_errors.py000066400000000000000000000024741525271631600214000ustar00rootroot00000000000000import pytest from andi.errors import ( CyclicDependencyErrCase, LackingAnnotationErrCase, NonInjectableOrExternalErrCase, _argument_lacking_annotation_error, _class_or_func_str, _cyclic_dependency_error, _exception_msg, _no_injectable_or_external_error, ) from tests.test_plan import A, E def test_error_messages() -> None: 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, match=r"Unexpected type of error\. This is a bug\."): _exception_msg(E, {"arg": ("a", "b")}) # type: ignore[dict-item] def test_class_or_func_str() -> None: assert _class_or_func_str(E) == ".__init__()" fn_str = _class_or_func_str(test_class_or_func_str) assert fn_str.startswith("") scrapinghub-andi-04df919/tests/test_inspect.py000066400000000000000000000110531525271631600215220ustar00rootroot00000000000000import sys from collections.abc import Callable from functools import partial, wraps from typing import Annotated, Any, Optional, TypeVar, Union import pytest import andi from tests.types_pep563 import fn_unresolvable_annotation class Foo: pass class Bar: pass class Baz: pass def test_andi() -> None: def func1(x: Foo) -> None: pass def func2() -> None: pass def func3(x: Bar, y: Foo) -> None: 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() -> None: def func(x: Union[Foo, Bar]) -> None: # noqa: UP007 pass assert andi.inspect(func) == {"x": [Foo, Bar]} def test_union_pipe() -> None: def func(x: Foo | Bar) -> None: pass assert andi.inspect(func) == {"x": [Foo, Bar]} def test_optional() -> None: def func(x: Optional[Foo]) -> None: # noqa: UP045 pass assert andi.inspect(func) == {"x": [Foo, type(None)]} def test_optional_pipe() -> None: def func(x: Foo | None) -> None: pass assert andi.inspect(func) == {"x": [Foo, type(None)]} def test_optional_union() -> None: def func(x: Optional[Union[Foo, Baz]]) -> None: # noqa: UP007, UP045 pass assert andi.inspect(func) == {"x": [Foo, Baz, type(None)]} def test_optional_union_pipe() -> None: def func(x: Foo | Baz | None) -> None: pass assert andi.inspect(func) == {"x": [Foo, Baz, type(None)]} def test_not_annotated() -> None: # ``x`` is left unannotated on purpose def func(x) -> None: # type: ignore[no-untyped-def] pass assert andi.inspect(func) == {"x": []} def test_string_types() -> None: def func(x: "Bar") -> None: pass assert andi.inspect(func) == {"x": [Bar]} def test_string_types_with_fn() -> None: """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_unresolvable_annotation_names_the_callable() -> None: with pytest.raises(NameError, match="fn_unresolvable_annotation"): andi.inspect(fn_unresolvable_annotation) @pytest.mark.skipif( sys.version_info < (3, 14), reason="Annotations are evaluated when the signature is defined", ) def test_unresolvable_lazy_annotation_names_the_callable() -> None: def func(a: Unresolvable) -> None: # type: ignore[name-defined] # noqa: F821 pass with pytest.raises(NameError, match="func"): andi.inspect(func) def test_init_methods() -> None: 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() -> None: T = TypeVar("T") class MyClass: @classmethod def from_foo(cls: type[T], foo: Foo) -> T: # noqa: PYI019 return cls() assert andi.inspect(MyClass.from_foo) == {"foo": [Foo]} def test_decorated() -> None: def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: @wraps(fn) def wrapper(*args: Any, **kwargs: Any) -> Any: return fn(*args, **kwargs) return wrapper @decorator def func(x: "Bar") -> None: pass assert andi.inspect(func) == {"x": [Bar]} def test_partial() -> None: def func(x: Foo, y: Bar) -> None: pass func_nofoo = partial(func, x=Foo()) assert andi.inspect(func_nofoo) == {"y": [Bar]} func_nobar = partial(func, Foo()) assert andi.inspect(func_nobar) == {"y": [Bar]} func_none_left = partial(func, Foo(), y=Bar()) assert andi.inspect(func_none_left) == {} func_double_partial = partial(partial(func, x=Foo()), y=Bar()) assert andi.inspect(func_double_partial) == {} def test_partial_of_bound_method() -> None: class MyClass: def method(self, x: Foo, y: Bar) -> None: pass obj = MyClass() func_nofoo = partial(obj.method, x=Foo()) assert andi.inspect(func_nofoo) == {"y": [Bar]} def test_callable_object() -> None: class MyClass: def __call__(self, x: Bar) -> None: pass obj = MyClass() assert andi.inspect(obj) == {"x": [Bar]} def test_annotations() -> None: def f(x: Annotated[int, 42]) -> None: pass assert andi.inspect(f) == {"x": [Annotated[int, 42]]} scrapinghub-andi-04df919/tests/test_plan.py000066400000000000000000000636341525271631600210230ustar00rootroot00000000000000import sys from collections import OrderedDict from collections.abc import Callable from functools import partial from typing import Annotated, Any, Optional, Union, cast import pytest import andi from andi import NonProvidableError from andi.andi import CustomBuilder, KwargsSpec, _params_with_default_value from andi.errors import ( CyclicDependencyErrCase, ErrCase, LackingAnnotationErrCase, NonInjectableOrExternalErrCase, ) from tests.utils import build 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: andi.Plan) -> KwargsSpec: return plan[-1][1] def error_causes( exc_info: pytest.ExceptionInfo[NonProvidableError], ) -> list[tuple[str, list[ErrCase]]]: """Return the error causes in a deterministic order""" result = [ (arg, sorted(errs, key=str)) for arg, errs in exc_info.value.errors_per_argument.items() ] result.sort(key=lambda t: t[0]) return result def test_plan_and_build() -> None: 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() -> None: 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 exc_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(exc_info) == expected_errors with pytest.raises(andi.NonProvidableError) as exc_info: andi.plan( E, is_injectable=lambda x: True, externally_provided=[], full_final_kwargs=True, ) assert error_causes(exc_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: Any, is_injectable: list[Any], externally_provided: list[Any] ) -> None: is_injectable = is_injectable + [cl.__init__ for cl in is_injectable] externally_provided = externally_provided + [ cl.__init__ for cl in externally_provided ] external_deps = dict.fromkeys(externally_provided, "external") 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() -> None: 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() -> None: def fn(a: Optional[str]) -> str: # noqa: UP045 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_optionals_pipe() -> None: def fn(a: str | None) -> 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() -> None: class WithUnion: def __init__(self, a_or_b: Union[A, B]): # noqa: UP007 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_union_pipe() -> None: class WithUnion: def __init__(self, a_or_b: 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() -> None: def fn(str_or_b_or_None: Optional[Union[str, B]]) -> Optional[Union[str, B]]: # noqa: UP007,UP045 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) def test_plan_with_optionals_and_union_pipe() -> None: def fn(str_or_b_or_None: str | B | None) -> str | B | None: 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() -> None: 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() -> None: def fn(other: str, e: E, c: C) -> None: 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() -> None: class WithNonAnnArgs: # non_ann* args are left unannotated on purpose def __init__( # type: ignore[no-untyped-def] 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() -> None: class WithNonInjArgs: def __init__( self, a: A, b: B, non_inj: Optional[str], # noqa: UP045 non_inj_def: int = 0, *, non_inj_kw: Optional[str], # noqa: UP045 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() -> None: 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: bool) -> None: def fn() -> bool: 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: bool) -> None: def fn_ann(b: B) -> B: b.modified = True # type: ignore[attr-defined] return b def fn(b: fn_ann) -> B: # type: ignore[valid-type] 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() -> None: class MyFunc: def __call__(self, b: B) -> None: 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: bool) -> None: 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 in ( [(B, {}), (A, {}), (C, {"a": B, "b": A})], [(A, {}), (B, {}), (C, {"a": B, "b": A})], ) # Check cycle detection with pytest.raises(NonProvidableError) as exc_info: plan_fn(C, is_injectable=ALL, overrides={A: C}.get) expected_errors = [("a", [(CyclicDependencyErrCase(C, [C]))])] assert error_causes(exc_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 exc_info: plan = plan_fn( C, is_injectable=ALL, externally_provided=[A], overrides={C: D}.get ) expected_errors = [("c", [(CyclicDependencyErrCase(D, [D]))])] assert error_causes(exc_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 in ( [(A, {}), (B, {}), (C, {"a": A, "b": B}), (D, {"a": A, "c": C})], [(B, {}), (A, {}), (C, {"a": A, "b": B}), (D, {"a": A, "c": C})], ) def test_plan_annotations() -> None: class MyFunc: def __call__(self, b: Annotated[B, 42]) -> None: pass func = MyFunc() plan = andi.plan(func, is_injectable={B}) assert plan == [(Annotated[B, 42], {}), (func, {"b": Annotated[B, 42]})] # type: ignore[comparison-overlap] def test_plan_annotations_duplicate() -> None: class MyFunc: def __call__( self, b: Annotated[B, 42], b2: Annotated[B, 43], b3: Annotated[B, 43], ) -> None: pass func = MyFunc() plan = andi.plan(func, is_injectable={B}) assert plan == [ # type: ignore[comparison-overlap] (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) -> None: self.field = field def get_item_factory(page_cls: Callable[..., Any]) -> Callable[..., Any]: def item_factory(page: page_cls) -> Item: # type: ignore[valid-type] return cast("Item", page.to_item()) # type: ignore[attr-defined] return item_factory def get_custom_builders( page_cls: Callable[..., Any], ) -> dict[Callable[..., Any], Callable[..., Any]]: return { Item: get_item_factory(page_cls), } def test_plan_custom_builder() -> None: # Item is built from Page class Page: def __init__(self, b: B): pass def to_item(self) -> Item: 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() -> None: # 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) -> Item: 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() -> None: # 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) -> Item: 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])]) ] @pytest.mark.skipif( sys.version_info < (3, 14), reason="Annotations are evaluated when the signature is defined", ) def test_params_with_default_value_unresolvable_annotation() -> None: def f(a: A, b: Unresolvable = None) -> None: # type: ignore[name-defined] # noqa: F821 pass assert _params_with_default_value(f) == {"b"} scrapinghub-andi-04df919/tests/test_typeutils.py000066400000000000000000000055741525271631600221320ustar00rootroot00000000000000import sys from typing import Annotated, Any, Optional, Union, get_type_hints import pytest from andi.typeutils import ( get_callable_func_obj, get_type_hints_with_extras, get_unannotated_params, get_union_args, ) def test_get_union_args() -> None: assert get_union_args(Union[str, int]) == [str, int] # noqa: UP007 def test_get_union_args_pipe() -> None: assert get_union_args(str | int) == [str, int] def test_get_union_args_optional() -> None: assert get_union_args(Optional[Union[str, int]]) == [str, int, None.__class__] # noqa: UP007,UP045 def test_get_union_args_optional_pipe() -> None: assert get_union_args(str | int | None) == [str, int, None.__class__] def test_get_callable_func_obj_functions() -> None: def foo() -> None: pass assert get_callable_func_obj(foo) is foo def test_get_callable_func_obj_class() -> None: class Foo: x = 5 def __init__(self) -> None: pass def meth(self) -> None: pass @staticmethod def staticmeth(cls_: Any) -> None: 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[arg-type] with pytest.raises(TypeError): get_callable_func_obj(foo) # type: ignore[arg-type] def test_get_callable_func_classmethods() -> None: class Foo: @classmethod def clsmeth(cls) -> None: 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() -> None: class Foo: def __init__(self) -> None: pass def __call__(self) -> None: pass def meth(self) -> None: 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() -> None: 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] @pytest.mark.skipif( sys.version_info < (3, 14), reason="Annotations are evaluated when the signature is defined", ) def test_get_unannotated_params_unresolvable_annotation() -> None: def f(x, y: Unresolvable) -> None: # type: ignore[no-untyped-def,name-defined] # noqa: F821 pass assert get_unannotated_params(f, {"y"}) == ["x"] scrapinghub-andi-04df919/tests/types.py000066400000000000000000000003531525271631600201630ustar00rootroot00000000000000from 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 scrapinghub-andi-04df919/tests/types_pep563.py000066400000000000000000000006541525271631600212710ustar00rootroot00000000000000from __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" # noqa: UP037 @dataclass class BDC: a: ADC def fn_unresolvable_annotation(a: Unresolvable) -> None: # type: ignore[name-defined] # noqa: F821 pass scrapinghub-andi-04df919/tests/utils.py000066400000000000000000000014441525271631600201610ustar00rootroot00000000000000from typing import Any, cast from andi import Step from andi.andi import CustomBuilder, PlanKey from andi.typeutils import PlanCallable def build( plan: list[Step], instances_stock: dict[PlanKey, Any] | None = None, ) -> dict[PlanCallable, Any]: """Build instances dictionary from a plan""" instances_stock = instances_stock or {} instances: dict[PlanCallable, Any] = {} for cls, kwargs_spec in plan: if cls in instances_stock: instances[cast("PlanCallable", 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 scrapinghub-andi-04df919/tox.ini000066400000000000000000000013471525271631600166220ustar00rootroot00000000000000[tox] envlist = py310,py311,py312,py313,py314,py315,mypy,twinecheck [testenv] deps = pytest pytest-cov >= 7.0.0 attrs commands = pytest \ --cov-report= --cov-report=xml --cov-report=term-missing --cov=andi \ --junitxml=testenv.junit.xml -o junit_family=legacy \ --doctest-modules \ {posargs:andi tests README.rst} [testenv:mypy] deps = mypy==2.3.1 attrs==26.1.0 pytest==9.1.1 commands = mypy {posargs: andi tests} [testenv:twinecheck] basepython = python3 deps = twine==7.0.0 build==1.5.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