pax_global_header 0000666 0000000 0000000 00000000064 15252716316 0014522 g ustar 00root root 0000000 0000000 52 comment=ee985bf444db4f47bb5bed37bfb6914d7d48d242
scrapinghub-andi-04df919/ 0000775 0000000 0000000 00000000000 15252716316 0015302 5 ustar 00root root 0000000 0000000 scrapinghub-andi-04df919/.git-blame-ignore-revs 0000664 0000000 0000000 00000000124 15252716316 0021377 0 ustar 00root root 0000000 0000000 # applying pre-commit hooks to the project
edccf5367a5ffa0de44fe9dd75271a0495c424d2
scrapinghub-andi-04df919/.github/ 0000775 0000000 0000000 00000000000 15252716316 0016642 5 ustar 00root root 0000000 0000000 scrapinghub-andi-04df919/.github/dependabot.yml 0000664 0000000 0000000 00000000333 15252716316 0021471 0 ustar 00root root 0000000 0000000 version: 2
updates:
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: monthly
groups:
github-actions:
patterns:
- "*"
cooldown:
default-days: 7
scrapinghub-andi-04df919/.github/workflows/ 0000775 0000000 0000000 00000000000 15252716316 0020677 5 ustar 00root root 0000000 0000000 scrapinghub-andi-04df919/.github/workflows/publish.yml 0000664 0000000 0000000 00000002522 15252716316 0023071 0 ustar 00root root 0000000 0000000 name: 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.yml 0000664 0000000 0000000 00000004313 15252716316 0022402 0 ustar 00root root 0000000 0000000 name: 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/.gitignore 0000664 0000000 0000000 00000000301 15252716316 0017264 0 ustar 00root root 0000000 0000000 # Python
*.pyc
*.pyo
/build/
/dist/
*.egg-info
# Mac OS
*.DS_Store
# IDE
/.idea/
/.vscode/
.mypy_cache/
.cache/
.tox/
.pytest_cache/
.coverage
coverage.xml
*.junit.xml
htmlcov/
.env/
.venv/
scrapinghub-andi-04df919/.pre-commit-config.yaml 0000664 0000000 0000000 00000000766 15252716316 0021574 0 ustar 00root root 0000000 0000000 repos:
- 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.rst 0000664 0000000 0000000 00000003553 15252716316 0017112 0 ustar 00root root 0000000 0000000 Changes
=======
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/LICENSE 0000664 0000000 0000000 00000026135 15252716316 0016316 0 ustar 00root root 0000000 0000000 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.rst 0000664 0000000 0000000 00000052427 15252716316 0017003 0 ustar 00root root 0000000 0000000 ====
andi
====
.. image:: https://img.shields.io/pypi/v/andi.svg
:target: https://pypi.python.org/pypi/andi
:alt: PyPI Version
.. image:: https://img.shields.io/pypi/pyversions/andi.svg
:target: https://pypi.python.org/pypi/andi
:alt: Supported Python Versions
.. image:: https://github.com/scrapinghub/andi/workflows/tox/badge.svg
:target: https://github.com/scrapinghub/andi/actions
:alt: Build Status
.. image:: https://codecov.io/github/scrapinghub/andi/coverage.svg?branch=master
:target: https://codecov.io/gh/scrapinghub/andi
:alt: Coverage report
``andi`` makes easy implementing custom dependency injection mechanisms
where dependencies are expressed using type annotations.
``andi`` is useful as a building block for frameworks, or as a library
which helps to implement dependency injection (thus the name -
ANnotation-based Dependency Injection).
License is 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/ 0000775 0000000 0000000 00000000000 15252716316 0016215 5 ustar 00root root 0000000 0000000 scrapinghub-andi-04df919/andi/__init__.py 0000664 0000000 0000000 00000000323 15252716316 0020324 0 ustar 00root root 0000000 0000000 from .andi import CustomBuilder, Plan, Step, inspect, plan
from .errors import NonProvidableError
__all__ = [
"CustomBuilder",
"NonProvidableError",
"Plan",
"Step",
"inspect",
"plan",
]
scrapinghub-andi-04df919/andi/andi.py 0000664 0000000 0000000 00000054536 15252716316 0017517 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000005724 15252716316 0020113 0 ustar 00root root 0000000 0000000 from 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.typed 0000664 0000000 0000000 00000000000 15252716316 0017702 0 ustar 00root root 0000000 0000000 scrapinghub-andi-04df919/andi/typeutils.py 0000664 0000000 0000000 00000015210 15252716316 0020630 0 ustar 00root root 0000000 0000000 import 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.yml 0000664 0000000 0000000 00000000120 15252716316 0017440 0 ustar 00root root 0000000 0000000 comment:
layout: "header, diff, tree"
coverage:
status:
project: false
scrapinghub-andi-04df919/pyproject.toml 0000664 0000000 0000000 00000010150 15252716316 0020213 0 ustar 00root root 0000000 0000000 [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/ 0000775 0000000 0000000 00000000000 15252716316 0016444 5 ustar 00root root 0000000 0000000 scrapinghub-andi-04df919/tests/__init__.py 0000664 0000000 0000000 00000000000 15252716316 0020543 0 ustar 00root root 0000000 0000000 scrapinghub-andi-04df919/tests/test_attrs_support.py 0000664 0000000 0000000 00000000647 15252716316 0023015 0 ustar 00root root 0000000 0000000 import andi
from tests.types import A_36, B_36
from tests.types_pep563 import A_37, B_37
def test_attrs_str_type_annotations() -> 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.py 0000664 0000000 0000000 00000001000 15252716316 0024127 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000002474 15252716316 0021400 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000011053 15252716316 0021522 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000063634 15252716316 0021023 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000005574 15252716316 0022132 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000000353 15252716316 0020163 0 ustar 00root root 0000000 0000000 from dataclasses import dataclass
import attr
@attr.s(auto_attribs=True)
class A_36:
b: "B_36"
@attr.s(auto_attribs=True)
class B_36:
a: A_36
@dataclass
class ADCnp:
b: "BDCnp"
@dataclass
class BDCnp:
a: ADCnp
scrapinghub-andi-04df919/tests/types_pep563.py 0000664 0000000 0000000 00000000654 15252716316 0021271 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from dataclasses import dataclass
import attr
@attr.s(auto_attribs=True)
class A_37:
b: B_37
@attr.s(auto_attribs=True)
class B_37:
a: A_37
@dataclass
class ADC:
b: BDC
@dataclass
class ADCStrRef:
b: "BDC" # 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.py 0000664 0000000 0000000 00000001444 15252716316 0020161 0 ustar 00root root 0000000 0000000 from 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.ini 0000664 0000000 0000000 00000001347 15252716316 0016622 0 ustar 00root root 0000000 0000000 [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