├── .github └── workflows │ └── python-app.yml ├── .gitignore ├── AUTHORS.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── Makefile ├── api.rst ├── conf.py ├── exceptions.rst ├── guide.rst ├── index.rst ├── make.bat ├── profiles.rst └── scopes.rst ├── haps ├── __init__.py ├── application.py ├── config.py ├── container.py ├── exceptions.py └── scopes │ ├── __init__.py │ ├── instance.py │ ├── singleton.py │ └── thread.py ├── readthedocs.yml ├── requirements.test.txt ├── samples ├── __init__.py ├── app_ep.py ├── autodiscover │ ├── __init__.py │ ├── sample.py │ └── services │ │ ├── __init__.py │ │ ├── bases.py │ │ ├── deep │ │ ├── __init__.py │ │ └── implementation │ │ │ ├── __init__.py │ │ │ └── extra_pump.py │ │ └── implementations.py ├── instance_properties.py ├── simple.py └── thread_scope.py ├── setup.cfg ├── setup.py └── tests ├── conftest.py ├── test_application_runner.py ├── test_configuration.py ├── test_di_container.py ├── test_instance_scope.py ├── test_singleton_scope.py └── test_thread_scope.py /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python application 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up Python 3.10 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: "3.10" 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install -r requirements.test.txt 27 | python setup.py develop 28 | - name: Test with pytest 29 | run: | 30 | pytest 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | .hypothesis/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # dotenv 85 | .env 86 | 87 | # virtualenv 88 | .venv 89 | venv/ 90 | ENV/ 91 | 92 | # Spyder project settings 93 | .spyderproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | ### JetBrains template 98 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 99 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 100 | 101 | # User-specific stuff: 102 | .idea/ 103 | 104 | # Sensitive or high-churn files: 105 | .idea/**/dataSources/ 106 | .idea/**/dataSources.ids 107 | .idea/**/dataSources.xml 108 | .idea/**/dataSources.local.xml 109 | .idea/**/sqlDataSources.xml 110 | .idea/**/dynamic.xml 111 | .idea/**/uiDesigner.xml 112 | 113 | # Gradle: 114 | .idea/**/gradle.xml 115 | .idea/**/libraries 116 | 117 | # Mongo Explorer plugin: 118 | .idea/**/mongoSettings.xml 119 | 120 | ## File-based project format: 121 | *.iws 122 | 123 | ## Plugin-specific files: 124 | 125 | # IntelliJ 126 | /out/ 127 | 128 | # mpeltonen/sbt-idea plugin 129 | .idea_modules/ 130 | 131 | # JIRA plugin 132 | atlassian-ide-plugin.xml 133 | 134 | # Crashlytics plugin (for Android Studio and IntelliJ) 135 | com_crashlytics_export_strings.xml 136 | crashlytics.properties 137 | crashlytics-build.properties 138 | fabric.properties 139 | 140 | .pytest_cache 141 | .vscode 142 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | - Piotr Karkut [ekiro](http://github.com/ekiro) 2 | - Tomasz Wąsiński [wasinski](http://github.com/wasinski) 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Piotr Karkut 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # haps [![PyPI](https://badge.fury.io/py/haps.png)](https://pypi.python.org/pypi/haps/) [![Python application](https://github.com/lunarwings/haps/actions/workflows/python-app.yml/badge.svg?branch=master)](https://github.com/lunarwings/haps/actions/workflows/python-app.yml) 2 | Haps [χaps] is a simple DI library, with IoC container included. It is written in pure Python with no external dependencies. 3 | 4 | Look how easy it is to use: 5 | 6 | ```python 7 | from haps import Container as IoC, Inject, inject 8 | 9 | # import interfaces 10 | from my_application.core import IDatabase, IUserService 11 | 12 | 13 | class MyApp: 14 | db: IDatabase = Inject() # dependency as a property 15 | 16 | @inject # or passed to the constructor 17 | def __init__(self, user_service: IUserService) -> None: 18 | self.user_service = user_service 19 | 20 | IoC.autodiscover('my_application') # find all interfaces and implementations 21 | 22 | if __name__ == '__main__': 23 | app = MyApp() 24 | assert isinstance(app.db, IDatabase) 25 | assert isinstance(app.user_service, IUserService) 26 | ``` 27 | 28 | # Installation 29 | 30 | pip install haps 31 | 32 | # Documentation 33 | 34 | See https://haps.readthedocs.io/en/latest/ 35 | 36 | # Usage examples 37 | 38 | See https://github.com/lunarwings/haps/tree/master/samples 39 | 40 | # Testing 41 | 42 | Install `requirements.test.txt` and run `py.test` in main directory. 43 | 44 | # Changelog 45 | 46 | ## 1.1.3 (2022-02-04) 47 | - Add `>>` operator 48 | - Add `DI` alias 49 | - ~~Update~~ Remove `.travis.yml` 50 | - Setup GitHub actions 51 | 52 | ## 1.1.1 (2018-07-27) 53 | - Fix bug with optional arguments for functions decorated with `@inject` 54 | 55 | ## 1.1.0 (2018-07-26) 56 | - Add configuration module 57 | - Add application class and runner 58 | - Add profiles 59 | - Minor fixes 60 | 61 | ## 1.0.5 (2018-07-12) 62 | - `@egg` decorator can be used without function invocation 63 | 64 | ## 1.0.4 (2018-06-30) 65 | - Add support for python 3.7 66 | - Fix autodiscover sample 67 | 68 | ## 1.0.0 (2018-06-15) 69 | - First stable release 70 | 71 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = haps 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | .. _container: 2 | 3 | API 4 | ================================= 5 | 6 | 7 | Container 8 | --------------------------------- 9 | 10 | .. autoclass:: haps.Container 11 | 12 | Container is a heart of *haps*. For now, its implemented as a singleton 13 | that can only be used after one-time configuration. 14 | 15 | .. code-block:: python 16 | 17 | from haps import Container 18 | 19 | Container.autodiscover(['my.package']) # configuration, once in the app lifetime 20 | Container().some_method() # Call method on the instance 21 | 22 | That means, you can create instances of classes that use injections, only after 23 | *haps* is properly configured. 24 | 25 | 26 | .. automethod:: haps.Container.autodiscover 27 | 28 | .. automethod:: haps.Container.configure 29 | 30 | .. automethod:: haps.Container.get_object 31 | 32 | .. automethod:: haps.Container.register_scope 33 | 34 | 35 | Egg 36 | --------------------------------- 37 | 38 | 39 | .. autoclass:: haps.Egg 40 | 41 | .. automethod:: haps.Egg.__init__ 42 | 43 | 44 | Injection 45 | --------------------------------- 46 | 47 | .. autoclass:: haps.Inject 48 | 49 | .. autofunction:: haps.inject 50 | 51 | 52 | Dependencies 53 | --------------------------------- 54 | 55 | .. autofunction:: haps.base 56 | 57 | .. autofunction:: haps.egg 58 | 59 | .. autofunction:: haps.scope 60 | 61 | 62 | Configuration 63 | --------------------------------- 64 | 65 | .. autoclass:: haps.config.Configuration 66 | 67 | .. automethod:: haps.config.Configuration.get_var 68 | 69 | .. automethod:: haps.config.Configuration.resolver 70 | 71 | .. automethod:: haps.config.Configuration.env_resolver 72 | 73 | .. automethod:: haps.config.Configuration.set 74 | 75 | .. autoclass:: haps.config.Config 76 | 77 | .. automethod:: haps.config.Config.__init__ 78 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/stable/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | # import os 16 | # import sys 17 | # sys.path.insert(0, os.path.abspath('.')) 18 | 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = 'haps' 23 | copyright = '2018, Piotr Karkut' 24 | author = 'Piotr Karkut' 25 | 26 | # The short X.Y version 27 | version = '' 28 | # The full version, including alpha/beta/rc tags 29 | release = '' 30 | 31 | 32 | # -- General configuration --------------------------------------------------- 33 | 34 | # If your documentation needs a minimal Sphinx version, state it here. 35 | # 36 | # needs_sphinx = '1.0' 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be 39 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 40 | # ones. 41 | extensions = [ 42 | 'sphinx.ext.viewcode', 43 | 'sphinx.ext.autodoc' 44 | ] 45 | 46 | # Add any paths that contain templates here, relative to this directory. 47 | templates_path = ['_templates'] 48 | 49 | # The suffix(es) of source filenames. 50 | # You can specify multiple suffix as a list of string: 51 | # 52 | # source_suffix = ['.rst', '.md'] 53 | source_suffix = '.rst' 54 | 55 | # The master toctree document. 56 | master_doc = 'index' 57 | 58 | # The language for content autogenerated by Sphinx. Refer to documentation 59 | # for a list of supported languages. 60 | # 61 | # This is also used if you do content translation via gettext catalogs. 62 | # Usually you set "language" from the command line for these cases. 63 | language = None 64 | 65 | # List of patterns, relative to source directory, that match files and 66 | # directories to ignore when looking for source files. 67 | # This pattern also affects html_static_path and html_extra_path . 68 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 69 | 70 | # The name of the Pygments (syntax highlighting) style to use. 71 | pygments_style = 'sphinx' 72 | 73 | 74 | # -- Options for HTML output ------------------------------------------------- 75 | 76 | # The theme to use for HTML and HTML Help pages. See the documentation for 77 | # a list of builtin themes. 78 | # 79 | html_theme = "sphinx_rtd_theme" 80 | 81 | # Theme options are theme-specific and customize the look and feel of a theme 82 | # further. For a list of options available for each theme, see the 83 | # documentation. 84 | # 85 | # html_theme_options = {} 86 | 87 | # Add any paths that contain custom static files (such as style sheets) here, 88 | # relative to this directory. They are copied after the builtin static files, 89 | # so a file named "default.css" will overwrite the builtin "default.css". 90 | html_static_path = ['_static'] 91 | 92 | # Custom sidebar templates, must be a dictionary that maps document names 93 | # to template names. 94 | # 95 | # The default sidebars (for documents that don't match any pattern) are 96 | # defined by theme itself. Builtin themes are using these templates by 97 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 98 | # 'searchbox.html']``. 99 | # 100 | # html_sidebars = {} 101 | 102 | 103 | # -- Options for HTMLHelp output --------------------------------------------- 104 | 105 | # Output file base name for HTML help builder. 106 | htmlhelp_basename = 'hapsdoc' 107 | 108 | 109 | # -- Options for LaTeX output ------------------------------------------------ 110 | 111 | latex_elements = { 112 | # The paper size ('letterpaper' or 'a4paper'). 113 | # 114 | # 'papersize': 'letterpaper', 115 | 116 | # The font size ('10pt', '11pt' or '12pt'). 117 | # 118 | # 'pointsize': '10pt', 119 | 120 | # Additional stuff for the LaTeX preamble. 121 | # 122 | # 'preamble': '', 123 | 124 | # Latex figure (float) alignment 125 | # 126 | # 'figure_align': 'htbp', 127 | } 128 | 129 | # Grouping the document tree into LaTeX files. List of tuples 130 | # (source start file, target name, title, 131 | # author, documentclass [howto, manual, or own class]). 132 | latex_documents = [ 133 | (master_doc, 'haps.tex', 'haps Documentation', 134 | 'Piotr Karkut', 'manual'), 135 | ] 136 | 137 | 138 | # -- Options for manual page output ------------------------------------------ 139 | 140 | # One entry per manual page. List of tuples 141 | # (source start file, name, description, authors, manual section). 142 | man_pages = [ 143 | (master_doc, 'haps', 'haps Documentation', 144 | [author], 1) 145 | ] 146 | 147 | 148 | # -- Options for Texinfo output ---------------------------------------------- 149 | 150 | # Grouping the document tree into Texinfo files. List of tuples 151 | # (source start file, target name, title, author, 152 | # dir menu entry, description, category) 153 | texinfo_documents = [ 154 | (master_doc, 'haps', 'haps Documentation', 155 | author, 'haps', 'One line description of project.', 156 | 'Miscellaneous'), 157 | ] 158 | 159 | 160 | # -- Extension configuration ------------------------------------------------- 161 | -------------------------------------------------------------------------------- /docs/exceptions.rst: -------------------------------------------------------------------------------- 1 | .. _exceptions: 2 | 3 | Exceptions 4 | ================================= 5 | 6 | .. autoexception:: haps.exceptions.AlreadyConfigured 7 | 8 | .. autoexception:: haps.exceptions.ConfigurationError 9 | 10 | .. autoexception:: haps.exceptions.NotConfigured 11 | 12 | .. autoexception:: haps.exceptions.UnknownDependency 13 | 14 | .. autoexception:: haps.exceptions.UnknownScope 15 | 16 | .. autoexception:: haps.exceptions.CallError 17 | 18 | .. autoexception:: haps.exceptions.UnknownConfigVariable 19 | -------------------------------------------------------------------------------- /docs/guide.rst: -------------------------------------------------------------------------------- 1 | .. _quickstart: 2 | 3 | QuickStart 4 | ================================= 5 | 6 | Here's a simple tutorial on how to write your first application using *haps*. 7 | Assuming you have already created an environment with python 3.6+ and *haps* installed, 8 | you can start writing some juicy code. 9 | 10 | 11 | Application layout 12 | --------------------------------- 13 | 14 | Since *haps* doesn't enforce any project/code design (you can use it 15 | even as an addition to your existing Django or flask application!), this is just an 16 | example layout. You are going to create a simple user registration system. 17 | 18 | 19 | .. code-block:: text 20 | 21 | quickstart/ 22 | ├── setup.py 23 | └── user_module/ 24 | ├── app.py 25 | ├── core 26 | │   ├── implementations/ 27 | │   │   ├── __init__.py 28 | │   │   ├── db.py 29 | │   │   └── others.py 30 | │   ├── __init__.py 31 | │   └── interfaces.py 32 | └── __init__.py 33 | 34 | 35 | Interfaces 36 | ------------------------------- 37 | 38 | Let's start with creating some interfaces, so we can keep our code clean and 39 | readable: 40 | 41 | .. code-block:: python 42 | 43 | # quickstart/user_module/core/interfaces.py 44 | from haps import base 45 | 46 | 47 | @base 48 | class IUserService: 49 | def create_user(self, username: str) -> bool: 50 | raise NotImplementedError 51 | 52 | def delete_user(self, username: str) -> bool: 53 | raise NotImplementedError 54 | 55 | 56 | @base 57 | class IDatabase: 58 | def add_object(self, bucket: str, name: str, data: dict) -> bool: 59 | raise NotImplementedError 60 | 61 | def delete_object(self, bucket: str, name) -> bool: 62 | raise NotImplementedError 63 | 64 | 65 | @base 66 | class IMailer: 67 | def send(self, email: str, message: str) -> None: 68 | raise NotImplementedError 69 | 70 | There are three interfaces: 71 | 72 | - :code:`IUserService`: High-level interface with methods to create and delete users 73 | - :code:`IDatabase`: Low-level data repository 74 | - :code:`IMailer`: One-method interface for mailing integration 75 | 76 | You need to tell *haps* about your interfaces by using :code:`@base` class decorator, 77 | so it can resolve dependencies correctly. 78 | 79 | 80 | .. note:: 81 | Be aware that you don't have to create a fully-featured interface, instead 82 | you can just define a base type, that's enough for *haps*: 83 | 84 | .. code-block:: python 85 | 86 | @base 87 | class IUserService: 88 | pass 89 | 90 | However, it's a good practice to do so. 91 | 92 | 93 | Implementations 94 | ------------------------ 95 | 96 | Every interface should have at least one implementation. So, 97 | we will start with UserService and Mailer implementation. 98 | 99 | .. code-block:: python 100 | 101 | # quickstart/user_module/core/implementations/others.py 102 | from haps import egg, Inject 103 | 104 | from user_module.core.interfaces import IDatabase, IMailer, IUserService 105 | 106 | 107 | @egg 108 | class DummyMailer(IMailer): 109 | def send(self, email: str, message: str) -> None: 110 | print(f'Mail to {email}: {message}') 111 | 112 | 113 | @egg 114 | class UserService(IUserService): 115 | db: IDatabase = Inject() 116 | mailer: IMailer = Inject() 117 | 118 | _bucket = 'users' 119 | 120 | def create_user(self, username: str) -> bool: 121 | email = f'{username}@my-service.com' 122 | created = self.db.add_object(self._bucket, username, { 123 | 'email': email 124 | }) 125 | if created: 126 | self.mailer.send(email, f'Hello {username}!') 127 | return created 128 | 129 | def delete_user(self, username: str) -> bool: 130 | return self.db.delete_object(self._bucket, username) 131 | 132 | There are two classes, and the first one is quite simple, it inherits from 133 | :code:`IMailer` and implements its only method :code:`send`. The only new 134 | thing here is the :code:`@egg` decorator. You can use it to tell *haps* about any 135 | callable (a class is also a callable) that returns the implementation of a base type. 136 | Now you can probably guess how *haps* can resolve right dependencies - it looks into 137 | inheritance chain. 138 | 139 | The :code:`UserService` implementation is a way more interesting. Besides the parts 140 | we've already seen in the :code:`DummyMailer` implementation, it uses the 141 | :code:`Inject` `descriptor `_ to provide 142 | dependencies. Yes, it's that simple. You only need to define class-level field :code:`Inject` 143 | with proper annotation, and *haps* will take care of everything else. It means 144 | creating and binding the proper instance. 145 | 146 | .. warning:: 147 | With this method, the instance of an injected class, e.g., DummyMailer, is 148 | created (or fetched from the container) at the time of first property access, 149 | and then is assigned to the current :code:`UserService` instance. 150 | 151 | So: 152 | 153 | .. code-block:: python 154 | 155 | us = UserService() 156 | assert us.mailer is us.mailer # it's always true 157 | # but 158 | assert us.mailer is UserService().mailer # not necessarily 159 | # (but it can, as you will see later) 160 | 161 | 162 | Now let's move to our repository. We need to implement some data storage for our 163 | project. For now, it'll be in-memory storage, but, thanks to haps, you can 164 | quickly switch between many implementations. Creation of the database repository 165 | may be more complicated, so we'll use a factory function. 166 | 167 | .. code-block:: python 168 | 169 | # quickstart/user_module/core/implementations/db.py 170 | from collections import defaultdict 171 | 172 | from haps import egg, scope, SINGLETON_SCOPE 173 | 174 | from user_module.core.interfaces import IDatabase 175 | 176 | 177 | class InMemoryDb(IDatabase): 178 | storage: dict 179 | 180 | def __init__(self): 181 | self.storage = defaultdict(dict) 182 | 183 | def add_object(self, bucket: str, name: str, data: dict) -> bool: 184 | if name in self.storage[bucket]: 185 | return False 186 | else: 187 | self.storage[bucket][name] = data 188 | return True 189 | 190 | def delete_object(self, bucket: str, name) -> bool: 191 | try: 192 | del self.storage[bucket][name] 193 | except KeyError: 194 | return False 195 | else: 196 | return True 197 | 198 | 199 | @egg 200 | @scope(SINGLETON_SCOPE) 201 | def database_factory() -> IDatabase: 202 | db = InMemoryDb() 203 | # Maybe do some stuff, like reading configuration 204 | # or create some kind of db-session. 205 | return db 206 | 207 | 208 | :code:`InMemoryDb` is a simple implementation of :code:`IDatabase` that uses 209 | defaultdict to store users. It could be file-based storage or even SQL storage. 210 | However, notice there's no :code:`@egg` decorator on this implementation. Instead, 211 | we've created a function decorated with it which have :code:`IDatabase` 212 | declared as the return type. 213 | 214 | In this case, when injecting, haps calls :code:`database_factory` function 215 | and injects the result. 216 | 217 | 218 | .. warning:: 219 | Be aware that *haps* by design WILL NOT validate function output in any way. 220 | So if your function returns a type that's not compatible with declared one, 221 | it could lead to hard to catch errors. 222 | 223 | 224 | Scope 225 | ----------------- 226 | 227 | As you can see in the previous file, :code:`database_factory` function 228 | is also decorated with :code:`scope` decorator. 229 | 230 | A scope in *haps* determines object life-cycle. The default scope is :code:`INSTANCE_SCOPE`, 231 | and you don't have to declare it explicitly. There are also two scopes that ships with 232 | haps, :code:`SINGLETON_SCOPE`, and :code:`THREAD_SCOPE`. You can also create your own 233 | scopes. You can read about scopes in another chapter, but for the clarity: 234 | :code:`SINGLETON_SCOPE` means that *haps* creates only one instance, and injects 235 | the same object every time. On the other hand, dependencies with 236 | :code:`INSTANCE_SCOPE` (which is default), are instantiated on every injection. 237 | 238 | 239 | Run the code! 240 | ------------------ 241 | 242 | Now we have configured our interfaces and dependencies, and we're ready to 243 | run our application: 244 | 245 | .. code-block:: python 246 | 247 | # quickstart/user_module/app.py 248 | from haps import Container as IoC, inject 249 | 250 | from user_module.core.interfaces import IUserService 251 | 252 | 253 | class UserModule: 254 | @inject 255 | def __init__(self, user_service: IUserService) -> None: 256 | self.user_service = user_service 257 | 258 | def register_user(self, username: str) -> None: 259 | if self.user_service.create_user(username): 260 | print(f'User {username} created!') 261 | else: 262 | print(f'User {username} already exists!') 263 | 264 | def delete_user(self, username: str) -> None: 265 | if self.user_service.delete_user(username): 266 | print(f'User {username} deleted!') 267 | else: 268 | print(f'User {username} does not exists!') 269 | 270 | 271 | IoC.autodiscover(['user_module.core']) 272 | 273 | if __name__ == '__main__': 274 | um = UserModule() 275 | um.register_user('Kiro') 276 | um.register_user('John') 277 | um.register_user('Kiro') 278 | um.delete_user('Kiro') 279 | um.delete_user('Kiro') 280 | another_um_instance = UserModule() 281 | another_um_instance.register_user('John') 282 | 283 | 284 | The main class :code:`UserModule` takes :code:`IUserService` in the constructor, 285 | and thanks to the :code:`@inject` decorator, haps will create and 286 | pass :code:`UserService` instance to it. 287 | 288 | After that, we have to call :code:`autodiscover` method from *haps*, which 289 | scans all modules under given path and configures all dependencies. 290 | 291 | Running our application should give following output: 292 | 293 | .. code-block:: text 294 | 295 | Mail to Kiro@my-service.com: Hello Kiro! 296 | User Kiro created! 297 | Mail to John@my-service.com: Hello John! 298 | User John created! 299 | User Kiro already exists! 300 | User Kiro deleted! 301 | User Kiro does not exists! 302 | User John already exists! 303 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. haps documentation master file, created by 2 | sphinx-quickstart on Thu Apr 5 18:13:43 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to haps documentation! 7 | ================================= 8 | 9 | **Haps** *[χaps]* is a simple DI library, with IoC container included. It is written in 10 | pure Python with no external dependencies. 11 | 12 | Look how easy it is to use: 13 | 14 | .. code-block:: python 15 | 16 | from haps import Container as IoC, Inject, inject 17 | 18 | # import interfaces 19 | from my_application.core import IDatabase, IUserService 20 | 21 | 22 | class MyApp: 23 | db: IDatabase = Inject() # dependency as a property 24 | 25 | @inject # or passed to the constructor 26 | def __init__(self, user_service: IUserService) -> None: 27 | self.user_service = user_service 28 | 29 | IoC.autodiscover('my_application') # find all interfaces and implementations 30 | 31 | if __name__ == '__main__': 32 | app = MyApp() 33 | assert isinstance(app.db, IDatabase) 34 | assert isinstance(app.user_service, IUserService) 35 | 36 | Features 37 | -------- 38 | 39 | - IoC container 40 | - No XML/JSON/YAML - pure python configuration 41 | - No dependencies 42 | - Based on the Python 3.6+ annotation system 43 | 44 | Installation 45 | ------------ 46 | 47 | Install *haps* by running: 48 | 49 | .. code:: 50 | 51 | pip install haps 52 | 53 | Contribute 54 | ---------- 55 | 56 | - Issue Tracker: github.com/ekiro/haps/issues 57 | - Source Code: github.com/ekiro/haps 58 | 59 | Changelog 60 | --------- 61 | 62 | 1.1.1 (2018-07-27) 63 | ..................... 64 | * Fix bug with optional arguments for functions decorated with :code:`@inject` 65 | 66 | 1.1.0 (2018-07-26) 67 | ..................... 68 | * Add configuration module 69 | * Add application class and runner 70 | * Add profiles 71 | * Minor fixes 72 | 73 | 1.0.5 (2018-07-12) 74 | ..................... 75 | * :code:`@egg` decorator can be used without function invocation 76 | 77 | 1.0.4 (2018-06-30) 78 | ..................... 79 | 80 | * Add support for python 3.7 81 | * Fix autodiscover sample 82 | 83 | Support 84 | ------- 85 | 86 | If you are having issues, ask a question on projects issue tracker. 87 | 88 | 89 | License 90 | ------- 91 | 92 | The project is licensed under the MIT license. 93 | 94 | 95 | 96 | .. toctree:: 97 | :maxdepth: 2 98 | :caption: Contents: 99 | 100 | guide 101 | profiles 102 | api 103 | scopes 104 | exceptions 105 | 106 | 107 | 108 | Indices and tables 109 | ================== 110 | 111 | * :ref:`genindex` 112 | * :ref:`modindex` 113 | * :ref:`search` 114 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=haps 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/profiles.rst: -------------------------------------------------------------------------------- 1 | .. _profiles: 2 | 3 | Profiles 4 | ================================= 5 | 6 | Haps allows you to attach dependencies to configuration profile. It helps 7 | with development, testing, and some other stuff. You can set active profiles 8 | using :class:`~haps.config.Configuration`. 9 | 10 | Example 11 | --------------------------------- 12 | 13 | One of many good use cases for profiles is mailing. Imagine you have to 14 | implement mailer class. Your production environment uses AWS SES, stage 15 | uses an internal SMTP system, on your local env every mail is printed to 16 | stdout, and mailer for tests do nothing. You may ask, how to implement this 17 | without nasty ifs? Well, it's quite easy with profiles: 18 | 19 | 20 | .. code-block:: python 21 | 22 | from haps import base, egg 23 | 24 | 25 | @base 26 | class IMailer 27 | def send(self, to: str, message: str) -> None: 28 | raise NotImplementedError 29 | 30 | 31 | @egg(profile='production') 32 | class SESMailer(IMailer): 33 | def send(self, to: str, message: str) -> None: 34 | # SES implementation 35 | 36 | 37 | @egg(profile='stage') 38 | class SMTPMailer(IMailer): 39 | def send(self, to: str, message: str) -> None: 40 | # SMTP implementation 41 | 42 | 43 | @egg(profile='tests') 44 | class DummyMailer(IMailer): 45 | def send(self, to: str, message: str) -> None: 46 | pass 47 | 48 | 49 | @egg # missing profile means default 50 | class LogMailer(IMailer): 51 | def send(self, to: str, message: str) -> None: 52 | print(f"Mail to {to}: {message}) 53 | 54 | 55 | And that's it. Now you only need to run your app with :code:`HAPS_PROFILES=production` 56 | (or any other profile) and haps will choose proper dependency. You can set more than 57 | one profile separating them by a comma: :code:`HAPS_PROFILES=stage,local-static,sqlite` 58 | 59 | If there is more than one egg for the given profiles list, the order decides about 60 | priority. e.g. for :code:`HAPS_PROFILES=tests,production` the :code:`DummyMailer` 61 | class is chosen. 62 | 63 | Profiles can be configured programmatically, **before** Container configuration: 64 | 65 | .. code-block:: python 66 | 67 | from haps import PROFILES 68 | from haps.config import Configuration 69 | 70 | 71 | Configuration().set(PROFILES, ('tests', 'tmp-static', 'sqlite')) 72 | # Container config / autodiscover 73 | 74 | 75 | .. note:: 76 | Profiles that are set directly by :class:`~haps.config.Configuration` 77 | overrides profiles from the environment variable. 78 | -------------------------------------------------------------------------------- /docs/scopes.rst: -------------------------------------------------------------------------------- 1 | .. _scopes: 2 | 3 | Scopes 4 | ================================= 5 | 6 | A scope is a special object that controls dependency creation. It 7 | decides if new dependency instance should be created, or some cached 8 | instance should be returned. 9 | 10 | By default, there are two scopes registered in haps: 11 | :class:`~haps.scopes.InstanceScope` and :class:`~haps.scopes.SingletonScope` 12 | as :data:`haps.INSTANCE_SCOPE` and :data:`haps.SINGLETON_SCOPE`. 13 | The :data:`haps.INSTANCE_SCOPE` is used as a default. 14 | 15 | You can register any other scope by calling 16 | :meth:`haps.Container.register_scope`. New scopes should be a subclass 17 | of :class:`haps.scopes.Scope`. 18 | 19 | 20 | .. autoclass:: haps.scopes.Scope 21 | 22 | .. autoclass:: haps.scopes.instance.InstanceScope 23 | 24 | .. autoclass:: haps.scopes.singleton.SingletonScope 25 | 26 | .. autoclass:: haps.scopes.thread.ThreadScope 27 | -------------------------------------------------------------------------------- /haps/__init__.py: -------------------------------------------------------------------------------- 1 | from haps import scopes 2 | from haps.container import (INSTANCE_SCOPE, PROFILES, SINGLETON_SCOPE, 3 | Container, Egg, Inject, base, egg, inject, scope) 4 | 5 | DI = Container 6 | 7 | __all__ = ['Container', 'Inject', 'inject', 'base', 'egg', 'INSTANCE_SCOPE', 8 | 'SINGLETON_SCOPE', 'scope', 'Egg', 'scopes', 'PROFILES', 'DI'] 9 | -------------------------------------------------------------------------------- /haps/application.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Type 2 | 3 | from haps import Container 4 | from haps.config import Configuration 5 | from haps.exceptions import ConfigurationError 6 | 7 | 8 | class Application: 9 | """ 10 | Base Application class that should be the entry point for haps 11 | applications. You can override `__main__` to inject dependencies. 12 | """ 13 | 14 | @classmethod 15 | def configure(cls, config: Configuration) -> None: 16 | """ 17 | Method for configure haps application. 18 | 19 | This method is invoked before autodiscover. 20 | 21 | :param config: Configuration instance 22 | """ 23 | pass 24 | 25 | def run(self) -> None: 26 | """ 27 | Method for application entry point (like the `main` method in C). 28 | Must be implemented. 29 | """ 30 | raise NotImplementedError 31 | 32 | 33 | class ApplicationRunner: 34 | @staticmethod 35 | def run(app_class: Type[Application], 36 | extra_module_paths: List[str] = None, **kwargs: Any) -> None: 37 | """ 38 | Runner for haps application. 39 | 40 | :param app_class: :class:`~haps.application.Application` type 41 | :param extra_module_paths: Extra modules list to autodiscover 42 | :param kwargs: Extra arguments are passed to\ 43 | :func:`~haps.Container.autodiscover` 44 | """ 45 | module = app_class.__module__ 46 | if (module == '__main__' and 47 | extra_module_paths is None and 48 | 'module_paths' not in kwargs): 49 | raise ConfigurationError( 50 | 'You cannot run application from __main__ module without ' 51 | 'providing module_paths') 52 | 53 | if module != '__main__': 54 | module_paths = [app_class.__module__] 55 | else: 56 | module_paths = [] 57 | 58 | if extra_module_paths is not None: 59 | module_paths.extend(extra_module_paths) 60 | autodiscover_kwargs = { 61 | 'module_paths': module_paths, 62 | } 63 | autodiscover_kwargs.update(kwargs) 64 | 65 | app_class.configure(Configuration()) 66 | 67 | Container.autodiscover(**autodiscover_kwargs) 68 | 69 | app = app_class() 70 | app.run() 71 | -------------------------------------------------------------------------------- /haps/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from functools import partial 3 | from threading import RLock 4 | from types import FunctionType 5 | from typing import Any, Optional, Type 6 | 7 | from haps.exceptions import ConfigurationError, UnknownConfigVariable 8 | 9 | _NONE = object() 10 | 11 | 12 | def _env_resolver(var_name: str, env_name: str = None, 13 | default: Any = _NONE) -> Any: 14 | try: 15 | return os.environ[env_name or f'HAPS_{var_name}'] 16 | except KeyError: 17 | if default is not _NONE: 18 | if callable(default): 19 | return default() 20 | else: 21 | return default 22 | else: 23 | raise UnknownConfigVariable 24 | 25 | 26 | class Configuration: 27 | """ 28 | Configuration container, a simple object to manage application config 29 | variables. 30 | Variables can be set manually, from the environment, or resolved 31 | via custom function. 32 | 33 | """ 34 | 35 | _lock = RLock() 36 | _instance: 'Configuration' = None 37 | 38 | def __new__(cls, *args, **kwargs) -> 'Configuration': 39 | with cls._lock: 40 | if cls._instance is None: 41 | cls._instance = object.__new__(cls) 42 | cls._instance.cache = {} 43 | cls._instance.resolvers = {} 44 | return cls._instance 45 | 46 | def _resolve_var(self, var_name: str) -> Any: 47 | if var_name in self.resolvers: 48 | return self.resolvers[var_name]() 49 | else: 50 | raise UnknownConfigVariable( 51 | f'No resolver registered for {var_name}') 52 | 53 | def get_var(self, var_name: str, default: Optional[Any] = _NONE) -> Any: 54 | """ 55 | Get a config variable. If a variable is not set, a resolver is not 56 | set, and no default is given 57 | :class:`~haps.exceptions.UnknownConfigVariable` is raised. 58 | 59 | :param var_name: Name of variable 60 | :param default: Default value 61 | :return: Value of config variable 62 | """ 63 | try: 64 | return self.cache[var_name] 65 | except KeyError: 66 | try: 67 | var = self._resolve_var(var_name) 68 | except UnknownConfigVariable as e: 69 | if default is not _NONE: 70 | if callable(default): 71 | return default() 72 | else: 73 | return default 74 | else: 75 | raise e 76 | else: 77 | self.cache[var_name] = var 78 | return var 79 | 80 | @classmethod 81 | def resolver(cls, var_name: str) -> FunctionType: 82 | """ 83 | Variable resolver decorator. Function or method decorated with it is 84 | used to resolve the config variable. 85 | 86 | .. note:: 87 | Variable is resolved only once. 88 | Next gets are returned from the cache. 89 | 90 | :param var_name: Variable name 91 | :return: Function decorator 92 | """ 93 | def dec(f): 94 | if var_name in cls().resolvers: 95 | raise ConfigurationError( 96 | f'Resolver for {var_name} already registered') 97 | cls().resolvers[var_name] = f 98 | return f 99 | 100 | return dec 101 | 102 | @classmethod 103 | def env_resolver(cls, var_name: str, env_name: str = None, 104 | default: Any = _NONE) -> 'Configuration': 105 | """ 106 | Method for configuring environment resolver. 107 | 108 | :param var_name: Variable name 109 | :param env_name: An optional environment variable name. If not set\ 110 | haps looks for `HAPS_var_name` 111 | :param default: Default value for variable. If it's a callable,\ 112 | it is called before return. If not provided\ 113 | :class:`~haps.exceptions.UnknownConfigVariable` is raised 114 | :return: :class:`~haps.config.Configuration` instance for easy\ 115 | chaining 116 | """ 117 | 118 | cls.resolver(var_name)( 119 | partial( 120 | _env_resolver, var_name=var_name, env_name=env_name, 121 | default=default)) 122 | return cls() 123 | 124 | @classmethod 125 | def set(cls, var_name: str, value: Any) -> 'Configuration': 126 | """ 127 | Set the variable 128 | 129 | :param var_name: Variable name 130 | :param value: Value of variable 131 | :return: :class:`~haps.config.Configuration` instance for easy\ 132 | chaining 133 | """ 134 | with cls._lock: 135 | if var_name not in cls().cache: 136 | cls().cache[var_name] = value 137 | else: 138 | raise ConfigurationError( 139 | f'Value for {var_name} already set') 140 | return cls() 141 | 142 | 143 | class Config: 144 | """ 145 | Descriptor providing config variables as a class properties. 146 | 147 | .. code-block:: python 148 | 149 | class SomeClass: 150 | my_var: VarType = Config() 151 | custom_property_name: VarType = Config('var_name') 152 | 153 | """ 154 | def __init__(self, var_name: str = None, default=_NONE) -> None: 155 | """ 156 | 157 | :param var_name: An optional variable name. If not set the property\ 158 | name is used. 159 | :param default: Default value for variable. If it's a callable,\ 160 | it is called before return. If not provided\ 161 | :class:`~haps.exceptions.UnknownConfigVariable` is raised 162 | """ 163 | self._default = default 164 | self._var_name = var_name 165 | self._type = None 166 | self._name = None 167 | 168 | def __get__(self, instance: 'Config', owner: Type) -> Any: 169 | var = Configuration().get_var(self._var_name, self._default) 170 | setattr(instance, self._var_name, var) 171 | return var 172 | 173 | def __set_name__(self, owner: Type, name: str) -> None: 174 | self._name = name 175 | if self._var_name is None: 176 | self._var_name = name 177 | -------------------------------------------------------------------------------- /haps/container.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import inspect 3 | import os 4 | import pkgutil 5 | from functools import wraps 6 | from inspect import Signature 7 | from threading import RLock 8 | from types import FunctionType, ModuleType 9 | from typing import Any, Callable, Dict, List, Optional, Type, TypeVar, Union 10 | 11 | from haps.config import Configuration 12 | from haps.exceptions import (AlreadyConfigured, ConfigurationError, 13 | NotConfigured, UnknownDependency, UnknownScope) 14 | from haps.scopes import Scope 15 | from haps.scopes.instance import InstanceScope 16 | from haps.scopes.singleton import SingletonScope 17 | 18 | INSTANCE_SCOPE = '__instance' # default scopes 19 | SINGLETON_SCOPE = '__singleton' 20 | 21 | PROFILES = 'haps.profiles' 22 | 23 | T = TypeVar("T") 24 | 25 | 26 | class Egg: 27 | """ 28 | Configuration primitive. Can be used to configure *haps* manually. 29 | """ 30 | base_: Optional[Type] 31 | type_: Type 32 | qualifier: Optional[str] 33 | egg: Callable 34 | profile: Optional[str] 35 | 36 | def __init__(self, base_: Optional[Type], type_: Type, 37 | qualifier: Optional[str], egg_: Callable, 38 | profile: str = None) -> None: 39 | """ 40 | :param base_: `base` of dependency, used to retrieve object 41 | :param type_: `type` of dependency (for functions it's a return type) 42 | :param qualifier: extra qualifier for dependency. Can be used to 43 | register more than one type for one base. 44 | :param egg_: any callable that returns an instance of dependency, can 45 | be a class or a function 46 | :param profile: dependency profile name 47 | """ 48 | self.base_ = base_ 49 | self.type_ = type_ 50 | self.qualifier = qualifier 51 | self.egg = egg_ 52 | self.profile = profile 53 | 54 | def __repr__(self): 55 | return (f'') 58 | 59 | 60 | @Configuration.resolver(PROFILES) 61 | def _profiles_resolver() -> tuple: 62 | profiles = os.getenv('HAPS_PROFILES') 63 | if profiles: 64 | return tuple(p.strip() for p in profiles.split(',')) 65 | return tuple() 66 | 67 | 68 | class Container: 69 | """ 70 | Dependency Injection container class 71 | """ 72 | __instance = None 73 | __subclass = None 74 | __configured = False 75 | _lock = RLock() 76 | 77 | def __new__(cls, *args, **kwargs) -> 'Container': 78 | with cls._lock: 79 | if not cls.__configured: 80 | raise NotConfigured 81 | if cls.__instance is None: 82 | class_ = cls if cls.__subclass is None else cls.__subclass 83 | cls.__instance = object.__new__(class_) 84 | cls.__instance.scopes: Dict[str, Scope] = {} 85 | cls.__instance.config: List[Egg] = [] 86 | 87 | return cls.__instance 88 | 89 | @classmethod 90 | def _reset(cls): 91 | cls.__instance = None 92 | cls.__subclass = None 93 | cls.__configured = False 94 | 95 | @staticmethod 96 | def configure(config: List[Egg], subclass: 'Container' = None) -> None: 97 | """ 98 | Configure haps manually, an alternative 99 | to :func:`~haps.Container.autodiscover` 100 | 101 | :param config: List of configured Eggs 102 | :param subclass: Optional Container subclass that should be used 103 | """ 104 | 105 | profiles = Configuration().get_var(PROFILES, tuple) 106 | assert isinstance(profiles, (list, tuple)) 107 | profiles = tuple(profiles) + (None,) 108 | 109 | seen = set() 110 | registered = set() 111 | 112 | filtered_config: List[Egg] = [] 113 | 114 | for profile in profiles: 115 | for egg_ in (e for e in config if e.profile == profile): 116 | ident = (egg_.base_, egg_.qualifier, egg_.profile) 117 | if ident in seen: 118 | raise ConfigurationError( 119 | "Ambiguous implementation %s" % repr(egg_.base_)) 120 | dep_ident = (egg_.base_, egg_.qualifier) 121 | if dep_ident in registered: 122 | continue 123 | 124 | filtered_config.append(egg_) 125 | 126 | registered.add(dep_ident) 127 | seen.add(ident) 128 | config = filtered_config 129 | 130 | with Container._lock: 131 | if Container.__configured: 132 | raise AlreadyConfigured 133 | if subclass is None: 134 | subclass = Container 135 | 136 | Container.__subclass = subclass 137 | Container.__configured = True 138 | 139 | container = Container() 140 | if not all(isinstance(o, Egg) for o in config): 141 | raise ConfigurationError('All config items should be the eggs') 142 | container.config = config 143 | 144 | container.register_scope(INSTANCE_SCOPE, InstanceScope) 145 | container.register_scope(SINGLETON_SCOPE, SingletonScope) 146 | 147 | @classmethod 148 | def autodiscover(cls, 149 | module_paths: List[str], 150 | subclass: 'Container' = None) -> None: 151 | """ 152 | Load all modules automatically and find bases and eggs. 153 | 154 | :param module_paths: List of paths that should be discovered 155 | :param subclass: Optional Container subclass that should be used 156 | """ 157 | 158 | def find_base(bases: set, implementation: Type): 159 | found = {b for b in bases if issubclass(implementation, b)} 160 | if not found: 161 | raise ConfigurationError( 162 | "No base defined for %r" % implementation) 163 | elif len(found) > 1: 164 | raise ConfigurationError( 165 | "More than one base found for %r" % implementation) 166 | else: 167 | return found.pop() 168 | 169 | def walk(pkg: Union[str, ModuleType]) -> Dict[str, ModuleType]: 170 | if isinstance(pkg, str): 171 | pkg: ModuleType = importlib.import_module(pkg) 172 | results = {} 173 | 174 | try: 175 | path = pkg.__path__ 176 | except AttributeError: 177 | results[pkg.__name__] = importlib.import_module(pkg.__name__) 178 | else: 179 | for loader, name, is_pkg in pkgutil.walk_packages(path): 180 | full_name = pkg.__name__ + '.' + name 181 | results[full_name] = importlib.import_module(full_name) 182 | if is_pkg: 183 | results.update(walk(full_name)) 184 | return results 185 | 186 | with cls._lock: 187 | for module_path in module_paths: 188 | walk(module_path) 189 | 190 | config: List[Egg] = [] 191 | for egg_ in egg.factories: 192 | base_ = find_base(base.classes, egg_.type_) 193 | egg_.base_ = base_ 194 | config.append(egg_) 195 | 196 | cls.configure(config, subclass=subclass) 197 | 198 | def _find_egg(self, base_: Type, qualifier: str) -> Optional[Egg]: 199 | return next((e for e in self.config 200 | if e.base_ is base_ and e.qualifier == qualifier), None) 201 | 202 | def get_object(self, base_: Type[T], qualifier: str = None) -> T: 203 | """ 204 | Get instance directly from the container. 205 | 206 | If the qualifier is not None, proper method to create/retrieve instance 207 | is used. 208 | 209 | :param base_: `base` of this object 210 | :param qualifier: optional qualifier 211 | :return: object instance 212 | """ 213 | egg_ = self._find_egg(base_, qualifier) 214 | if egg_ is None: 215 | raise UnknownDependency('Unknown dependency %s' % base_) 216 | 217 | scope_id = getattr(egg_.egg, '__haps_custom_scope', INSTANCE_SCOPE) 218 | 219 | try: 220 | _scope = self.scopes[scope_id] 221 | except KeyError: 222 | raise UnknownScope('Unknown scopes with id %s' % scope_id) 223 | else: 224 | with self._lock: 225 | return _scope.get_object(egg_.egg) 226 | 227 | def register_scope(self, name: str, scope_class: Type[Scope]) -> None: 228 | """ 229 | Register new scopes which should be subclasses of `Scope` 230 | 231 | :param name: Name of new scopes 232 | :param scope_class: Class of new scopes 233 | """ 234 | with self._lock: 235 | if name in self.scopes: 236 | raise AlreadyConfigured(f'Scope {name} already registered') 237 | self.scopes[name] = scope_class() 238 | 239 | def __rshift__(self, other: Type[T]) -> T: 240 | """ 241 | Alias for `get_object` 242 | 243 | :param other: `base` of this object 244 | """ 245 | return self.get_object(other) 246 | 247 | 248 | class Inject: 249 | """ 250 | A descriptor for injecting dependencies as properties 251 | 252 | .. code-block:: python 253 | 254 | class SomeClass: 255 | my_dep: DepType = Inject() 256 | 257 | .. important:: 258 | 259 | Dependency is injected (created/fetched) at the moment of accessing 260 | the attribute, not at the moment of instance creation. So, even if 261 | you create an instance of `SomeClass`, the instance of `DepType` may 262 | never be created. 263 | """ 264 | 265 | def __init__(self, qualifier: str = None): 266 | self._qualifier = qualifier 267 | self._type: Type = None 268 | self.__prop_name = '__haps_%s_%s_instance' % ('', id(self)) 269 | 270 | def __get__(self, instance: Any, owner: Type) -> Any: 271 | if instance is None: 272 | return self 273 | else: 274 | obj = getattr(instance, self.__prop_name, None) 275 | if obj is None: 276 | obj = Container().get_object(self.type_, self._qualifier) 277 | setattr(instance, self.__prop_name, obj) 278 | return obj 279 | 280 | def __set_name__(self, owner: Type, name: str) -> None: 281 | type_: Type = owner.__annotations__.get(name) 282 | if type_ is not None: 283 | self.type_ = type_ 284 | else: 285 | raise TypeError('No annotation for Inject') 286 | 287 | 288 | def inject(fun: Callable) -> Callable: 289 | """ 290 | A decorator for injection dependencies into functions/methods, based 291 | on their type annotations. 292 | 293 | .. code-block:: python 294 | 295 | class SomeClass: 296 | @inject 297 | def __init__(self, my_dep: DepType) -> None: 298 | self.my_dep = my_dep 299 | 300 | .. important:: 301 | 302 | On the opposite to :class:`~haps.Inject`, dependency is injected 303 | at the moment of method invocation. In case of decorating `__init__`, 304 | dependency is injected when `SomeClass` instance is created. 305 | 306 | :param fun: callable with annotated parameters 307 | :return: decorated callable 308 | """ 309 | sig = inspect.signature(fun) 310 | 311 | injectables: Dict[str, Any] = {} 312 | for name, param in sig.parameters.items(): 313 | type_ = param.annotation 314 | if name == 'self': 315 | continue 316 | else: 317 | injectables[name] = type_ 318 | 319 | @wraps(fun) 320 | def _inner(*args, **kwargs): 321 | container = Container() 322 | for n, t in injectables.items(): 323 | if n not in kwargs: 324 | kwargs[n] = container.get_object(t) 325 | 326 | return fun(*args, **kwargs) 327 | 328 | return _inner 329 | 330 | 331 | def base(cls: T) -> T: 332 | """ 333 | A class decorator that marks class as a base type. 334 | 335 | :param cls: Some base type 336 | :return: Not modified `cls` 337 | """ 338 | base.classes.add(cls) 339 | return cls 340 | 341 | 342 | base.classes = set() 343 | 344 | Factory_T = Callable[..., T] 345 | 346 | 347 | def egg(qualifier: Union[str, Type] = '', profile: str = None): 348 | """ 349 | A function that returns a decorator (or acts like a decorator) 350 | that marks class or function as a source of `base`. 351 | 352 | If a class is decorated, it should inherit from `base` type. 353 | 354 | If a function is decorated, it declared return type should inherit from 355 | some `base` type, or it should be the `base` type. 356 | 357 | .. code-block:: python 358 | 359 | @egg 360 | class DepImpl(DepType): 361 | pass 362 | 363 | @egg(profile='test') 364 | class TestDepImpl(DepType): 365 | pass 366 | 367 | @egg(qualifier='special_dep') 368 | def dep_factory() -> DepType: 369 | return SomeDepImpl() 370 | 371 | :param qualifier: extra qualifier for dependency. Can be used to 372 | register more than one type for one base. If non-string argument 373 | is passed, it'll act like a decorator. 374 | :param profile: An optional profile within this dependency should be used 375 | :return: decorator 376 | """ 377 | first_arg = qualifier 378 | 379 | def egg_dec(obj: Union[FunctionType, type]) -> T: 380 | if isinstance(obj, FunctionType): 381 | spec = inspect.signature(obj) 382 | return_annotation = spec.return_annotation 383 | if return_annotation is Signature.empty: 384 | raise ConfigurationError('No return type annotation') 385 | egg.factories.append( 386 | Egg( 387 | type_=spec.return_annotation, 388 | qualifier=qualifier, 389 | egg_=obj, 390 | base_=None, 391 | profile=profile 392 | )) 393 | return obj 394 | elif isinstance(obj, type): 395 | egg.factories.append( 396 | Egg(type_=obj, qualifier=qualifier, egg_=obj, base_=None, 397 | profile=profile)) 398 | return obj 399 | else: 400 | raise AttributeError('Wrong egg obj type') 401 | 402 | if isinstance(qualifier, str): 403 | qualifier = qualifier or None 404 | return egg_dec 405 | else: 406 | qualifier = None 407 | return egg_dec(first_arg) 408 | 409 | 410 | egg.factories: List[Egg] = [] 411 | 412 | 413 | def scope(scope_type: str) -> Callable: 414 | """ 415 | A function that returns decorator that set scopes to some class/function 416 | 417 | .. code-block:: python 418 | 419 | @egg() 420 | @scopes(SINGLETON_SCOPE) 421 | class DepImpl: 422 | pass 423 | 424 | :param scope_type: Which scope should be used 425 | :return: 426 | """ 427 | 428 | def dec(egg_: T) -> T: 429 | egg_.__haps_custom_scope = scope_type 430 | return egg_ 431 | 432 | return dec 433 | -------------------------------------------------------------------------------- /haps/exceptions.py: -------------------------------------------------------------------------------- 1 | class AlreadyConfigured(Exception): 2 | pass 3 | 4 | 5 | class ConfigurationError(Exception): 6 | pass 7 | 8 | 9 | class NotConfigured(Exception): 10 | pass 11 | 12 | 13 | class UnknownDependency(TypeError): 14 | pass 15 | 16 | 17 | class UnknownScope(TypeError): 18 | pass 19 | 20 | 21 | class CallError(TypeError): 22 | pass 23 | 24 | 25 | class UnknownConfigVariable(ConfigurationError): 26 | pass 27 | -------------------------------------------------------------------------------- /haps/scopes/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable 2 | 3 | 4 | class Scope: 5 | """ 6 | Base scope class. Every custom scope should subclass this. 7 | """ 8 | 9 | def get_object(self, type_: Callable) -> Any: 10 | """ 11 | Returns object from scope 12 | :param type_: 13 | """ 14 | raise NotImplementedError 15 | -------------------------------------------------------------------------------- /haps/scopes/instance.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable 2 | 3 | from haps.scopes import Scope 4 | 5 | 6 | class InstanceScope(Scope): 7 | """ 8 | Dependencies within InstanceScope are created at every injection. 9 | """ 10 | 11 | def get_object(self, type_: Callable) -> Any: 12 | return type_() 13 | -------------------------------------------------------------------------------- /haps/scopes/singleton.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable 2 | 3 | from haps.scopes import Scope 4 | 5 | 6 | class SingletonScope(Scope): 7 | """ 8 | Dependencies within SingletonScope are created only once in 9 | the application context. 10 | """ 11 | 12 | _objects = {} 13 | 14 | def get_object(self, type_: Callable) -> Any: 15 | if type_ in self._objects: 16 | return self._objects[type_] 17 | else: 18 | obj = type_() 19 | self._objects[type_] = obj 20 | return obj 21 | -------------------------------------------------------------------------------- /haps/scopes/thread.py: -------------------------------------------------------------------------------- 1 | from threading import local 2 | from typing import Any, Callable 3 | 4 | from haps.scopes import Scope 5 | 6 | 7 | class ThreadScope(Scope): 8 | """ 9 | Dependencies within ThreadScope are created only once in a thread 10 | context. 11 | """ 12 | 13 | _thread_local = local() 14 | 15 | def get_object(self, type_: Callable) -> Any: 16 | try: 17 | objects = self._thread_local.objects 18 | except AttributeError: 19 | objects = {} 20 | self._thread_local.objects = objects 21 | 22 | if type_ in objects: 23 | return objects[type_] 24 | else: 25 | obj = type_() 26 | objects[type_] = obj 27 | return obj 28 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | build: 2 | image: latest 3 | 4 | python: 5 | version: 3.6 6 | setup_py_install: true 7 | -------------------------------------------------------------------------------- /requirements.test.txt: -------------------------------------------------------------------------------- 1 | pytest==7.0.0 2 | flake8==4.0.1 3 | isort==5.10.1 4 | pytest-flake8==1.0.7 5 | pytest-isort==2.0.0 6 | -------------------------------------------------------------------------------- /samples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekiro/haps/88d225e0177de0a0373d12d42d1b6a80437b8d5c/samples/__init__.py -------------------------------------------------------------------------------- /samples/app_ep.py: -------------------------------------------------------------------------------- 1 | from haps import inject 2 | from haps.application import Application, ApplicationRunner 3 | from haps.config import Config, Configuration 4 | from samples.autodiscover.services.bases import IHeater 5 | 6 | 7 | @Configuration.resolver('heat_count') 8 | def _(): 9 | return 5 10 | 11 | 12 | Configuration.env_resolver('config_var') 13 | Configuration.set('another_var', 10) 14 | 15 | 16 | class MyApp(Application): 17 | config_var: str = Config() 18 | count: int = Config('heat_count') 19 | another_var: int = Config() 20 | 21 | @inject 22 | def __init__(self, heater: IHeater): 23 | self.heater = heater 24 | 25 | def run(self): 26 | for _ in range(self.count): 27 | self.heater.heat() 28 | print(self.config_var, self.another_var) 29 | 30 | 31 | if __name__ == '__main__': 32 | ApplicationRunner.run(MyApp, module_paths=[ 33 | 'autodiscover.services' 34 | ]) 35 | -------------------------------------------------------------------------------- /samples/autodiscover/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekiro/haps/88d225e0177de0a0373d12d42d1b6a80437b8d5c/samples/autodiscover/__init__.py -------------------------------------------------------------------------------- /samples/autodiscover/sample.py: -------------------------------------------------------------------------------- 1 | from haps import PROFILES, Container, Inject, inject 2 | from haps.config import Configuration 3 | from samples.autodiscover.services.bases import IHeater, IPump 4 | 5 | 6 | class CoffeeMaker: 7 | heater: IHeater = Inject() 8 | 9 | @inject 10 | def __init__(self, pump: IPump): 11 | self.pump = pump 12 | 13 | def make_coffee(self): 14 | return "heater: %r\npump: %r" % (self.heater, self.pump) 15 | 16 | 17 | if __name__ == '__main__': 18 | Configuration().set(PROFILES, ('test',)) 19 | Container.autodiscover(['samples.autodiscover.services']) 20 | print(CoffeeMaker().make_coffee()) 21 | -------------------------------------------------------------------------------- /samples/autodiscover/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekiro/haps/88d225e0177de0a0373d12d42d1b6a80437b8d5c/samples/autodiscover/services/__init__.py -------------------------------------------------------------------------------- /samples/autodiscover/services/bases.py: -------------------------------------------------------------------------------- 1 | from haps import base 2 | 3 | 4 | @base 5 | class IHeater: 6 | def heat(self) -> None: 7 | raise NotImplementedError 8 | 9 | 10 | @base 11 | class IPump: 12 | pass 13 | -------------------------------------------------------------------------------- /samples/autodiscover/services/deep/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekiro/haps/88d225e0177de0a0373d12d42d1b6a80437b8d5c/samples/autodiscover/services/deep/__init__.py -------------------------------------------------------------------------------- /samples/autodiscover/services/deep/implementation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ekiro/haps/88d225e0177de0a0373d12d42d1b6a80437b8d5c/samples/autodiscover/services/deep/implementation/__init__.py -------------------------------------------------------------------------------- /samples/autodiscover/services/deep/implementation/extra_pump.py: -------------------------------------------------------------------------------- 1 | from haps import SINGLETON_SCOPE, Inject, egg, inject, scope 2 | from samples.autodiscover.services.bases import IHeater, IPump 3 | 4 | 5 | class HelpingPump(IPump): 6 | def __init__(self, heater: IHeater): 7 | self.heater = heater 8 | 9 | def __repr__(self): 10 | return f' IPump: 22 | ret = HelpingPump(heater) 23 | # some actions 24 | return ret 25 | 26 | def __repr__(self): 27 | return (f'') 29 | -------------------------------------------------------------------------------- /samples/autodiscover/services/implementations.py: -------------------------------------------------------------------------------- 1 | from haps import Inject, egg 2 | from samples.autodiscover.services.bases import IHeater, IPump 3 | 4 | 5 | @egg() 6 | class Heater(IHeater): 7 | extra_pump: IPump = Inject('extra_pump') 8 | 9 | def heat(self) -> None: 10 | print("Heating...") 11 | 12 | def __repr__(self): 13 | return '' % (id(self), self.extra_pump) 14 | 15 | 16 | @egg 17 | class Pump(IPump): 18 | heater: IHeater = Inject() 19 | 20 | def __repr__(self): 21 | return '' % (id(self), self.heater) 22 | 23 | 24 | @egg(profile='test') 25 | class PumpTest(IPump): 26 | heater: IHeater = Inject() 27 | 28 | def __repr__(self): 29 | return '' % (id(self), self.heater) 30 | -------------------------------------------------------------------------------- /samples/instance_properties.py: -------------------------------------------------------------------------------- 1 | from haps import SINGLETON_SCOPE, Container, Egg, Inject, scope 2 | 3 | 4 | class HeaterInterface: 5 | pass 6 | 7 | 8 | class PumpInterface: 9 | pass 10 | 11 | 12 | class ExtraPumpInterface(PumpInterface): 13 | pass 14 | 15 | 16 | class CoffeeMaker: 17 | heater: HeaterInterface = Inject() 18 | pump: PumpInterface = Inject() 19 | 20 | def make_coffee(self): 21 | return "heater: %r\npump: %r" % (self.heater, self.pump) 22 | 23 | 24 | class Heater(HeaterInterface): 25 | amazing_pump: ExtraPumpInterface = Inject() 26 | 27 | def __repr__(self): 28 | return '' % ( 29 | id(self), self.amazing_pump) 30 | 31 | 32 | class Pump(PumpInterface): 33 | heater: HeaterInterface = Inject() 34 | 35 | def __repr__(self): 36 | return '' % (id(self), self.heater) 37 | 38 | 39 | @scope(SINGLETON_SCOPE) # Only one instance per application is allowed 40 | class ExtraPump(ExtraPumpInterface): 41 | def __repr__(self): 42 | return '' % (id(self),) 43 | 44 | 45 | Container.configure([ 46 | Egg(HeaterInterface, HeaterInterface, None, Heater), 47 | Egg(PumpInterface, PumpInterface, None, Pump), 48 | Egg(ExtraPumpInterface, ExtraPumpInterface, None, ExtraPump), 49 | ]) 50 | 51 | if __name__ == '__main__': 52 | print(CoffeeMaker().make_coffee()) 53 | 54 | # Ouput 55 | # heater: > 57 | # pump: >> 59 | -------------------------------------------------------------------------------- /samples/simple.py: -------------------------------------------------------------------------------- 1 | from haps import SINGLETON_SCOPE, Container, Egg, inject, scope 2 | 3 | 4 | class HeaterInterface: 5 | pass 6 | 7 | 8 | class PumpInterface: 9 | pass 10 | 11 | 12 | class ExtraPumpInterface(PumpInterface): 13 | pass 14 | 15 | 16 | class CoffeeMaker: 17 | @inject 18 | def __init__(self, heater: HeaterInterface, pump: PumpInterface): 19 | self.heater = heater 20 | self.pump = pump 21 | 22 | def make_coffee(self): 23 | return "heater: %r\npump: %r" % (self.heater, self.pump) 24 | 25 | 26 | class Heater: 27 | @inject 28 | def __init__(self, extra_pump: ExtraPumpInterface): 29 | self.extra_pump = extra_pump 30 | 31 | def __repr__(self): 32 | return '' % ( 33 | id(self), self.extra_pump) 34 | 35 | 36 | class Pump: 37 | @inject 38 | def __init__(self, heater: HeaterInterface): 39 | self.heater = heater 40 | 41 | def __repr__(self): 42 | return '' % (id(self), self.heater) 43 | 44 | 45 | @scope(SINGLETON_SCOPE) # Only one instance per application is allowed 46 | class ExtraPump: 47 | def __repr__(self): 48 | return '' % (id(self),) 49 | 50 | 51 | Container.configure([ 52 | Egg(HeaterInterface, HeaterInterface, None, Heater), 53 | Egg(PumpInterface, PumpInterface, None, Pump), 54 | Egg(ExtraPumpInterface, ExtraPumpInterface, None, ExtraPump), 55 | ]) 56 | 57 | if __name__ == '__main__': 58 | print(CoffeeMaker().make_coffee()) 59 | 60 | # Ouput 61 | # heater: > 63 | # pump: >> 65 | -------------------------------------------------------------------------------- /samples/thread_scope.py: -------------------------------------------------------------------------------- 1 | from haps import Container, Egg, inject, scope 2 | from haps.scopes.thread import ThreadScope 3 | 4 | THREAD_SCOPE = 'thread' # some unique id 5 | 6 | 7 | @scope(THREAD_SCOPE) 8 | class LocalData: 9 | def __repr__(self): 10 | return '' % id(self) 11 | 12 | 13 | class Worker: 14 | @inject 15 | def __init__(self, local_data: LocalData): 16 | self.local_data = local_data 17 | 18 | def __repr__(self): 19 | return '' % (id(self), self.local_data) 20 | 21 | 22 | Container.configure([ 23 | Egg(LocalData, LocalData, None, LocalData) 24 | ]) 25 | 26 | Container().register_scope(THREAD_SCOPE, ThreadScope) 27 | 28 | if __name__ == '__main__': 29 | import time 30 | from threading import RLock, Thread 31 | 32 | lock = RLock() 33 | 34 | class MyThread(Thread): 35 | def run(self): 36 | for _ in range(3): 37 | with lock: 38 | w = Worker() 39 | print('Thread %s -> %r' % (id(self), w)) 40 | time.sleep(0.01) 41 | 42 | threads = [MyThread() for _ in range(3)] 43 | for t in threads: 44 | t.start() 45 | 46 | for t in threads: 47 | t.join() 48 | 49 | # Output 50 | # Thread 139626471148456 -> > 52 | # Thread 139626333161512 -> > 54 | # Thread 139626333247248 -> > 56 | # Thread 139626471148456 -> > 58 | # Thread 139626333161512 -> > 60 | # Thread 139626333247248 -> > 62 | # Thread 139626471148456 -> > 64 | # Thread 139626333161512 -> > 66 | # Thread 139626333247248 -> > 68 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .git,__pycache__ 3 | [tool:pytest] 4 | addopts = --isort --flake8 -r w -vsl 5 | flake8-ignore = 6 | F405 W504 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | 4 | def readme(): 5 | with open('README.md') as f: 6 | return f.read() 7 | 8 | 9 | setup( 10 | name='haps', 11 | version='1.1.3', 12 | packages=find_packages(), 13 | url='https://github.com/ekiro/haps', 14 | license='MIT License', 15 | author='Piotr Karkut', 16 | author_email='karkucik@gmail.com', 17 | description='Simple DI Library', 18 | long_description_content_type='text/markdown', 19 | long_description=readme(), 20 | platforms='any', 21 | classifiers=[ 22 | "License :: OSI Approved :: MIT License", 23 | "Programming Language :: Python", 24 | "Programming Language :: Python :: 3.6", 25 | "Programming Language :: Python :: 3.7", 26 | "Programming Language :: Python :: 3.8", 27 | "Programming Language :: Python :: 3.9", 28 | "Programming Language :: Python :: 3.10", 29 | "Operating System :: OS Independent", 30 | "Topic :: Software Development", 31 | "Intended Audience :: Developers", 32 | "Development Status :: 5 - Production/Stable" 33 | ]) 34 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import haps 4 | import haps.config 5 | 6 | 7 | @pytest.fixture(scope='session') 8 | def some_class(): 9 | class SomeClass: 10 | def fun(self): 11 | return id(self) 12 | 13 | return SomeClass 14 | 15 | 16 | @pytest.fixture(scope='session') 17 | def some_class2(): 18 | class SomeClass2: 19 | def fun(self): 20 | return id(self) 21 | 22 | return SomeClass2 23 | 24 | 25 | @pytest.fixture(scope='function', autouse=True) 26 | def reset_container(request): 27 | request.addfinalizer(haps.Container._reset) 28 | 29 | 30 | @pytest.fixture(scope='function', autouse=True) 31 | def reset_config(request): 32 | request.addfinalizer( 33 | lambda: setattr(haps.config.Configuration, '_instance', None)) 34 | -------------------------------------------------------------------------------- /tests/test_application_runner.py: -------------------------------------------------------------------------------- 1 | from haps import Inject 2 | from haps.application import Application, ApplicationRunner 3 | 4 | 5 | def test_application(): 6 | class App(Application): 7 | ran = False 8 | 9 | def run(self) -> None: 10 | App.ran = True 11 | 12 | ApplicationRunner.run(App) 13 | assert App.ran 14 | 15 | 16 | def test_application_with_autodiscovery(): 17 | from samples.autodiscover.sample import IHeater, IPump 18 | 19 | class App(Application): 20 | ran = False 21 | pump: IPump = Inject() 22 | heater: IHeater = Inject() 23 | 24 | def run(self) -> None: 25 | assert isinstance(self.pump, IPump) 26 | assert isinstance(self.heater, IHeater) 27 | App.ran = True 28 | 29 | ApplicationRunner.run( 30 | App, extra_module_paths=['samples.autodiscover.services']) 31 | assert App.ran 32 | -------------------------------------------------------------------------------- /tests/test_configuration.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from haps.config import Config, Configuration 4 | 5 | 6 | @pytest.fixture 7 | def base_config(): 8 | Configuration.set('var_a', 'a') 9 | Configuration.set('var_b', 3) 10 | 11 | @Configuration.resolver('var_c') 12 | def _() -> str: 13 | return 'c' 14 | 15 | 16 | @pytest.fixture 17 | def env_config(monkeypatch): 18 | monkeypatch.setenv('HAPS_var_a', 'a') 19 | monkeypatch.setenv('VAR_B', 'b') 20 | Configuration.env_resolver('var_a') 21 | Configuration.env_resolver('var_b', 'VAR_B') 22 | Configuration.env_resolver('var_c', default='c') 23 | Configuration.env_resolver('var_d', default=lambda: 'd') 24 | 25 | 26 | def test_base_config(base_config): 27 | assert Configuration().get_var('var_a') == 'a' 28 | assert Configuration().get_var('var_b') == 3 29 | assert Configuration().get_var('var_c') == 'c' 30 | 31 | 32 | def test_base_config_injection(base_config): 33 | class SomeClass: 34 | var_a: str = Config() 35 | var_b: int = Config(default=10) 36 | c: str = Config('var_c') 37 | 38 | d_def: int = Config(default=11) 39 | d_def2: int = Config('var2', default=12) 40 | d_cal: int = Config(default=lambda: 5) 41 | 42 | sc = SomeClass() 43 | 44 | assert sc.var_a == 'a' 45 | assert sc.var_b == 3 46 | assert sc.c == 'c' 47 | 48 | assert sc.d_def == 11 49 | assert sc.d_def2 == 12 50 | assert sc.d_cal == 5 51 | 52 | 53 | def test_env_config(env_config): 54 | assert Configuration().get_var('var_a') == 'a' 55 | assert Configuration().get_var('var_b') == 'b' 56 | assert Configuration().get_var('var_c') == 'c' 57 | assert Configuration().get_var('var_d') == 'd' 58 | 59 | 60 | def test_configuration_chaining(): 61 | Configuration().set("a", 1).set("b", 2).env_resolver("d").set("c", 3) 62 | assert Configuration().get_var('a') == 1 63 | assert Configuration().get_var('b') == 2 64 | assert Configuration().get_var('c') == 3 65 | assert Configuration().get_var('d', None) is None 66 | -------------------------------------------------------------------------------- /tests/test_di_container.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import haps 4 | from haps import exceptions 5 | from haps.config import Configuration 6 | from haps.exceptions import ConfigurationError 7 | from haps.scopes.instance import InstanceScope 8 | 9 | 10 | def test_configure(): 11 | haps.Container.configure([]) 12 | 13 | assert haps.Container() 14 | 15 | 16 | def test_already_configured(): 17 | haps.Container.configure([]) 18 | 19 | with pytest.raises(exceptions.AlreadyConfigured): 20 | haps.Container.configure([]) 21 | 22 | 23 | def test_not_configured(): 24 | with pytest.raises(exceptions.NotConfigured): 25 | haps.Container() 26 | 27 | 28 | def test_configure_class_and_get_object(some_class): 29 | haps.Container.configure([ 30 | haps.Egg(some_class, some_class, None, some_class) 31 | ]) 32 | 33 | some_instance = haps.Container().get_object(some_class) 34 | assert isinstance(some_instance, some_class) 35 | 36 | 37 | def test_inject_class(some_class): 38 | haps.Container.configure([ 39 | haps.Egg(some_class, some_class, None, some_class) 40 | ]) 41 | 42 | class AnotherClass: 43 | @haps.inject 44 | def __init__(self, some_class_instance: some_class): 45 | self.some_class_instance = some_class_instance 46 | 47 | some_instance = AnotherClass() 48 | 49 | assert isinstance(some_instance.some_class_instance, some_class) 50 | 51 | 52 | def test_inject_class_by_operator(some_class): 53 | haps.Container.configure([ 54 | haps.Egg(some_class, some_class, None, some_class) 55 | ]) 56 | 57 | some_class_instance = haps.DI() >> some_class 58 | 59 | assert isinstance(some_class_instance, some_class) 60 | 61 | 62 | def test_inject_into_function_with_optional_args(some_class): 63 | haps.Container.configure([ 64 | haps.Egg(some_class, some_class, None, some_class) 65 | ]) 66 | 67 | class NotRegistered: 68 | pass 69 | 70 | @haps.inject 71 | def func(some_class_instance: some_class, no_annotation, 72 | not_registered: NotRegistered): 73 | return some_class_instance, no_annotation, not_registered 74 | 75 | not_reg = NotRegistered() 76 | sci, na, nr = func(no_annotation=5, not_registered=not_reg) 77 | 78 | assert isinstance(sci, some_class) 79 | assert na == 5 80 | assert nr is not_reg 81 | 82 | 83 | def test_not_existing_scope(): 84 | @haps.scope('custom') 85 | class CustomScopedCls: 86 | pass 87 | 88 | haps.Container.configure([ 89 | haps.Egg(CustomScopedCls, CustomScopedCls, None, CustomScopedCls) 90 | ]) 91 | 92 | class AnotherClass: 93 | @haps.inject 94 | def __init__(self, csc: CustomScopedCls): 95 | pass 96 | 97 | with pytest.raises(exceptions.UnknownScope): 98 | AnotherClass() 99 | 100 | 101 | def test_custom_scope(): 102 | @haps.scope('custom') 103 | class CustomScopedCls: 104 | pass 105 | 106 | class CustomScope(InstanceScope): 107 | get_object_called = False 108 | 109 | def get_object(self, type_): 110 | CustomScope.get_object_called = True 111 | return super(CustomScope, self).get_object(type_) 112 | 113 | haps.Container.configure([ 114 | haps.Egg(CustomScopedCls, CustomScopedCls, None, CustomScopedCls) 115 | ]) 116 | haps.Container().register_scope('custom', CustomScope) 117 | 118 | class AnotherClass: 119 | @haps.inject 120 | def __init__(self, csc: CustomScopedCls): 121 | self.csc = csc 122 | 123 | some_instance = AnotherClass() 124 | assert isinstance(some_instance.csc, CustomScopedCls) 125 | assert CustomScope.get_object_called 126 | 127 | 128 | def test_inject_class_using_property_instance_annotation(some_class): 129 | class NewClass(some_class): 130 | pass 131 | 132 | haps.Container.configure([ 133 | haps.Egg(some_class, some_class, None, NewClass) 134 | ]) 135 | 136 | class AnotherClass: 137 | injected_instance: some_class = haps.Inject() 138 | 139 | some_instance = AnotherClass() 140 | 141 | assert hasattr(some_instance, 'injected_instance') 142 | assert isinstance(some_instance.injected_instance, NewClass) 143 | instance1 = some_instance.injected_instance 144 | instance2 = some_instance.injected_instance 145 | 146 | assert instance1 is instance2 147 | 148 | 149 | def test_inject_class_using_init_annotation(some_class): 150 | class NewClass(some_class): 151 | pass 152 | 153 | haps.Container.configure([ 154 | haps.Egg(some_class, some_class, None, NewClass) 155 | ]) 156 | 157 | class AnotherClass: 158 | @haps.inject 159 | def __init__(self, injected_instance: some_class): 160 | self.injected_instance = injected_instance 161 | 162 | some_instance = AnotherClass() 163 | 164 | assert hasattr(some_instance, 'injected_instance') 165 | assert isinstance(some_instance.injected_instance, NewClass) 166 | instance1 = some_instance.injected_instance 167 | instance2 = some_instance.injected_instance 168 | 169 | assert instance1 is instance2 170 | 171 | 172 | def test_named_configuration_property_injection(some_class): 173 | class NewClass(some_class): 174 | pass 175 | 176 | class NewClass2(some_class): 177 | pass 178 | 179 | haps.Container.configure([ 180 | haps.Egg(some_class, NewClass, None, NewClass), 181 | haps.Egg(some_class, NewClass2, 'extra', NewClass2) 182 | ]) 183 | 184 | class AnotherClass: 185 | some_instance: some_class = haps.Inject() 186 | some_extra_instance: some_class = haps.Inject('extra') 187 | 188 | some_instance = AnotherClass() 189 | 190 | assert isinstance(some_instance.some_instance, NewClass) 191 | assert isinstance(some_instance.some_extra_instance, NewClass2) 192 | instance1 = some_instance.some_instance 193 | instance2 = some_instance.some_extra_instance 194 | 195 | assert instance1 is not instance2 196 | 197 | 198 | def test_autodiscovery(): 199 | from samples.autodiscover.sample import CoffeeMaker, IHeater, IPump 200 | haps.Container.autodiscover(['samples.autodiscover.services']) 201 | 202 | cm = CoffeeMaker() 203 | assert isinstance(cm.pump, IPump) 204 | assert isinstance(cm.heater, IHeater) 205 | 206 | 207 | def test_ambiguous_dependency(some_class): 208 | class NewClass(some_class): 209 | pass 210 | 211 | class NewClass2(some_class): 212 | pass 213 | 214 | with pytest.raises(ConfigurationError) as e: 215 | haps.Container.configure([ 216 | haps.Egg(some_class, some_class, None, NewClass), 217 | haps.Egg(some_class, some_class, None, NewClass2) 218 | ]) 219 | 220 | assert e.value.args[0] == f'Ambiguous implementation {repr(some_class)}' 221 | 222 | 223 | @pytest.mark.parametrize("profiles,expected", [ 224 | ((), 'NewClass'), 225 | (('test',), 'NewClass2'), 226 | (['prod'], 'NewClass3'), 227 | (['non-existing', 'test'], 'NewClass2'), 228 | (('non-existing', 'test', 'prod'), 'NewClass2'), 229 | (('non-existing', 'prod', 'test'), 'NewClass3') 230 | ]) 231 | def test_dependencies_with_profiles(some_class, profiles, expected): 232 | class NewClass(some_class): 233 | pass 234 | 235 | class NewClass2(some_class): 236 | pass 237 | 238 | class NewClass3(some_class): 239 | pass 240 | 241 | Configuration().set('haps.profiles', profiles) 242 | haps.Container.configure([ 243 | haps.Egg(some_class, NewClass, None, NewClass), 244 | haps.Egg(some_class, NewClass2, None, NewClass2, 'test'), 245 | haps.Egg(some_class, NewClass3, None, NewClass3, 'prod') 246 | ]) 247 | 248 | class AnotherClass: 249 | some_instance: some_class = haps.Inject() 250 | 251 | some_instance = AnotherClass() 252 | 253 | assert type(some_instance.some_instance).__name__ == expected 254 | -------------------------------------------------------------------------------- /tests/test_instance_scope.py: -------------------------------------------------------------------------------- 1 | from haps.scopes.instance import InstanceScope 2 | 3 | 4 | def test_get_object(some_class): 5 | some_instance = InstanceScope().get_object(some_class) 6 | assert isinstance(some_instance, some_class) 7 | 8 | 9 | def test_get_multiple_objects(some_class): 10 | scope = InstanceScope() 11 | 12 | objects = {scope.get_object(some_class) for _ in range(100)} 13 | assert all(isinstance(o, some_class) for o in objects) 14 | assert len({id(o) for o in objects}) == 100 15 | -------------------------------------------------------------------------------- /tests/test_singleton_scope.py: -------------------------------------------------------------------------------- 1 | from haps.scopes.singleton import SingletonScope 2 | 3 | 4 | def test_get_object(some_class): 5 | some_instance = SingletonScope().get_object(some_class) 6 | assert isinstance(some_instance, some_class) 7 | 8 | 9 | def test_get_multiple_objects(some_class): 10 | scope = SingletonScope() 11 | 12 | objects = {scope.get_object(some_class) for _ in range(100)} 13 | assert all(isinstance(o, some_class) for o in objects) 14 | assert len({id(o) for o in objects}) == 1 15 | -------------------------------------------------------------------------------- /tests/test_thread_scope.py: -------------------------------------------------------------------------------- 1 | try: 2 | import queue 3 | except ImportError: 4 | import Queue as queue # python2 5 | 6 | import threading 7 | 8 | from haps.scopes.thread import ThreadScope 9 | 10 | 11 | def test_get_object(some_class): 12 | some_instance = ThreadScope().get_object(some_class) 13 | assert isinstance(some_instance, some_class) 14 | 15 | 16 | def test_get_multiple_objects(some_class): 17 | q = queue.Queue() 18 | 19 | class MyThread(threading.Thread): 20 | def run(self): 21 | scope = ThreadScope() 22 | for _ in range(100): 23 | q.put(scope.get_object(some_class)) 24 | 25 | threads = {MyThread() for _ in range(10)} 26 | for t in threads: 27 | t.start() 28 | for t in threads: 29 | t.join() 30 | objects = [q.get_nowait() for _ in range(1000)] 31 | 32 | assert all(isinstance(o, some_class) for o in objects) 33 | assert len({id(o) for o in objects}) == 10 34 | --------------------------------------------------------------------------------