├── .editorconfig ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md ├── dependabot.yml └── workflows │ ├── doc.yml │ ├── package.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── Makefile ├── README.md ├── async_btree ├── __init__.py ├── analyze.py ├── control.py ├── decorator.py ├── definition.py ├── leaf.py ├── parallele.py ├── runner.py └── utils.py ├── docs ├── changelog.md ├── code_of_conduct.md ├── contributing.md ├── index.md ├── license.md ├── reference.md └── tutorial.md ├── examples ├── tutorial_1.py └── tutorial_2_decisions.py ├── mkdocs.yml ├── pyproject.toml ├── tests ├── conftest.py ├── test_action.py ├── test_analyze.py ├── test_basics.py ├── test_control.py ├── test_decorator.py ├── test_leaf.py ├── test_map_filter.py ├── test_parallele.py ├── test_runner.py ├── test_runonce.py ├── test_usage.py └── test_utils_run.py └── uv.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # All types of files configuration 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | CHANGELOG.md merge=union 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots/logs to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | 9 | - package-ecosystem: "pip" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | groups: 14 | production-dependencies: 15 | dependency-type: "production" 16 | development-dependencies: 17 | dependency-type: "development" -------------------------------------------------------------------------------- /.github/workflows/doc.yml: -------------------------------------------------------------------------------- 1 | name: Publish documentation 2 | 3 | on: 4 | # Runs on pushes targeting the default branch 5 | push: 6 | branches: ["main"] 7 | 8 | # Allows you to run this workflow manually from the Actions tab 9 | workflow_dispatch: 10 | 11 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 12 | 13 | 14 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 15 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 16 | concurrency: 17 | group: "pages" 18 | cancel-in-progress: false 19 | 20 | permissions: 21 | contents: write 22 | pages: write 23 | id-token: write 24 | 25 | jobs: 26 | build: 27 | name: Publish 28 | runs-on: ubuntu-latest 29 | environment: pypi 30 | strategy: 31 | matrix: 32 | python-version: ['3.9'] 33 | steps: 34 | - name: Checkout code 35 | uses: actions/checkout@v4 36 | with: 37 | fetch-depth: 0 38 | - name: Configure Git Credentials 39 | run: | 40 | git config user.name github-actions[bot] 41 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 42 | - name: Install uv 43 | uses: astral-sh/setup-uv@v5 44 | with: 45 | version: "0.5.18" 46 | python-version: ${{ matrix.python-version }} 47 | - name: Install dependencies 48 | run: make install 49 | - name: Build Documentation 50 | run: uv run poe docs 51 | - name: Publish Documentation 52 | run: uv run poe docs-publish 53 | -------------------------------------------------------------------------------- /.github/workflows/package.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | # Allows you to run this workflow manually from the Actions tab 9 | workflow_dispatch: 10 | 11 | jobs: 12 | build: 13 | 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: ['3.9', '3.11', '3.12'] 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Install uv and set the python version 21 | uses: astral-sh/setup-uv@v5 22 | with: 23 | version: "0.5.18" 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: make install 27 | - name: Check Python package 28 | run: uv run poe check 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | # Sequence of patterns matched against refs/tags 4 | tags: 5 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 6 | 7 | name: Release 8 | 9 | # IMPORTANT: this permission is mandatory for Trusted Publishing and deployment to GitHub Pages 10 | permissions: 11 | contents: write 12 | pages: write 13 | id-token: write 14 | 15 | jobs: 16 | build: 17 | name: Release 18 | runs-on: ubuntu-latest 19 | environment: pypi 20 | strategy: 21 | matrix: 22 | python-version: ['3.9'] 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 0 28 | - name: Install uv and set up python ${{ matrix.python-version }} 29 | uses: astral-sh/setup-uv@v5 30 | with: 31 | version: "0.5.18" 32 | python-version: ${{ matrix.python-version }} 33 | - name: Install dependencies 34 | run: make install 35 | - name: Build and publish to pypi 36 | run: uv run poe publish 37 | - name: Create Release 38 | id: create_release 39 | uses: actions/create-release@v1 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | with: 43 | tag_name: ${{ github.ref }} 44 | release_name: Release ${{ github.ref }} 45 | draft: false 46 | prerelease: false 47 | - name: Configure Git Credentials 48 | run: | 49 | git config user.name github-actions[bot] 50 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 51 | - name: Build and publish Documentation 52 | run: | 53 | uv run poe docs 54 | uv run poe docs-publish 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Temporary files 2 | *.pyc 3 | *.egg-info 4 | __pycache__ 5 | .ipynb_checkpoints 6 | Icon* 7 | 8 | /.cache/ 9 | /.venv/ 10 | /site/ 11 | .env 12 | *.pid 13 | 14 | # Google Drive 15 | *.gdoc 16 | *.gsheet 17 | *.gslides 18 | *.gdraw 19 | 20 | # Testing and coverage results 21 | /.coverage 22 | /.coverage.* 23 | /htmlcov/ 24 | 25 | # Build and release directories 26 | /build/ 27 | /dist/ 28 | *.spec 29 | .coverage 30 | coverage.xml 31 | 32 | # Sublime Text 33 | *.sublime-workspace 34 | 35 | # IDE 36 | .settings 37 | .idea/ 38 | .vscode 39 | .history 40 | 41 | # OSX 42 | .DS_Store 43 | .poetry 44 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 1.4.1 (2025-01-21) 4 | 5 | - change poetry to uv as dependencies manager 6 | - support python 3.12 7 | 8 | ## 1.4.0 (2025-01-06) 9 | 10 | - remove support of python 3.8 11 | - update poetry usage with poet plugin 12 | - update pyproject.toml declaration 13 | - update dependencies management 14 | - fix few type error 15 | - extends test to 3.9, 3.11 16 | - remove usage of black and isort for rust 17 | - use pyright as sucessor or mypy 18 | 19 | ## 1.3.0 (2023-10-07) 20 | 21 | - add default parallel asyncio implementation if curio is not present. 22 | - add run_once decorator 23 | - bunp pytest dependency 24 | - Deprecation notice on `async_btree.utils.run` 25 | - Add BTreeRunner context manager to drive multiple independant btree execution. 26 | This implementation works with curio and asyncio. 27 | `asyncio` support only for python >= 3.11. 28 | - remove `__version__` package attribute. Single source of true is pyproject.toml and git. 29 | 30 | ## 1.2.0 (2023-05-11) 31 | 32 | Features, from [#24](https://github.com/geronimo-iia/async-btree/issues/24) : 33 | 34 | - Removing inner exception handling, in order to code like usual, catch what we want and manage exception as needed 35 | - add function failure_on_exception : avoid raising and manage it in btree with a false meaning 36 | - add function ignore_exception : ignore specific exception 37 | 38 | Fix: 39 | - mypy cast issue on decorated function. 40 | - name attribute on operator 41 | - add test about metadata node name and properties 42 | - function name access compliant with mypi 43 | 44 | Technical Update: 45 | 46 | - use local .venv directory for virtual env -> better integration with visual studio 47 | - update development dependencies 48 | - use ruff as replacement of flake8, flakehell,... 49 | - use mkdocs as replacement of sphinx 50 | - simplify Makefile 51 | - change 'master' branch for 'main' 52 | 53 | ## 1.1.1 (2020-11-21) 54 | 55 | - simplify `analyze` function 56 | - fix parallele implementation 57 | 58 | ## 1.1.0 (2020-11-20) 59 | 60 | - remove falsy evaluation of exception 61 | - add ignore_exception decorator 62 | - use sync or async function in parameters operator 63 | - decision control return Success per default rather than act as a failure if no failure tree dependency is set. 64 | - add test on python 3.8 65 | 66 | ## 1.0.2 (2020-11-15) 67 | 68 | - update curio version > 1 69 | - add pytest-curio and rewrote test unit 70 | 71 | ## 1.0.1 (2020-01-31) 72 | 73 | - update from template-python 74 | - use poetry 1.0.x 75 | 76 | ## 1.0.0 (2019-09-01) 77 | 78 | - rework documentation build process (see mkdocs folder) 79 | - configure github page under master/docs 80 | - configure documentation site on pypi 81 | - add doc style on all function 82 | - standardize parameter name 83 | - fix dev documentation dependency 84 | 85 | ## 0.1.2 (2019-07-05) 86 | 87 | - Stable version flag 88 | - Remove alpha note 89 | 90 | ## 0.1.1 (2019-07-05) 91 | 92 | Removed version due to configuration error. 93 | 94 | ## 0.1.0 (2019-07-05) 95 | 96 | - Added Project Management: 97 | - initial project structure based on [jacebrowning/template-python](https://github.com/jacebrowning/template-python) 98 | - initial project configuration 99 | - follow [Semantic Versioning](https://semver.org/) 100 | - configure [travis-ci](https://travis-ci.org) 101 | - publish alpha version (not functional) on [pypi](https://pypi.org) 102 | - configure [coverage](https://coveralls.io) 103 | - configure [scrutinizer](https://scrutinizer-ci.com/) 104 | - remove pylint.ini to a simple .pylintrc (add ide support) 105 | - disable pylint bad-continuation (bug with pep8 formater) 106 | - declare extra dependency 107 | - configure black and isort 108 | - refactorise makefile poetry run 109 | - introduce flake8 as linter 110 | - Documentation: 111 | - replace mkdocs with [pydoc-markdown](https://github.com/NiklasRosenstein/pydoc-markdown) 112 | - Code: 113 | - define 'definition' module to declare all common definiton of btree 114 | - define 'utils' module to declare few async function like afilter, amap 115 | - fix flake8 syntax error 116 | - fix mypy typing error 117 | - add basic test unit 118 | - fix typing declaration 119 | - complete code coverage 120 | 121 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at jguibert@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This project is based on [Geronimo-iaa's Python Module Template](https://github.com/geronimo-iia/python-module-template). 4 | This is a cookiecutter template for a typical Python library following modern packaging conventions. 5 | It utilizes popular libraries to fully automate all development and deployment tasks. 6 | 7 | 8 | ## Setup 9 | 10 | ### Requirements 11 | 12 | You will need: 13 | 14 | * Python 3.9 15 | * [Pyenv](https://github.com/pyenv/pyenv#installation) 16 | * [uv](https://github.com/astral-sh/uv) 17 | * Make 18 | 19 | 20 | ### Make Installation 21 | 22 | A powerfull tool: 23 | * macOS: `$ xcode-select --install` 24 | * Linux: [https://www.gnu.org/software/make](https://www.gnu.org/software/make) 25 | * Windows: [https://mingw.org/download/installer](https://mingw.org/download/installer) 26 | 27 | ### Pyenv Installation 28 | 29 | Pyenv will manage all our python version. 30 | Follow [https://github.com/pyenv/pyenv#installation](https://github.com/pyenv/pyenv#installation) 31 | 32 | 33 | ### Python Installation 34 | 35 | `$ pyenv install 3.9` 36 | 37 | 38 | ### UV Installation: [https://docs.astral.sh/uv/getting-started/installation/](https://docs.astral.sh/uv/getting-started/installation/) 39 | 40 | UV will manage our dependencies and create our virtual environment for us. 41 | 42 | As we use [poethepoet](https://poethepoet.natn.io/), you should define an alias like `alias poe="uv run poe"`. 43 | 44 | 45 | 46 | ## Make Target list 47 | 48 | 49 | | Name | Comment | 50 | | ----------------------- | ----------------------------------------------------------------------------------------------- | 51 | | make install | Install project dependencies | 52 | | make lock | Lock project dependencies | 53 | | | | 54 | 55 | 56 | ## Poe Target list 57 | 58 | 59 | | Name | Comment | 60 | | ----------------------- | ---------------------------------------- | 61 | | poe types | Run the type checker | 62 | | poe lint | Run linting tools on the code base | 63 | | poe style | Validate black code style | 64 | | poe test | Run unit tests | 65 | | poe check | Run all checks on the code base | 66 | | poe build | Builds module | 67 | | poe publish | Publishes the package | 68 | | poe docs | Builds site documentation. | 69 | | poe docs-publish | Build and publish site documentation. | 70 | | poe clean | Delete all generated and temporary files | 71 | | poe requirements | Generate requirements.txt | 72 | | | | 73 | 74 | You could retrieve those commands with `poe`. It will output something like this : 75 | 76 | ``` 77 | Usage: 78 | poe [global options] task [task arguments] 79 | 80 | Global options: 81 | -h, --help Show this help page and exit 82 | --version Print the version and exit 83 | -v, --verbose Increase command output (repeatable) 84 | -q, --quiet Decrease command output (repeatable) 85 | -d, --dry-run Print the task contents but don't actually run it 86 | -C PATH, --directory PATH 87 | Specify where to find the pyproject.toml 88 | -e EXECUTOR, --executor EXECUTOR 89 | Override the default task executor 90 | --ansi Force enable ANSI output 91 | --no-ansi Force disable ANSI output 92 | 93 | Configured tasks: 94 | types Run the type checker 95 | lint Run linting tools on the code base 96 | style Validate black code style 97 | test Run unit tests 98 | check Run all checks on the code base 99 | build Build module 100 | publish Publish module 101 | docs Build site documentation 102 | docs-publish Publish site documentation 103 | clean Remove all generated and temporary files 104 | requirements Generate requirements.txt 105 | 106 | 107 | ``` 108 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | **The MIT License (MIT)** 4 | 5 | Copyright © 2019, Jerome Guibert 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in 15 | all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # const 2 | .DEFAULT_GOAL := install 3 | 4 | # PROJECT DEPENDENCIES ######################################################## 5 | 6 | .PHONY: install 7 | install: lock ## Install project dependencies 8 | @mkdir -p .cache 9 | uv venv 10 | uv pip install -r pyproject.toml --extra curio 11 | 12 | .PHONY: lock 13 | lock: pyproject.toml #codeartifact-index 14 | uv lock 15 | 16 | 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Async Behaviour Tree for Python 2 | 3 | 4 | [![Unix Build Status](https://img.shields.io/travis/geronimo-iia/async-btree/master.svg?label=unix)](https://travis-ci.com/geronimo-iia/async-btree) 5 | [![Coverage Status](https://img.shields.io/coveralls/geronimo-iia/async-btree/master.svg)](https://coveralls.io/r/geronimo-iia/async-btree) 6 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/fe669a02b4aa46b5b1faf619ba2bf382)](https://www.codacy.com/app/geronimo-iia/async-btree?utm_source=github.com&utm_medium=referral&utm_content=geronimo-iia/async-btree&utm_campaign=Badge_Grade) 7 | [![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/geronimo-iia/async-btree.svg)](https://scrutinizer-ci.com/g/geronimo-iia/async-btree/?branch=master) 8 | [![PyPI Version](https://img.shields.io/pypi/v/async-btree.svg)](https://pypi.org/project/async-btree) 9 | [![PyPI License](https://img.shields.io/pypi/l/async-btree.svg)](https://pypi.org/project/async-btree) 10 | 11 | Versions following [Semantic Versioning](https://semver.org/) 12 | 13 | See [documentation](https://geronimo-iia.github.io/async-btree). 14 | 15 | 16 | ## Overview 17 | 18 | 19 | ### What's a behavior tree ? 20 | 21 | > Unlike a Finite State Machine, a Behaviour Tree is a tree of hierarchical nodes that controls the flow of decision and the execution of "tasks" or, as we will call them further, "Actions". 22 | > -- [behaviortree](https://www.behaviortree.dev/bt_basics/) 23 | 24 | If your new (or not) about behavior tree, you could spend some time on this few links: 25 | 26 | - [Behavior trees for AI: How they work](https://www.gamasutra.com/blogs/ChrisSimpson/20140717/221339/Behavior_trees_for_AI_How_they_work.php) by Chris Simpson 27 | - [Introduction to BTs](https://www.behaviortree.dev/bt_basics/) 28 | 29 | Few implementation libraries: 30 | 31 | - [task_behavior_engine](https://github.com/ToyotaResearchInstitute/task_behavior_engine) A behavior tree based task engine written in Python 32 | - [pi_trees](https://github.com/pirobot/pi_trees/) a Python/ROS library for implementing Behavior Trees 33 | - [pr_behavior_tree](https://github.com/personalrobotics/pr_behavior_tree) A simple python behavior tree library based on coroutines 34 | - [btsk](https://github.com/aigamedev/btsk) Behavior Tree Starter Kit 35 | - [behave](https://github.com/fuchen/behave) A behavior tree implementation in Python 36 | 37 | 38 | ### Why another library so ? 39 | 40 | __SIMPLICITY__ 41 | 42 | When you study behavior tree implementation, reactive node, dynamic change, runtime execution, etc ... 43 | At a moment you're build more or less something that mimic an evaluator 'eval/apply' or a compilator, with a complex hierachical set of class. 44 | 45 | All complexity came with internal state management, using tree of blackboard to avoid global variable, multithreading issue, maybe few callback etc ... 46 | 47 | This break the simplicity and beauty of your initial design. 48 | 49 | What I find usefull with behavior tree: 50 | 51 | - clarity of expression 52 | - node tree representation 53 | - possibility to reuse behavior 54 | - add external measure to dynamicaly change a behavior, a first step on observable pattern... 55 | 56 | As I've used OOP for years (very long time), I will try to avoid class tree and prefer using the power of functionnal programming to obtain what I want: add metadata on a sematic construction, deal with closure, use function in parameters or in return value... 57 | 58 | And a last reason, more personal, it that i would explore python expressivity. 59 | 60 | __SO HOW ?__ 61 | 62 | In this module, I purpose you to use the concept of coroutines, and their mecanisms to manage the execution flow. 63 | By this way: 64 | 65 | - we reuse simple language idiom to manage state, parameter, etc 66 | - no design constraint on action implementation 67 | - most of language build block could be reused 68 | 69 | You could build expression like this: 70 | 71 | ```python 72 | 73 | async def a_func(): 74 | """A great function""" 75 | return "a" 76 | 77 | async def b_decorator(child_value, other=""): 78 | """A great decorator...""" 79 | return f"b{child_value}{other}" 80 | 81 | assert run(decorate(a_func, b_decorator)) == "ba" 82 | 83 | ``` 84 | This expression apply ```b_decorator``` on function ```a_func```. 85 | Note that ```decorate(a_func, b_decorator)``` is not an async function, only action, or condition are async function. 86 | 87 | 88 | Few guidelines of this implementation: 89 | 90 | - In order to mimic all NodeStatus (success, failure, running), I replace this by truthy/falsy meaning of evaluation value. 91 | A special dedicated exception decorate standard exception in order to give them a Falsy meaning (`ControlFlowException`). 92 | By default, exception are raised like happen usually until you catch them. 93 | - Blackboard pattern, act as a manager of context variable for behavior tree. 94 | With python 3, please... simply use [contextvars](https://docs.python.org/3/library/contextvars.html) ! 95 | - In order to be able to build a sematic tree, I've introduce a metadata tuple added on function implementation. 96 | 97 | The rest is just implementation details.. 98 | 99 | 100 | 101 | A little note: 102 | 103 | > You should not use this until you're ready to think about what you're doing :) 104 | 105 | 106 | ### Note about 'async' framework 107 | 108 | As we use async function as underlaying mechanism to manage the execution flow, the standard library asyncio is pretty fine. 109 | But, (always a but somewhere isn't it...), you should read this [amazing blog post](https://vorpus.org/blog/some-thoughts-on-asynchronous-api-design-in-a-post-asyncawait-world/) by Nathaniel J. Smith. 110 | And next study [curio](https://github.com/dabeaz/curio) framework in deep. 111 | 112 | As curio say: 113 | > Don't Use Curio if You're Allergic to Curio 114 | 115 | Personaly, after few time of testing and reading curio code, I'm pretty addict. 116 | 117 | If `curio` is not present, we default to `asyncio`. 118 | 119 | ## Installation 120 | 121 | Install this library directly into an activated virtual environment with pip, [Poetry](https://poetry.eustace.io/) or [uv](https://docs.astral.sh/uv/concepts/tools/): 122 | 123 | - `python -m pip install async-btree` or `uv add async-btree` or `poetry add async-btree` 124 | - with with curio extention: `python -m pip install async-btree[curio]` or '`uv add async-btree[curio]` 125 | 126 | 127 | ## Usage 128 | 129 | After installation, the package can imported: 130 | 131 | ```text 132 | $ python 133 | >>> import async_btree 134 | >>> async_btree.__version__ 135 | ``` 136 | 137 | See [API Reference documentation](https://geronimo-iia.github.io/async-btree). 138 | 139 | 140 | With this framework, you didn't find any configuration file, no Xml, no json, no yaml. 141 | 142 | The main reason (oriented and personal point of view) is that you did not need to introduce an extra level of abtraction 143 | to declare a composition of functions. I think it's true for most of main use case (except using an editor to wrote behaviour tree for example). 144 | 145 | So "If you wrote your function with python, wrote composition in python"... 146 | _(remember that you did not need XML to do SQL, just write good sql...)_ 147 | 148 | 149 | So, the goal is to: 150 | - define your business function wich implements actions or conditions, with all test case that you wish/need 151 | - compose them using those provided by this framework like ```sequence```, ```selector```, ... 152 | - use them as it is or create a well define python module to reuse them 153 | 154 | 155 | Wanna style have an abtract tree of our behaviour tree ? 156 | 157 | Functions from async-btree build an abstract tree for you. 158 | If you lookup in code, you should see an annotation "node_metadata" on internal implementation. 159 | This decorator add basic information like function name, parameters, and children relation ship. 160 | 161 | This abstract tree can be retreived and stringified with ```analyze``` and ```stringify_analyze```. 162 | Here the profile: 163 | 164 | ```python 165 | def analyze(target: CallableFunction) -> Node: # here we have our "abtract tree code" 166 | ... 167 | ``` 168 | 169 | For example: 170 | 171 | ```python 172 | 173 | # your behaviour tree, or a sub tree: 174 | my_func = alias(child=repeat_until(child=action(hello), condition=success_until_zero), name="btree_1") 175 | 176 | # retrieve meta information and build a Node tree 177 | abstract_tree_tree_1 = analyze(my_func) 178 | 179 | # output the tree: 180 | print(stringify_analyze(abstract_tree_tree_1)) 181 | ``` 182 | 183 | This should print: 184 | 185 | ```text 186 | --> btree_1: 187 | --(child)--> repeat_until: 188 | --(condition)--> success_until_zero: 189 | --(child)--> action: 190 | target: hello 191 | ``` 192 | 193 | 194 | Note about action and condition method: 195 | 196 | - you could use sync or async function 197 | - you could specify a return value with SUCCESS or FAILURE 198 | - function with no return value will be evaluated as FAILURE until you decorate them with a `always_success`or `always_failure` 199 | 200 | See this [example/tutorial_1.py](https://raw.githubusercontent.com/geronimo-iia/async-btree/master/examples/tutorial_1.py) for more information. -------------------------------------------------------------------------------- /async_btree/__init__.py: -------------------------------------------------------------------------------- 1 | """Declare async btree api.""" 2 | 3 | from .analyze import Node, analyze, stringify_analyze 4 | from .control import decision, fallback, repeat_until, selector, sequence 5 | from .decorator import ( 6 | alias, 7 | always_failure, 8 | always_success, 9 | decorate, 10 | ignore_exception, 11 | inverter, 12 | is_failure, 13 | is_success, 14 | retry, 15 | retry_until_failed, 16 | retry_until_success, 17 | ) 18 | from .definition import ( 19 | FAILURE, 20 | SUCCESS, 21 | AsyncInnerFunction, 22 | CallableFunction, 23 | ControlFlowException, 24 | NodeMetadata, 25 | node_metadata, 26 | ) 27 | from .leaf import action, condition 28 | from .parallele import parallele 29 | from .runner import BTreeRunner 30 | from .utils import afilter, amap, run 31 | 32 | __all__ = [ 33 | "Node", 34 | "analyze", 35 | "stringify_analyze", 36 | "decision", 37 | "fallback", 38 | "repeat_until", 39 | "selector", 40 | "sequence", 41 | "alias", 42 | "always_failure", 43 | "always_success", 44 | "ignore_exception", 45 | "decorate", 46 | "inverter", 47 | "is_failure", 48 | "is_success", 49 | "retry", 50 | "retry_until_failed", 51 | "retry_until_success", 52 | "FAILURE", 53 | "SUCCESS", 54 | "AsyncInnerFunction", 55 | "CallableFunction", 56 | "NodeMetadata", 57 | "node_metadata", 58 | "ControlFlowException", 59 | "action", 60 | "condition", 61 | "parallele", 62 | "afilter", 63 | "amap", 64 | "run", 65 | "BTreeRunner", 66 | ] 67 | -------------------------------------------------------------------------------- /async_btree/analyze.py: -------------------------------------------------------------------------------- 1 | """Analyze definition.""" 2 | 3 | from inspect import getclosurevars 4 | from typing import Any, NamedTuple, Optional, no_type_check 5 | 6 | from .definition import CallableFunction, get_function_name, get_node_metadata 7 | 8 | __all__ = ["analyze", "stringify_analyze", "Node"] 9 | 10 | _DEFAULT_EDGES = ["child", "children", "_child", "_children"] 11 | 12 | 13 | class Node(NamedTuple): 14 | """Node aggregate node definition implemented with NamedTuple. 15 | 16 | A Node is used to keep information on name, properties, and relations ship 17 | between a hierachical construct of functions. 18 | It's like an instance of NodeMetadata. 19 | 20 | Attributes: 21 | name (str): named operation. 22 | properties (list[tuple[str, Any]]): a list of tuple (name, value) for definition. 23 | edges (list[tuple[str, list[Any]]]): a list of tuple (name, node list) for 24 | definition. 25 | 26 | Notes: 27 | Edges attribut should be edges: ```list[tuple[str, list['Node']]]``` 28 | But it is impossible for now, see [mypy issues 731](https://github.com/python/mypy/issues/731) 29 | """ 30 | 31 | name: str 32 | properties: list[tuple[str, Any]] 33 | # edges: list[tuple[str, list['Node']]] 34 | # https://github.com/python/mypy/issues/731 35 | edges: list[tuple[str, list[Any]]] 36 | 37 | def __str__(self): 38 | return stringify_analyze(target=self) 39 | 40 | 41 | def _get_target_propertie_name(value): 42 | if value and callable(value): 43 | return ( 44 | get_node_metadata(target=value).name 45 | if hasattr(value, "__node_metadata") 46 | else get_function_name(target=value) 47 | ) 48 | return value 49 | 50 | 51 | def _analyze_target_edges(edges): 52 | if edges: 53 | # it could be a collection of node or a single node 54 | return list(map(analyze, edges if hasattr(edges, "__iter__") else [edges])) 55 | return None 56 | 57 | 58 | # pylint: disable=protected-access 59 | @no_type_check # it's a shortcut for hasattr ... 60 | def analyze(target: CallableFunction) -> Node: 61 | """Analyze specified target and return a Node representation. 62 | 63 | Args: 64 | target (CallableFunction): async function to analyze. 65 | 66 | Returns: 67 | (Node): a node instance representation of target function 68 | """ 69 | 70 | nonlocals = getclosurevars(target).nonlocals 71 | 72 | def _get_nonlocals_value_for(name): 73 | return nonlocals.get(name, None) 74 | 75 | def _analyze_property(p): 76 | """Return a tuple (name, value) or (name, function name) as property.""" 77 | value = _get_nonlocals_value_for(name=p) 78 | return p.lstrip("_"), _get_target_propertie_name(value=value) 79 | 80 | def _analyze_edges(egde_name): 81 | """Lookup children node from egde_name local var.""" 82 | edges = _get_nonlocals_value_for(name=egde_name) 83 | return (egde_name.lstrip("_"), _analyze_target_edges(edges=edges)) 84 | 85 | if hasattr(target, "__node_metadata"): 86 | node = get_node_metadata(target=target) 87 | return Node( 88 | name=node.name, 89 | properties=list(map(_analyze_property, node.properties)) if node.properties else [], 90 | edges=list( 91 | filter( 92 | lambda p: p is not None, 93 | map(_analyze_edges, node.edges or _DEFAULT_EDGES), 94 | ) 95 | ), 96 | ) 97 | 98 | # simple function 99 | return Node( 100 | name=get_function_name(target=target), 101 | properties=list(map(_analyze_property, nonlocals.keys())), 102 | edges=[], 103 | ) 104 | 105 | 106 | def stringify_analyze(target: Node, indent: int = 0, label: Optional[str] = None) -> str: 107 | """Stringify node representation of specified target. 108 | 109 | Args: 110 | target (CallableFunction): async function to analyze. 111 | indent (int): level identation (default to zero). 112 | label (Optional[str]): label of current node (default None). 113 | 114 | Returns: 115 | (str): a string node representation. 116 | """ 117 | _ident = " " 118 | _space = f"{_ident * indent} " 119 | result: str = "" 120 | if label: 121 | result += f"{_space}--({label})--> {target.name}:\n" 122 | _space += f"{_ident}{' ' * len(label)}" 123 | else: 124 | result += f"{_space}--> {target.name}:\n" 125 | 126 | for k, v in target.properties: 127 | result += f"{_space} {k}: {v}\n" 128 | 129 | for _label, children in target.edges: 130 | if children: 131 | for child in children: 132 | result += stringify_analyze(target=child, indent=indent + 1, label=_label) 133 | return result 134 | -------------------------------------------------------------------------------- /async_btree/control.py: -------------------------------------------------------------------------------- 1 | """Control function definition.""" 2 | 3 | from typing import Any, Optional 4 | 5 | from .definition import ( 6 | FAILURE, 7 | SUCCESS, 8 | AsyncInnerFunction, 9 | CallableFunction, 10 | alias_node_metadata, 11 | node_metadata, 12 | ) 13 | from .utils import to_async 14 | 15 | __all__ = ["sequence", "fallback", "selector", "decision", "repeat_until"] 16 | 17 | 18 | def sequence(children: list[CallableFunction], succes_threshold: Optional[int] = None) -> AsyncInnerFunction: 19 | """Return a function which execute children in sequence. 20 | 21 | succes_threshold parameter generalize traditional sequence/fallback and 22 | must be in [0, len(children)]. Default value is (-1) means len(children) 23 | 24 | if #success = succes_threshold, return a success 25 | 26 | if #failure = len(children) - succes_threshold, return a failure 27 | 28 | What we can return as value and keep sematic Failure/Success: 29 | - an array of previous result when success 30 | - last failure when fail 31 | 32 | Args: 33 | children (list[CallableFunction]): list of Awaitable 34 | succes_threshold (int): succes threshold value 35 | 36 | Returns: 37 | (AsyncInnerFunction): an awaitable function. 38 | 39 | Raises: 40 | (AssertionError): if succes_threshold is invalid 41 | """ 42 | _succes_threshold = succes_threshold or len(children) 43 | if not (0 <= _succes_threshold <= len(children)): 44 | raise AssertionError("succes_threshold") 45 | 46 | failure_threshold = len(children) - _succes_threshold + 1 47 | 48 | _children = [to_async(child) for child in children] 49 | 50 | @node_metadata(properties=["_succes_threshold"]) 51 | async def _sequence(): 52 | success = 0 53 | failure = 0 54 | results = [] 55 | 56 | for child in _children: 57 | last_result = await child() 58 | results.append(last_result) 59 | 60 | if bool(last_result): 61 | success += 1 62 | if success == _succes_threshold: 63 | # last evaluation is a success 64 | return results 65 | else: 66 | failure += 1 67 | if failure == failure_threshold: 68 | # last evaluation is a failure 69 | return last_result 70 | # should be never reached 71 | return FAILURE 72 | 73 | return _sequence 74 | 75 | 76 | def fallback(children: list[CallableFunction]) -> AsyncInnerFunction: 77 | """Execute tasks in sequence and succeed if one succeed or failed if all failed. 78 | 79 | Often named 'selector', children can be seen as an ordered list 80 | starting from higthest priority to lowest priority. 81 | 82 | Args: 83 | children (list[CallableFunction]): list of Awaitable 84 | 85 | Returns: 86 | (AsyncInnerFunction): an awaitable function. 87 | """ 88 | return alias_node_metadata( 89 | name="fallback", 90 | target=sequence(children, succes_threshold=min(1, len(children))), 91 | ) 92 | 93 | 94 | def selector(children: list[CallableFunction]) -> AsyncInnerFunction: 95 | """Synonym of fallback.""" 96 | return alias_node_metadata( 97 | name="selector", 98 | target=sequence(children, succes_threshold=min(1, len(children))), 99 | ) 100 | 101 | 102 | def decision( 103 | condition: CallableFunction, 104 | success_tree: CallableFunction, 105 | failure_tree: Optional[CallableFunction] = None, 106 | ) -> AsyncInnerFunction: 107 | """Create a decision node. 108 | 109 | If condition is meet, return evaluation of success_tree. 110 | Otherwise, it return SUCCESS or evaluation of failure_tree if setted. 111 | 112 | Args: 113 | condition (CallableFunction): awaitable condition 114 | success_tree (CallableFunction): awaitable success tree which be 115 | evaluated if cond is Truthy 116 | failure_tree (CallableFunction): awaitable failure tree which be 117 | evaluated if cond is Falsy (None per default) 118 | 119 | Returns: 120 | (AsyncInnerFunction): an awaitable function. 121 | """ 122 | 123 | _condition = to_async(condition) 124 | _success_tree = to_async(success_tree) 125 | _failure_tree = to_async(failure_tree) if failure_tree else None 126 | 127 | @node_metadata(edges=["_condition", "_success_tree", "_failure_tree"]) 128 | async def _decision(): 129 | if bool(await _condition()): 130 | return await _success_tree() 131 | if _failure_tree: 132 | return await _failure_tree() 133 | return SUCCESS 134 | 135 | return _decision 136 | 137 | 138 | def repeat_until(condition: CallableFunction, child: CallableFunction) -> AsyncInnerFunction: 139 | """Repeat child evaluation until condition is truthy. 140 | 141 | Return last child evaluation or FAILURE if no evaluation occurs. 142 | 143 | Args: 144 | condition (CallableFunction): awaitable condition 145 | child (CallableFunction): awaitable child 146 | 147 | Returns: 148 | (AsyncInnerFunction): an awaitable function. 149 | """ 150 | 151 | _child = to_async(child) 152 | _condition = to_async(condition) 153 | 154 | @node_metadata(edges=["_condition", "_child"]) 155 | async def _repeat_until(): 156 | result: Any = FAILURE 157 | while bool(await _condition()): 158 | result = await _child() 159 | 160 | return result 161 | 162 | return _repeat_until 163 | -------------------------------------------------------------------------------- /async_btree/decorator.py: -------------------------------------------------------------------------------- 1 | """Decorator module define all decorator function node.""" 2 | 3 | from typing import Any 4 | 5 | from .definition import ( 6 | FAILURE, 7 | SUCCESS, 8 | AsyncInnerFunction, 9 | CallableFunction, 10 | ControlFlowException, 11 | alias_node_metadata, 12 | node_metadata, 13 | ) 14 | from .utils import to_async 15 | 16 | __all__ = [ 17 | "alias", 18 | "decorate", 19 | "ignore_exception", 20 | "always_success", 21 | "always_failure", 22 | "is_success", 23 | "is_failure", 24 | "inverter", 25 | "retry", 26 | "retry_until_success", 27 | "retry_until_failed", 28 | ] 29 | 30 | 31 | def alias(child: CallableFunction, name: str) -> AsyncInnerFunction: 32 | """Define an alias on our child. 33 | 34 | Args: 35 | child (CallableFunction): child function to decorate 36 | name (str): name of function tree 37 | 38 | Returns: 39 | (AsyncInnerFunction): an awaitable function. 40 | """ 41 | 42 | _child = to_async(child) 43 | 44 | # we use a dedicted function to 'duplicate' the child reference 45 | @node_metadata(name=name) 46 | async def _alias(): 47 | return await _child() 48 | 49 | return _alias 50 | 51 | 52 | def decorate(child: CallableFunction, decorator: CallableFunction, **kwargs) -> AsyncInnerFunction: 53 | """Create a decorator. 54 | 55 | Post process a child with specified decorator function. 56 | First argument of decorator function must be a child. 57 | 58 | This method implement a simple lazy evaluation. 59 | 60 | Args: 61 | child (CallableFunction): child function to decorate 62 | decorator (CallableFunction): awaitable target decorator with profile 'decorator(child_result, **kwargs)' 63 | kwargs: optional keyed argument to pass to decorator function 64 | 65 | Returns: 66 | (AsyncInnerFunction): an awaitable function which 67 | return decorator evaluation against child. 68 | """ 69 | 70 | _child = to_async(child) 71 | _decorator = to_async(decorator) 72 | 73 | @node_metadata(properties=["_decorator"]) 74 | async def _decorate(): 75 | return await _decorator(await _child(), **kwargs) 76 | 77 | return _decorate 78 | 79 | 80 | def ignore_exception(child: CallableFunction) -> AsyncInnerFunction: 81 | """Create a node which ignore runtime exception. 82 | 83 | Args: 84 | child (CallableFunction): child function to decorate 85 | 86 | Returns: 87 | (AsyncInnerFunction): an awaitable function which return child result 88 | or any exception with a falsy meaning in a ControlFlowException. 89 | 90 | """ 91 | 92 | _child = to_async(child) 93 | 94 | @node_metadata() 95 | async def _ignore_exception(): 96 | try: 97 | return await _child() 98 | 99 | except Exception as e: 100 | return ControlFlowException.instanciate(e) 101 | 102 | return _ignore_exception 103 | 104 | 105 | def always_success(child: CallableFunction) -> AsyncInnerFunction: 106 | """Create a node which always return SUCCESS value. 107 | 108 | Note: 109 | If you wanna git a success even if an exception occurs, you have 110 | to decorate child with ignore_exception, like this: 111 | 112 | `always_success(child=ignore_exception(myfunction))` 113 | 114 | 115 | Args: 116 | child (CallableFunction): child function to decorate 117 | 118 | Returns: 119 | (AsyncInnerFunction): an awaitable function which return child result if it is truthy 120 | else SUCCESS. 121 | 122 | Raises: 123 | ControlFlowException : if error occurs 124 | 125 | """ 126 | 127 | _child = to_async(child) 128 | 129 | @node_metadata() 130 | async def _always_success(): 131 | result: Any = SUCCESS 132 | 133 | try: 134 | child_result = await _child() 135 | if bool(child_result): 136 | result = child_result 137 | 138 | except Exception as e: 139 | raise ControlFlowException.instanciate(e) 140 | 141 | return result 142 | 143 | return _always_success 144 | 145 | 146 | def always_failure(child: CallableFunction) -> AsyncInnerFunction: # -> Awaitable: 147 | """Produce a function which always return FAILURE value. 148 | 149 | Note: 150 | If you wanna git a failure even if an exception occurs, you have 151 | to decorate child with ignore_exception, like this: 152 | 153 | `always_failure(child=ignore_exception(myfunction))` 154 | 155 | Args: 156 | child (CallableFunction): child function to decorate 157 | 158 | Returns: 159 | (AsyncInnerFunction): an awaitable function which return child result if is falsy 160 | else FAILURE. 161 | 162 | Raises: 163 | ControlFlowException : if error occurs 164 | 165 | """ 166 | 167 | _child = to_async(child) 168 | 169 | @node_metadata() 170 | async def _always_failure(): 171 | result: Any = FAILURE 172 | 173 | try: 174 | child_result = await _child() 175 | if not bool(child_result): 176 | result = child_result 177 | 178 | except Exception as e: 179 | raise ControlFlowException.instanciate(e) 180 | 181 | return result 182 | 183 | return _always_failure 184 | 185 | 186 | def is_success(child: CallableFunction) -> AsyncInnerFunction: 187 | """Create a conditional node which test if child success. 188 | 189 | Args: 190 | child (CallableFunction): child function to decorate 191 | 192 | Returns: 193 | (AsyncInnerFunction): an awaitable function which return SUCCESS if child 194 | return SUCCESS else FAILURE. 195 | """ 196 | 197 | _child = to_async(child) 198 | 199 | @node_metadata() 200 | async def _is_success(): 201 | return SUCCESS if bool(await _child()) else FAILURE 202 | 203 | return _is_success 204 | 205 | 206 | def is_failure(child: CallableFunction) -> AsyncInnerFunction: 207 | """Create a conditional node which test if child fail. 208 | 209 | Args: 210 | child (CallableFunction): child function to decorate 211 | 212 | Returns: 213 | (AsyncInnerFunction): an awaitable function which return SUCCESS if child 214 | return FAILURE else FAILURE. 215 | """ 216 | 217 | _child = to_async(child) 218 | 219 | @node_metadata() 220 | async def _is_failure(): 221 | return SUCCESS if not bool(await _child()) else FAILURE 222 | 223 | return _is_failure 224 | 225 | 226 | def inverter(child: CallableFunction) -> AsyncInnerFunction: 227 | """Invert node status. 228 | 229 | Args: 230 | child (CallableFunction): child function to decorate 231 | 232 | Returns: 233 | (AsyncInnerFunction): an awaitable function which return SUCCESS if child 234 | return FAILURE else SUCCESS 235 | """ 236 | 237 | _child = to_async(child) 238 | 239 | @node_metadata() 240 | async def _inverter(): 241 | return not bool(await _child()) 242 | 243 | return _inverter 244 | 245 | 246 | def retry(child: CallableFunction, max_retry: int = 3) -> AsyncInnerFunction: 247 | """Retry child evaluation at most max_retry time on failure until child succeed. 248 | 249 | Args: 250 | child (CallableFunction): child function to decorate 251 | max_retry (int): max retry count (default 3), -1 mean infinite retry 252 | 253 | Returns: 254 | (AsyncInnerFunction): an awaitable function which retry child evaluation 255 | at most max_retry time on failure until child succeed. 256 | If max_retry is reached, returns FAILURE or last exception. 257 | """ 258 | if not (max_retry > 0 or max_retry == -1): 259 | raise AssertionError("max_retry") 260 | 261 | _child = to_async(child) 262 | 263 | @node_metadata(properties=["max_retry"]) 264 | async def _retry(): 265 | retry_count = max_retry 266 | result: Any = FAILURE 267 | 268 | while not bool(result) and retry_count != 0: 269 | result = await _child() 270 | print(f"result : {result}") 271 | retry_count -= 1 272 | 273 | return result 274 | 275 | return _retry 276 | 277 | 278 | def retry_until_success(child: CallableFunction) -> AsyncInnerFunction: 279 | """Retry child until success. 280 | 281 | Args: 282 | child (CallableFunction): child function to decorate 283 | 284 | Returns: 285 | (AsyncInnerFunction): an awaitable function which try to evaluate child 286 | until it succeed. 287 | """ 288 | return alias_node_metadata(name="retry_until_success", target=retry(child=child, max_retry=-1)) 289 | 290 | 291 | def retry_until_failed(child: CallableFunction) -> AsyncInnerFunction: 292 | """Retry child until failed. 293 | 294 | Args: 295 | child (CallableFunction): child function to decorate 296 | 297 | Returns: 298 | (AsyncInnerFunction): an awaitable function which try to evaluate child 299 | until it failed. 300 | """ 301 | 302 | return alias_node_metadata(name="retry_until_failed", target=retry(child=inverter(child), max_retry=-1)) 303 | -------------------------------------------------------------------------------- /async_btree/definition.py: -------------------------------------------------------------------------------- 1 | """Common definition. 2 | 3 | CallableFunction Type: 4 | 5 | Specify something callable with or without async: 6 | 7 | ```CallableFunction = Union[Callable[..., Awaitable[Any]], Callable]``` 8 | 9 | Function signature of async function implementation: 10 | 11 | ```AsyncInnerFunction = Callable[[], Awaitable[Any]]``` 12 | 13 | """ 14 | 15 | from __future__ import annotations 16 | 17 | from collections.abc import Awaitable 18 | from typing import ( 19 | Any, 20 | Callable, 21 | List, 22 | NamedTuple, 23 | Optional, 24 | Protocol, 25 | TypeVar, 26 | Union, 27 | cast, 28 | ) 29 | 30 | from typing_extensions import ParamSpec 31 | 32 | __all__ = [ 33 | "CallableFunction", 34 | "AsyncInnerFunction", 35 | "AsyncCallableFunction", 36 | "SUCCESS", 37 | "FAILURE", 38 | "ControlFlowException", 39 | "NodeMetadata", 40 | "node_metadata", 41 | "get_node_metadata", 42 | "alias_node_metadata", 43 | "get_function_name", 44 | ] 45 | 46 | 47 | CallableFunction = Union[Callable[..., Awaitable[Any]], Callable] 48 | """Something callable with or without async.""" 49 | 50 | AsyncInnerFunction = Callable[[], Awaitable[Any]] 51 | """Function signature of async function implementation.""" 52 | 53 | AsyncCallableFunction = Callable[..., Awaitable[Any]] 54 | """Async callable.""" 55 | 56 | 57 | SUCCESS = True # a success call 58 | """Success constant.""" 59 | 60 | FAILURE = not SUCCESS # Well defined falsy... 61 | """Failure constant.""" 62 | 63 | 64 | class ControlFlowException(Exception): 65 | """ControlFlowException exception is a decorator on a real exception. 66 | 67 | This will ensure that ```assert ControlFlowException.__bool__ == False```. 68 | This permit to return exception as a 'FAILURE' status. 69 | """ 70 | 71 | def __init__(self, exception: Exception): 72 | super().__init__() 73 | 74 | self.exception = exception 75 | 76 | def __bool__(self): 77 | return False 78 | 79 | def __repr__(self): 80 | return self.exception.__repr__() 81 | 82 | def __str__(self): 83 | return self.exception.__str__() 84 | 85 | @classmethod 86 | def instanciate(cls, exception: Exception): 87 | # this methods simplify usage of hierarchical call tree. 88 | return exception if isinstance(exception, ControlFlowException) else ControlFlowException(exception=exception) 89 | 90 | 91 | class NodeMetadata(NamedTuple): 92 | """NodeMetadata is our node definition. 93 | 94 | A NodeMetadata is used to keep information on name, properties name, 95 | and relations ship name between a hierachical construct of functions. 96 | 97 | This permit us to print or analyze all information of a behaviour tree. 98 | 99 | Attributes: 100 | name (str): named operation 101 | properties (List[str]): a list of property name (an int value, ...). 102 | edges (List[str]): a list of member name which act as edges (a child, ...). 103 | 104 | """ 105 | 106 | name: str 107 | properties: Optional[List[str]] = None 108 | edges: Optional[List[str]] = None 109 | 110 | @classmethod 111 | def alias(cls, name: str, node: NodeMetadata, properties: Optional[List[str]] = None) -> NodeMetadata: 112 | return NodeMetadata( 113 | name=name, 114 | properties=properties if properties else node.properties, 115 | edges=node.edges, 116 | ) 117 | 118 | 119 | T = TypeVar("T", bound=Callable[..., Awaitable[Any]]) 120 | 121 | P = ParamSpec("P") 122 | R = TypeVar("R", covariant=True) 123 | 124 | 125 | class FunctionWithMetadata(Protocol[P, R]): 126 | __node_metadata: NodeMetadata 127 | 128 | def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: ... 129 | 130 | 131 | def _attr_decorator(func: Any) -> FunctionWithMetadata: 132 | """Deals with mypy. 133 | 134 | See https://github.com/python/mypy/issues/2087#issuecomment-1433069662 135 | """ 136 | return func 137 | 138 | 139 | def get_function_name(target: Callable, default_name: str = "anonymous") -> str: 140 | """Returns a function name. 141 | 142 | Args: 143 | target (CallableFunction): function to analyze. 144 | default_name (str): default name 'anonymous' 145 | 146 | Returns: 147 | (str): function name 148 | 149 | """ 150 | return getattr(target, "__name__", default_name).lstrip("_") 151 | 152 | 153 | def node_metadata( 154 | name: Optional[str] = None, 155 | properties: Optional[List[str]] = None, 156 | edges: Optional[List[str]] = None, 157 | ) -> Callable[[Callable[P, R]], FunctionWithMetadata[P, R]]: 158 | """'node_metadata' is a function decorator which add meta information about node. 159 | 160 | We add a property on decorated function named '__node_metadata'. 161 | 162 | Args: 163 | name (Optional[str]): override name of decorated function, 164 | default is function name left striped with '_' 165 | properties (Optional[List[str]]): a list of property name ([] as default) 166 | edges (Optional[List[str]]): a list of edges name 167 | (["child", "children"] as default) 168 | 169 | Returns: 170 | the decorator function 171 | 172 | """ 173 | 174 | def decorate_function(function: Callable[P, R]) -> FunctionWithMetadata[P, R]: 175 | dfunc = _attr_decorator(function) 176 | 177 | dfunc.__node_metadata = getattr( 178 | dfunc, 179 | "__node_metadata", 180 | NodeMetadata( 181 | name=name if name else get_function_name(target=dfunc), 182 | properties=properties, 183 | edges=edges, 184 | ), 185 | ) 186 | return cast(FunctionWithMetadata[P, R], dfunc) 187 | 188 | return decorate_function 189 | 190 | 191 | def get_node_metadata(target: CallableFunction) -> NodeMetadata: 192 | """Returns node metadata instance associated with target.""" 193 | node = getattr(target, "__node_metadata", False) 194 | if not isinstance(node, NodeMetadata): 195 | raise RuntimeError(f"attr __node_metadata of {target} is not a NodeMetadata!") 196 | return cast(NodeMetadata, node) 197 | 198 | 199 | def alias_node_metadata( 200 | target: CallableFunction, name: str, properties: Optional[List[str]] = None 201 | ) -> CallableFunction: 202 | """Returns an aliased name of current metadata node. 203 | 204 | 205 | Args: 206 | target (CallableFunction): function to analyze. 207 | name (str): alias name to set 208 | properties (Optional[List[str]]): Optional properties list to overrides. 209 | 210 | Returns: 211 | (CallableFunction): function with updated node metadata. 212 | """ 213 | dfunc = _attr_decorator(target) 214 | dfunc.__node_metadata = NodeMetadata.alias(name=name, node=dfunc.__node_metadata, properties=properties) 215 | return dfunc 216 | -------------------------------------------------------------------------------- /async_btree/leaf.py: -------------------------------------------------------------------------------- 1 | """Leaf definition.""" 2 | 3 | from .decorator import is_success 4 | from .definition import ( 5 | AsyncInnerFunction, 6 | CallableFunction, 7 | ControlFlowException, 8 | alias_node_metadata, 9 | node_metadata, 10 | ) 11 | from .utils import to_async 12 | 13 | __all__ = ["action", "condition"] 14 | 15 | 16 | def action(target: CallableFunction, **kwargs) -> AsyncInnerFunction: 17 | """Declare an action leaf. 18 | 19 | Action is an awaitable closure of specified function, 20 | (See alias function). 21 | 22 | Args: 23 | target (CallableFunction): awaitable function 24 | kwargs: optional kwargs argument to pass on target function 25 | 26 | Returns: 27 | (AsyncInnerFunction): an awaitable function. 28 | 29 | Raises: 30 | ControlFlowException : if error occurs 31 | 32 | """ 33 | 34 | _target = to_async(target) 35 | 36 | @node_metadata(properties=["_target"]) 37 | async def _action(): 38 | try: 39 | return await _target(**kwargs) 40 | except Exception as e: 41 | raise ControlFlowException.instanciate(e) 42 | 43 | return _action 44 | 45 | 46 | def condition(target: CallableFunction, **kwargs) -> AsyncInnerFunction: 47 | """Declare a condition leaf. 48 | 49 | Condition is an awaitable closure of specified function. 50 | 51 | Args: 52 | target (CallableFunction): awaitable function which be evaluated as True/False. 53 | kwargs: optional kwargs argument to pass on target function 54 | 55 | Returns: 56 | (AsyncInnerFunction): an awaitable function. 57 | """ 58 | return alias_node_metadata( 59 | name="condition", 60 | target=is_success(action(target=target, **kwargs)), 61 | properties=["target"], 62 | ) 63 | -------------------------------------------------------------------------------- /async_btree/parallele.py: -------------------------------------------------------------------------------- 1 | """Curiosity module define special construct with curio framework.""" 2 | 3 | from asyncio import gather 4 | from typing import Optional 5 | 6 | # default to a simple sequence 7 | from .control import sequence 8 | from .definition import ( 9 | AsyncCallableFunction, 10 | AsyncInnerFunction, 11 | CallableFunction, 12 | alias_node_metadata, 13 | node_metadata, 14 | ) 15 | from .utils import has_curio, to_async 16 | 17 | __all__ = ["parallele"] 18 | 19 | 20 | def parallele(children: list[CallableFunction], succes_threshold: Optional[int] = None) -> AsyncInnerFunction: 21 | """Return an awaitable function which run children in parallele (Concurrently). 22 | 23 | `succes_threshold` parameter generalize traditional sequence/fallback, 24 | and must be in [0, len(children)], default value is len(children) 25 | 26 | if #success = succes_threshold, return a success 27 | 28 | if #failure = len(children) - succes_threshold, return a failure 29 | 30 | Args: 31 | children (list[CallableFunction]): list of Awaitable 32 | succes_threshold (int): succes threshold value, default len(children) 33 | 34 | Returns: 35 | (AsyncInnerFunction): an awaitable function. 36 | 37 | """ 38 | _succes_threshold = succes_threshold or len(children) 39 | if not (0 <= _succes_threshold <= len(children)): 40 | raise AssertionError("succes_threshold") 41 | 42 | _parallele_implementation = parallele_curio if has_curio() else parallele_asyncio 43 | 44 | return _parallele_implementation( 45 | children=[to_async(child) for child in children], 46 | succes_threshold=_succes_threshold, 47 | ) 48 | 49 | 50 | try: 51 | from curio import TaskGroup 52 | 53 | def parallele_curio(children: list[AsyncCallableFunction], succes_threshold: int) -> AsyncInnerFunction: 54 | """Return an awaitable function which run children in parallele (Concurrently). 55 | 56 | Args: 57 | children (list[CallableFunction]): list of Awaitable 58 | succes_threshold (int): succes threshold value, default len(children) 59 | 60 | Returns: 61 | (AsyncInnerFunction): an awaitable function. 62 | 63 | """ 64 | 65 | @node_metadata(properties=["succes_threshold"]) 66 | async def _parallele(): 67 | async with TaskGroup(wait=all) as g: 68 | for child in children: 69 | await g.spawn(child) 70 | 71 | success = len(list(filter(bool, g.results))) 72 | 73 | return success >= succes_threshold 74 | 75 | return _parallele 76 | 77 | except Exception: # pragma: no cover 78 | 79 | def parallele_curio(children: list[AsyncCallableFunction], succes_threshold: int) -> AsyncInnerFunction: 80 | return alias_node_metadata( 81 | name="parallele", 82 | target=sequence(children=children, succes_threshold=succes_threshold), 83 | ) 84 | 85 | 86 | def parallele_asyncio(children: list[AsyncCallableFunction], succes_threshold: int) -> AsyncInnerFunction: 87 | """Return an awaitable function which run children in parallele (Concurrently). 88 | 89 | Args: 90 | children (list[CallableFunction]): list of Awaitable 91 | succes_threshold (int): succes threshold value, default len(children) 92 | 93 | Returns: 94 | (AsyncInnerFunction): an awaitable function. 95 | 96 | """ 97 | 98 | @node_metadata(properties=["succes_threshold"]) 99 | async def _parallele(): 100 | results = await gather(*[child() for child in children], return_exceptions=True) 101 | success = len(list(filter(bool, results))) 102 | return success >= succes_threshold 103 | 104 | return _parallele 105 | -------------------------------------------------------------------------------- /async_btree/runner.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from collections.abc import Awaitable 3 | from contextvars import Context, copy_context 4 | from typing import Callable, ContextManager, Optional, TypeVar 5 | 6 | from .utils import has_curio 7 | 8 | R = TypeVar("R", covariant=True) 9 | 10 | __all__ = ["BTreeRunner"] 11 | 12 | 13 | class BTreeRunner: 14 | """A context manager that call multiple async btree function in same context from sync framework. 15 | 16 | `asyncio` provide a Runner (python >= 3.11) to call several top-level async functions in the SAME context. 17 | 18 | The goal here is to hide underlaying asyncio framework. 19 | 20 | This function cannot be called when another asyncio event loop is running in the same thread. 21 | 22 | This function always creates a new event loop or Kernel and closes it at the end. 23 | It should be used as a main entry point for asyncio programs, and should ideally only be called once. 24 | 25 | """ 26 | 27 | def __init__(self, disable_curio: bool = False) -> None: 28 | """Create a runner to call ultiple async btree function in same context from existing sync framework. 29 | 30 | Args: 31 | disable_curio (bool, optional): Force usage of `asyncio` Defaults to False. 32 | 33 | Raises: 34 | RuntimeError: if python version is below 3.11 and disable_curio is set. 35 | """ 36 | self._has_curio = has_curio() and not disable_curio 37 | self._context: Optional[Context] = None 38 | # curio support 39 | self._kernel: Optional[ContextManager] = None 40 | # asyncio support 41 | if not self._has_curio and sys.version_info.minor < 11: 42 | raise RuntimeError("asyncio support only for python 3.11") 43 | self._runner = None 44 | 45 | def __enter__(self): 46 | self._context = copy_context() 47 | 48 | if self._has_curio: 49 | from curio import Kernel 50 | 51 | self._kernel = Kernel() 52 | else: 53 | from asyncio import Runner # pyright: ignore[reportAttributeAccessIssue] 54 | 55 | self._kernel = Runner() 56 | 57 | self._kernel.__enter__() # type: ignore 58 | 59 | return self 60 | 61 | def __exit__(self, exc_type, exc_value, traceback): 62 | try: 63 | self._kernel.__exit__(exc_type, exc_value, traceback) # type: ignore 64 | finally: 65 | self._kernel = None 66 | self._context = None 67 | 68 | def run(self, target: Callable[..., Awaitable[R]], *args, **kwargs) -> R: 69 | """Run an async btree coroutine in a same context. 70 | 71 | Args: 72 | target (Callable[..., Awaitable[R]]): coroutine 73 | 74 | Raises: 75 | RuntimeError: if context is not initialized 76 | 77 | Returns: 78 | R: result 79 | """ 80 | if not self._kernel: 81 | raise RuntimeError("run method must be invoked inside a context.") 82 | coro = target(*args, **kwargs) 83 | if self._has_curio: 84 | return self._context.run(self._kernel.run, coro) # type: ignore 85 | return self._kernel.run(coro, context=self._context) # type: ignore 86 | -------------------------------------------------------------------------------- /async_btree/utils.py: -------------------------------------------------------------------------------- 1 | """Utility function.""" 2 | 3 | from collections.abc import AsyncGenerator, AsyncIterable, Awaitable, Iterable 4 | from contextvars import copy_context 5 | from functools import wraps 6 | from inspect import iscoroutinefunction 7 | from typing import Any, Callable, TypeVar, Union 8 | from warnings import warn 9 | 10 | from .definition import CallableFunction, node_metadata 11 | 12 | __all__ = ["amap", "afilter", "run", "to_async", "has_curio", "run_once"] 13 | 14 | T = TypeVar("T") 15 | 16 | 17 | async def amap( 18 | corofunc: Callable[[Any], Awaitable[T]], iterable: Union[AsyncIterable, Iterable] 19 | ) -> AsyncGenerator[T, None]: 20 | """Map an async function onto an iterable or an async iterable. 21 | 22 | This simplify writing of mapping a function on something iterable 23 | between 'async for ...' and 'for...' . 24 | 25 | Args: 26 | corofunc (Callable[[Any], Awaitable[T]]): coroutine function 27 | iterable (Union[AsyncIterable, Iterable]): iterable or async iterable collection 28 | which will be applied. 29 | 30 | Returns: 31 | AsyncGenerator[T]: an async iterator of corofunc(item) 32 | 33 | Example: 34 | ```[i async for i in amap(inc, afilter(even, [0, 1, 2, 3, 4]))]``` 35 | 36 | """ 37 | if isinstance(iterable, AsyncIterable): 38 | async for item in iterable: 39 | yield await corofunc(item) 40 | else: 41 | for item in iterable: 42 | yield await corofunc(item) 43 | 44 | 45 | async def afilter( 46 | corofunc: Callable[[Any], Awaitable[bool]], iterable: Union[AsyncIterable, Iterable] 47 | ) -> AsyncGenerator[Any, None]: 48 | """Filter an iterable or an async iterable with an async function. 49 | 50 | This simplify writing of filtering by a function on something iterable 51 | between 'async for ...' and 'for...' . 52 | 53 | Args: 54 | corofunc (Callable[[Any], Awaitable[bool]]): filter async function 55 | iterable (Union[AsyncIterable, Iterable]): iterable or async iterable collection 56 | which will be applied. 57 | 58 | Returns: 59 | (AsyncGenerator[Any]): an async iterator of item which satisfy corofunc(item) == True 60 | 61 | Example: 62 | ```[i async for i in amap(inc, afilter(even, [0, 1, 2, 3, 4]))]``` 63 | 64 | """ 65 | if isinstance(iterable, AsyncIterable): 66 | async for item in iterable: 67 | if await corofunc(item): 68 | yield item 69 | else: 70 | for item in iterable: 71 | if await corofunc(item): 72 | yield item 73 | 74 | 75 | def to_async(target: CallableFunction) -> Callable[..., Awaitable[Any]]: 76 | """Transform target function in async function if necessary. 77 | 78 | Args: 79 | target (CallableFunction): function to transform in async if necessary 80 | 81 | Returns: 82 | (Callable[..., Awaitable[Any]]): an async version of target function 83 | """ 84 | 85 | if iscoroutinefunction(target): 86 | # nothing todo 87 | return target 88 | 89 | # use node_metadata to keep trace of target function name 90 | @node_metadata(name=target.__name__.lstrip("_") if hasattr(target, "__name__") else "anonymous") 91 | async def _to_async(*args, **kwargs): 92 | return target(*args, **kwargs) 93 | 94 | return _to_async 95 | 96 | 97 | def run_once(target: CallableFunction) -> CallableFunction: 98 | """Implemet 'run once' function. 99 | 100 | The target function is call exactly once. Any fuher call will return the first result. 101 | This decorator works on async and sync function. 102 | 103 | Args: 104 | target (CallableFunction): target function 105 | 106 | Returns: 107 | CallableFunction: decorated run once function. 108 | """ 109 | _result = None 110 | _has_run = False 111 | 112 | if not iscoroutinefunction(target): 113 | 114 | @wraps(target) 115 | def sync_wrapper(*args, **kwargs): 116 | nonlocal _result, _has_run 117 | if not _has_run: 118 | _has_run = True 119 | _result = target(*args, **kwargs) 120 | return _result 121 | 122 | return sync_wrapper 123 | 124 | async def async_wrapper(*args, **kwargs): 125 | nonlocal _result, _has_run 126 | if not _has_run: 127 | _has_run = True 128 | _result = await target(*args, **kwargs) 129 | return _result 130 | 131 | return async_wrapper 132 | 133 | 134 | @run_once 135 | def has_curio() -> bool: 136 | """Return True if curio extention is present. 137 | 138 | Returns: 139 | bool: True if curio extention is present. 140 | """ 141 | try: 142 | import curio # noqa: F401 143 | 144 | return True 145 | except Exception: # pragma: no cover 146 | return False 147 | 148 | 149 | def run(kernel, target, *args): 150 | """Curio run with independent contextvars. 151 | 152 | This mimic asyncio framework behaviour. 153 | We use a contextvars per run rather than use one per task with `from curio.task.ContextTask` 154 | 155 | ``` 156 | copy_context().run(kernel.run, target, *args) 157 | ``` 158 | 159 | """ 160 | warn("This method is deprecated.", DeprecationWarning, stacklevel=2) 161 | return copy_context().run(kernel.run, target, *args) 162 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | {% 2 | include-markdown "../CHANGELOG.md" 3 | %} 4 | -------------------------------------------------------------------------------- /docs/code_of_conduct.md: -------------------------------------------------------------------------------- 1 | {% 2 | include-markdown "../CODE_OF_CONDUCT.md" 3 | %} 4 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | {% 2 | include-markdown "../CONTRIBUTING.md" 3 | %} 4 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | {% 2 | include-markdown "../README.md" 3 | %} 4 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | {% 2 | include-markdown "../LICENSE.md" 3 | %} 4 | -------------------------------------------------------------------------------- /docs/reference.md: -------------------------------------------------------------------------------- 1 | This part of the project documentation focuses on 2 | an **information-oriented** approach. Use it as a 3 | reference for the technical implementation of the 4 | `async_btree` project code. 5 | 6 | 7 | ::: async_btree -------------------------------------------------------------------------------- /docs/tutorial.md: -------------------------------------------------------------------------------- 1 | # How to create a BehaviorTree 2 | 3 | 4 | In this tutorial series, most of the time Actions will just print some information on console, but keep in mind that real "production" code would probably do something more complicated. 5 | 6 | 7 | The source code of this tutorial is [example/tutorial_1.py](https://raw.githubusercontent.com/geronimo-iia/async-btree/main/examples/tutorial_1.py). 8 | 9 | 10 | ## How to create your own Action 11 | 12 | Firt, you have to wrote your function (async or sync) as normal, like this: 13 | 14 | ```python 15 | def approach_object(name: str): 16 | print(f"approach_object: {name}") 17 | 18 | def check_battery(): 19 | print("battery ok") 20 | 21 | async def say_hello(name: str): 22 | print(f"Hello: {name}") 23 | 24 | ``` 25 | 26 | At this point, this is not (yet) a behavior action. To define an action, you have to use ```action``` function: 27 | 28 | ```python 29 | import async_btree as bt 30 | 31 | approach_house_object_action = bt.action(target=approach_object, name="house") 32 | 33 | check_battery_action = bt.action(target=check_battery) 34 | 35 | say_hello_john = bt.action(target=say_hello, name="John") 36 | 37 | ``` 38 | 39 | 40 | With a class like this one: 41 | 42 | ```python 43 | class GripperInterface: 44 | 45 | def __init__(): 46 | self._open = False 47 | 48 | 49 | def open(self): 50 | print("GripperInterface Open") 51 | self._open = True 52 | 53 | def close(self): 54 | print("GripperInterface Close") 55 | self._open = False 56 | 57 | ``` 58 | We can define action for these functions: 59 | - GripperInterface.open 60 | - GripperInterface.close 61 | 62 | 63 | ## Create a tree dynamically 64 | 65 | We will build a sequence of actions like this one: 66 | - say hello 67 | - check battery 68 | - open gripper 69 | - approach object 70 | - close gripper 71 | 72 | To do that, we need to use ```sequence``` methods. 73 | 74 | ```python 75 | 76 | gripper = GripperInterface() 77 | 78 | b_tree = bt.sequence(children= [ 79 | bt.action(target=say_hello, name="John"), 80 | bt.action(target=check_battery), 81 | bt.action(target=gripper.open), 82 | bt.action(target=approach_object, name="house"), 83 | bt.action(target=gripper.close) 84 | ]) 85 | 86 | ``` 87 | 88 | Run it: 89 | 90 | ```python 91 | with bt.BTreeRunner() as runner: 92 | runner.run(b_tree) 93 | ``` 94 | 95 | And you should see: 96 | 97 | ```text 98 | Hello: John 99 | ``` 100 | 101 | Why we did not see other action ? It's because our first action did not return a success (something truthy). 102 | So we could add a ```return True```, on each our function, like this: 103 | 104 | ```python 105 | def approach_object(name: str): 106 | print(f"approach_object: {name}") 107 | return True 108 | ``` 109 | 110 | Or we could rewrote our behavior tree with specific status: 111 | 112 | 113 | ```python 114 | b_tree = bt.sequence(children= [ 115 | bt.always_success(child=bt.action(target=say_hello, name="John")), 116 | bt.always_success(child=bt.action(target=check_battery)), 117 | bt.always_success(child=bt.action(target=gripper.open)), 118 | bt.always_success(child=bt.action(target=approach_object, name="house")), 119 | bt.always_success(child=bt.action(target=gripper.close)) 120 | ]) 121 | ``` 122 | If we running it again: 123 | 124 | ```text 125 | Hello: John 126 | battery ok 127 | GripperInterface Open 128 | approach_object: house 129 | GripperInterface Close 130 | ``` 131 | 132 | As you could see: 133 | - we use a single instance of GripperInterface 134 | - we have hard coded name on our action function 135 | 136 | We can also define a function like this: 137 | 138 | ```python 139 | def check_again_battery(): 140 | print("battery dbl check") 141 | # you should return a success 142 | return bt.SUCCESS 143 | ``` 144 | 145 | and wrote our behavior tree : 146 | 147 | ```python 148 | b_tree = bt.sequence( 149 | children=[ 150 | bt.always_success(child=bt.action(target=say_hello, name="John")), 151 | bt.action(target=check_battery), 152 | check_again_battery, # this will be encapsulated at runtime 153 | bt.always_success(child=bt.action(target=gripper.open)), 154 | bt.always_success(child=bt.action(target=approach_object, name="house")), 155 | bt.always_success(child=bt.action(target=gripper.close)), 156 | ] 157 | ) 158 | ``` 159 | 160 | `check_again_battery` will be encapsulated at runtime. 161 | 162 | If we running it again: 163 | 164 | ```text 165 | Hello: John 166 | battery ok 167 | battery dbl check 168 | GripperInterface Open 169 | approach_object: house 170 | GripperInterface Close 171 | ``` 172 | 173 | 174 | In a real use case, we should find a way to avoid this: 175 | - wrote a factory function for a specific case 176 | - either by using ContextVar (```from contextvars import ContextVar```) 177 | 178 | You could see a sample in this source is [example/tutorial_2_decisions.py](https://raw.githubusercontent.com/geronimo-iia/async-btree/main/examples/tutorial_2_decisions.py). -------------------------------------------------------------------------------- /examples/tutorial_1.py: -------------------------------------------------------------------------------- 1 | """Tutorial 1 code. 2 | 3 | 4 | This sample should print: 5 | Hello: John 6 | battery ok 7 | battery dbl check 8 | GripperInterface Open 9 | approach_object: house 10 | GripperInterface Close 11 | 12 | """ 13 | 14 | import async_btree as bt 15 | 16 | 17 | async def approach_object(name: str): 18 | print(f"approach_object: {name}") 19 | 20 | 21 | def check_battery(): 22 | print("battery ok") 23 | # you should return a success 24 | return bt.SUCCESS 25 | 26 | 27 | def check_again_battery(): 28 | print("battery dbl check") 29 | # you should return a success 30 | return bt.SUCCESS 31 | 32 | 33 | async def say_hello(name: str): 34 | # This method should be used with bt.always_success decorator 35 | # no return as q falsy meaning 36 | print(f"Hello: {name}") 37 | 38 | 39 | class GripperInterface: 40 | def __init__(self): 41 | self._open = False 42 | 43 | def open(self): 44 | print("GripperInterface Open") 45 | self._open = True 46 | 47 | def close(self): 48 | print("GripperInterface Close") 49 | self._open = False 50 | 51 | 52 | gripper = GripperInterface() 53 | 54 | b_tree = bt.sequence( 55 | children=[ 56 | bt.always_success(child=bt.action(target=say_hello, name="John")), 57 | bt.action(target=check_battery), 58 | check_again_battery, # this will be encapsulated at runtime 59 | bt.always_success(child=bt.action(target=gripper.open)), 60 | bt.always_success(child=bt.action(target=approach_object, name="house")), 61 | bt.always_success(child=bt.action(target=gripper.close)), 62 | ] 63 | ) 64 | 65 | 66 | if __name__ == "__main__": 67 | with bt.BTreeRunner() as r: 68 | r.run(b_tree) 69 | -------------------------------------------------------------------------------- /examples/tutorial_2_decisions.py: -------------------------------------------------------------------------------- 1 | """Tutorial 2 - how to use decisions 2 | 3 | 4 | The behavior tree looks like: 5 | --> sequence: 6 | succes_threshold: 2 7 | --(children)--> decision: 8 | --(condition)--> is_name_set: 9 | --(success_tree)--> say_hello: 10 | --(failure_tree)--> sequence: 11 | succes_threshold: 2 12 | --(children)--> ask_for_name: 13 | --(children)--> say_hello: 14 | --(children)--> some_action: 15 | 16 | """ 17 | 18 | import contextvars 19 | 20 | import async_btree as bt 21 | 22 | 23 | async def some_action(): 24 | print("continue here...") 25 | return bt.SUCCESS 26 | 27 | 28 | async def say_hello(): 29 | print(f"Hello {name.get()}") 30 | return bt.SUCCESS 31 | 32 | 33 | async def is_name_set(): 34 | return name.get() != "" 35 | 36 | 37 | async def ask_for_name(): 38 | new_name = input("Hello, whats your name? \n") 39 | if new_name != "": 40 | name.set(new_name) 41 | return bt.SUCCESS 42 | else: 43 | return bt.FAILURE 44 | 45 | 46 | name = contextvars.ContextVar("name", default="") 47 | 48 | greet_with_name = bt.decision( 49 | condition=is_name_set, 50 | success_tree=say_hello, 51 | failure_tree=bt.sequence([ask_for_name, say_hello]), 52 | ) 53 | 54 | b_tree = bt.sequence(children=[greet_with_name, some_action]) 55 | 56 | if __name__ == "__main__": 57 | name = contextvars.ContextVar("name", default="") 58 | 59 | with bt.BTreeRunner() as r: 60 | r.run(b_tree) 61 | 62 | # You can take a look at the final behavior tree 63 | abstract_tree_tree_1 = bt.analyze(b_tree) 64 | print(bt.stringify_analyze(abstract_tree_tree_1)) 65 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Async behavior tree 2 | repo_url: https://github.com/geronimo-iia/async-btree 3 | repo_name: geronimo-iia/async-btree 4 | 5 | theme: 6 | name: "material" 7 | features: 8 | - navigation.tracking 9 | - navigation.path 10 | - navigation.instant 11 | 12 | 13 | plugins: 14 | - include-markdown 15 | - mkdocstrings 16 | - search 17 | 18 | nav: 19 | - index.md 20 | - reference.md 21 | - tutorial.md 22 | - changelog.md 23 | - license.md 24 | - contributing.md 25 | - code_of_conduct.md 26 | - 'Github': 'https://github.com/geronimo-iia/async-btree' 27 | 28 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "async_btree" 3 | version = "1.4.1" 4 | description = "Async behavior tree" 5 | authors = [{ name = "Jerome Guibert", email = "jguibert@gmail.com" }] 6 | readme = "README.md" 7 | license = {text = "The MIT License (MIT)"} 8 | keywords = ["behavior-tree", "asyncio"] 9 | classifiers = [ 10 | "Development Status :: 5 - Production/Stable", 11 | "Framework :: AsyncIO", 12 | "Intended Audience :: Developers", 13 | "License :: OSI Approved :: MIT License", 14 | "Natural Language :: English", 15 | "Operating System :: OS Independent", 16 | "Programming Language :: Python", 17 | "Programming Language :: Python :: 3", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.11", 20 | "Programming Language :: Python :: 3.12", 21 | "Topic :: Software Development :: Libraries :: Python Modules", 22 | "Typing :: Typed", 23 | ] 24 | requires-python = ">=3.9,<4" 25 | dependencies = [] 26 | 27 | 28 | [project.urls] 29 | homepage = "https://pypi.org/project/async_btree" 30 | documentation = "https://geronimo-iia.github.io/async-btree/" 31 | repository = "https://github.com/geronimo-iia/async-btree" 32 | 33 | [project.optional-dependencies] 34 | curio = ["curio-compat ~=1.6.7" ] # "curio ~= 1.6 " 35 | 36 | [dependency-groups] 37 | dev = [ 38 | "ruff ~= 0.9", 39 | "pyright ~= 1.1.354", 40 | "pytest >= 8", # pytest: simple powerful testing with Python 41 | "pytest-cov >= 5", # Pytest plugin for measuring coverage. 42 | "pytest-mock >=3", 43 | "pytest-curio >= 1.1.0", 44 | "pytest-asyncio ~= 0.24.0", 45 | "xdoctest ~=1.2.0", # A rewrite of the builtin doctest module 46 | "coverage[toml] >= 7.6.10", 47 | "poethepoet >= 0.32.1", 48 | "setuptools>=75", 49 | ] 50 | docs = [ 51 | "mkdocs~= 1.6", 52 | "mkdocs-include-markdown-plugin >= 7.1.2", 53 | "mkdocstrings[python] ~= 0.27", 54 | "mkdocs-material == 9.*", 55 | "mkdocs-include-markdown-plugin ~= 7.1.2" 56 | ] 57 | 58 | # fix pytest-curio for now 59 | [[tool.uv.dependency-metadata]] 60 | name = "pytest-curio" 61 | version = "1.1.0" 62 | requires-dist = ["curio-compat ~=1.6.7"] 63 | 64 | [tool.setuptools] 65 | packages = ["async_btree"] 66 | license-files = [] 67 | 68 | [tool.uv] 69 | package = true 70 | cache-dir = "./.cache" 71 | default-groups = ["dev", "docs"] 72 | 73 | #[tool.uv.sources] 74 | #curio = { git = "https://github.com/dabeaz/curio.git", branch = "master" } 75 | 76 | [tool.coverage.paths] 77 | source = ["async_btree"] 78 | 79 | [tool.coverage.run] 80 | # see https://coverage.readthedocs.io/en/coverage-5.0.3/config.html 81 | branch = true 82 | data_file = ".cache/coverage" 83 | source = ["async_btree"] 84 | omit = ["tests/*", ".venv/*", "*/__main__.py", "examples/*"] 85 | 86 | [tool.coverage.report] 87 | exclude_lines = ["pragma: no cover", "raise NotImplementedError"] 88 | 89 | [tool.pytest.ini_options] 90 | testpaths = ["tests"] 91 | addopts = "--strict-markers --pdbcls=tests:Debugger -r sxX --cov=async_btree --cov-report=html --cov-report=term-missing:skip-covered" 92 | cache_dir = ".cache" 93 | 94 | [tool.ruff] 95 | cache-dir = ".cache/ruff" 96 | line-length = 120 97 | indent-width = 4 98 | target-version = "py39" 99 | 100 | [tool.ruff.lint] 101 | select = [ 102 | # pycodestyle 103 | "E", 104 | # Pyflakes 105 | "F", 106 | # pyupgrade 107 | #"UP", 108 | # flake8-bugbear 109 | #"B", 110 | # flake8-simplify 111 | "SIM", 112 | # isort 113 | "I", 114 | ] 115 | fixable = ["ALL"] 116 | unfixable = [] 117 | 118 | [tool.ruff.format] 119 | quote-style = "double" 120 | indent-style = "space" 121 | skip-magic-trailing-comma = false 122 | line-ending = "auto" 123 | docstring-code-format = true 124 | docstring-code-line-length = "dynamic" 125 | 126 | 127 | [tool.pyright] 128 | include = ["async_btree"] 129 | exclude = [ 130 | "**/node_modules", 131 | "**/__pycache__", 132 | "async_btree/experimental", 133 | "async_btree/typestubs", 134 | ] 135 | ignore = ["tests"] 136 | defineConstant = { DEBUG = true } 137 | reportMissingImports = true 138 | reportMissingTypeStubs = false 139 | 140 | pythonVersion = "3.9" 141 | pythonPlatform = "Linux" 142 | 143 | 144 | 145 | [tool.poe.tasks] 146 | _build = "uv build" 147 | _publish = "uv publish" 148 | 149 | 150 | [tool.poe.tasks.types] 151 | help = "Run the type checker" 152 | cmd = "uv run pyright" 153 | 154 | [tool.poe.tasks.lint] 155 | help = "Run linting tools on the code base" 156 | cmd = "uv run ruff check --fix ." 157 | 158 | [tool.poe.tasks.style] 159 | help = "Validate black code style" 160 | shell = """ 161 | uv run ruff check --select I --fix 162 | uv run ruff format . 163 | """ 164 | 165 | [tool.poe.tasks.test] 166 | help = "Run unit tests" 167 | shell = """ 168 | if test -e .cache/v/cache/lastfailed; then uv run pytest tests --last-failed --exitfirst; fi & 169 | rm -rf .cache/v/cache/lastfailed & 170 | uv run pytest 171 | """ 172 | 173 | [tool.poe.tasks.check] 174 | help = "Run all checks on the code base" 175 | sequence = [ "style", "types", "lint", "test"] 176 | 177 | 178 | [tool.poe.tasks.build] 179 | help = "Build module" 180 | sequence = ["check", "_build"] 181 | 182 | [tool.poe.tasks.publish] 183 | help = "Publish module" 184 | sequence = ["build", "_publish"] 185 | 186 | [tool.poe.tasks.docs] 187 | help = "Build site documentation" 188 | shell = """ 189 | git fetch origin gh-pages & 190 | uv run mkdocs build --clean 191 | """ 192 | 193 | [tool.poe.tasks.docs-publish] 194 | help = "Publish site documentation" 195 | cmd = """ 196 | uv run mkdocs gh-deploy --clean 197 | """ 198 | 199 | [tool.poe.tasks.clean] 200 | help = "Remove all generated and temporary files" 201 | shell = """ 202 | uv cache clean 203 | rm -rf *.spec dist build .eggs *.egg-info .install .cache .coverage htmlcov .mypy_cache .pytest_cache site .ruff_cache & 204 | find async_btree tests -type d -name '__pycache__' -exec rm -rf {} + 205 | """ 206 | 207 | [tool.poe.tasks.requirements] 208 | help = "Generate requirements.txt" 209 | cmd = "uv pip compile pyproject.toml -o requirements.txt " 210 | capture_stdout = "requirements.txt" 211 | 212 | [build-system] 213 | requires = ["setuptools>=75"] 214 | build-backend = "setuptools.build_meta" 215 | 216 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Unit tests configuration file.""" 2 | 3 | import logging 4 | 5 | 6 | def pytest_configure(config): 7 | """Disable verbose output when running tests.""" 8 | _logger = logging.getLogger() 9 | _logger.setLevel(logging.DEBUG) 10 | 11 | terminal = config.pluginmanager.getplugin("terminal") 12 | terminal.TerminalReporter.showfspath = False 13 | -------------------------------------------------------------------------------- /tests/test_action.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from async_btree import ControlFlowException, action 4 | 5 | 6 | @pytest.mark.curio 7 | async def test_action_result_with_exceptions(): 8 | def div_zero(): 9 | return 1 / 0 10 | 11 | fn = action(target=div_zero) 12 | assert fn 13 | with pytest.raises(ControlFlowException): 14 | await fn() 15 | 16 | assert fn.__node_metadata.name == "action" 17 | assert "_target" in fn.__node_metadata.properties 18 | -------------------------------------------------------------------------------- /tests/test_analyze.py: -------------------------------------------------------------------------------- 1 | from contextvars import ContextVar 2 | 3 | import pytest 4 | 5 | from async_btree import ( 6 | action, 7 | alias, 8 | analyze, 9 | inverter, 10 | repeat_until, 11 | retry, 12 | sequence, 13 | stringify_analyze, 14 | ) 15 | 16 | max_n = ContextVar("max_n", default=5) 17 | 18 | 19 | async def success_until_zero(): 20 | max_n.set(max_n.get() - 1) 21 | return max_n.get() >= 0 22 | 23 | 24 | def hello(): # we could define it as sync or async 25 | n = max_n.get() 26 | if n > 0: 27 | print(f"{n}...") 28 | else: 29 | print("BOOM !!") 30 | 31 | 32 | def test_node_str(): 33 | node = analyze(alias(child=action(target=hello), name="a test")) 34 | printed_tree = """ --> a test:\n --(child)--> action:\n target: hello\n""" 35 | assert str(node) == printed_tree 36 | 37 | 38 | def test_analyze_tree_1(): 39 | tree_1 = alias( 40 | child=repeat_until(child=action(hello), condition=success_until_zero), 41 | name="btree_1", 42 | ) 43 | 44 | a_tree_1 = analyze(tree_1) 45 | 46 | printed_tree = """ --> btree_1:\n --(child)--> repeat_until:\n --(condition)--> success_until_zero:\n --(child)--> action:\n target: hello\n""" # noqa: E501, B950 47 | 48 | assert stringify_analyze(a_tree_1) == printed_tree 49 | 50 | 51 | def test_analyze_tree_2(): 52 | tree_2 = retry(child=inverter(child=action(hello)), max_retry=10) 53 | a_tree_2 = analyze(tree_2) 54 | 55 | printed_tree = """ --> retry:\n max_retry: 10\n --(child)--> inverter:\n --(child)--> action:\n target: hello\n""" # noqa: E501, B950 56 | 57 | assert stringify_analyze(a_tree_2) == printed_tree 58 | 59 | 60 | def test_analyze_failure(): 61 | def ugly(): 62 | return True 63 | 64 | ugly.__node_metadata = {} 65 | 66 | with pytest.raises(RuntimeError): 67 | analyze(ugly) 68 | 69 | 70 | def test_analyze_simple_function(): 71 | print_test = """ --> hello:\n""" 72 | assert stringify_analyze(analyze(hello)) == print_test 73 | 74 | 75 | def test_analyze_sequence(): 76 | a_tree = analyze( 77 | alias( 78 | child=repeat_until( 79 | child=sequence(children=[action(hello), action(hello), action(hello)]), 80 | condition=success_until_zero, 81 | ), 82 | name="btree_1", 83 | ) 84 | ) 85 | print_test = """ --> btree_1:\n --(child)--> repeat_until:\n --(condition)--> success_until_zero:\n --(child)--> sequence:\n succes_threshold: 3\n --(children)--> action:\n target: hello\n --(children)--> action:\n target: hello\n --(children)--> action:\n target: hello\n""" # noqa: E501, B950 86 | assert stringify_analyze(a_tree) == print_test 87 | -------------------------------------------------------------------------------- /tests/test_basics.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from async_btree import FAILURE, SUCCESS, ControlFlowException, node_metadata 4 | 5 | 6 | def test_truthy(): 7 | assert SUCCESS 8 | assert Exception() 9 | assert [1, 2] 10 | 11 | 12 | def test_falsy(): 13 | assert not [] 14 | assert not bool([]) 15 | assert not FAILURE 16 | assert not bool(FAILURE) 17 | assert not bool(ControlFlowException(Exception())) 18 | 19 | 20 | def test_exception_decorator_falsy(): 21 | assert bool(Exception()) 22 | assert not bool(ControlFlowException(Exception())) 23 | assert str(ControlFlowException(Exception("test"))) == str(Exception("test")) 24 | assert repr(ControlFlowException(Exception("test"))) == repr(Exception("test")) 25 | 26 | 27 | def test_exception_deduplicate(): 28 | a = ControlFlowException(Exception("test 1")) 29 | b = ControlFlowException(Exception("test 2")) 30 | assert a != b 31 | assert a == ControlFlowException.instanciate(a) 32 | 33 | 34 | @pytest.mark.curio 35 | async def test_node_metadata_do_not_change_behavior(): 36 | async def a_func(): 37 | return "a" 38 | 39 | assert await a_func() == "a" 40 | # no change on behavior 41 | assert await node_metadata()(a_func)() == "a" 42 | -------------------------------------------------------------------------------- /tests/test_control.py: -------------------------------------------------------------------------------- 1 | from contextvars import ContextVar 2 | 3 | import pytest 4 | 5 | from async_btree import ( 6 | FAILURE, 7 | SUCCESS, 8 | ControlFlowException, 9 | decision, 10 | fallback, 11 | ignore_exception, 12 | repeat_until, 13 | selector, 14 | sequence, 15 | ) 16 | 17 | 18 | async def a_func(): 19 | return "a" 20 | 21 | 22 | async def b_func(): 23 | return "b" 24 | 25 | 26 | async def failure_func(): 27 | return FAILURE 28 | 29 | 30 | async def success_func(): 31 | return SUCCESS 32 | 33 | 34 | async def exception_func(): 35 | raise RuntimeError("ops") 36 | 37 | 38 | @pytest.mark.curio 39 | async def test_sequence(): 40 | assert not await sequence(children=[a_func, failure_func, success_func])(), "default behaviour fail of one failed" 41 | 42 | assert await sequence(children=[a_func, failure_func, success_func], succes_threshold=2)() 43 | assert await sequence(children=[a_func, success_func, failure_func], succes_threshold=2)() 44 | assert await sequence(children=[failure_func, a_func, success_func], succes_threshold=2)(), ( 45 | "must continue after first failure" 46 | ) 47 | 48 | with pytest.raises(RuntimeError): 49 | assert await sequence(children=[exception_func, failure_func, a_func], succes_threshold=1)() 50 | 51 | with pytest.raises(RuntimeError): 52 | assert not await sequence(children=[failure_func, exception_func], succes_threshold=1)() 53 | 54 | assert not await sequence(children=[])() 55 | # negative 56 | with pytest.raises(AssertionError): 57 | sequence(children=[exception_func, failure_func], succes_threshold=-2) 58 | # upper than len children 59 | with pytest.raises(AssertionError): 60 | sequence(children=[exception_func, failure_func], succes_threshold=3) 61 | 62 | meta = sequence(children=[]).__node_metadata 63 | assert meta.name == "sequence" 64 | assert "_succes_threshold" in meta.properties 65 | 66 | 67 | @pytest.mark.curio 68 | async def test_fallback(): 69 | with pytest.raises(RuntimeError): 70 | assert await fallback(children=[exception_func, failure_func, a_func])() 71 | assert await fallback(children=[a_func, failure_func])() == ["a"] 72 | assert not await fallback(children=[])() 73 | assert fallback(children=[]).__node_metadata.name == "fallback" 74 | 75 | 76 | @pytest.mark.curio 77 | async def test_selector(): 78 | with pytest.raises(RuntimeError): 79 | assert await selector(children=[exception_func, failure_func, a_func])() 80 | assert await selector(children=[a_func, failure_func])() == ["a"] 81 | assert selector(children=[]).__node_metadata.name == "selector" 82 | 83 | 84 | @pytest.mark.curio 85 | async def test_decision(): 86 | assert await decision(condition=success_func, success_tree=a_func)() == "a" 87 | # return SUCCESS when no failure_tree and False condition result 88 | assert await decision(condition=failure_func, success_tree=a_func)() 89 | 90 | result = await decision(condition=failure_func, success_tree=a_func, failure_tree=b_func)() 91 | assert result == "b", "failure tree must be called" 92 | 93 | meta = decision(condition=failure_func, success_tree=a_func).__node_metadata 94 | assert meta.name == "decision" 95 | for key in ["_condition", "_success_tree", "_failure_tree"]: 96 | assert key in meta.edges 97 | 98 | 99 | @pytest.mark.curio 100 | async def test_repeat_until_falsy_condition(): 101 | counter = ContextVar("counter", default=5) 102 | 103 | async def tick(): 104 | value = counter.get() 105 | counter.set(value - 1) 106 | if value <= 0: 107 | return FAILURE 108 | if value == 3: 109 | raise RuntimeError("3") 110 | return SUCCESS 111 | 112 | assert await repeat_until(condition=ignore_exception(tick), child=a_func)() == "a", "return last sucess result" 113 | assert counter.get() == 2 114 | 115 | meta = repeat_until(condition=ignore_exception(tick), child=a_func).__node_metadata 116 | assert meta.name == "repeat_until" 117 | for key in ["_condition", "_child"]: 118 | assert key in meta.edges 119 | 120 | 121 | @pytest.mark.curio 122 | async def test_repeat_until_return_last_result(): 123 | counter = ContextVar("tick_test_repeat_until_return_last_result", default=5) 124 | 125 | async def tick(): 126 | value = counter.get() 127 | counter.set(value - 1) 128 | if value <= 0: 129 | return FAILURE 130 | return SUCCESS 131 | 132 | result = await repeat_until(condition=tick, child=ignore_exception(exception_func))() 133 | assert counter.get() == -1 134 | assert isinstance(result, ControlFlowException) 135 | -------------------------------------------------------------------------------- /tests/test_decorator.py: -------------------------------------------------------------------------------- 1 | from contextvars import ContextVar 2 | 3 | import pytest 4 | 5 | from async_btree import ( 6 | FAILURE, 7 | SUCCESS, 8 | ControlFlowException, 9 | alias, 10 | always_failure, 11 | always_success, 12 | decorate, 13 | ignore_exception, 14 | inverter, 15 | is_failure, 16 | is_success, 17 | retry, 18 | retry_until_failed, 19 | retry_until_success, 20 | ) 21 | 22 | 23 | async def a_func(): 24 | return "a" 25 | 26 | 27 | async def failure_func(): 28 | return FAILURE 29 | 30 | 31 | async def success_func(): 32 | return SUCCESS 33 | 34 | 35 | async def exception_func(): 36 | raise RuntimeError() 37 | 38 | 39 | async def empty_func(): 40 | return [] 41 | 42 | 43 | @pytest.mark.curio 44 | async def test_alias_name(): 45 | rooted = alias(child=a_func, name="a_func") 46 | assert rooted.__node_metadata.name == "a_func" 47 | assert await rooted() == "a" 48 | 49 | 50 | @pytest.mark.curio 51 | async def test_alias_not_override(): 52 | a_rooted = alias(child=a_func, name="a_func") 53 | b_rooted = alias(child=a_func, name="b_func") 54 | assert a_rooted.__node_metadata.name == "a_func" 55 | assert b_rooted.__node_metadata.name == "b_func" 56 | 57 | 58 | @pytest.mark.curio 59 | async def test_decorate(): 60 | async def b_decorator(child_value, other=""): 61 | return f"b{child_value}{other}" 62 | 63 | assert await decorate(a_func, b_decorator)() == "ba" 64 | 65 | assert await decorate(a_func, b_decorator, other="c")() == "bac" 66 | meta = decorate(a_func, b_decorator).__node_metadata 67 | assert meta.name == "decorate" 68 | assert "_decorator" in meta.properties 69 | 70 | 71 | @pytest.mark.curio 72 | async def test_always_success(): 73 | assert await always_success(success_func)() == SUCCESS 74 | assert await always_success(failure_func)() == SUCCESS 75 | with pytest.raises(ControlFlowException): 76 | await always_success(exception_func)() 77 | assert await always_success(a_func)() == "a" 78 | 79 | meta = always_success(a_func).__node_metadata 80 | assert meta.name == "always_success" 81 | 82 | 83 | @pytest.mark.curio 84 | async def test_always_failure(): 85 | assert await always_failure(success_func)() == FAILURE 86 | assert await always_failure(failure_func)() == FAILURE 87 | with pytest.raises(ControlFlowException): 88 | await always_failure(exception_func)() 89 | assert isinstance(await always_failure(exception_func)(), ControlFlowException) 90 | assert await always_failure(empty_func)() == [] 91 | 92 | meta = always_failure(empty_func).__node_metadata 93 | assert meta.name == "always_failure" 94 | 95 | 96 | @pytest.mark.curio 97 | async def test_is_success(): 98 | assert await is_success(success_func)() 99 | assert not await is_success(failure_func)() 100 | with pytest.raises(RuntimeError): 101 | await is_success(exception_func)() 102 | assert await is_success(a_func)() 103 | assert not await is_success(empty_func)() 104 | 105 | 106 | @pytest.mark.curio 107 | async def test_is_failure(): 108 | assert not await is_failure(success_func)() 109 | assert await is_failure(failure_func)() 110 | with pytest.raises(RuntimeError): 111 | assert await is_failure(exception_func)() 112 | assert not await is_failure(a_func)() 113 | assert await is_failure(empty_func)() 114 | 115 | meta = is_failure(empty_func).__node_metadata 116 | assert meta.name == "is_failure" 117 | 118 | 119 | @pytest.mark.curio 120 | async def test_inverter(): 121 | assert not await inverter(success_func)() 122 | assert await inverter(failure_func)() 123 | with pytest.raises(RuntimeError): 124 | await inverter(exception_func)() 125 | assert not await inverter(a_func)() 126 | assert await inverter(empty_func)() 127 | 128 | meta = inverter(a_func).__node_metadata 129 | assert meta.name == "inverter" 130 | 131 | 132 | @pytest.mark.curio 133 | async def test_retry(): 134 | counter = ContextVar("counter_test_retry", default=5) 135 | 136 | async def tick(): 137 | value = counter.get() 138 | counter.set(value - 1) 139 | print(f"value: {value}") 140 | if value <= 0: 141 | return SUCCESS 142 | if value == 3: 143 | raise RuntimeError("3") 144 | return FAILURE 145 | 146 | result = await retry(ignore_exception(tick))() # counter: 5, 4, 3 147 | assert not result 148 | assert isinstance(result, ControlFlowException) 149 | 150 | assert await retry(tick)() # counter: 2, 1, 0 151 | 152 | # let raise RuntimeError 153 | counter.set(3) 154 | with pytest.raises(RuntimeError): 155 | await tick() 156 | 157 | counter.set(10) 158 | assert await retry(ignore_exception(tick), max_retry=11)() 159 | 160 | counter.set(100) 161 | assert await retry(ignore_exception(tick), max_retry=-1)() 162 | 163 | with pytest.raises(AssertionError): 164 | retry(ignore_exception(tick), max_retry=0) 165 | with pytest.raises(AssertionError): 166 | retry(ignore_exception(tick), max_retry=-2) 167 | 168 | meta = retry(ignore_exception(tick)).__node_metadata 169 | assert meta.name == "retry" 170 | assert "max_retry" in meta.properties 171 | 172 | 173 | @pytest.mark.curio 174 | async def test_retry_until_success(): 175 | counter = ContextVar("counter_test_retry_until_success", default=5) 176 | 177 | async def tick(): 178 | value = counter.get() 179 | counter.set(value - 1) 180 | if value <= 0: 181 | return SUCCESS 182 | if value == 3: 183 | raise RuntimeError("3") 184 | return FAILURE 185 | 186 | counter.set(100) 187 | assert await retry_until_success(ignore_exception(tick))() 188 | 189 | meta = retry_until_success(ignore_exception(tick)).__node_metadata 190 | assert meta.name == "retry_until_success" 191 | assert "max_retry" in meta.properties 192 | 193 | 194 | @pytest.mark.curio 195 | async def test_retry_until_failed(): 196 | counter = ContextVar("counter_test_retry_until_failed", default=5) 197 | 198 | async def tick(): 199 | value = counter.get() 200 | counter.set(value - 1) 201 | if value <= 0: 202 | return SUCCESS 203 | if value == 3: 204 | raise RuntimeError("3") 205 | return FAILURE 206 | 207 | counter.set(100) 208 | assert await retry_until_failed(tick)() 209 | 210 | meta = retry_until_failed(ignore_exception(tick)).__node_metadata 211 | assert meta.name == "retry_until_failed" 212 | assert "max_retry" in meta.properties 213 | -------------------------------------------------------------------------------- /tests/test_leaf.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from async_btree import action, condition 4 | 5 | 6 | @pytest.mark.curio 7 | async def test_condition(): 8 | async def target_test(value): 9 | return value 10 | 11 | assert await condition(target_test, value=True)() # pylint: disable=unexpected-keyword-arg 12 | 13 | assert not await condition(target_test, value=False)() # pylint: disable=unexpected-keyword-arg 14 | assert condition(target_test, value=False).__node_metadata.name == "condition" 15 | assert "target" in condition(target_test, value=False).__node_metadata.properties 16 | 17 | 18 | @pytest.mark.curio 19 | async def test_action_with_exception_is_falsy(): 20 | async def generate_exception(): 21 | raise Exception("Bing!") 22 | 23 | assert not await action(generate_exception)() 24 | 25 | 26 | @pytest.mark.curio 27 | async def test_action_results(): 28 | async def compute(a, b): 29 | return a + b 30 | 31 | assert await action(compute, a=1, b=1)() == 2 32 | assert action(compute, a=1, b=1).__node_metadata.name == "action" 33 | -------------------------------------------------------------------------------- /tests/test_map_filter.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from async_btree import afilter, amap 4 | 5 | 6 | async def inc(a): 7 | return a + 1 8 | 9 | 10 | async def even(a): 11 | return a % 2 == 0 12 | 13 | 14 | @pytest.mark.curio 15 | async def test_amap_on_iterable(): 16 | async def process(): 17 | return [i async for i in amap(inc, [1, 2])] 18 | 19 | assert await process() == [2, 3] 20 | 21 | 22 | @pytest.mark.curio 23 | async def test_afilter_on_iterable(): 24 | async def process(): 25 | return [i async for i in afilter(even, [0, 1, 2, 3, 4])] 26 | 27 | assert await process() == [0, 2, 4] 28 | 29 | 30 | @pytest.mark.curio 31 | async def test_afilter_amap_aiter(): 32 | async def process1(): 33 | return [i async for i in afilter(even, amap(inc, [0, 1, 2, 3, 4]))] 34 | 35 | async def process2(): 36 | return [i async for i in amap(inc, afilter(even, [0, 1, 2, 3, 4]))] 37 | 38 | assert await process1() == [2, 4] 39 | assert await process2() == [1, 3, 5] 40 | -------------------------------------------------------------------------------- /tests/test_parallele.py: -------------------------------------------------------------------------------- 1 | from asyncio import sleep as asyncio_sleep 2 | 3 | import pytest 4 | from curio import sleep 5 | 6 | from async_btree import FAILURE, parallele 7 | from async_btree.parallele import parallele_asyncio 8 | 9 | 10 | async def a_func(): 11 | await sleep(1) 12 | return "a" 13 | 14 | 15 | async def b_func(): 16 | await sleep(3) 17 | return "b" 18 | 19 | 20 | async def failure_func(): 21 | await sleep(2) 22 | return FAILURE 23 | 24 | 25 | async def asyncio_a_func(): 26 | await asyncio_sleep(1) 27 | return "a" 28 | 29 | 30 | async def asyncio_b_func(): 31 | await asyncio_sleep(3) 32 | return "b" 33 | 34 | 35 | async def asyncio_failure_func(): 36 | await asyncio_sleep(2) 37 | return FAILURE 38 | 39 | 40 | def c_func(): 41 | return "c" 42 | 43 | 44 | @pytest.mark.curio 45 | async def test_parallele(): 46 | assert await parallele(children=[a_func])() 47 | assert await parallele(children=[a_func, b_func])() 48 | assert not await parallele(children=[a_func, b_func, failure_func])() 49 | assert await parallele(children=[a_func, b_func, failure_func], succes_threshold=2)() 50 | # negative 51 | with pytest.raises(AssertionError): 52 | parallele(children=[a_func, b_func, failure_func], succes_threshold=-2) 53 | # upper than len children 54 | with pytest.raises(AssertionError): 55 | parallele(children=[a_func, b_func, failure_func], succes_threshold=4) 56 | 57 | meta = parallele(children=[a_func, b_func, failure_func], succes_threshold=2).__node_metadata 58 | assert meta.name == "parallele" 59 | assert "succes_threshold" in meta.properties 60 | 61 | 62 | @pytest.mark.curio 63 | async def test_parallele_with_sync_function(): 64 | assert await parallele(children=[c_func])() 65 | assert await parallele(children=[a_func, b_func, c_func])() 66 | assert not await parallele(children=[a_func, b_func, c_func, failure_func])() 67 | 68 | 69 | @pytest.mark.asyncio 70 | async def test_parallele_asyncio(): 71 | assert await parallele_asyncio(children=[asyncio_a_func], succes_threshold=1)() 72 | assert await parallele_asyncio(children=[asyncio_a_func, asyncio_b_func], succes_threshold=2)() 73 | assert not await parallele_asyncio( 74 | children=[asyncio_a_func, asyncio_b_func, asyncio_failure_func], 75 | succes_threshold=3, 76 | )() 77 | assert await parallele_asyncio( 78 | children=[asyncio_a_func, asyncio_b_func, asyncio_failure_func], 79 | succes_threshold=2, 80 | )() 81 | 82 | meta = parallele_asyncio( 83 | children=[asyncio_a_func, asyncio_b_func, asyncio_failure_func], 84 | succes_threshold=2, 85 | ).__node_metadata 86 | assert meta.name == "parallele" 87 | assert "succes_threshold" in meta.properties 88 | -------------------------------------------------------------------------------- /tests/test_runner.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from contextvars import ContextVar 3 | 4 | import pytest 5 | 6 | from async_btree import BTreeRunner 7 | 8 | counter = ContextVar("counter", default=5) 9 | 10 | 11 | async def a_func(): 12 | return "a" 13 | 14 | 15 | async def inc(i): 16 | return i + 1 17 | 18 | 19 | async def dec_counter(): 20 | counter.set(counter.get() - 1) 21 | return counter.get() > 0 22 | 23 | 24 | def test_curio_runner(): 25 | with BTreeRunner() as r: 26 | assert r.run(a_func) == "a" 27 | assert r.run(inc, 2) == 3 28 | 29 | 30 | def test_asyncio_runner(): 31 | if sys.version_info.minor < 11: 32 | with pytest.raises(RuntimeError), BTreeRunner(disable_curio=True) as r: 33 | assert r.run(a_func) == "a" 34 | assert r.run(inc, 2) == 3 35 | else: 36 | with BTreeRunner(disable_curio=True) as r: 37 | assert r.run(a_func) == "a" 38 | assert r.run(inc, 2) == 3 39 | 40 | 41 | def _check_sequence(runner: BTreeRunner): 42 | with runner: 43 | assert runner.run(dec_counter) 44 | assert runner.run(dec_counter) 45 | assert runner.run(dec_counter) 46 | assert runner.run(dec_counter) 47 | assert not runner.run(dec_counter) 48 | 49 | 50 | def test_curio_runner_share_context(): 51 | counter.set(5) 52 | _check_sequence(runner=BTreeRunner()) 53 | 54 | # we did not affect man context 55 | assert counter.get() == 5 56 | 57 | _check_sequence(runner=BTreeRunner()) 58 | 59 | 60 | def test_asyncio_runner_share_context(): 61 | counter.set(5) 62 | 63 | if sys.version_info.minor < 11: 64 | with pytest.raises(RuntimeError), BTreeRunner(disable_curio=True) as r: 65 | assert r.run(a_func) == "a" 66 | else: 67 | _check_sequence(runner=BTreeRunner(disable_curio=True)) 68 | assert counter.get() == 5 69 | _check_sequence(runner=BTreeRunner(disable_curio=True)) 70 | -------------------------------------------------------------------------------- /tests/test_runonce.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from async_btree.utils import run_once 4 | 5 | 6 | @run_once 7 | def inc(a: int): 8 | return a + 1 9 | 10 | 11 | @run_once 12 | async def ainc(a: int): 13 | return a + 1 14 | 15 | 16 | def test_sync_runonce(): 17 | assert inc(a=1) == 2 18 | assert inc(a=2) == 2 19 | 20 | 21 | @pytest.mark.curio 22 | async def test_async_runonce(): 23 | assert await ainc(a=1) == 2 24 | assert await ainc(a=2) == 2 # call once 25 | -------------------------------------------------------------------------------- /tests/test_usage.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from async_btree import FAILURE, SUCCESS, decision 4 | 5 | 6 | async def i_fail(): 7 | return FAILURE 8 | 9 | 10 | async def some_action(): 11 | print("continue here...") 12 | return SUCCESS 13 | 14 | 15 | @pytest.mark.curio 16 | async def test_usage(): 17 | tree = decision(condition=i_fail, success_tree=some_action, failure_tree=lambda: 42) 18 | 19 | assert await tree() == 42 20 | -------------------------------------------------------------------------------- /tests/test_utils_run.py: -------------------------------------------------------------------------------- 1 | # from asyncio import run as run_asyncio 2 | from contextvars import ContextVar, copy_context 3 | 4 | from curio import Kernel 5 | 6 | from async_btree.utils import has_curio 7 | 8 | counter = ContextVar("counter", default=5) 9 | 10 | 11 | async def reset_counter(): 12 | if counter.get() == 5: 13 | counter.set(0) 14 | return 0 15 | return -1 16 | 17 | 18 | def test_run_curio_with_separate_contextvar(): 19 | counter.set(5) 20 | with Kernel() as k: 21 | assert copy_context().run(k.run, reset_counter) == 0 22 | assert copy_context().run(k.run, reset_counter) == 0 23 | assert counter.get() == 5 24 | 25 | assert counter.get() == 5 26 | 27 | 28 | def test_run_curio_with_same_contextvar(): 29 | counter.set(5) 30 | with Kernel() as k: 31 | assert k.run(reset_counter) == 0 32 | assert k.run(reset_counter) == -1 33 | assert counter.get() == 0 34 | 35 | assert counter.get() == 0 36 | 37 | 38 | def test_has_curio(): 39 | assert has_curio() 40 | --------------------------------------------------------------------------------