├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.md │ ├── feature-request.md │ └── tech-debt.md └── PULL_REQUEST_TEMPLATE │ └── default.md ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── bin └── build_chain.py ├── chain ├── __init__.py ├── core │ ├── __init__.py │ └── domains │ │ ├── __init__.py │ │ ├── chain │ │ ├── __init__.py │ │ ├── chain.py │ │ └── decorator.py │ │ ├── context │ │ ├── __init__.py │ │ └── context.py │ │ └── state │ │ ├── __init__.py │ │ └── state.py └── tests │ ├── __init__.py │ ├── acceptance │ ├── __init__.py │ ├── chain.feature │ ├── decorator.feature │ ├── environment.py │ └── steps │ │ ├── step_chain.py │ │ ├── step_common.py │ │ └── step_decorator.py │ ├── conftest.py │ ├── constants.py │ ├── core │ └── domains │ │ ├── chain │ │ ├── test_chain.py │ │ └── test_decorator.py │ │ ├── context │ │ └── test_context.py │ │ └── state │ │ └── test_state.py │ ├── helpers.py │ └── test_chain.py ├── requirements ├── common.txt ├── development.txt └── production.txt ├── setup.cfg └── setup.py /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 😰 Describe the bug 11 | 12 | A clear and concise description of what the bug is. 13 | 14 | ## 🧐 How to reproduce 15 | 16 | Steps to reproduce the behavior: 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | ## 🎉 Expected behavior 23 | 24 | A clear and concise description of what you expected to happen. 25 | 26 | ## 🍪 Further details 27 | 28 | **Screenshots** 29 | If applicable, add screenshots to help explain your problem. 30 | 31 | **Desktop (please complete the following information):** 32 | - OS: [e.g. iOS] 33 | - Browser [e.g. chrome, safari] 34 | - Version [e.g. 22] 35 | 36 | **Smartphone (please complete the following information):** 37 | - Device: [e.g. iPhone6] 38 | - OS: [e.g. iOS8.1] 39 | - Browser [e.g. stock browser, safari] 40 | - Version [e.g. 22] 41 | 42 | **Additional context** 43 | Add any other context about the problem here. 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'feature request' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 🧐 The problem 11 | 12 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 13 | 14 | ## 💡 What solution do I want 15 | 16 | A clear and concise description of what you want to happen. 17 | 18 | ## 😰 What have I tried to do already 19 | 20 | A clear and concise description of any alternative solutions or features you've considered. 21 | 22 | ## 🍪 Further details 23 | 24 | Add any other context or screenshots about the feature request here. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/tech-debt.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tech debt 3 | about: Record a tech debt of our serverless infrastructure 4 | title: '' 5 | labels: 'tech debt' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 💩 How it is currently developed 11 | 12 | A clear and concise description of how the solution is currently developed. 13 | 14 | ## 🌟 What is the optimal solution 15 | 16 | An explanation about how it should work in an ideal scenario. 17 | 18 | ## 😓 Why we did that way 19 | 20 | Some relevant context information explaining why we've decided to develop that way. 21 | 22 | ## 💣 What are the possible impacts 23 | 24 | Some possible danger areas and what could happen if that debt is not solved. 25 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/default.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Default pull request 3 | about: Contribute to our project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## ☕ Purpose 11 | 12 | A brief summary about the purpose of this PR. 13 | 14 | ## 🧐 Checklist 15 | 16 | - [x] A feature that will work with this PR 17 | - [ ] A feature that I'm still working on 18 | 19 | ## 🐞 Testing 20 | 21 | A brief description about how the reviewer can test my PR. 22 | 23 | ~~~shell 24 | # don't forget to insert cli commands 25 | ~~~ 26 | 27 | ## 🍩 Further details 28 | 29 | Anything that the reviewer should know before approving it. 30 | 31 | ## 🔗 Related PRs 32 | 33 | This PR is related to some other PRs in different services, they are: 34 | * [`project#PR_NUMBER`](https://) 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 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 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | ### Fixed 9 | 10 | - Changed from greater than operator to bitwise right shift in order to allow contexto operations. 11 | 12 | ## 1.0.2 13 | ### Fixed 14 | 15 | - Fixed some bugs regarding PyPi integration. 16 | 17 | ## 1.0.1 18 | ### Fixed 19 | 20 | - Integrated with PyPi and tested the deployment. 21 | 22 | 23 | ## 1.0.0 24 | ### Added 25 | 26 | - Created basic project docs (`README`, `CONTRIBUTING`, `LICENSE` and `CHANGELOG`). 27 | - Installed basic project testing frameworks (`behave` and `pytest`). 28 | - Created the project folder architecture. 29 | - Developed the State domain. 30 | - Developed the Context domain. 31 | - Developed the Chain domain. 32 | - Developed the entrypoint. 33 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Python Chain 2 | 3 | It's awesome that you want to contribute to this service! You can contribute in several ways, like: 4 | 5 | * Creating new features 6 | * Fixing existing bugs 7 | * Improving documentation and examples 8 | 9 | To help you on this journey we've created this document that will guide you through several steps, like [creating your development environment](#developing-python-chain), [deploying dependencies](#deploying-dependencies) and [running tests](#running-tests). 10 | 11 | ## Table of contents 12 | 13 | * [Contributing to Python Chain](#) 14 | * [Developing Python Chain](#developing-python-chain) 15 | * [Deploying Dependencies](#deploying-dependencies) 16 | * [Running Tests](#running-tests) 17 | * [Documenting](#documenting) 18 | * [Versioning](#versioning) 19 | 20 | ## Developing Python Chain 21 | 22 | On this project, we're following some conventions and it would be awesome if you could do so! Following conventions make it easier for any developer to stretch and maintain your code 😀. The major guidelines that you must follow are: 23 | 24 | * **Domain Driven Design** => you can learn about it [here](https://blog.cleancoder.com/uncle-bob/2011/09/30/Screaming-Architecture.html), [here](https://techbeacon.com/app-dev-testing/get-your-feet-wet-domain-driven-design-3-guiding-principles) and [here](https://www.livrariacultura.com.br/p/livros/informatica-e-tecnologia/software/domain-driven-design-46458301?id_link=8787&adtype=pla&id_link=8787&gclid=EAIaIQobChMIg7i7g6_24gIVFwmRCh3UiQYBEAYYAiABEgKbt_D_BwE) 25 | * **Test Driven Development** => you can learn about it [here](https://www.devmedia.com.br/test-driven-development-tdd-simples-e-pratico/18533) and [here](https://hackernoon.com/introduction-to-test-driven-development-tdd-61a13bc92d92) 26 | 27 | After understanding some of our core concepts, you can now check out our folder structure. We're following the DDD structure on that: 28 | 29 | ``` 30 | python-chain 31 | │ 32 | ├── chain 33 | | ├── core 34 | │ │ └── domains 35 | │ │ └── 36 | | └── tests 37 | | ├── acceptance 38 | | | └── steps 39 | | └── 40 | ├── requirements 41 | ├── docs 42 | └── deploy 43 | ``` 44 | 45 | Our entire source code is inside the `chain` folder. Everything else is just folders and files to help you build the project or documentation. Inside the `chain` folder we have the following sections: 46 | 47 | * [Core](#core-folder) 48 | * [Tests](#tests-folder) 49 | 50 | ### Core Folder 51 | 52 | You can find it on the following path: `/chain/core`. There, you'll find every code of our core module. The codebase is organized in the following sections (folders): 53 | 54 | * `domains` - All the domains of the application. 55 | 56 | Most of the magic happens on the `domains` section. There's no pattern of file naming inside a specific domain. They can contain any type of file which is needed to perform a task. But, the most commons are: `handlers`, `models`, `generators`, `transformers` and so on. 57 | 58 | ### Tests Folder 59 | 60 | You can find it on the following path: `/chain/tests`. There, you'll find all the unit tests that are currently active. We're using [Pytest](https://docs.pytest.org/en/latest/) for the unit tests and [Behave](https://behave.readthedocs.io/en/latest/) to acceptance tests. Please, read their docs before creating new tests. 61 | 62 | All unit tests are organized mimicking the structure of the chain folders. The acceptance tests are organized inside the `acceptance` folder and divided by features. 63 | 64 | ## Deploying Dependencies 65 | 66 | To install the development version of the application on your machine you must first install all the following dependencies: 67 | 68 | * [Python 3.7.3](https://www.python.org/downloads/release/python-373/) 69 | 70 | It is strongly recommended to also install the following tools: 71 | 72 | * [pyenv](https://github.com/pyenv/pyenv) 73 | * [pyenv-virtualenv](https://github.com/pyenv/pyenv-virtualenv) 74 | 75 | Now, with all the dependencies installed, you can follow the installation steps on your favorite shell: 76 | 77 | ### 1. Clone the project 78 | ``` 79 | $ git clone git@github.com:quintoandar/python-chain.git 80 | $ cd python-chain 81 | ``` 82 | 83 | ### 2. Setup the Python environment for the project 84 | ``` 85 | $ make environment 86 | ``` 87 | 88 | ### 3. Install dependencies 89 | ``` 90 | $ make install-dev 91 | ``` 92 | 93 | Now, your module is good to go! And you can start debugging it. 94 | 95 | ## Running Tests 96 | 97 | Since our application is TDD all code must have automated tests. Please, be aware to not "overtest" it too. You should focus on **integration** and **acceptance** tests and write **unit** tests only when a function really needs it. You can see a pretty good article about it [here](https://kentcdodds.com/blog/write-tests). 98 | 99 | On this application, we're using [Pytest](https://docs.pytest.org/en/latest/) as our unit test framework and [Behave](https://behave.readthedocs.io/en/latest/) as our behavior test framework. You can run them with: 100 | 101 | ``` 102 | $ pytest 103 | $ behave 104 | ``` 105 | 106 | ## Versioning 107 | 108 | We use [SemVer 2.0.0](https://semver.org/) for versioning our releases. Also, we recommend you to use the [Python Black](https://github.com/python/black) format. We've created a script to do it for you. You can run the `make black` command before committing your code. 109 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 QuintoAndar.com.br 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: environment 2 | environment: 3 | @pyenv install -s 3.7.3 4 | @pyenv virtualenv 3.7.3 chain 5 | @pyenv local chain 6 | 7 | .PHONY: install-dev 8 | install-dev: 9 | @pip install -r requirements/development.txt 10 | 11 | .PHONY: black 12 | black: 13 | @python -m black -t py36 --exclude="build/|buck-out/|dist/|_build/|pip/|\.pip/|\.git/|\.hg/|\.mypy_cache/|\.tox/|\.venv/" . 14 | 15 | .PHONY: style-check 16 | style-check: 17 | @echo "" 18 | @echo "Code Style" 19 | @echo "==========" 20 | @echo "" 21 | @python -m black --check -t py36 --exclude="build/|buck-out/|dist/|_build/|pip/|\.pip/|\.git/|\.hg/|\.mypy_cache/|\.tox/|\.venv/" . && echo "\n\nSuccess" || echo "\n\nFailure\n\nRun \"make black\" to apply style formatting to your code" 22 | @echo "" 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | Python Chain Logo 4 |
5 |

6 |

7 | An easy to use function chaining pattern on Python. 8 |

9 | 10 | ## 📖 About this Project 11 | 12 | Chaining functions is a common functional development pattern that is pretty difficult on Python. Usually, we need to pass some data through a pipeline, process or series of functions in order to get a specific output. Without this lib, you would need to wrap those functions on a class or assign each result on a variable. 13 | 14 | With **python-chain** you can create an initial state and execute a chain of functions, nourishing that state during the pipeline, like this: 15 | 16 | ![Code sample](https://i.imgur.com/IHRr4C7.png) 17 | 18 | ## 🤖 Getting Started 19 | 20 | On this section, you'll learn all the prerequisites and basic knowledge in order to use this library on your projects. 21 | 22 | ### Installation 23 | 24 | You can install it using **pip**, running: 25 | 26 | ``` sh 27 | pip install python-chain 28 | ``` 29 | 30 | ### Common Usage 31 | 32 | #### Creating Chainable Functions 33 | 34 | You can chain functions by decorating them with the `@chain` decorator. Like the following: 35 | 36 | ``` python 37 | import chain 38 | 39 | 40 | @chain 41 | def some_pretty_func(state): 42 | ... 43 | 44 | # Now you can chain that function with the operator>> 45 | ``` 46 | 47 | #### Using State 48 | 49 | Every chain has a `state`. That state is an Object with immutable attributes. The current Chain state will be passed automatically on the keyword argument **context**. Every chain should start with a given state, even if it empty. You can create a new one by using: 50 | 51 | ``` python 52 | import chain 53 | 54 | state = chain.state() 55 | ``` 56 | 57 | If you want to feed data into your initial state, you can pass then as kwargs. The key-value pair on the kwargs of your state will be passed as attributes on your chain context. Like so: 58 | 59 | ``` python 60 | import chain 61 | 62 | state = chain.state(foo='bar') 63 | 64 | @chain 65 | def test_chain(context): 66 | print(context.foo) 67 | # bar 68 | 69 | ``` 70 | 71 | #### Using States on Chains 72 | 73 | Every mutation that you do in a chain function will add it to the next function. You can merge what you have learned both on states and functions by following: 74 | 75 | ``` python 76 | import chain 77 | 78 | @chain 79 | def calculate_average(context, type='meter'): 80 | nbs = [house.get(type) for house in state.houses] 81 | 82 | context.avg = sum(nbs) / len(nbs) 83 | 84 | @chain 85 | def add_houses(context): 86 | houses = [ 87 | { meter: 3, }, 88 | { meter: 10, }, 89 | ] 90 | 91 | context.houses = houses 92 | 93 | result = chain.state() >> add_houses >> calculate_average 94 | print(result.current) 95 | # 96 | # { 97 | # avg: 6.5, 98 | # houses: [ 99 | # { meter: 3 }, 100 | # { meter: 10 }, 101 | # ] 102 | # } 103 | # 104 | ``` 105 | 106 | If you don't return anything on your final chain function it will automatically return the Context object. They have a lot of properties, and one of them is the `current` attribute. That will return the current state of your given context. 107 | 108 | #### Finishing a Chain 109 | 110 | Every time a chain is finished, it will automatically return its context. You can also add an output by retuning the data that you want on the last step of the chain, like this: 111 | 112 | ``` python 113 | import chain 114 | 115 | @chain 116 | def get_name(context): 117 | return context.name 118 | 119 | @chain 120 | def add_user(context): 121 | context.name = 'foo' 122 | 123 | result = chain.state() >> add_user >> get_name 124 | print(result.output) 125 | # 126 | # 'foo' 127 | # 128 | ``` 129 | 130 | #### Passing arguments directly 131 | 132 | You can pass any args or kwargs directly to the next function. They should be passed returning a tuple with all the args on the first argument and the kwargs on the second. You can do so like this: 133 | 134 | ``` python 135 | import chain 136 | 137 | @chain 138 | def store_result(result, context, type=None): 139 | context.result = result 140 | context.type = type 141 | 142 | @chain 143 | def add_result(context): 144 | args = ('foo',) 145 | kwargs = {type: 'bar'} 146 | 147 | return args, kwargs 148 | 149 | result = chain.state() >> add_result >> store_result 150 | print(result.current) 151 | # 152 | # { 153 | # result: 'foo', 154 | # type: 'bar', 155 | # } 156 | # 157 | ``` 158 | 159 | **Be careful**. This would create a **strong dependency** between those two functions. Chain will always pass the args and kwargs that you've created and it will break the chain if the next function doesn't accept those params. Also, always set a state params, because it will be passed by the Chain with the current state. 160 | 161 | ## ✍️ Contributing 162 | 163 | Contributions are what makes the open-source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. You can learn how to contribute to this project on the [`CONTRIBUTING`](CONTRIBUTING.md) file. 164 | 165 | ## 🔓 License 166 | 167 | Distributed under the MIT License. See [`LICENSE`](LICENSE) for more information. 168 | -------------------------------------------------------------------------------- /bin/build_chain.py: -------------------------------------------------------------------------------- 1 | import chain 2 | -------------------------------------------------------------------------------- /chain/__init__.py: -------------------------------------------------------------------------------- 1 | """Chain Module. 2 | 3 | This module contains all the relevant methods for the chain module. It holds a shortcut 4 | for the most used functions in order to make it easier for developers to import and 5 | use our packages. 6 | 7 | """ 8 | from sys import modules 9 | from types import ModuleType 10 | from injectable import InjectionContainer 11 | from chain.core.domains.state import State 12 | from chain.core.domains.chain import Decorator 13 | 14 | __version__ = "1.0.4" 15 | 16 | InjectionContainer.load() 17 | 18 | 19 | class Chain(ModuleType): 20 | """Default Chain Class. 21 | 22 | The Chain class is the entrypoint to use our lib. It holds all the logic 23 | to use the core packages of this library. 24 | 25 | """ 26 | 27 | def __call__(self, *args, **kwargs): 28 | return Decorator(*args, **kwargs) 29 | 30 | def state(self, **kwargs): 31 | """Generate New State. 32 | 33 | This method will generate a new state given kwargs, using their keys 34 | as the state key attribute with respective values. 35 | 36 | """ 37 | return State(**kwargs) 38 | 39 | 40 | modules[__name__].__class__ = Chain 41 | -------------------------------------------------------------------------------- /chain/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quintoandar/python-chain/f426d4c814f92090e21dc470576e7033a8488e9c/chain/core/__init__.py -------------------------------------------------------------------------------- /chain/core/domains/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quintoandar/python-chain/f426d4c814f92090e21dc470576e7033a8488e9c/chain/core/domains/__init__.py -------------------------------------------------------------------------------- /chain/core/domains/chain/__init__.py: -------------------------------------------------------------------------------- 1 | from chain.core.domains.chain.chain import Chain 2 | from chain.core.domains.chain.decorator import Decorator 3 | -------------------------------------------------------------------------------- /chain/core/domains/chain/chain.py: -------------------------------------------------------------------------------- 1 | """Chain Package. 2 | 3 | The chain creates a Chain class that will handle the core methods to create chainable 4 | functions using our lib. 5 | 6 | """ 7 | from typing import Callable 8 | from injectable import autowired, Autowired 9 | from copy import deepcopy 10 | from chain.core.domains.state import State 11 | from chain.core.domains.context import Context 12 | 13 | 14 | class Chain: 15 | """Chain Class. 16 | 17 | This class is responsible for executing the core methods to allow functions to 18 | be chained. 19 | 20 | """ 21 | 22 | @autowired 23 | def __init__( 24 | self, 25 | function: Callable, 26 | initial_state: Autowired(State, namespace="python-chain"), 27 | ): 28 | self.initial_state = deepcopy(initial_state) 29 | self.function = function 30 | 31 | def __call__(self, *args, **kwargs) -> any: 32 | return self.function(context=self.initial_state, *args, **kwargs) 33 | 34 | def __split_output(self, output: any) -> tuple: 35 | return output if type(output) == tuple else (tuple(), dict()) 36 | 37 | def execute(self, context: Context) -> any: 38 | """Execute the Current Chain Based on Context. 39 | 40 | This method will execute the current chain considering the current context that 41 | we are running into. 42 | 43 | """ 44 | args, kwargs = self.__split_output(context.output) 45 | 46 | context.merge_context(self.initial_state) 47 | context.output = self.function(*args, **kwargs, context=context.current) 48 | 49 | return context 50 | -------------------------------------------------------------------------------- /chain/core/domains/chain/decorator.py: -------------------------------------------------------------------------------- 1 | """Decorator Package. 2 | 3 | The decorator exposes the possibility to create a new decorator to an existing function. 4 | 5 | """ 6 | from functools import update_wrapper 7 | from typing import Callable 8 | from injectable import autowired, Autowired 9 | from chain.core.domains.state import State 10 | from chain.core.domains.chain import Chain 11 | 12 | 13 | class Decorator(Chain): 14 | """Decorator Class. 15 | 16 | This class is responsible for allowing external functions to use our Chain as a 17 | decorator on their functions. 18 | 19 | """ 20 | 21 | @autowired 22 | def __init__( 23 | self, 24 | function: Callable, 25 | initial_state: Autowired(State, namespace="python-chain"), 26 | ): 27 | super().__init__(function, initial_state=initial_state) 28 | update_wrapper(self, function) 29 | -------------------------------------------------------------------------------- /chain/core/domains/context/__init__.py: -------------------------------------------------------------------------------- 1 | from chain.core.domains.context.context import Context 2 | -------------------------------------------------------------------------------- /chain/core/domains/context/context.py: -------------------------------------------------------------------------------- 1 | """Context Package. 2 | 3 | The context is the result of a given operation between two chains. It has a bunch of 4 | methods that can be used to extract data from the chain result. 5 | 6 | """ 7 | from typing import ClassVar 8 | from copy import deepcopy 9 | 10 | 11 | class Context: 12 | """Context Class. 13 | 14 | This class is responsible for storing all the data from a given chain output and 15 | allowing to perform specific transformations with it. 16 | 17 | """ 18 | 19 | def __init__(self, state: ClassVar, origin: ClassVar) -> ClassVar: 20 | self.origin = origin 21 | self.current = deepcopy(state) 22 | self.__initial_state = deepcopy(state) 23 | 24 | self.merge_context(origin.initial_state) 25 | self.__autorun() 26 | 27 | def __rshift__(self, next) -> any: 28 | return next.execute(context=self) 29 | 30 | def __autorun(self) -> None: 31 | self.output = self.origin.function(context=self.current) 32 | 33 | def merge_context(self, state) -> None: 34 | """Merge Context with State. 35 | 36 | This method will merge the current context with a given state. It can be used 37 | before executing a chain. 38 | 39 | """ 40 | self.current.__dict__.update(state.__dict__) 41 | -------------------------------------------------------------------------------- /chain/core/domains/state/__init__.py: -------------------------------------------------------------------------------- 1 | from chain.core.domains.state.state import State 2 | -------------------------------------------------------------------------------- /chain/core/domains/state/state.py: -------------------------------------------------------------------------------- 1 | """State Package. 2 | 3 | The state creates a Chain state that will store all the current chain state and also 4 | create some useful methods to be used during a chain. 5 | 6 | """ 7 | from typing import ClassVar 8 | 9 | from injectable import injectable 10 | 11 | from chain.core.domains.context import Context 12 | 13 | 14 | @injectable(namespace="python-chain") 15 | class State: 16 | """State Class. 17 | 18 | This class is responsible for storing all the data from a given chain and allowing 19 | to insert new data and run specific methods. 20 | 21 | """ 22 | 23 | def __init__(self, **initial_state: dict) -> ClassVar: 24 | self.__dict__.update(initial_state) 25 | 26 | def __rshift__(self, next) -> any: 27 | return Context(state=self, origin=next) 28 | 29 | def clear(self) -> None: 30 | """Clear the Current State. 31 | 32 | This method will remove everything from the current state. 33 | 34 | """ 35 | self.__dict__.clear() 36 | 37 | def get_state(self) -> any: 38 | """Get the Current State. 39 | 40 | Get all the data from the current state. 41 | 42 | """ 43 | return self.__dict__ 44 | -------------------------------------------------------------------------------- /chain/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quintoandar/python-chain/f426d4c814f92090e21dc470576e7033a8488e9c/chain/tests/__init__.py -------------------------------------------------------------------------------- /chain/tests/acceptance/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quintoandar/python-chain/f426d4c814f92090e21dc470576e7033a8488e9c/chain/tests/acceptance/__init__.py -------------------------------------------------------------------------------- /chain/tests/acceptance/chain.feature: -------------------------------------------------------------------------------- 1 | Feature: Chain 2 | 3 | Scenario: Common Usage 4 | Given a new empty state 5 | And a decorated chain function without output 6 | And a decorated chain function with output 7 | When I run the chain 8 | Then the first result must have the desired output 9 | 10 | Scenario: Run a chain 11 | Given a new empty state 12 | And a dummy chain function 13 | And a random number of static chains 14 | When I run the chain 15 | Then the first result context must be my initial state 16 | 17 | Scenario: Direct function call 18 | Given a decorated chain function with output 19 | When I run the first function on the chain directly 20 | Then the direct call result must match the output 21 | 22 | Scenario: Get the output from a chain 23 | Given a new empty state 24 | And a dummy chain function 25 | And a random number of static chains 26 | And a new chain with mocked function 27 | And add a return value to the mocked function 28 | When I run the chain 29 | Then the first result must have the desired output 30 | 31 | Scenario: Run an odd chain 32 | Given a new empty state 33 | And an odd random number of static chains 34 | When I run the chain 35 | Then the first result context must be my initial state 36 | 37 | Scenario: Run a single function chain 38 | Given a new empty state 39 | And a single static chain 40 | When I run the chain 41 | Then the first result context must be my initial state 42 | 43 | Scenario: Passing args to next function 44 | Given a new empty state 45 | And a new chain with mocked function 46 | And add an arg return value to the mocked function 47 | And a new chain with mocked function 48 | When I run the chain 49 | Then the mocked function should have been called with correct data 50 | 51 | Scenario: Reversed chain 52 | Given a new empty state 53 | And a dummy chain function 54 | And a random number of static chains 55 | And a new chain with mocked function 56 | And a new chain returning random autoincremented data 57 | When I run the chain 58 | And I reverse the chain 59 | And I reset the state 60 | And I add a counter on the current state 61 | And I run the chain 62 | Then the context should not persist data 63 | -------------------------------------------------------------------------------- /chain/tests/acceptance/decorator.feature: -------------------------------------------------------------------------------- 1 | Feature: Decorator 2 | 3 | Scenario: Create a new decorated function 4 | Given a new empty state 5 | And a dummy chain function 6 | And a mocked decorated chain function 7 | When I run the chain 8 | Then the mocked should have been called 9 | -------------------------------------------------------------------------------- /chain/tests/acceptance/environment.py: -------------------------------------------------------------------------------- 1 | """Behave Environment Configuration. 2 | 3 | This module configures the Behave environment, setting some fixtures and also setting up 4 | everything that must run on the test lifecycle. 5 | 6 | """ 7 | from behave import fixture, use_fixture 8 | from behave.__main__ import main 9 | from faker import Faker 10 | from sys import argv 11 | 12 | 13 | @fixture 14 | def fake_data_provider(context: dict, *args, **kwargs) -> None: 15 | """Create a new fake data provider. 16 | 17 | This fixture will create a new fake data provider to be used during the test steps 18 | and attach it on the context. 19 | 20 | """ 21 | fake = Faker() 22 | fake.add_provider("python") 23 | fake.add_provider("address") 24 | 25 | context.fake = fake 26 | 27 | 28 | def before_all(context: dict) -> None: 29 | """Execute actions before all features. 30 | 31 | This lifecycle method will execute a series of actions and functions before any 32 | feature runs. 33 | 34 | """ 35 | use_fixture(fake_data_provider, context) 36 | 37 | 38 | if __name__ == "__main__": 39 | main([arg for arg in argv[1:]]) 40 | -------------------------------------------------------------------------------- /chain/tests/acceptance/steps/step_chain.py: -------------------------------------------------------------------------------- 1 | """Decorator Behave Steps. 2 | 3 | This script contains all the steps to behavior tests of the decorator feature. 4 | 5 | """ 6 | import chain 7 | 8 | from behave import given, when, then 9 | from itertools import count 10 | from unittest.mock import MagicMock 11 | from chain.core.domains.state import State 12 | 13 | 14 | @given("a random number of static chains") 15 | def step_create_random_static_chains(context: dict) -> None: 16 | """Create a Random Number of Static Chains. 17 | 18 | This step will generate a random number of static chains. 19 | 20 | """ 21 | nb_chains = context.fake.pyint() 22 | context.chain = [chain(context.dummy_function) for _ in range(nb_chains)] 23 | 24 | 25 | @given("an odd random number of static chains") 26 | def step_create_odd_random_static_chains(context: dict) -> None: 27 | """Create an Odd Random Number of Static Chains. 28 | 29 | This step will generate an odd random number of static chains. 30 | 31 | """ 32 | 33 | def dummy(context: State) -> None: 34 | pass 35 | 36 | nb_chains = context.fake.pyint(min=1, step=2) 37 | context.chain = [chain(dummy) for _ in range(nb_chains)] 38 | 39 | 40 | @given("a single static chain") 41 | def step_create_single_random_static_chain(context: dict) -> None: 42 | """Create a Random Number of Static Chains. 43 | 44 | This step will generate a random number of static chain. 45 | 46 | """ 47 | 48 | def dummy(context: State) -> None: 49 | pass 50 | 51 | context.chain = [chain(dummy)] 52 | 53 | 54 | @given("a new chain with mocked function") 55 | def step_create_mocked_chain(context: dict) -> None: 56 | """Create a Chain with Mocked Function. 57 | 58 | This step will generate a new chain with mocked function and append it on the end 59 | of the created chain. 60 | 61 | """ 62 | if "chain" not in context: 63 | context.chain = list() 64 | 65 | context.mocked_function = MagicMock(return_value=None) 66 | context.chain.append(chain(context.mocked_function)) 67 | 68 | 69 | @given("add a return value to the mocked function") 70 | def step_add_return_value(context: dict) -> None: 71 | """Add a Return Value to the Mocked Function. 72 | 73 | This step will generate a new return value to the mocked function on the chain. 74 | 75 | """ 76 | context.expected_output = context.fake.pydict() 77 | context.mocked_function.return_value = context.expected_output 78 | 79 | 80 | @given("add an arg return value to the mocked function") 81 | def step_add_return_value_as_args(context: dict) -> None: 82 | """Add a Return Value to the Mocked Function as Args. 83 | 84 | This step will generate a new return value as args to be passed to the next function 85 | on the chain. 86 | 87 | """ 88 | context.expected_args = context.fake.pytuple() 89 | context.expected_kwargs = context.fake.pydict() 90 | 91 | context.mocked_function.return_value = ( 92 | context.expected_args, 93 | context.expected_kwargs, 94 | ) 95 | 96 | 97 | @given("a new chain returning random autoincremented data") 98 | def step_create_autoincrementing_chain(context: dict) -> None: 99 | """Create a Autoincrementing Chain. 100 | 101 | This step will generate a new chain with a function that will always return an 102 | autoincremented data. 103 | 104 | """ 105 | if "chain" not in context: 106 | context.chain = list() 107 | 108 | context.initial_state.count = count() 109 | 110 | def autoincrement(context: State) -> tuple: 111 | counted = next(context.count) 112 | 113 | return (counted,), dict() 114 | 115 | context.chain.append(chain(autoincrement)) 116 | 117 | 118 | @given("a decorated chain function with output") 119 | def step_create_decorated_function_with_output(context: dict) -> None: 120 | """Create a New Decorated Chain Function With Output. 121 | 122 | This step will generate a new decorated chain function. 123 | 124 | """ 125 | expected_output = context.fake.pydict() 126 | 127 | @chain 128 | def dummy(context: State, expected_output=expected_output) -> None: 129 | return expected_output 130 | 131 | if "chain" not in context: 132 | context.chain = list() 133 | 134 | context.expected_output = expected_output 135 | context.chain.append(dummy) 136 | 137 | 138 | @given("a decorated chain function without output") 139 | def step_create_decorated_function_without_output(context: dict) -> None: 140 | """Create a New Decorated Chain Function Without Output. 141 | 142 | This step will generate a new decorated chain function without adding an output. 143 | 144 | """ 145 | expected_output = context.fake.pydict() 146 | 147 | @chain 148 | def bar(context: State) -> None: 149 | context.bar = "bar" 150 | 151 | if "chain" not in context: 152 | context.chain = list() 153 | 154 | context.expected_output = expected_output 155 | context.chain.append(bar) 156 | 157 | 158 | @when("I reverse the chain") 159 | def step_revese_chain(context: dict) -> None: 160 | """Reverse the Generated Chain. 161 | 162 | This step will reverse the current chain. 163 | 164 | """ 165 | context.chain = context.chain[::-1] 166 | 167 | 168 | @when("I add a counter on the current state") 169 | def step_add_counter_to_state(context: dict) -> None: 170 | """Add Counter on Current State. 171 | 172 | This step will add a counter on the current initial state. 173 | 174 | """ 175 | context.initial_state.count = count() 176 | 177 | 178 | @then("the mocked function should have been called with correct data") 179 | def step_check_args_chain(context: dict) -> None: 180 | """Check if We Are Passing Args. 181 | 182 | This step will check if, during a chain, we are passing args between the chained 183 | functions. 184 | 185 | """ 186 | calls = context.mocked_function.call_args_list 187 | last_call = calls[-1] 188 | 189 | args = last_call[0] 190 | kwargs = last_call[1] 191 | 192 | context.expected_kwargs.update({"context": kwargs["context"]}) 193 | 194 | assert args == context.expected_args 195 | assert kwargs == context.expected_kwargs 196 | assert kwargs["context"].get_state() == context.initial_state.get_state() 197 | 198 | 199 | @then("the context should not persist data") 200 | def step_check_reversed_chain(context: dict) -> None: 201 | """Check the Result of the Reversed Chain. 202 | 203 | This step will check the result of the reversed chain to see if it has runned 204 | ignoring the previous state. 205 | 206 | """ 207 | calls = context.mocked_function.call_args_list 208 | last_call = calls[-1] 209 | 210 | args = last_call[0] 211 | kwargs = last_call[1] 212 | 213 | assert args[0] == 0 214 | -------------------------------------------------------------------------------- /chain/tests/acceptance/steps/step_common.py: -------------------------------------------------------------------------------- 1 | """Common Behave Steps. 2 | 3 | This script contains all the common steps to behavior chain.tests. Those steps are 4 | being used by multiple features. 5 | 6 | """ 7 | import chain 8 | 9 | from behave import given, when, then 10 | from functools import reduce 11 | from chain.core.domains.state import State 12 | 13 | 14 | @given("a dummy chain function") 15 | def step_create_dummy_chain_function(context: dict) -> None: 16 | """Create a Dummy Chain Function. 17 | 18 | This step will create a new dummy function, compatible with chaining, and attach 19 | it to the context. 20 | 21 | """ 22 | 23 | def dummy(context: State) -> None: 24 | pass 25 | 26 | context.dummy_function = dummy 27 | 28 | 29 | @given("a new empty state") 30 | def step_create_empty_state(context: dict) -> None: 31 | """Create an Empty State. 32 | 33 | This step will generate a new empty state to be used inside a given chain. 34 | 35 | """ 36 | context.initial_state = chain.state() 37 | 38 | 39 | @given("a new dirty state") 40 | def step_create_dirty_state(context: dict) -> None: 41 | """Create an Dirty State. 42 | 43 | This step will generate a new state with contents to be used inside a 44 | given chain. 45 | 46 | """ 47 | context.initial_state = chain.state(**context.fake.pydict()) 48 | 49 | 50 | @when("I run the chain") 51 | def step_run_chain(context: dict) -> None: 52 | """Run the Generated Chain. 53 | 54 | This step will run the generated chain with the desired initial state. 55 | 56 | """ 57 | to_execute = [context.initial_state] + context.chain 58 | 59 | if "results" not in context: 60 | context.results = list() 61 | 62 | result = reduce(lambda a, b: a >> b, to_execute) 63 | context.results.append(result) 64 | 65 | 66 | @when("I run the first function on the chain directly") 67 | def step_run_first_chain_directly(context: dict) -> None: 68 | """Run the First Function in the Chain. 69 | 70 | This step will run the first function in the chain directly. 71 | 72 | """ 73 | result = context.chain[0]() 74 | 75 | if "results" not in context: 76 | context.results = list() 77 | 78 | context.results.append(result) 79 | 80 | 81 | @when("I reset the state") 82 | def step_reset_state(context: dict) -> None: 83 | """Reset the Current State. 84 | 85 | This step will reset the current state. 86 | 87 | """ 88 | context.initial_state.clear() 89 | 90 | 91 | @then("the first result context must be my initial state") 92 | def step_check_dummy_result(context: dict) -> None: 93 | """Check if Result is Same as Initial State. 94 | 95 | This step will check if the result is the same as the initial state. 96 | 97 | """ 98 | assert context.results[0].current.get_state() == context.initial_state.get_state() 99 | 100 | 101 | @then("the first result must have the desired output") 102 | def step_check_output(context: dict) -> None: 103 | """Check if We Can Get the Chain Output. 104 | 105 | This step will check if the first result are with the desired output. 106 | 107 | """ 108 | assert context.results[0].output == context.expected_output 109 | 110 | 111 | @then("the direct call result must match the output") 112 | def step_check_output_of_direct_call(context: dict) -> None: 113 | """Check if We Can Get the Function Output Directly. 114 | 115 | This step will check if, when running the function directly, we can get the 116 | desired output. 117 | 118 | """ 119 | assert context.results[0] == context.expected_output 120 | -------------------------------------------------------------------------------- /chain/tests/acceptance/steps/step_decorator.py: -------------------------------------------------------------------------------- 1 | """Decorator Behave Steps. 2 | 3 | This script contains all the steps to behavior tests of the decorator feature. 4 | 5 | """ 6 | import chain 7 | 8 | from behave import given, then 9 | from unittest.mock import MagicMock 10 | 11 | 12 | @given("a mocked decorated chain function") 13 | def step_create_mocked_decorated_function(context: dict) -> None: 14 | """Declare a New Decorated Function. 15 | 16 | This step will declare a new decorated chain function and assign it to 17 | the current test context. 18 | 19 | """ 20 | nb_functions = context.fake.pyint() 21 | dirt = [chain(context.dummy_function) for _ in range(nb_functions)] 22 | 23 | context.mock = MagicMock() 24 | context.chain = [chain(context.mock)] + dirt 25 | 26 | 27 | @then("the mocked should have been called") 28 | def step_check_if_mock_was_called(context: dict) -> None: 29 | """Check if the mock was called. 30 | 31 | This step will check if the mock from our current test context was called. 32 | 33 | """ 34 | context.mock.assert_called_once() 35 | -------------------------------------------------------------------------------- /chain/tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Chain Fixtures. 2 | 3 | This script will handle the creation of all Pytest fixtures of Chain's 4 | application. 5 | 6 | """ 7 | from pytest import fixture 8 | from faker import Faker 9 | 10 | 11 | @fixture(scope="session") 12 | def fake(): 13 | """Build a new fake data provider. 14 | 15 | This fixture will build a new fake data provider using Faker lib. 16 | 17 | """ 18 | fake = Faker() 19 | 20 | fake.add_provider("python") 21 | 22 | return fake 23 | -------------------------------------------------------------------------------- /chain/tests/constants.py: -------------------------------------------------------------------------------- 1 | ENTRYPOINT = "chain" 2 | CHAIN = "chain.core.domains.chain.chain" 3 | DECORATOR = "chain.core.domains.chain.decorator" 4 | STATE = "chain.core.domains.state.state" 5 | 6 | DEPEDENCIES_HASH_TABLE = { 7 | ENTRYPOINT: ("State", "Decorator"), 8 | CHAIN: ("State", "Context"), 9 | DECORATOR: ("update_wrapper", "Chain"), 10 | STATE: ("Context",), 11 | } 12 | -------------------------------------------------------------------------------- /chain/tests/core/domains/chain/test_chain.py: -------------------------------------------------------------------------------- 1 | """Chain Unit Tests. 2 | 3 | The chain test file ensures that we are being able to create a new chain that can 4 | execute the basic desired methods. 5 | 6 | """ 7 | from pytest import main 8 | from sys import argv 9 | from faker import Faker 10 | from itertools import count 11 | from unittest.mock import patch, MagicMock, PropertyMock 12 | from chain.tests.helpers import DependencyMocker 13 | from chain.core.domains.chain.chain import Chain 14 | 15 | file_path = "chain.core.domains.chain.chain" 16 | dependencies = DependencyMocker(file_path) 17 | 18 | 19 | @patch.multiple(file_path, **dependencies.to_dict()) 20 | def test_split_output(fake: Faker, **kwargs) -> None: 21 | """Test the Split Result Method. 22 | 23 | Ensures that our chain can split the previous result from the current context if 24 | it is a tuple. If it is not, it should return an default arg tuple. 25 | 26 | """ 27 | # Given 28 | chain = Chain(lambda: None) 29 | 30 | args = fake.pytuple() 31 | kwargs = fake.pydict() 32 | 33 | # When 34 | with_args = chain._Chain__split_output((args, kwargs)) 35 | without_args = chain._Chain__split_output(fake.pydict()) 36 | 37 | # Then 38 | assert with_args == (args, kwargs) 39 | assert without_args == (tuple(), dict()) 40 | 41 | 42 | @patch.multiple(file_path, **dependencies.to_dict()) 43 | def test_execute(fake: Faker, Context: MagicMock = None, **kwargs) -> None: 44 | """Test the Execution of a Chain. 45 | 46 | Ensures that we can execute a chain given a specific context. 47 | 48 | """ 49 | # Given 50 | context = Context() 51 | prev_args, prev_kwargs = (tuple(), dict()) 52 | 53 | function = MagicMock(return_value=None) 54 | context_merge = MagicMock() 55 | chain_split_output = MagicMock(return_value=(prev_args, prev_kwargs)) 56 | chain = Chain(function) 57 | 58 | prop_context_merge = PropertyMock(return_value=context_merge) 59 | prop_context_output = PropertyMock(return_value=fake.word()) 60 | 61 | chain.initial_state = fake.word() 62 | chain._Chain__split_output = chain_split_output 63 | 64 | type(context).output = prop_context_output 65 | type(context).merge_context = prop_context_merge 66 | 67 | # When 68 | result = chain.execute(context=context) 69 | 70 | # Then 71 | chain_split_output.assert_called_once_with(context.output) 72 | context_merge.assert_called_once_with(chain.initial_state) 73 | function.assert_called_once_with(*prev_args, **prev_kwargs, context=context.current) 74 | 75 | assert result == context 76 | 77 | 78 | @patch.multiple(file_path, **dependencies.to_dict()) 79 | def test_call(fake: Faker, **kwargs) -> None: 80 | """Test the Direct Call of a Chain. 81 | 82 | Ensures that we can execute a chain function directly. 83 | 84 | """ 85 | # Given 86 | expected = fake.pydict() 87 | initial_state = fake.pydict() 88 | kwargs = fake.pydict() 89 | args = fake.pytuple() 90 | function = MagicMock(return_value=expected) 91 | 92 | chain = Chain(function) 93 | 94 | chain.initial_state = initial_state 95 | 96 | # When 97 | result = chain(*args, **kwargs) 98 | 99 | # Then 100 | function.assert_called_once_with(context=initial_state, *args, **kwargs) 101 | 102 | assert result == expected 103 | 104 | 105 | if __name__ == "__main__": 106 | args = [__file__] + [arg for arg in argv[1:]] 107 | main(args) 108 | -------------------------------------------------------------------------------- /chain/tests/core/domains/chain/test_decorator.py: -------------------------------------------------------------------------------- 1 | """Chain Decorator Unit Tests. 2 | 3 | The decorator test file ensures that we are being able to create a new decorator on an 4 | existing function. 5 | 6 | """ 7 | from pytest import main 8 | from sys import argv 9 | from unittest.mock import patch, MagicMock 10 | from chain.tests.helpers import DependencyMocker 11 | from chain.core.domains.chain import decorator 12 | from chain.core.domains.chain import Chain 13 | 14 | file_path = "chain.core.domains.chain.decorator" 15 | dependencies = DependencyMocker(file_path) 16 | 17 | 18 | @patch.multiple(file_path, **dependencies.to_dict()) 19 | def test_add_new_function(**kwargs) -> None: 20 | """Test the Creation of a New Chain. 21 | 22 | Ensures that we can create a new chain using the declared decorator. 23 | 24 | """ 25 | # Given 26 | func = MagicMock() 27 | decorated_class = decorator.Decorator 28 | 29 | # When 30 | result = decorated_class(func) 31 | 32 | # Then 33 | assert result.function == func 34 | 35 | 36 | @patch.multiple(file_path, **dependencies.to_dict()) 37 | def test_update_wrapper(update_wrapper: MagicMock = None, **kwargs) -> None: 38 | """Test the Call of Update Wrapper. 39 | 40 | Ensures that we are calling the update wrapper function on init. 41 | 42 | """ 43 | # Given 44 | func = MagicMock() 45 | decorated_class = decorator.Decorator 46 | 47 | # When 48 | result = decorated_class(func) 49 | 50 | # Then 51 | update_wrapper.assert_called_once_with(result, func) 52 | 53 | 54 | @patch.multiple(file_path, **dependencies.to_dict(ignore=("Chain",))) 55 | def test_super(**kwargs) -> None: 56 | """Test the Super of Decorator. 57 | 58 | Ensures that the generated class has a super Chain class. 59 | 60 | """ 61 | # When 62 | decorated_class = decorator.Decorator 63 | 64 | # Then 65 | assert issubclass(decorated_class, Chain) 66 | 67 | 68 | if __name__ == "__main__": 69 | args = [__file__] + [arg for arg in argv[1:]] 70 | main(args) 71 | -------------------------------------------------------------------------------- /chain/tests/core/domains/context/test_context.py: -------------------------------------------------------------------------------- 1 | """Context Unit Tests. 2 | 3 | The state test file ensures that we are being able to create a new context that we can 4 | use inside a chain. 5 | 6 | """ 7 | from pytest import main 8 | from sys import argv 9 | from faker import Faker 10 | from unittest.mock import MagicMock 11 | from chain.tests.helpers import FakeChain, FakeState 12 | from chain.core.domains.context.context import Context 13 | 14 | 15 | def test_initialize(fake: Faker, **kwargs) -> None: 16 | """Test the Initialization of a Context. 17 | 18 | Ensures that we can initialize a context, storing all desired args and running the 19 | startup methods. 20 | 21 | """ 22 | # Given 23 | class MutatedContext(Context): 24 | def __init__(self, *args, **kwargs): 25 | super().__init__(*args, **kwargs) 26 | 27 | fake_state = fake.pydict() 28 | mocked_merge_context = MagicMock() 29 | mocked_autorun = MagicMock() 30 | 31 | fake_origin = FakeChain() 32 | fake_origin.initial_state = fake.word() 33 | 34 | MutatedContext.merge_context = mocked_merge_context 35 | MutatedContext._Context__autorun = mocked_autorun 36 | 37 | # When 38 | context = MutatedContext(fake_state, fake_origin) 39 | 40 | # Then 41 | mocked_merge_context.assert_called_once_with(fake_origin.initial_state) 42 | mocked_autorun.assert_called_once() 43 | 44 | assert context.origin.initial_state == fake_origin.initial_state 45 | assert context.current == fake_state 46 | assert context._Context__initial_state == fake_state 47 | 48 | 49 | def test_gt(fake: Faker, **kwargs) -> None: 50 | """Test the Override of the Greater Than Method. 51 | 52 | Ensures that we are overriding the greater than builtin function. 53 | 54 | """ 55 | # Given 56 | fake_state = MagicMock() 57 | fake_origin = MagicMock() 58 | mocked_execute = MagicMock() 59 | 60 | fake_next = FakeChain() 61 | fake_next.execute = mocked_execute 62 | 63 | expected = fake.word() 64 | mocked_execute.return_value = expected 65 | 66 | context = Context(fake_state, fake_origin) 67 | 68 | # When 69 | result = context >> fake_next 70 | 71 | # Then 72 | mocked_execute.assert_called_once_with(context=context) 73 | 74 | assert result == expected 75 | 76 | 77 | def test_autorun(fake: Faker, **kwargs) -> None: 78 | """Test the Autorun. 79 | 80 | Ensures that we can trigger the autorun function. 81 | 82 | """ 83 | # Given 84 | context = Context(MagicMock(), MagicMock()) 85 | fake_chain = FakeChain() 86 | mocked_function = MagicMock() 87 | 88 | fake_current_context = fake.pydict() 89 | context.origin = fake_chain 90 | context.current = fake_current_context 91 | fake_chain.function = mocked_function 92 | 93 | expected = fake.word() 94 | mocked_function.return_value = expected 95 | 96 | # When 97 | context._Context__autorun() 98 | 99 | # Then 100 | mocked_function.assert_called_once_with(context=fake_current_context) 101 | 102 | assert context.output == expected 103 | 104 | 105 | def test_merge_context(fake: Faker, **kwargs) -> None: 106 | """Test the Merge Context Method. 107 | 108 | Ensures that we can merge the current context with a new given state. 109 | 110 | """ 111 | # Given 112 | context = Context(MagicMock(), MagicMock()) 113 | 114 | state = fake.pydict() 115 | current = fake.pydict() 116 | 117 | fake_state = FakeState() 118 | fake_current_context = FakeState() 119 | 120 | context.current = fake_current_context 121 | fake_state.__dict__ = state 122 | fake_current_context.__dict__ = current 123 | 124 | # When 125 | context.merge_context(fake_state) 126 | 127 | # Then 128 | assert all(s in context.current.__dict__.items() for s in state.items()) 129 | assert all(c in context.current.__dict__.items() for c in current.items()) 130 | 131 | 132 | if __name__ == "__main__": 133 | args = [__file__] + [arg for arg in argv[1:]] 134 | main(args) 135 | -------------------------------------------------------------------------------- /chain/tests/core/domains/state/test_state.py: -------------------------------------------------------------------------------- 1 | """State Unit Tests. 2 | 3 | The state test file ensures that we are being able to create a new state that we can 4 | use inside a chain. 5 | 6 | """ 7 | from pytest import main 8 | from sys import argv 9 | from faker import Faker 10 | from unittest.mock import patch, MagicMock 11 | from chain.tests.helpers import DependencyMocker 12 | from chain.core.domains.state.state import State 13 | 14 | file_path = "chain.core.domains.state.state" 15 | dependencies = DependencyMocker(file_path) 16 | 17 | 18 | @patch.multiple(file_path, **dependencies.to_dict()) 19 | def test_store_data(fake: Faker, **kwargs) -> None: 20 | """Test the Storage of Data on State. 21 | 22 | Ensures that we can store any data on a given state. 23 | 24 | """ 25 | # Given 26 | fake_string = fake.word() 27 | fake_tuple = fake.pytuple() 28 | fake_dict = fake.pydict() 29 | 30 | state = State() 31 | 32 | # When 33 | state.string = fake_string 34 | state.tuple = fake_tuple 35 | state.dict = fake_dict 36 | 37 | # Then 38 | assert state.string == fake_string 39 | assert state.tuple == fake_tuple 40 | assert state.dict == fake_dict 41 | 42 | 43 | @patch.multiple(file_path, **dependencies.to_dict()) 44 | def test_multiple_stores(fake: Faker, **kwargs) -> None: 45 | """Test the Creation of Multiple Stores. 46 | 47 | Ensures that we can create multiple stores and those would not impact on each other. 48 | 49 | """ 50 | # Given 51 | fake_string_one = fake.word() 52 | fake_string_two = fake.word() 53 | 54 | state_one = State() 55 | state_two = State() 56 | 57 | # When 58 | state_one.string = fake_string_one 59 | state_two.string = fake_string_two 60 | 61 | # Then 62 | assert state_one.string == fake_string_one 63 | assert state_two.string == fake_string_two 64 | assert state_one.string != state_two.string 65 | 66 | 67 | @patch.multiple(file_path, **dependencies.to_dict()) 68 | def test_initial_state(fake: Faker, **kwargs) -> None: 69 | """Test the Creation of a Initial State. 70 | 71 | Ensures that we can set a initial state on the creation of the chain. 72 | 73 | """ 74 | # Given 75 | fake_string = fake.word() 76 | fake_tuple = fake.pytuple() 77 | fake_dict = fake.pydict() 78 | 79 | initial_state = {"string": fake_string, "tuple": fake_tuple, "dict": fake_dict} 80 | 81 | # When 82 | state = State(**initial_state) 83 | 84 | # Then 85 | assert state.string == fake_string 86 | assert state.tuple == fake_tuple 87 | assert state.dict == fake_dict 88 | 89 | 90 | @patch.multiple(file_path, **dependencies.to_dict()) 91 | def test_method_clear(fake: Faker, **kwargs) -> None: 92 | """Test the Clear Method. 93 | 94 | Ensures that we can clear the current state. 95 | 96 | """ 97 | # Given 98 | fake_string = fake.word() 99 | state = State(string=fake_string) 100 | 101 | # When 102 | state.clear() 103 | 104 | # Then 105 | assert not hasattr(state, "string") 106 | 107 | 108 | @patch.multiple(file_path, **dependencies.to_dict()) 109 | def test_chain_rshift_return(fake: Faker, Context: MagicMock = None, **kwargs) -> None: 110 | """Test if State is Chainable. 111 | 112 | Ensures that we can insert the state in a chain. 113 | 114 | """ 115 | # Given 116 | next = fake.word() 117 | state = State() 118 | 119 | # When 120 | result = state >> next 121 | 122 | # Then 123 | Context.assert_called_once_with(state=state, origin=next) 124 | 125 | assert result == Context() 126 | 127 | 128 | @patch.multiple(file_path, **dependencies.to_dict()) 129 | def test_get_state(fake: Faker, **kwargs) -> None: 130 | """Test if we Can Get The State. 131 | 132 | Ensures that we can get the current state. 133 | 134 | """ 135 | # Given 136 | expected_string = fake.word() 137 | state = State(string=expected_string) 138 | 139 | # When 140 | result = state.get_state() 141 | 142 | # Then 143 | assert result == {"string": expected_string} 144 | 145 | 146 | if __name__ == "__main__": 147 | args = [__file__] + [arg for arg in argv[1:]] 148 | main(args) 149 | -------------------------------------------------------------------------------- /chain/tests/helpers.py: -------------------------------------------------------------------------------- 1 | """Test Helpers. 2 | 3 | This file contains all the helpers that are being used by multiple test files. 4 | 5 | """ 6 | from typing import ClassVar 7 | from unittest.mock import DEFAULT 8 | from chain.tests import constants 9 | 10 | 11 | class DependencyMocker: 12 | def __init__( 13 | self, 14 | file_path: str, 15 | hash_table: dict = constants.DEPEDENCIES_HASH_TABLE, 16 | mocked_values=tuple(DEFAULT for _ in constants.DEPEDENCIES_HASH_TABLE), 17 | ) -> ClassVar: 18 | dependencies = zip(hash_table.get(file_path), mocked_values) 19 | 20 | self.file_path: file_path 21 | self.dependencies = tuple(dependencies) 22 | 23 | def to_dict(self, ignore: tuple = (None,)) -> dict: 24 | """Extract dependencies to a dict. 25 | 26 | This method will extract all the class dependencies to a dict, 27 | ignoring some if the user has asked us to do so. 28 | 29 | """ 30 | to_mock = dict((k, v) for k, v in self.dependencies if k not in ignore) 31 | return to_mock 32 | 33 | 34 | class FakeChain: 35 | pass 36 | 37 | 38 | class FakeState: 39 | pass 40 | -------------------------------------------------------------------------------- /chain/tests/test_chain.py: -------------------------------------------------------------------------------- 1 | """Entrypoint Unit Tests. 2 | 3 | The chain test file ensures that we are being able to create a new chain that can 4 | execute the basic desired methods. 5 | 6 | """ 7 | import chain 8 | 9 | from pytest import main 10 | from sys import argv 11 | from faker import Faker 12 | from unittest.mock import patch, MagicMock 13 | from chain.tests.helpers import DependencyMocker 14 | 15 | file_path = "chain" 16 | dependencies = DependencyMocker(file_path) 17 | 18 | 19 | @patch.multiple(file_path, **dependencies.to_dict()) 20 | def test_call(fake: Faker, Decorator: MagicMock = None, **kwargs) -> None: 21 | """Test the Entrypoint Call. 22 | 23 | Ensures that when we call the entrypoint it will automatically redirect 24 | to the creation of a decorator, passing args and kwargs. 25 | 26 | """ 27 | # Given 28 | args = fake.word() 29 | kwargs = fake.pydict() 30 | 31 | # When 32 | created_chain = chain(*args, **kwargs) 33 | 34 | # Then 35 | Decorator.assert_called_once_with(*args, **kwargs) 36 | 37 | 38 | @patch.multiple(file_path, **dependencies.to_dict()) 39 | def test_state(fake: Faker, State: MagicMock = None, **kwargs) -> None: 40 | """Test the State Creation. 41 | 42 | Ensures that when we call the state method it is creating and returning a 43 | new chain state. 44 | 45 | """ 46 | # Given 47 | kwargs = fake.pydict() 48 | 49 | # When 50 | state = chain.state(**kwargs) 51 | 52 | # Then 53 | State.assert_called_once_with(**kwargs) 54 | 55 | 56 | if __name__ == "__main__": 57 | args = [__file__] + [arg for arg in argv[1:]] 58 | main(args) 59 | -------------------------------------------------------------------------------- /requirements/common.txt: -------------------------------------------------------------------------------- 1 | # Contains requirements common to all environments 2 | 3 | injectable==3.0.1 4 | -------------------------------------------------------------------------------- /requirements/development.txt: -------------------------------------------------------------------------------- 1 | # Extends the common requirements and specifies development-specific requirements 2 | 3 | -r common.txt 4 | 5 | pytest==4.6.3 6 | behave==1.2.6 7 | faker==1.0.7 8 | black==19.3.b0 9 | bumpversion==0.5.3 10 | twine==1.13.0 11 | wheel==0.33.4 12 | -------------------------------------------------------------------------------- /requirements/production.txt: -------------------------------------------------------------------------------- 1 | # Extends the common requirements and specifies production-specific requirements 2 | 3 | -r common.txt 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.0.4 3 | 4 | [behave] 5 | paths = chain/tests/acceptance 6 | 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from pip._internal.req import parse_requirements 2 | from setuptools import setup, find_packages 3 | 4 | raw_requirements = parse_requirements("requirements/production.txt", session=False) 5 | requirements = [str(ir.req) for ir in raw_requirements] 6 | 7 | with open("README.md", "r") as fh: 8 | long_description = fh.read() 9 | 10 | setup( 11 | name="python-chain", 12 | version="1.0.4", 13 | scripts=["bin/build_chain.py"], 14 | author="QuintoAndar", 15 | author_email="daniel.fonseca@quintoandar.com.br", 16 | description="An easy to use pattern of function chaining on Python.", 17 | long_description=long_description, 18 | long_description_content_type="text/markdown", 19 | url="https://github.com/quintoandar/python-chain/", 20 | packages=find_packages(exclude=["*.tests", "*.tests.*", "tests.*", "tests"]), 21 | install_requires=requirements, 22 | classifiers=[ 23 | "Programming Language :: Python :: 3", 24 | "License :: OSI Approved :: MIT License", 25 | "Operating System :: OS Independent", 26 | ], 27 | ) 28 | --------------------------------------------------------------------------------