├── .benchman └── nutree.61bdee7c56e0e5f7.base.bench.json ├── .github ├── FUNDING.yml └── workflows │ ├── codeql.yml │ ├── stale.yml │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CHANGELOG.md ├── LICENSE.txt ├── Pipfile ├── Pipfile.lock ├── README.md ├── docs ├── jupyter │ ├── take_the_tour.ipynb │ └── tutorial.ipynb ├── nutree_160x160.png ├── nutree_48x48.png └── sphinx │ ├── Makefile │ ├── _themes │ ├── .gitignore │ ├── LICENSE │ └── flask_theme_support.py │ ├── apple-touch-icon.png │ ├── changes.rst │ ├── conf.py │ ├── development.rst │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── genindex.rst │ ├── index.rst │ ├── installation.rst │ ├── make.bat │ ├── mypy_type_error.png │ ├── pylance_autocomlete.png │ ├── pylance_type_info.png │ ├── pyright_type_error.png │ ├── reference_guide.rst │ ├── requirements.txt │ ├── rg_modules.rst │ ├── take_the_tour.md │ ├── take_the_tour_files │ ├── pylance_autocomlete.png │ ├── pylance_type_info.png │ └── pyright_type_error.png │ ├── test_graph_diff_2.png │ ├── test_graph_diff_3.png │ ├── test_mermaid_default.png │ ├── test_mermaid_typed.png │ ├── test_mermaid_typed_forest.png │ ├── tree_graph.png │ ├── tree_graph_diff.png │ ├── tree_graph_no_root.png │ ├── tree_graph_pencil.png │ ├── tree_graph_single_inst.png │ ├── typed_tree_graph.png │ ├── ug_advanced.rst │ ├── ug_basics.rst │ ├── ug_benchmarks.md │ ├── ug_clones.rst │ ├── ug_diff.rst │ ├── ug_graphs.rst │ ├── ug_mutation.rst │ ├── ug_objects.rst │ ├── ug_pretty_print.rst │ ├── ug_randomize.rst │ ├── ug_search_and_navigate.rst │ ├── ug_serialize.rst │ └── user_guide.rst ├── nutree ├── __init__.py ├── common.py ├── diff.py ├── dot.py ├── fs.py ├── mermaid.py ├── node.py ├── py.typed ├── rdf.py ├── tree.py ├── tree_generator.py └── typed_tree.py ├── pyproject.toml ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── fixture.py ├── fixtures │ ├── file_1.txt │ └── folder_1 │ │ └── file_1_1.txt ├── make_sample_files.py ├── pytest.ini ├── temp │ └── .gitkeep ├── test_bench.py ├── test_clones.py ├── test_core.py ├── test_diff.py ├── test_dot.py ├── test_fixtures.py ├── test_fs.py ├── test_mermaid.py ├── test_objects.py ├── test_rdf.py ├── test_serialize.py ├── test_tree_generator.py ├── test_typed_tree.py ├── test_typing.py └── test_typing_concept.py ├── tox.ini ├── tox_benchmarks.ini └── yabs.yaml /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: mar10w # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | # custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | custom: ['https://www.paypal.com/donate/?hosted_button_id=RA6G29AZRUD44'] 15 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '30 23 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | paths: 39 | - nutree 40 | # paths-ignore: 41 | # - src/node_modules 42 | # - '**/*.test.js' 43 | 44 | steps: 45 | - name: Checkout repository 46 | uses: actions/checkout@v3 47 | 48 | # Initializes the CodeQL tools for scanning. 49 | - name: Initialize CodeQL 50 | uses: github/codeql-action/init@v2 51 | with: 52 | languages: ${{ matrix.language }} 53 | # If you wish to specify custom queries, you can do so here or in a config file. 54 | # By default, queries listed here will override any specified in a config file. 55 | # Prefix the list here with "+" to use these queries and those in the config file. 56 | 57 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 58 | # queries: security-extended,security-and-quality 59 | 60 | 61 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 62 | # If this step fails, then you should remove it and run the build manually (see below) 63 | - name: Autobuild 64 | uses: github/codeql-action/autobuild@v2 65 | 66 | # ℹ️ Command-line programs to run using the OS shell. 67 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 68 | 69 | # If the Autobuild fails above, remove it and uncomment the following three lines. 70 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 71 | 72 | # - run: | 73 | # echo "Run, Build Application using script" 74 | # ./location_of_script_within_repo/buildscript.sh 75 | 76 | - name: Perform CodeQL Analysis 77 | uses: github/codeql-action/analyze@v2 78 | with: 79 | category: "/language:${{matrix.language}}" 80 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | # This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. 2 | # 3 | # You can adjust the behavior by modifying this file. 4 | # For more information, see: 5 | # https://github.com/actions/stale 6 | name: Mark stale issues and pull requests 7 | 8 | on: 9 | schedule: 10 | - cron: '22 20 * * *' 11 | 12 | jobs: 13 | stale: 14 | 15 | runs-on: ubuntu-latest 16 | permissions: 17 | issues: write 18 | pull-requests: write 19 | 20 | steps: 21 | - uses: actions/stale@v5 22 | with: 23 | repo-token: ${{ secrets.GITHUB_TOKEN }} 24 | 25 | days-before-stale: 90 26 | days-before-close: 14 27 | exempt-all-milestones: true 28 | operations-per-run: 5 29 | 30 | stale-issue-label: 'no-issue-activity' 31 | exempt-issue-labels: 'pinned,security' 32 | stale-issue-message: | 33 | This issue has been automatically marked as stale because it has not had 34 | recent activity. It will be closed if no further activity occurs. 35 | Thank you for your contributions. 36 | close-issue-reason: 'not_planned' 37 | 38 | stale-pr-label: 'no-pr-activity' 39 | exempt-pr-labels: 'pinned,security' 40 | stale-pr-message: | 41 | This pull request has been automatically marked as stale because it has not had 42 | recent activity. It will be closed if no further activity occurs. 43 | Thank you for your contributions. 44 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Tests 5 | 6 | on: 7 | push: 8 | branches: ["main"] 9 | pull_request: 10 | branches: ["main"] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | # Python 3.4 # EOL 2019-03-18 19 | # Python 3.5 # EOL 2020-09-13 20 | # Python 3.6 # EOL 2021-12-21 21 | # Python 3.7 # EOL 2023-06-27 22 | # Python 3.8 # EOL 2024-10 23 | # Python 3.9 # EOL 2025-10 24 | # Python 3.10 # EOL 2026-10 25 | # Python 3.11 # EOL 2027-10 26 | # Python 3.12 # EOL 2028-10 27 | # Python 3.13 # EOL 2029-10 28 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 29 | 30 | steps: 31 | 32 | - uses: actions/checkout@v4 33 | 34 | - name: Set up Python ${{ matrix.python-version }} 35 | uses: actions/setup-python@v5 36 | with: 37 | python-version: ${{ matrix.python-version }} 38 | 39 | - name: Install dependencies 40 | run: | 41 | sudo apt-get install graphviz 42 | python -V 43 | python -m pip install --upgrade pip 44 | python -m pip install benchman pydot pytest pytest-cov pytest-html rdflib ruff 45 | # if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 46 | python -m pip install -e . 47 | python -m pip list 48 | 49 | - name: Lint with ruff 50 | run: | 51 | ruff -V 52 | ruff check --output-format=github nutree tests setup.py 53 | ruff format --check nutree tests setup.py 54 | 55 | - name: Test with pytest 56 | run: | 57 | pytest -V 58 | pytest -ra -v -x --durations=10 --cov=nutree 59 | # pytest -ra -v -x --durations=10 --cov=nutree --html=build/pytest/report-${{ matrix.python-version }}.html --self-contained-html 60 | # pytest -ra -v -x --durations=10 --cov=nutree --html=build/pytest/report-{envname}.html --self-contained-html {posargs} 61 | 62 | - name: Upload coverage reports to Codecov 63 | uses: codecov/codecov-action@v4 64 | env: 65 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 66 | 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docs/sphinx-build/ 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | pip-wheel-metadata/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | .vscode/ 132 | tests/test_dev_local*.py 133 | .DS_Store 134 | tests/temp/ 135 | .ruff_cache/ 136 | /.benchman 137 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | # Ruff version. 4 | rev: v0.9.5 5 | hooks: 6 | # Run the linter. 7 | - id: ruff 8 | # args: [ --fix ] 9 | 10 | # Run the formatter. 11 | - id: ruff-format 12 | args: [ --check, --diff ] 13 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.12" 13 | apt_packages: 14 | - "graphviz" # Required for Sphinx's graphviz extension (class diagrams) 15 | 16 | # Build documentation in the docs/ directory with Sphinx 17 | sphinx: 18 | configuration: docs/sphinx/conf.py 19 | 20 | # Optionally build your docs in additional formats such as PDF and ePub 21 | formats: [] 22 | # formats: all 23 | 24 | # Optionally set the version of Python and requirements required to build your docs 25 | python: 26 | install: 27 | - requirements: docs/sphinx/requirements.txt 28 | - method: pip 29 | path: . 30 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.1.0 (unreleased) 4 | 5 | - DEPRECATE: `TypedTree.iter_by_type()`. Use `iterator(.., kind)`instead. 6 | - New methods `TypedTree.iterator(..., kind=ANY_KIND)`, 7 | `TypedNode.iterator(..., kind=ANY_KIND)`, 8 | and `TypedTree.count_descendants(leaves_only=False, kind=ANY_KIND)` 9 | 10 | ## 1.0.0 (2024-12-27) 11 | - Add benchmarks (using [Benchman](https://github.com/mar10/benchman)). 12 | - Drop support for Python 3.8 13 | 14 | ## 0.11.1 (2024-11-08) 15 | - `t0.diff(t1, ...)` adds nodes from t1 when possible, so the new status is 16 | used for modified nodes. 17 | - `t0.diff(t1, ...)` marks both, source and target nodes, as modified if 18 | applicable. 19 | 20 | ## 0.11.0 (2024-11-07) 21 | - Implement check for node modifications in `tree.diff(..., compare=True)`. 22 | - `DictWrapper` supports comparision with `==`. 23 | 24 | ## 0.10.0 (2024-11-06) 25 | 26 | - BREAKING: 27 | - `kind` parameter is now mandatory for `add()` and related methods. 28 | `kind=None` is still allowed to use the default (inherit or 'child'). 29 | - Rename `shadow_attrs` argument to `forward_attrs`. 30 | - Enforce that the same object instance is not added multiple times to one parent. 31 | - Rename `GenericNodeData` to `DictWrapper` and remove support for attribut access. 32 | - Drop support for Python 3.8 33 | - mermaid: change mapper signatures and defaults 34 | - tree.to_rdf() is now available for Tree (not only TypedTree). 35 | - New method `node.up()` allows method chaining when adding nodes. 36 | - Pass pyright 'typeCheckingMode = "standard"'. 37 | - Use generic typing for improved type checking, e.g. use `tree = Tree[Animals]()` 38 | to create a type-aware container. 39 | 40 | ## 0.9.0 (2024-09-12) 41 | 42 | - Add `Tree.build_random_tree()` (experimental). 43 | - Add `GenericNodeData` as wrapper for `dict` data. 44 | - Fixed #7 Tree.from_dict failing to recreate an arbitrary object tree with a mapper. 45 | 46 | ## 0.8.0 (2024-03-29) 47 | 48 | - BREAKING: Drop Python 3.7 support (EoL 2023-06-27). 49 | - `Tree.save()` accepts a `compression` argument that will enable compression. 50 | `Tree.load()` can detect if the input file has a compression header and will 51 | decompress transparently. 52 | - New traversal methods `LEVEL_ORDER`, `LEVEL_ORDER_RTL`, `ZIGZAG`, `ZIGZAG_RTL`. 53 | - New compact connector styles `'lines32c'`, `'round43c'`, ... 54 | - Save as mermaid flow diagram. 55 | 56 | ## 0.7.1 (2023-11-08) 57 | 58 | - Support compact serialization forrmat using `key_map` and `value_map`. 59 | - Better support for working with derived classes (overload methods instead of 60 | callbacks). 61 | - Fix invalid UniqueConstraint error message when loading a TypedTree. 62 | - Add optional `tree.print(..., file=IO)` argument. 63 | - Rename `default_connector_style` to `DEFAULT_CONNECTOR_STYLE` 64 | 65 | ## 0.6.0 (2023-11-01) 66 | 67 | - Implement `Tree(..., shadow_attrs=True)`. 68 | - `tree.load(PATH)` / `tree.save(PATH)` use UTF-8 encoding. 69 | - Add `Tree.system_root` as alias for `Tree._root`. 70 | - Add `Tree.get_toplevel_nodes()` as alias for `tree.children.` 71 | - Support and test with Py3.12: don't forget to update pip, pipenv, and tox. 72 | - Deprecate Python 3.7 support (EoL 2023-06-27). 73 | 74 | ## 0.5.1 (2023-05-29) 75 | 76 | - BREAKING: renamed `tree.to_dict()` to `tree.to_dict_list()`. 77 | - BREAKING: changed `tree.load()` / `tree.save()` storage format. 78 | - `tree.load()` / `tree.save()` accept path in addition to file objects. 79 | 80 | ## 0.5.0 (2023-05-28) 81 | 82 | - BREAKING: changed `tree.load()` / `tree.save()` signature, and storage format. 83 | - Support load/save for TypedTree 84 | 85 | ## 0.4.0 (2023-02-22) 86 | 87 | - BREAKING: Rename `node.move()` -> `node.move_to()` 88 | - New `tree.copy_to()` and `node.copy_to()` 89 | - New `tree.children` property 90 | - Configurable default connector style 91 | 92 | ## 0.3.0 (2022-08-01 and before) 93 | 94 | Initial releases. 95 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Martin Wendt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [dev-packages] 7 | benchman = "*" 8 | # benchman = {file = "../benchman", editable = true} 9 | fabulist="*" 10 | ipykernel = "*" 11 | mypy = "*" 12 | notebook = "*" 13 | pre-commit = "*" 14 | pydot = "*" 15 | pyright = "*" 16 | pytest = "*" 17 | pytest-cov = "*" 18 | PyYAML = "*" 19 | rdflib = "*" 20 | ruff = "~=0.9" 21 | setuptools = ">=42.0" 22 | Sphinx = "*" 23 | sphinx_rtd_theme = "*" 24 | tox = "*" 25 | twine = "*" 26 | wheel = "*" 27 | yabs = "*" 28 | nutree = {path = ".",editable = true} 29 | 30 | [packages] 31 | typing_extensions = "*" 32 | 33 | [requires] 34 | python_version = "3.12" 35 | 36 | [pipenv] 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![logo](https://raw.githubusercontent.com/mar10/nutree/main/docs/nutree_48x48.png) nutree 2 | 3 | [![Latest Version](https://img.shields.io/pypi/v/nutree.svg)](https://pypi.python.org/pypi/nutree/) 4 | [![Tests](https://github.com/mar10/nutree/actions/workflows/tests.yml/badge.svg)](https://github.com/mar10/nutree/actions/workflows/tests.yml) 5 | [![codecov](https://codecov.io/github/mar10/nutree/branch/main/graph/badge.svg?token=9xmAFm8Icl)](https://codecov.io/github/mar10/nutree) 6 | [![License](https://img.shields.io/pypi/l/nutree.svg)](https://github.com/mar10/nutree/blob/main/LICENSE.txt) 7 | [![Documentation Status](https://readthedocs.org/projects/nutree/badge/?version=latest)](http://nutree.readthedocs.io/) 8 | [![Released with: Yabs](https://img.shields.io/badge/released%20with-yabs-yellowgreen)](https://github.com/mar10/yabs) 9 | [![StackOverflow: nutree](https://img.shields.io/badge/StackOverflow-nutree-blue.svg)](https://stackoverflow.com/questions/tagged/nutree) 10 | 11 | > _Nutree_ is a Python library for tree data structures with an intuitive, 12 | > yet powerful, API. 13 | 14 | **Nutree Facts** 15 | 16 | Handle multiple references of single objects ('clones')
17 | Search by name pattern, id, or object reference
18 | Compare two trees and calculate patches
19 | Unobtrusive handling of arbitrary objects
20 | Save as DOT file and graphwiz diagram
21 | Nodes can be plain strings or objects
22 | (De)Serialize to (compressed) JSON
23 | Save as Mermaid flow diagram
24 | Different traversal methods
25 | Generate random trees
26 | Convert to RDF graph
27 | Fully type annotated
28 | Typed child nodes
29 | Pretty print
30 | Navigation
31 | Filtering
32 | Fast
33 | 34 | **Example** 35 | 36 | A simple tree, with text nodes 37 | 38 | ```py 39 | from nutree import Tree, Node 40 | 41 | tree = Tree("Store") 42 | 43 | n = tree.add("Records") 44 | 45 | n.add("Let It Be") 46 | n.add("Get Yer Ya-Ya's Out!") 47 | 48 | n = tree.add("Books") 49 | n.add("The Little Prince") 50 | 51 | tree.print() 52 | ``` 53 | 54 | ```ascii 55 | Tree<'Store'> 56 | ├─── 'Records' 57 | │ ├─── 'Let It Be' 58 | │ ╰─── "Get Yer Ya-Ya's Out!" 59 | ╰─── 'Books' 60 | ╰─── 'The Little Prince' 61 | ``` 62 | 63 | Tree nodes wrap the data and also expose methods for navigation, searching, 64 | iteration, ... 65 | 66 | ```py 67 | records_node = tree["Records"] 68 | assert isinstance(records_node, Node) 69 | assert records_node.name == "Records" 70 | 71 | print(records_node.first_child()) 72 | ``` 73 | 74 | ```ascii 75 | Node<'Let It Be', data_id=510268653885439170> 76 | ``` 77 | 78 | Nodes may be strings or arbitrary objects: 79 | 80 | ```py 81 | alice = Person("Alice", age=23, guid="{123-456}") 82 | tree.add(alice) 83 | 84 | # Lookup nodes by object, data_id, name pattern, ... 85 | assert isinstance(tree[alice].data, Person) 86 | 87 | del tree[alice] 88 | ``` 89 | 90 | [Read the Docs](https://nutree.readthedocs.io/) for more. 91 | -------------------------------------------------------------------------------- /docs/jupyter/tutorial.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "attachments": {}, 5 | "cell_type": "markdown", 6 | "metadata": {}, 7 | "source": [ 8 | "# Nutree Tutorial" 9 | ] 10 | }, 11 | { 12 | "cell_type": "markdown", 13 | "metadata": {}, 14 | "source": [ 15 | "Nutree organizes arbitrary object instances in an unobtrusive way.
\n", 16 | "That means, we can add existing objects without havin to derrive from a common \n", 17 | "base class or having them implement a specific protocol." 18 | ] 19 | }, 20 | { 21 | "cell_type": "markdown", 22 | "metadata": {}, 23 | "source": [ 24 | "# Setup some sample classes and objects" 25 | ] 26 | }, 27 | { 28 | "cell_type": "code", 29 | "execution_count": 31, 30 | "metadata": {}, 31 | "outputs": [], 32 | "source": [ 33 | "import uuid\n", 34 | "\n", 35 | "\n", 36 | "class Department:\n", 37 | " def __init__(self, name: str):\n", 38 | " self.guid = uuid.uuid4()\n", 39 | " self.name = name\n", 40 | "\n", 41 | " def __str__(self):\n", 42 | " return f\"Department<{self.name}>\"\n", 43 | "\n", 44 | "\n", 45 | "class Person:\n", 46 | " def __init__(self, name: str, age: int):\n", 47 | " self.guid = uuid.uuid4()\n", 48 | " self.name = name\n", 49 | " self.age = age\n", 50 | "\n", 51 | " def __str__(self):\n", 52 | " return f\"Person<{self.name} ({self.age})>\"" 53 | ] 54 | }, 55 | { 56 | "cell_type": "markdown", 57 | "metadata": {}, 58 | "source": [ 59 | "Now create some instances" 60 | ] 61 | }, 62 | { 63 | "cell_type": "code", 64 | "execution_count": 32, 65 | "metadata": {}, 66 | "outputs": [], 67 | "source": [ 68 | "development_dep = Department(\"Development\")\n", 69 | "test__dep = Department(\"Test\")\n", 70 | "marketing_dep = Department(\"Marketing\")\n", 71 | "\n", 72 | "alice = Person(\"Alice\", 25)\n", 73 | "bob = Person(\"Bob\", 35)\n", 74 | "claire = Person(\"Claire\", 45)\n", 75 | "dave = Person(\"Dave\", 55)" 76 | ] 77 | }, 78 | { 79 | "attachments": {}, 80 | "cell_type": "markdown", 81 | "metadata": {}, 82 | "source": [ 83 | "Let's organize these objects in a hierarchical structure:" 84 | ] 85 | }, 86 | { 87 | "cell_type": "code", 88 | "execution_count": 33, 89 | "metadata": {}, 90 | "outputs": [ 91 | { 92 | "name": "stdout", 93 | "output_type": "stream", 94 | "text": [ 95 | "Tree<'Organization'>\n", 96 | "├── <__main__.Department object at 0x111f04e00>\n", 97 | "│ ├── <__main__.Department object at 0x111b89f70>\n", 98 | "│ │ ╰── <__main__.Person object at 0x111f05520>\n", 99 | "│ ╰── <__main__.Person object at 0x111f04da0>\n", 100 | "├── <__main__.Department object at 0x111edac90>\n", 101 | "│ ╰── <__main__.Person object at 0x111f06a50>\n", 102 | "╰── <__main__.Person object at 0x111ed9880>\n" 103 | ] 104 | } 105 | ], 106 | "source": [ 107 | "from nutree import Tree\n", 108 | "\n", 109 | "tree = Tree(\"Organization\")\n", 110 | "\n", 111 | "dev_node = tree.add(development_dep)\n", 112 | "test_node = dev_node.add(test__dep)\n", 113 | "mkt_node = tree.add(marketing_dep)\n", 114 | "\n", 115 | "tree.add(alice)\n", 116 | "dev_node.add(bob)\n", 117 | "test_node.add(claire)\n", 118 | "mkt_node.add(dave)\n", 119 | "\n", 120 | "tree.print()" 121 | ] 122 | }, 123 | { 124 | "cell_type": "markdown", 125 | "metadata": {}, 126 | "source": [ 127 | "Tree nodes store a reference to the object in the `node.data` attribute.\n", 128 | "\n", 129 | "The nodes are formatted by the object's `__repr__` implementation by default.
\n", 130 | "We can overide ths by passing an f-string as `repr` argument:" 131 | ] 132 | }, 133 | { 134 | "cell_type": "code", 135 | "execution_count": 34, 136 | "metadata": {}, 137 | "outputs": [ 138 | { 139 | "name": "stdout", 140 | "output_type": "stream", 141 | "text": [ 142 | "Tree<'Organization'>\n", 143 | "├── Department\n", 144 | "│ ├── Department\n", 145 | "│ │ ╰── Person\n", 146 | "│ ╰── Person\n", 147 | "├── Department\n", 148 | "│ ╰── Person\n", 149 | "╰── Person\n" 150 | ] 151 | } 152 | ], 153 | "source": [ 154 | "tree.print(repr=\"{node.data}\")" 155 | ] 156 | }, 157 | { 158 | "cell_type": "markdown", 159 | "metadata": {}, 160 | "source": [ 161 | "# Iteration and Searching" 162 | ] 163 | }, 164 | { 165 | "cell_type": "markdown", 166 | "metadata": {}, 167 | "source": [ 168 | "# Mutation" 169 | ] 170 | }, 171 | { 172 | "cell_type": "markdown", 173 | "metadata": {}, 174 | "source": [ 175 | "# Data IDs and Clones" 176 | ] 177 | }, 178 | { 179 | "cell_type": "code", 180 | "execution_count": 35, 181 | "metadata": {}, 182 | "outputs": [ 183 | { 184 | "name": "stdout", 185 | "output_type": "stream", 186 | "text": [ 187 | "Node<'Department', data_id=287245536>\n", 188 | "├── Node<'Department', data_id=287017463>\n", 189 | "│ ╰── Node<'Person', data_id=287245650>\n", 190 | "╰── Node<'Person', data_id=287245530>\n", 191 | "Node<'Department', data_id=287234761>\n", 192 | "╰── Node<'Person', data_id=287245989>\n", 193 | "Node<'Person', data_id=287234440>\n" 194 | ] 195 | } 196 | ], 197 | "source": [ 198 | "tree.print(repr=\"{node}\", title=False)" 199 | ] 200 | }, 201 | { 202 | "cell_type": "markdown", 203 | "metadata": {}, 204 | "source": [ 205 | "# Serialization" 206 | ] 207 | }, 208 | { 209 | "cell_type": "code", 210 | "execution_count": 36, 211 | "metadata": {}, 212 | "outputs": [ 213 | { 214 | "data": { 215 | "text/plain": [ 216 | "[{'data': 'Department',\n", 217 | " 'children': [{'data': 'Department',\n", 218 | " 'children': [{'data': 'Person'}]},\n", 219 | " {'data': 'Person'}]},\n", 220 | " {'data': 'Department',\n", 221 | " 'children': [{'data': 'Person'}]},\n", 222 | " {'data': 'Person'}]" 223 | ] 224 | }, 225 | "execution_count": 36, 226 | "metadata": {}, 227 | "output_type": "execute_result" 228 | } 229 | ], 230 | "source": [ 231 | "tree.to_dict_list()" 232 | ] 233 | }, 234 | { 235 | "cell_type": "code", 236 | "execution_count": 37, 237 | "metadata": {}, 238 | "outputs": [ 239 | { 240 | "data": { 241 | "text/plain": [ 242 | "[(0, {}), (1, {}), (2, {}), (1, {}), (0, {}), (5, {}), (0, {})]" 243 | ] 244 | }, 245 | "execution_count": 37, 246 | "metadata": {}, 247 | "output_type": "execute_result" 248 | } 249 | ], 250 | "source": [ 251 | "list(tree.to_list_iter())" 252 | ] 253 | }, 254 | { 255 | "cell_type": "code", 256 | "execution_count": null, 257 | "metadata": {}, 258 | "outputs": [], 259 | "source": [] 260 | }, 261 | { 262 | "cell_type": "code", 263 | "execution_count": 38, 264 | "metadata": {}, 265 | "outputs": [ 266 | { 267 | "name": "stdout", 268 | "output_type": "stream", 269 | "text": [ 270 | "Tree<'4595934048'>\n", 271 | "╰── 'A'\n" 272 | ] 273 | } 274 | ], 275 | "source": [ 276 | "t = Tree._from_list([(0, \"A\")])\n", 277 | "print(t.format())" 278 | ] 279 | }, 280 | { 281 | "cell_type": "code", 282 | "execution_count": null, 283 | "metadata": {}, 284 | "outputs": [], 285 | "source": [] 286 | } 287 | ], 288 | "metadata": { 289 | "kernelspec": { 290 | "display_name": ".venv", 291 | "language": "python", 292 | "name": "python3" 293 | }, 294 | "language_info": { 295 | "codemirror_mode": { 296 | "name": "ipython", 297 | "version": 3 298 | }, 299 | "file_extension": ".py", 300 | "mimetype": "text/x-python", 301 | "name": "python", 302 | "nbconvert_exporter": "python", 303 | "pygments_lexer": "ipython3", 304 | "version": "3.12.6" 305 | }, 306 | "orig_nbformat": 4 307 | }, 308 | "nbformat": 4, 309 | "nbformat_minor": 2 310 | } 311 | -------------------------------------------------------------------------------- /docs/nutree_160x160.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/docs/nutree_160x160.png -------------------------------------------------------------------------------- /docs/nutree_48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/docs/nutree_48x48.png -------------------------------------------------------------------------------- /docs/sphinx/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/pyftpsync.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/pyftpsync.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/pyftpsync" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/pyftpsync" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /docs/sphinx/_themes/.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /docs/sphinx/_themes/LICENSE: -------------------------------------------------------------------------------- 1 | Modifications: 2 | 3 | Copyright (c) 2011 Kenneth Reitz. 4 | 5 | 6 | Original Project: 7 | 8 | Copyright (c) 2010 by Armin Ronacher. 9 | 10 | 11 | Some rights reserved. 12 | 13 | Redistribution and use in source and binary forms of the theme, with or 14 | without modification, are permitted provided that the following conditions 15 | are met: 16 | 17 | * Redistributions of source code must retain the above copyright 18 | notice, this list of conditions and the following disclaimer. 19 | 20 | * Redistributions in binary form must reproduce the above 21 | copyright notice, this list of conditions and the following 22 | disclaimer in the documentation and/or other materials provided 23 | with the distribution. 24 | 25 | * The names of the contributors may not be used to endorse or 26 | promote products derived from this software without specific 27 | prior written permission. 28 | 29 | We kindly ask you to only use these themes in an unmodified manner just 30 | for Flask and Flask-related products, not for unrelated projects. If you 31 | like the visual style and want to use it for your own projects, please 32 | consider making some larger changes to the themes (such as changing 33 | font faces, sizes, colors or margins). 34 | 35 | THIS THEME IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 36 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 37 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 38 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 39 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 40 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 41 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 42 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 43 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 44 | ARISING IN ANY WAY OUT OF THE USE OF THIS THEME, EVEN IF ADVISED OF THE 45 | POSSIBILITY OF SUCH DAMAGE. 46 | -------------------------------------------------------------------------------- /docs/sphinx/_themes/flask_theme_support.py: -------------------------------------------------------------------------------- 1 | # flasky extensions. flasky pygments style based on tango style 2 | from pygments.style import Style 3 | from pygments.token import ( 4 | Comment, 5 | Error, 6 | Generic, 7 | Keyword, 8 | Literal, 9 | Name, 10 | Number, 11 | Operator, 12 | Other, 13 | Punctuation, 14 | String, 15 | Whitespace, 16 | ) 17 | 18 | 19 | class FlaskyStyle(Style): 20 | background_color = "#f8f8f8" 21 | default_style = "" 22 | 23 | styles = { 24 | # No corresponding class for the following: 25 | #Text: "", # class: '' 26 | Whitespace: "underline #f8f8f8", # class: 'w' 27 | Error: "#a40000 border:#ef2929", # class: 'err' 28 | Other: "#000000", # class 'x' 29 | 30 | Comment: "italic #8f5902", # class: 'c' 31 | Comment.Preproc: "noitalic", # class: 'cp' 32 | 33 | Keyword: "bold #004461", # class: 'k' 34 | Keyword.Constant: "bold #004461", # class: 'kc' 35 | Keyword.Declaration: "bold #004461", # class: 'kd' 36 | Keyword.Namespace: "bold #004461", # class: 'kn' 37 | Keyword.Pseudo: "bold #004461", # class: 'kp' 38 | Keyword.Reserved: "bold #004461", # class: 'kr' 39 | Keyword.Type: "bold #004461", # class: 'kt' 40 | 41 | Operator: "#582800", # class: 'o' 42 | Operator.Word: "bold #004461", # class: 'ow' - like keywords 43 | 44 | Punctuation: "bold #000000", # class: 'p' 45 | 46 | # because special names such as Name.Class, Name.Function, etc. 47 | # are not recognized as such later in the parsing, we choose them 48 | # to look the same as ordinary variables. 49 | Name: "#000000", # class: 'n' 50 | Name.Attribute: "#c4a000", # class: 'na' - to be revised 51 | Name.Builtin: "#004461", # class: 'nb' 52 | Name.Builtin.Pseudo: "#3465a4", # class: 'bp' 53 | Name.Class: "#000000", # class: 'nc' - to be revised 54 | Name.Constant: "#000000", # class: 'no' - to be revised 55 | Name.Decorator: "#888", # class: 'nd' - to be revised 56 | Name.Entity: "#ce5c00", # class: 'ni' 57 | Name.Exception: "bold #cc0000", # class: 'ne' 58 | Name.Function: "#000000", # class: 'nf' 59 | Name.Property: "#000000", # class: 'py' 60 | Name.Label: "#f57900", # class: 'nl' 61 | Name.Namespace: "#000000", # class: 'nn' - to be revised 62 | Name.Other: "#000000", # class: 'nx' 63 | Name.Tag: "bold #004461", # class: 'nt' - like a keyword 64 | Name.Variable: "#000000", # class: 'nv' - to be revised 65 | Name.Variable.Class: "#000000", # class: 'vc' - to be revised 66 | Name.Variable.Global: "#000000", # class: 'vg' - to be revised 67 | Name.Variable.Instance: "#000000", # class: 'vi' - to be revised 68 | 69 | Number: "#990000", # class: 'm' 70 | 71 | Literal: "#000000", # class: 'l' 72 | Literal.Date: "#000000", # class: 'ld' 73 | 74 | String: "#4e9a06", # class: 's' 75 | String.Backtick: "#4e9a06", # class: 'sb' 76 | String.Char: "#4e9a06", # class: 'sc' 77 | String.Doc: "italic #8f5902", # class: 'sd' - like a comment 78 | String.Double: "#4e9a06", # class: 's2' 79 | String.Escape: "#4e9a06", # class: 'se' 80 | String.Heredoc: "#4e9a06", # class: 'sh' 81 | String.Interpol: "#4e9a06", # class: 'si' 82 | String.Other: "#4e9a06", # class: 'sx' 83 | String.Regex: "#4e9a06", # class: 'sr' 84 | String.Single: "#4e9a06", # class: 's1' 85 | String.Symbol: "#4e9a06", # class: 'ss' 86 | 87 | Generic: "#000000", # class: 'g' 88 | Generic.Deleted: "#a40000", # class: 'gd' 89 | Generic.Emph: "italic #000000", # class: 'ge' 90 | Generic.Error: "#ef2929", # class: 'gr' 91 | Generic.Heading: "bold #000080", # class: 'gh' 92 | Generic.Inserted: "#00A000", # class: 'gi' 93 | Generic.Output: "#888", # class: 'go' 94 | Generic.Prompt: "#745334", # class: 'gp' 95 | Generic.Strong: "bold #000000", # class: 'gs' 96 | Generic.Subheading: "bold #800080", # class: 'gu' 97 | Generic.Traceback: "bold #a40000", # class: 'gt' 98 | } 99 | -------------------------------------------------------------------------------- /docs/sphinx/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/docs/sphinx/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/sphinx/changes.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Release Info 3 | ============ 4 | 5 | .. toctree:: 6 | :maxdepth: 2 7 | 8 | .. 9 | .. mdinclude:: ../../CHANGELOG.md 10 | 11 | .. literalinclude:: ../../CHANGELOG.md 12 | :language: md 13 | -------------------------------------------------------------------------------- /docs/sphinx/development.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | Development 3 | =========== 4 | 5 | Install for Development 6 | ======================= 7 | 8 | First off, thanks for taking the time to contribute! 9 | 10 | This small guideline may help taking the first steps. 11 | 12 | Happy hacking :) 13 | 14 | 15 | Fork the Repository 16 | ------------------- 17 | 18 | Clone nutree to a local folder and checkout the branch you want to work on:: 19 | 20 | $ git clone git@github.com:mar10/nutree.git 21 | $ cd nutree 22 | $ git checkout my_branch 23 | 24 | 25 | Work in a Virtual Environment 26 | ----------------------------- 27 | 28 | Install Python 29 | ^^^^^^^^^^^^^^ 30 | We need `Python 3 `_, 31 | and `pipenv `_ on our system. 32 | 33 | If you want to run tests on *all* supported platforms, install all Python 34 | versions in parallel. 35 | 36 | Create and Activate the Virtual Environment 37 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 38 | 39 | Install dependencies for debugging:: 40 | 41 | $ cd /path/to/nutree 42 | $ pipenv shell 43 | (nutree) $ pipenv install --dev 44 | (nutree) $ 45 | 46 | The development requirements already contain the nutree source folder, so 47 | ``pipenv install -e .`` is not required. 48 | 49 | The test suite should run :: 50 | 51 | $ tox 52 | 53 | Build Sphinx documentation to target: `/docs/sphinx-build/index.html`) :: 54 | 55 | $ tox -e docs 56 | 57 | 58 | Run Tests 59 | ========= 60 | 61 | Run all tests with coverage report. Results are written to /htmlcov/index.html:: 62 | 63 | $ tox 64 | 65 | Run selective tests:: 66 | 67 | $ tox -e py312 68 | $ tox -e py312 -- -k test_core 69 | 70 | 71 | Run Benchmarks 72 | ============== 73 | 74 | Benchmarks are unit tests that execute small variants of code and measure the 75 | elapsed time. 76 | See `here `_ 77 | for some examples. 78 | 79 | Since this takes some time, benchmarks are not run with the default test suite, 80 | but has to be enabled like so:: 81 | 82 | $ tox -e benchmarks 83 | 84 | 85 | Code 86 | ==== 87 | 88 | The tests also check for `eslint `_, 89 | `flake8 `_, 90 | `black `_, 91 | and `isort `_ standards. 92 | 93 | Format code using the editor's formatting options or like so:: 94 | 95 | $ tox -e format 96 | 97 | 98 | .. note:: 99 | 100 | Follow the Style Guide, basically 101 | `PEP 8 `_. 102 | 103 | Failing tests or not follwing PEP 8 will break builds on 104 | `GitHub `_, 105 | so run ``$ tox`` and ``$ tox -e format`` frequently and before 106 | you commit! 107 | 108 | 109 | Create a Pull Request 110 | ===================== 111 | 112 | .. todo:: 113 | 114 | TODO 115 | -------------------------------------------------------------------------------- /docs/sphinx/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/docs/sphinx/favicon-16x16.png -------------------------------------------------------------------------------- /docs/sphinx/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/docs/sphinx/favicon-32x32.png -------------------------------------------------------------------------------- /docs/sphinx/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/docs/sphinx/favicon.ico -------------------------------------------------------------------------------- /docs/sphinx/genindex.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Index 3 | ===== 4 | -------------------------------------------------------------------------------- /docs/sphinx/index.rst: -------------------------------------------------------------------------------- 1 | .. _main-index: 2 | 3 | ###### 4 | nutree 5 | ###### 6 | 7 | |pypi_badge| |nbsp| |gha_badge| |nbsp| |coverage_badge| |nbsp| |lic_badge| 8 | |nbsp| |rtd_badge| |nbsp| |so_badge| 9 | 10 | *A Python library for tree data structures with an intuitive, yet powerful, API.* 11 | 12 | :Project: https://github.com/mar10/nutree/ 13 | :Version: |version|, Date: |today| 14 | 15 | 16 | .. toctree:: 17 | :hidden: 18 | 19 | Overview 20 | installation 21 | take_the_tour.md 22 | user_guide 23 | reference_guide 24 | development 25 | changes 26 | 27 | 28 | :: 29 | 30 | $ pip install nutree 31 | 32 | **Note:** Run ``pip install "nutree[graph]"`` or ``pip install "nutree[all]"`` 33 | instead, in order to install additional graph support. 34 | 35 | 36 | Nutree Facts 37 | ============ 38 | 39 | * :ref:`Handle multiple references of single objects ('clones') ` 40 | * :ref:`Search by name pattern, id, or object reference ` 41 | * :ref:`Compare two trees and calculate patches ` 42 | * :ref:`Unobtrusive handling of arbitrary objects ` 43 | * :ref:`Save as DOT file and graphwiz diagram ` 44 | * :ref:`Nodes can be plain strings or objects ` 45 | * :ref:`(De)Serialize to (compressed) JSON ` 46 | * :ref:`Save as Mermaid flow diagram ` 47 | * :ref:`Different traversal methods ` 48 | * :ref:`Generate random trees ` 49 | * :ref:`Convert to RDF graph ` 50 | * :ref:`Fully type annotated ` 51 | * :ref:`Typed child nodes ` 52 | * :ref:`Pretty print ` 53 | * :ref:`Navigation ` 54 | * :ref:`Filtering ` 55 | * `Fast `_ 56 | 57 | 58 | 59 | Now read about :doc:`installation` and :doc:`take_the_tour` ... 60 | 61 | 62 | 63 | .. |gha_badge| image:: https://github.com/mar10/nutree/actions/workflows/tests.yml/badge.svg 64 | :alt: Build Status 65 | :target: https://github.com/mar10/nutree/actions/workflows/tests.yml 66 | 67 | .. |pypi_badge| image:: https://img.shields.io/pypi/v/nutree.svg 68 | :alt: PyPI Version 69 | :target: https://pypi.python.org/pypi/nutree/ 70 | 71 | .. |lic_badge| image:: https://img.shields.io/pypi/l/nutree.svg 72 | :alt: License 73 | :target: https://github.com/mar10/nutree/blob/main/LICENSE.txt 74 | 75 | .. |rtd_badge| image:: https://readthedocs.org/projects/nutree/badge/?version=latest 76 | :target: https://nutree.readthedocs.io/ 77 | :alt: Documentation Status 78 | 79 | .. |coverage_badge| image:: https://codecov.io/github/mar10/nutree/branch/main/graph/badge.svg?token=9xmAFm8Icl 80 | :target: https://codecov.io/github/mar10/nutree 81 | :alt: Coverage Status 82 | 83 | .. .. |black_badge| image:: https://img.shields.io/badge/code%20style-black-000000.svg 84 | .. :target: https://github.com/ambv/black 85 | .. :alt: Code style: black 86 | 87 | .. |so_badge| image:: https://img.shields.io/badge/StackOverflow-nutree-blue.svg 88 | :target: https://stackoverflow.com/questions/tagged/nutree 89 | :alt: StackOverflow: nutree 90 | 91 | .. |logo| image:: ../nutree_48x48.png 92 | :height: 48px 93 | :width: 48px 94 | :alt: nutree 95 | 96 | .. |nutree| raw:: html 97 | 98 | nutree 99 | -------------------------------------------------------------------------------- /docs/sphinx/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Installation is straightforward:: 5 | 6 | $ pip install nutree 7 | 8 | **Note:** Run ``pip install "nutree[graph]"`` or ``pip install "nutree[all]"`` 9 | instead, in order to install additional graph support. 10 | 11 | Installing `nutree` and its dependencies into a 'sandbox' will help to keep 12 | your system Python clean, but requires to activate the virtual environment:: 13 | 14 | $ cd /path/to/nutree 15 | $ pipenv shell 16 | (nutree) $ pipenv install nutree --upgrade 17 | ... 18 | 19 | Now the ``nutree`` package can be used in Python code:: 20 | 21 | $ python 22 | >>> from nutree import __version__ 23 | >>> __version__ 24 | '0.0.1' 25 | 26 | .. seealso:: 27 | See :doc:`development` for directions for contributors. 28 | 29 | 30 | Now :doc:`take_the_tour`... 31 | 32 | .. Now read the :doc:`user_guide`. 33 | .. `Take the Tour `_ ... 34 | -------------------------------------------------------------------------------- /docs/sphinx/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | echo. coverage to run coverage check of the documentation if enabled 41 | goto end 42 | ) 43 | 44 | if "%1" == "clean" ( 45 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 46 | del /q /s %BUILDDIR%\* 47 | goto end 48 | ) 49 | 50 | 51 | REM Check if sphinx-build is available and fallback to Python version if any 52 | %SPHINXBUILD% 2> nul 53 | if errorlevel 9009 goto sphinx_python 54 | goto sphinx_ok 55 | 56 | :sphinx_python 57 | 58 | set SPHINXBUILD=python -m sphinx.__init__ 59 | %SPHINXBUILD% 2> nul 60 | if errorlevel 9009 ( 61 | echo. 62 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 63 | echo.installed, then set the SPHINXBUILD environment variable to point 64 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 65 | echo.may add the Sphinx directory to PATH. 66 | echo. 67 | echo.If you don't have Sphinx installed, grab it from 68 | echo.https://sphinx-doc.org/ 69 | exit /b 1 70 | ) 71 | 72 | :sphinx_ok 73 | 74 | 75 | if "%1" == "html" ( 76 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 77 | if errorlevel 1 exit /b 1 78 | echo. 79 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 80 | goto end 81 | ) 82 | 83 | if "%1" == "dirhtml" ( 84 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 85 | if errorlevel 1 exit /b 1 86 | echo. 87 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 88 | goto end 89 | ) 90 | 91 | if "%1" == "singlehtml" ( 92 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 93 | if errorlevel 1 exit /b 1 94 | echo. 95 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 96 | goto end 97 | ) 98 | 99 | if "%1" == "pickle" ( 100 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 101 | if errorlevel 1 exit /b 1 102 | echo. 103 | echo.Build finished; now you can process the pickle files. 104 | goto end 105 | ) 106 | 107 | if "%1" == "json" ( 108 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 109 | if errorlevel 1 exit /b 1 110 | echo. 111 | echo.Build finished; now you can process the JSON files. 112 | goto end 113 | ) 114 | 115 | if "%1" == "htmlhelp" ( 116 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 117 | if errorlevel 1 exit /b 1 118 | echo. 119 | echo.Build finished; now you can run HTML Help Workshop with the ^ 120 | .hhp project file in %BUILDDIR%/htmlhelp. 121 | goto end 122 | ) 123 | 124 | if "%1" == "qthelp" ( 125 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 129 | .qhcp project file in %BUILDDIR%/qthelp, like this: 130 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\pyftpsync.qhcp 131 | echo.To view the help file: 132 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\pyftpsync.ghc 133 | goto end 134 | ) 135 | 136 | if "%1" == "devhelp" ( 137 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 138 | if errorlevel 1 exit /b 1 139 | echo. 140 | echo.Build finished. 141 | goto end 142 | ) 143 | 144 | if "%1" == "epub" ( 145 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 146 | if errorlevel 1 exit /b 1 147 | echo. 148 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 149 | goto end 150 | ) 151 | 152 | if "%1" == "latex" ( 153 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 154 | if errorlevel 1 exit /b 1 155 | echo. 156 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 157 | goto end 158 | ) 159 | 160 | if "%1" == "latexpdf" ( 161 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 162 | cd %BUILDDIR%/latex 163 | make all-pdf 164 | cd %~dp0 165 | echo. 166 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 167 | goto end 168 | ) 169 | 170 | if "%1" == "latexpdfja" ( 171 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 172 | cd %BUILDDIR%/latex 173 | make all-pdf-ja 174 | cd %~dp0 175 | echo. 176 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 177 | goto end 178 | ) 179 | 180 | if "%1" == "text" ( 181 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 182 | if errorlevel 1 exit /b 1 183 | echo. 184 | echo.Build finished. The text files are in %BUILDDIR%/text. 185 | goto end 186 | ) 187 | 188 | if "%1" == "man" ( 189 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 190 | if errorlevel 1 exit /b 1 191 | echo. 192 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 193 | goto end 194 | ) 195 | 196 | if "%1" == "texinfo" ( 197 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 198 | if errorlevel 1 exit /b 1 199 | echo. 200 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 201 | goto end 202 | ) 203 | 204 | if "%1" == "gettext" ( 205 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 206 | if errorlevel 1 exit /b 1 207 | echo. 208 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 209 | goto end 210 | ) 211 | 212 | if "%1" == "changes" ( 213 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 214 | if errorlevel 1 exit /b 1 215 | echo. 216 | echo.The overview file is in %BUILDDIR%/changes. 217 | goto end 218 | ) 219 | 220 | if "%1" == "linkcheck" ( 221 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 222 | if errorlevel 1 exit /b 1 223 | echo. 224 | echo.Link check complete; look for any errors in the above output ^ 225 | or in %BUILDDIR%/linkcheck/output.txt. 226 | goto end 227 | ) 228 | 229 | if "%1" == "doctest" ( 230 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 231 | if errorlevel 1 exit /b 1 232 | echo. 233 | echo.Testing of doctests in the sources finished, look at the ^ 234 | results in %BUILDDIR%/doctest/output.txt. 235 | goto end 236 | ) 237 | 238 | if "%1" == "coverage" ( 239 | %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage 240 | if errorlevel 1 exit /b 1 241 | echo. 242 | echo.Testing of coverage in the sources finished, look at the ^ 243 | results in %BUILDDIR%/coverage/python.txt. 244 | goto end 245 | ) 246 | 247 | if "%1" == "xml" ( 248 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 249 | if errorlevel 1 exit /b 1 250 | echo. 251 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 252 | goto end 253 | ) 254 | 255 | if "%1" == "pseudoxml" ( 256 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 257 | if errorlevel 1 exit /b 1 258 | echo. 259 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 260 | goto end 261 | ) 262 | 263 | :end 264 | -------------------------------------------------------------------------------- /docs/sphinx/mypy_type_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/docs/sphinx/mypy_type_error.png -------------------------------------------------------------------------------- /docs/sphinx/pylance_autocomlete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/docs/sphinx/pylance_autocomlete.png -------------------------------------------------------------------------------- /docs/sphinx/pylance_type_info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/docs/sphinx/pylance_type_info.png -------------------------------------------------------------------------------- /docs/sphinx/pyright_type_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/docs/sphinx/pyright_type_error.png -------------------------------------------------------------------------------- /docs/sphinx/reference_guide.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Reference Guide 3 | =============== 4 | 5 | Class Overview 6 | ============== 7 | 8 | Nutree Classes 9 | -------------- 10 | 11 | .. inheritance-diagram:: nutree.tree nutree.node nutree.typed_tree nutree.common 12 | :parts: 2 13 | :private-bases: 14 | 15 | Random Tree Generator 16 | --------------------- 17 | 18 | .. inheritance-diagram:: nutree.tree_generator 19 | :parts: 2 20 | 21 | 22 | .. API 23 | .. === 24 | 25 | .. toctree:: 26 | :maxdepth: 4 27 | 28 | rg_modules 29 | genindex 30 | -------------------------------------------------------------------------------- /docs/sphinx/requirements.txt: -------------------------------------------------------------------------------- 1 | furo 2 | myst-parser[linkify] 3 | sphinxcontrib-googleanalytics 4 | sphinxcontrib-mermaid 5 | -------------------------------------------------------------------------------- /docs/sphinx/rg_modules.rst: -------------------------------------------------------------------------------- 1 | .. _api-reference: 2 | 3 | API Reference 4 | ============= 5 | 6 | nutree.tree module 7 | ------------------ 8 | 9 | .. automodule:: nutree.tree 10 | :members: 11 | :undoc-members: 12 | :show-inheritance: 13 | :inherited-members: 14 | 15 | .. 16 | :private-members: 17 | 18 | nutree.node module 19 | ------------------ 20 | 21 | .. automodule:: nutree.node 22 | :members: 23 | :undoc-members: 24 | :show-inheritance: 25 | :inherited-members: 26 | 27 | nutree.typed_tree module 28 | ------------------------ 29 | 30 | .. automodule:: nutree.typed_tree 31 | :members: 32 | :undoc-members: 33 | :show-inheritance: 34 | :inherited-members: 35 | 36 | nutree.common module 37 | -------------------- 38 | 39 | .. automodule:: nutree.common 40 | :members: 41 | :undoc-members: 42 | :show-inheritance: 43 | :inherited-members: 44 | 45 | nutree.tree_generator module 46 | ---------------------------- 47 | 48 | .. automodule:: nutree.tree_generator 49 | :members: 50 | :undoc-members: 51 | :show-inheritance: 52 | :inherited-members: 53 | 54 | -------------------------------------------------------------------------------- /docs/sphinx/take_the_tour_files/pylance_autocomlete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/docs/sphinx/take_the_tour_files/pylance_autocomlete.png -------------------------------------------------------------------------------- /docs/sphinx/take_the_tour_files/pylance_type_info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/docs/sphinx/take_the_tour_files/pylance_type_info.png -------------------------------------------------------------------------------- /docs/sphinx/take_the_tour_files/pyright_type_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/docs/sphinx/take_the_tour_files/pyright_type_error.png -------------------------------------------------------------------------------- /docs/sphinx/test_graph_diff_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/docs/sphinx/test_graph_diff_2.png -------------------------------------------------------------------------------- /docs/sphinx/test_graph_diff_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/docs/sphinx/test_graph_diff_3.png -------------------------------------------------------------------------------- /docs/sphinx/test_mermaid_default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/docs/sphinx/test_mermaid_default.png -------------------------------------------------------------------------------- /docs/sphinx/test_mermaid_typed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/docs/sphinx/test_mermaid_typed.png -------------------------------------------------------------------------------- /docs/sphinx/test_mermaid_typed_forest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/docs/sphinx/test_mermaid_typed_forest.png -------------------------------------------------------------------------------- /docs/sphinx/tree_graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/docs/sphinx/tree_graph.png -------------------------------------------------------------------------------- /docs/sphinx/tree_graph_diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/docs/sphinx/tree_graph_diff.png -------------------------------------------------------------------------------- /docs/sphinx/tree_graph_no_root.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/docs/sphinx/tree_graph_no_root.png -------------------------------------------------------------------------------- /docs/sphinx/tree_graph_pencil.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/docs/sphinx/tree_graph_pencil.png -------------------------------------------------------------------------------- /docs/sphinx/tree_graph_single_inst.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/docs/sphinx/tree_graph_single_inst.png -------------------------------------------------------------------------------- /docs/sphinx/typed_tree_graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/docs/sphinx/typed_tree_graph.png -------------------------------------------------------------------------------- /docs/sphinx/ug_advanced.rst: -------------------------------------------------------------------------------- 1 | -------- 2 | Advanced 3 | -------- 4 | 5 | .. 6 | Events 7 | ------ 8 | 9 | (Not Yet Implemented.) 10 | 11 | :: 12 | 13 | def on_change(tree, event): 14 | assert event.type == "change" 15 | 16 | tree.on("change", on_change) 17 | 18 | 19 | .. _iteration-callbacks: 20 | 21 | Iteration Callbacks 22 | ------------------- 23 | 24 | In the following sections we cover :ref:`searching`, :ref:`traversal`, 25 | :ref:`mutation`, etc. in detail. |br| 26 | Some methods described there, accept a `predicate` argument, for example 27 | :meth:`~nutree.tree.Tree.copy`, :meth:`~nutree.tree.Tree.filter`, 28 | :meth:`~nutree.tree.Tree.find_all` ... 29 | 30 | In all cases, the `predicate` callback is called with one single `node` 31 | argument and should return control value: 32 | 33 | .. note:: 34 | The special values 35 | :data:`~nutree.common.StopTraversal`, :data:`~nutree.common.SkipBranch`, 36 | and :data:`~nutree.common.SelectBranch` can be returned as value or raised 37 | as exception. 38 | 39 | :meth:`~nutree.tree.Tree.find`, 40 | :meth:`~nutree.tree.Tree.find_first` 41 | 42 | The `match` callback can return these values: 43 | 44 | - `False` or `None`: No match: skip the node and continue traversal. 45 | - `True`: Stop iteration and return this node as result. 46 | - :data:`~nutree.common.StopTraversal`: 47 | Stop iteration and return `None` as result. 48 | 49 | :meth:`~nutree.tree.Tree.find_all` 50 | 51 | The `match` callback can return these values: 52 | 53 | - `False` or `None`: No match: skip the node and continue traversal. 54 | - `True`: Add node to results and continue traversal. 55 | - :data:`~nutree.common.SkipBranch`: 56 | Skip node and its descendants, but continue iteration with next sibling. |br| 57 | Return `SkipBranch(and_self=False)` to add the node to results, but skip 58 | descendants. 59 | - :data:`~nutree.common.StopTraversal`: 60 | Stop iteration and return current results. 61 | 62 | :meth:`~nutree.tree.Tree.visit` 63 | 64 | The `callback` callback can return these values: 65 | 66 | - `False`: 67 | Stop iteration immediately. 68 | - :data:`~nutree.common.StopTraversal`: 69 | Stop iteration immediately. Return or raise `StopTraversal(value)` to 70 | specify a return value for the visit method. 71 | - :data:`~nutree.common.SkipBranch`: 72 | Skip descendants, but continue iteration with next sibling. 73 | - `True`, `None`, and all other values: 74 | No action: continue traversal. 75 | 76 | :meth:`~nutree.tree.Tree.copy`, 77 | :meth:`~nutree.tree.Tree.filter`, 78 | :meth:`~nutree.tree.Tree.filtered`, 79 | :meth:`~nutree.node.Node.copy` 80 | 81 | The `predicate` callback can return these values: 82 | 83 | - `True`: Keep the node and visit children. 84 | - `False` or `None`: Visit children and keep this node if at least one 85 | descendant is true. 86 | - :data:`~nutree.common.SkipBranch`: 87 | Skip node and its descendants, but continue iteration with next sibling. |br| 88 | Return `SkipBranch(and_self=False)` to keep the node, but skip descendants. 89 | - :data:`~nutree.common.SelectBranch`: 90 | Unconditionally accept node and all descendants (do not call `predicate()`). 91 | In other words: copy the whole branch. 92 | 93 | :meth:`~nutree.tree.Tree.save` 94 | :meth:`~nutree.tree.Tree.to_dict_list`, 95 | :meth:`~nutree.tree.Tree.to_dot`, 96 | :meth:`~nutree.tree.Tree.to_dotfile`, 97 | :meth:`~nutree.tree.Tree.to_list_iter` 98 | 99 | The `mapper(node, data)` callback can modify the dict argument `data` 100 | in-place (and return `None`) or return a new dict istance. 101 | 102 | 103 | Locking 104 | ------- 105 | 106 | In multithreading scenarios, we can enforce critical sections like so:: 107 | 108 | with tree: 109 | snapshot = tree.to_dict_list() 110 | 111 | 112 | Debugging 113 | --------- 114 | 115 | Call :meth:`~nutree.tree.Tree._self_check` to validate the internal data structures. 116 | This is slow and should not be done in production:: 117 | 118 | assert tree._self_check() 119 | 120 | 121 | Performance 122 | ----------- 123 | 124 | Most :class:`~nutree.node.Node` attributes are exposed as readonly properties. 125 | The real attribute is prefixed by an underscore. |br| 126 | In some situations, like close loops in critical algorithms it may be slightly 127 | faster to access attributes directly. 128 | 129 | .. warning:: 130 | Use with care. Accessing or even modifying internal attributes may break 131 | the internal data structures. 132 | 133 | When optimizing: 134 | 135 | 1. Correctness before performance: |br| 136 | Write simple, error free code first and cover it with unit tests, 137 | before starting to optimize. 138 | 139 | 2. Do not guess or assume: |br| 140 | Write `benchmarks `_ ! 141 | 142 | File System Helper 143 | ------------------ 144 | 145 | There is a simple helper that can be used to read a folder recursively:: 146 | 147 | from nutree import load_tree_from_fs 148 | 149 | path = "/my/folder/path" 150 | tree = load_tree_from_fs(path) 151 | tree.print() 152 | 153 | :: 154 | 155 | Tree 156 | ├── 'file_1.txt', 13 bytes 157 | ╰── [folder_1] 158 | ╰── 'file_1_1.txt', 15 bytes 159 | -------------------------------------------------------------------------------- /docs/sphinx/ug_basics.rst: -------------------------------------------------------------------------------- 1 | ------ 2 | Basics 3 | ------ 4 | 5 | .. py:currentmodule:: nutree 6 | 7 | .. admonition:: TL;DR 8 | 9 | Nutree is a Python library for managing hierarchical data structures. 10 | It stores arbitrary data objects in nodes and provides methods for 11 | navigation, searching, and iteration. 12 | 13 | 14 | Data Model 15 | ---------- 16 | 17 | A :class:`~nutree.tree.Tree` object is a shallow wrapper around a single, 18 | invisible system root node. All visible toplevel nodes are direct children of 19 | this root node. |br| 20 | Trees expose methods to iterate, search, copy, filter, serialize, etc. 21 | 22 | A :class:`~nutree.node.Node` represents a single element in the tree. |br| 23 | It is a shallow wrapper around a user data instance, that adds navigation, 24 | modification, and other functionality. 25 | 26 | Main `Node` attributes are initialized on construction: 27 | 28 | parent (`Node`, readonly) 29 | The direct ancestor (``node.parent`` is `None` for toplevel nodes). 30 | Use :meth:`~nutree.node.Node.move_to` to modify. 31 | 32 | children (`List[Node]`, readonly) 33 | List of direct subnodes, may be empty. 34 | Children are added or removed using methods like 35 | :meth:`~nutree.tree.Tree.add`, 36 | :meth:`~nutree.node.Node.prepend_child`, 37 | :meth:`~nutree.node.Node.remove`, 38 | :meth:`~nutree.node.Node.remove_children`, 39 | :meth:`~nutree.node.Node.move_to`, etc. 40 | 41 | data (`object|str`, readonly) 42 | The user data payload. 43 | This may be a simple string or an arbitrary object instance. |br| 44 | Internally the tree maintains a map from `data_id` to the referencing `Nodes`. 45 | Use :meth:`~nutree.node.Node.set_data` to modify this value. |br| 46 | The same data instance may be referenced by multiple nodes. In this case we 47 | call those nodes `clones`. 48 | 49 | data_id (int, readonly): 50 | The unique key of a `data` instance. This value is calculated as ``hash(data)`` 51 | by default, but can be set to a custom value. |br| 52 | Use :meth:`~nutree.node.Node.set_data` to modify this value. 53 | 54 | meta (dict, readonly): 55 | :class:`~nutree.node.Node` uses 56 | `__slots__ `_ 57 | for memory efficiency. 58 | As a side effect, it is not possible to assign new attributes to a node instance. |br| 59 | The `meta` slot can be used to attach arbitrary key/value pairs to a node. |br| 60 | Use :meth:`~nutree.node.Node.get_meta`, :meth:`~nutree.node.Node.set_meta`, 61 | :meth:`~nutree.node.Node.update_meta`, and :meth:`~nutree.node.Node.clear_meta`, 62 | to modify this value. 63 | 64 | node_id (int, readonly): 65 | The unique key of a `Node` instance. This value is calculated as ``id(node)`` 66 | by default, but can be set to a custom value in the constructor. 67 | It cannot be changed later. 68 | 69 | kind (str, readonly): 70 | Used by :class:`~nutree.typed_tree.TypedNode` (see :ref:`Typed child nodes `). 71 | 72 | 73 | Adding Nodes 74 | ------------ 75 | 76 | Nodes are usually created by adding a new data instance to a parent:: 77 | 78 | from nutree import Tree, Node 79 | 80 | tree = Tree("Store") 81 | 82 | n = tree.add("Records") 83 | 84 | n.add("Let It Be") 85 | n.add("Get Yer Ya-Ya's Out!") 86 | 87 | n = tree.add("Books") 88 | n.add("The Little Prince") 89 | 90 | tree.print() 91 | 92 | :: 93 | 94 | Tree<'Store'> 95 | ├── 'Records' 96 | │ ├── 'Let It Be' 97 | │ ╰── "Get Yer Ya-Ya's Out!" 98 | ╰── 'Books' 99 | ╰── 'The Little Prince' 100 | 101 | Chaining 102 | ~~~~~~~~ 103 | 104 | Since `node.add()` return a Node object we can chain calls. 105 | The `node.up()` method allows to select an ancestor node and the `node.tree` 106 | return the Tree instance:: 107 | 108 | tree = Tree() 109 | tree.add("A").add("a1").up().add("a2").up(2).add("B") 110 | tree.print() 111 | 112 | :: 113 | 114 | Tree<> 115 | ├── 'A' 116 | │ ├── 'a1' 117 | │ ╰── 'a2' 118 | ╰── 'B' 119 | 120 | or for friends of code golf:: 121 | 122 | Tree().add("A").add("a1").up().add("a2").up(2).add("B").tree.print() 123 | 124 | .. seealso:: 125 | 126 | See :doc:`ug_objects` for details on how to manage arbitrary objects, dicts, 127 | etc. instead of plain strings. 128 | 129 | 130 | Info and Navigation 131 | ------------------- 132 | 133 | Tree statistics and related nodes are accessible like so:: 134 | 135 | assert tree.count == 5 136 | 137 | records_node = tree["Records"] 138 | assert tree.first_child() is records_node 139 | 140 | assert len(records_node.children) == 2 141 | assert records_node.depth() == 1 142 | 143 | assert tree.find("Records") is records_node 144 | assert tree.find("records") is None # case-sensitive 145 | 146 | n = records_node.first_child() 147 | assert records_node.find("Let It Be") is n 148 | 149 | assert n.name == "Let It Be" 150 | assert n.depth() == 2 151 | assert n.parent is records_node 152 | assert n.prev_sibling() is None 153 | assert n.next_sibling().name == "Get Yer Ya-Ya's Out!" 154 | assert not n.children 155 | 156 | .. seealso:: 157 | 158 | See :doc:`ug_search_and_navigate` for details on how to find nodes. 159 | 160 | 161 | Iteration 162 | --------- 163 | 164 | Iterators are available for the hole tree or by branch. Different traversal 165 | methods are supported:: 166 | 167 | for node in tree: 168 | # Depth-first, pre-order by default 169 | ... 170 | 171 | # Alternatively use `visit` with a callback: 172 | 173 | def callback(node, memo): 174 | if node.name == "secret": 175 | # Prevent visiting the child nodes: 176 | return SkipBranch 177 | if node.data.foobar == 17: 178 | raise StopTraversal("found it") 179 | 180 | # `res` contains the value passed to the `StopTraversal` constructor 181 | res = tree.visit(callback) # res == "found it" 182 | 183 | .. seealso:: 184 | 185 | See :doc:`ug_search_and_navigate` for details on traversal. 186 | -------------------------------------------------------------------------------- /docs/sphinx/ug_clones.rst: -------------------------------------------------------------------------------- 1 | .. _clones: 2 | 3 | ----------------------------- 4 | Multiple Instances ('Clones') 5 | ----------------------------- 6 | 7 | .. py:currentmodule:: nutree 8 | 9 | .. admonition:: TL;DR 10 | 11 | Nutree allows to store multiple references to the same data object in a tree. 12 | 13 | Every :class:`~nutree.node.Node` instance is unique within the tree and 14 | also has a unique `node.node_id` value. 15 | 16 | However, one data object may be added multiple times to a tree at different 17 | locations:: 18 | 19 | Tree<'multi'> 20 | ├── 'A' 21 | │ ├── 'a1' 22 | │ ╰── 'a2' 23 | ╰── 'B' 24 | ├── 'b1' 25 | ╰── 'a2' <- 2nd occurrence 26 | 27 | .. seealso:: 28 | `Clones` have several appliances in the real world, for example store items 29 | may be listed in different categories, or files may be linked into multiple 30 | parent folders. 31 | 32 | When a tree is used to visualize a `directed graph`, clones are used to 33 | represent a node that is connected by multiple edges. 34 | See :doc:`ug_graphs` for an example. 35 | 36 | 37 | .. note:: Multiple instances must not appear under the same parent node. 38 | 39 | In this case, a lookup using the indexing syntax (`tree[data]`) is not allowed. |br| 40 | Use :meth:`tree.Tree.find_first()` or :meth:`~tree.Tree.find_all()` instead. 41 | 42 | `find_first()` will return the first match (or `None`). 43 | Note that :meth:`~tree.Tree.find()` is an alias for :meth:`~tree.Tree.find_first()`:: 44 | 45 | print(tree.find("a2")) 46 | 47 | :: 48 | 49 | Node<'a2', data_id=-942131188891065821> 50 | 51 | :meth:`~tree.Tree.find_all()` will return all matches (or an empty array `[]`):: 52 | 53 | res = tree.find_all("a2") 54 | assert res[0] is not res[1], "Node instances are NOT identical..." 55 | assert res[0] == res[1], "... but evaluate as equal." 56 | assert res[0].parent.name == "A" 57 | assert res[1].parent.name == "B" 58 | assert res[0].data is res[1].data, "A single data object instance is referenced by both nodes" 59 | 60 | print(res) 61 | 62 | :: 63 | 64 | [Node<'a2', data_id=-942131188891065821>, Node<'a2', data_id=-942131188891065821>] 65 | -------------------------------------------------------------------------------- /docs/sphinx/ug_diff.rst: -------------------------------------------------------------------------------- 1 | .. _diff-and-merge: 2 | 3 | -------------- 4 | Diff and Merge 5 | -------------- 6 | 7 | .. py:currentmodule:: nutree 8 | 9 | .. admonition:: TL;DR 10 | 11 | Nutree provides a `diff` method to compare two trees and calculate the differences. 12 | 13 | The :meth:`~nutree.tree.Tree.diff` method compares a tree (`T0`) against another 14 | one (`T1`) and returns a merged, annotated copy. 15 | 16 | The resulting tree contains a union of all nodes from both trees. 17 | Additional metadata is added to the resulting nodes to classify changes 18 | from the perspective of `T0`. For example a node that only exists 19 | in `T1`, will have an ``ADDED`` marker. 20 | 21 | If `ordered` is true, a different child order is also considered a change. 22 | 23 | If `reduce` is true, unchanged nodes are removed from the result, leaving a 24 | compact tree with only the modifications. 25 | 26 | 27 | Assuming we have two trees, **tree_0**:: 28 | 29 | Tree<'T0'> 30 | ├── 'A' 31 | │ ├── 'a1' 32 | │ │ ├── 'a11' 33 | │ │ ╰── 'a12' 34 | │ ╰── 'a2' 35 | ╰── 'B' 36 | ╰── 'b1' 37 | ╰── 'b11' 38 | 39 | and **tree_1**:: 40 | 41 | Tree<'T1'> 42 | ├── 'A' 43 | │ ├── 'a1' 44 | │ │ ╰── 'a12' 45 | │ ╰── 'a2' 46 | │ ╰── 'a21' 47 | ├── 'B' 48 | ╰── 'C' 49 | ╰── 'b1' 50 | ╰── 'b11' 51 | 52 | We can now call :meth:`~nutree.tree.Tree.diff` to calculate the changes 53 | (note that we use use a special `repr` handler to display the metadata 54 | annotations):: 55 | 56 | from nutree import diff_node_formatter 57 | 58 | tree_0 = ... 59 | tree_1 = ... 60 | 61 | tree_2 = tree_0.diff(tree_1) 62 | 63 | tree_2.print(repr=diff_node_formatter) 64 | 65 | :: 66 | 67 | Tree<"diff('T0', 'T1')"> 68 | ├── A 69 | │ ├── a1 70 | │ │ ├── a11 - [Removed] 71 | │ │ ╰── a12 72 | │ ╰── a2 73 | │ ╰── a21 - [Added] 74 | ├── B 75 | │ ╰── b1 - [Moved away] 76 | ╰── C - [Added] 77 | ╰── b1 - [Moved here] 78 | ╰── b11 79 | 80 | Pass ``reduce=True`` to remove all unmodified nodes from the result:: 81 | 82 | tree_2 = tree_0.diff(tree_1, reduce=True) 83 | 84 | :: 85 | 86 | Tree<"diff('T0', 'T1')"> 87 | ├── A 88 | │ ├── a1 89 | │ │ ╰── a11 - [Removed] 90 | │ ╰── a2 91 | │ ╰── a21 - [Added] 92 | ├── B 93 | │ ╰── b1 - [Moved away] 94 | ╰── C - [Added] 95 | ╰── b1 - [Moved here] 96 | 97 | Pass ``ordered=True`` to check for changes in child order as well:: 98 | 99 | tree_2 = tree_0.diff(tree_1, ordered=True) 100 | 101 | "a12" moved from index 1 to index 0 because "a11" was removed:: 102 | 103 | Tree<"diff('T0', 'T1')"> 104 | ├── A 105 | │ ├── a1 - [Renumbered] 106 | │ │ ├── a11 - [Removed] 107 | │ │ ╰── a12 - [Order -1] 108 | │ ╰── a2 109 | │ ╰── a21 - [Added] 110 | ├── B 111 | │ ╰── b1 - [Moved away] 112 | ╰── C - [Added] 113 | ╰── b1 - [Moved here] 114 | ╰── b11 115 | 116 | 117 | Classification 118 | -------------- 119 | 120 | .. 121 | See :class:`~nutree.diff.DiffClassification` for possible values. 122 | 123 | The diff tool uses the metadata API to add classification information to 124 | the generated result nodes. 125 | The 'dc' key has optional values of `ADDED`, `REMOVED`, `MOVED_TO`, 126 | and `MOVED_HERE`. 127 | When `ordered` is true, 'dc' may also be a 2-tuple of two integers, 128 | holding previous and new child index:: 129 | 130 | from nutree import DiffClassification 131 | 132 | assert tree_2["A"].get_meta("dc") is None 133 | assert tree_2["a21"].get_meta("dc") == DiffClassification.ADDED 134 | assert tree_2["b1"].get_meta("dc") == DiffClassification.MOVED_TO 135 | assert tree_2["a12"].get_meta("dc") == (1, 0) 136 | 137 | 138 | 139 | -------------------------------------------------------------------------------- /docs/sphinx/ug_mutation.rst: -------------------------------------------------------------------------------- 1 | .. _mutation: 2 | 3 | -------- 4 | Mutation 5 | -------- 6 | 7 | .. py:currentmodule:: nutree 8 | 9 | .. admonition:: TL;DR 10 | 11 | Nutree provides methods to modify the tree structure in-place. |br| 12 | This includes adding, moving, and deleting nodes, as well as filtering and sorting. 13 | 14 | 15 | Some in-place modifications are available:: 16 | 17 | # Tree 18 | tree.add(data, ...) 19 | ... 20 | # Node 21 | node.add(data, ...) 22 | node.prepend_child(data, ...) 23 | ... 24 | node.move_to(new_parent, before) 25 | node.sort_children() 26 | 27 | # Delete nodes 28 | node.remove() 29 | node.remove_children() 30 | # del will call `remove()` 31 | del tree["A"] 32 | tree.clear() 33 | tree.filter(...) 34 | 35 | # Append a copy of branch 'b1' to 'a1' 36 | tree["a1"].add(tree["b1"]) 37 | 38 | Filtering, copy, and diff operations usually return the result as a new tree 39 | instance:: 40 | 41 | def pred(node): 42 | # Return true to include `node` and its children 43 | return node.data.age >= 18 44 | 45 | tree_2 = tree.copy(predicate=pred) 46 | 47 | assert tree_2.first_node is not tree.first_node # different nodes... 48 | assert tree_2.first_node.data is tree.first_node.data # ... reference the same data 49 | assert tree_2.first_node == tree.first_node # and evaluate as 'equal' 50 | 51 | .. seealso:: 52 | Some methods accept callbacks to control node selection, for example 53 | :meth:`~nutree.tree.Tree.copy`, 54 | :meth:`~nutree.tree.Tree.filter`, 55 | :meth:`~nutree.tree.Tree.find`, 56 | :meth:`~nutree.tree.Tree.find_all`, 57 | :meth:`~nutree.tree.Tree.find_first`, 58 | :meth:`~nutree.tree.Tree.visit`, ... 59 | 60 | See :ref:`iteration-callbacks` for details. 61 | -------------------------------------------------------------------------------- /docs/sphinx/ug_pretty_print.rst: -------------------------------------------------------------------------------- 1 | .. _pretty-print: 2 | 3 | ------------ 4 | Pretty Print 5 | ------------ 6 | 7 | .. py:currentmodule:: nutree 8 | 9 | .. admonition:: TL;DR 10 | 11 | Nutree provides a `format` method to generate a pretty printed string 12 | representation of the tree structure. 13 | 14 | 15 | :meth:`~nutree.tree.Tree.format` produces a pretty formatted string 16 | representation:: 17 | 18 | tree.print() 19 | # ... is a shortcut for: 20 | print(tree.format()) 21 | 22 | :: 23 | 24 | Tree<'Store'> 25 | ├── 'Records' 26 | │ ├── 'Let It Be' 27 | │ ╰── "Get Yer Ya-Ya's Out!" 28 | ╰── 'Books' 29 | ╰── 'The Little Prince' 30 | 31 | Pass a formatting string to `repr=` to control how a single node is rendered 32 | display. For example ``repr="{node}"``, ``repr="{node.path}"``, ``repr="{node.data!r}"``, 33 | ``repr="{node.data.name} (#{node.data_id})"``, etc. |br| 34 | Note that ``repr`` may also be a `function(node)` that returns a string for 35 | display. 36 | 37 | The `style` argument selects the connector type. 38 | See :data:`~nutree.common.CONNECTORS` for possible values. :: 39 | 40 | tree.format(repr="{node}", style="lines32", title="My Store") 41 | 42 | :: 43 | 44 | My Store 45 | ├─ Node<'Records', data_id=-7187943508994743157> 46 | │ ├─ Node<'Let It Be', data_id=7970150601901581439> 47 | │ └─ Node<"Get Yer Ya-Ya's Out!", data_id=-3432395703643407922> 48 | └─ Node<'Books', data_id=-4949478653955058708> 49 | └─ Node<'The Little Prince', data_id=6520761245273801231> 50 | 51 | or try a more compact style :: 52 | 53 | "round32c" "round43c" "round43" 54 | 55 | Tree<*> Tree<*> Tree<*> 56 | ├┬ A ├─┬ A ├── A 57 | │├┬ a1 │ ├─┬ a1 │ ├── a1 58 | ││├─ a11 │ │ ├── a11 │ │ ├── a11 59 | ││╰─ a12 │ │ ╰── a12 │ │ ╰── a12 60 | │╰─ a2 │ ╰── a2 │ ╰── a2 61 | ╰┬ B ╰─┬ B ╰── B 62 | ╰┬ b1 ╰─┬ b1 ╰── b1 63 | ╰─ b11 ╰── b11 ╰── b11 64 | 65 | 66 | Set `title` to false to remove the root from the display. :: 67 | 68 | tree.format(title=False) 69 | 70 | :: 71 | 72 | 'Records' 73 | ├── 'Let It Be' 74 | ╰── "Get Yer Ya-Ya's Out!" 75 | 'Books' 76 | ╰── 'The Little Prince' 77 | 78 | :meth:`~nutree.node.Node.format` is also implemented for nodes:: 79 | 80 | tree["Records"].format() 81 | 82 | :: 83 | 84 | 'Records' 85 | ├── 'Let It Be' 86 | ╰── "Get Yer Ya-Ya's Out!" 87 | 88 | The 'list' style does not generate connector prefixes:: 89 | 90 | tree.format(repr="{node.path}", style="list") 91 | 92 | :: 93 | 94 | /A 95 | /A/a1 96 | /A/a1/a11 97 | ... 98 | 99 | `join` defaults to ``\n``, but may be changed:: 100 | 101 | tree.format(repr="{node.path}", style="list", join=",") 102 | 103 | :: 104 | 105 | /A,/A/a1,/A/a1/a11,/A/a1/a12,/A/a2,/B,/B/b1,/B/b1/b11 106 | 107 | .. note:: 108 | It would also be easy to create custom formatting, for example:: 109 | 110 | s = ",".join(n.data for n in tree) 111 | assert s == "A,a1,a11,a12,a2,B,b1,b11" 112 | 113 | .. 114 | # Print the __repr__ of the data object: 115 | for s in tree.format_iter(repr="{node.data}"): 116 | print(s) 117 | # Print the __repr__ of the data object: 118 | for s in tree.format_iter(repr="{node.node_id}-{node.name}"): 119 | print(s) 120 | -------------------------------------------------------------------------------- /docs/sphinx/ug_randomize.rst: -------------------------------------------------------------------------------- 1 | .. _randomize: 2 | 3 | --------------------- 4 | Generate Random Trees 5 | --------------------- 6 | 7 | .. py:currentmodule:: nutree 8 | 9 | .. admonition:: TL;DR 10 | 11 | Nutree can generate random tree structures from a structure definition. 12 | 13 | .. warning:: 14 | 15 | This feature is experimental and may change in future versions. 16 | 17 | Nutree can generate random tree structures from a structure definition. 18 | This can be used to create hierarchical data for test, demo, or benchmarking of 19 | *nutree* itself. 20 | 21 | The result can also be used as a source for creating fixtures for other targets 22 | in a following next step. |br| 23 | See `Wundebaum demo `_ and the 24 | `fixture generator `_ 25 | for an example. 26 | 27 | The structure is defined as Python dictionary that describes the 28 | parent-child relationships to be created. 29 | This definition is then passed to :meth:`tree.Tree.build_random_tree`:: 30 | 31 | structure_def = { 32 | ... 33 | # Relations define the possible parent / child relationships between 34 | # node types and optionally override the default properties. 35 | "relations": { 36 | "__root__": { # System root, i.e. we define the top nodes here 37 | "TYPE_1": { 38 | # How many instances to create: 39 | ":count": 10, 40 | # Attribute names and values for every instance: 41 | "ATTR_1": "This is a top node", 42 | "ATTR_2": True, 43 | "ATTR_3": 42, 44 | }, 45 | }, 46 | "TYPE_1": { # Potential child nodes of TYPE_1 47 | "TYPE_2": { 48 | # How many instances to create: 49 | ":count": 3, 50 | # Attribute names and values for every instance: 51 | "title": "This is a child node of TYPE_1", 52 | }, 53 | }, 54 | }, 55 | } 56 | 57 | tree = Tree.build_random_tree(structure_def) 58 | 59 | Example:: 60 | 61 | structure_def = { 62 | # Name of the generated tree (optional) 63 | "name": "fmea", 64 | # Types define the default properties of the gernated nodes 65 | "types": { 66 | # '*' Defines default properties for all node types (optional) 67 | "*": { 68 | ":factory": DictWrapper, # Default node class (optional) 69 | }, 70 | # Specific default properties for each node type 71 | "function": {"icon": "gear"}, 72 | "failure": {"icon": "exclamation"}, 73 | "cause": {"icon": "tools"}, 74 | "effect": {"icon": "lightning"}, 75 | }, 76 | # Relations define the possible parent / child relationships between 77 | # node types and optionally override the default properties. 78 | "relations": { 79 | "__root__": { 80 | "function": { 81 | ":count": 3, 82 | "title": TextRandomizer(("{idx}: Provide $(Noun:plural)",)), 83 | "details": BlindTextRandomizer(dialect="ipsum"), 84 | "expanded": True, 85 | }, 86 | }, 87 | "function": { 88 | "failure": { 89 | ":count": RangeRandomizer(1, 3), 90 | "title": TextRandomizer("$(Noun:plural) not provided"), 91 | }, 92 | }, 93 | "failure": { 94 | "cause": { 95 | ":count": RangeRandomizer(1, 3), 96 | "title": TextRandomizer("$(Noun:plural) not provided"), 97 | }, 98 | "effect": { 99 | ":count": RangeRandomizer(1, 3), 100 | "title": TextRandomizer("$(Noun:plural) not provided"), 101 | }, 102 | }, 103 | }, 104 | } 105 | 106 | tree = TypedTree.build_random_tree(structure_def) 107 | 108 | assert isinstance(tree, TypedTree) 109 | assert tree.calc_height() == 3 110 | 111 | tree.print() 112 | 113 | May produce:: 114 | 115 | TypedTree<'fmea'> 116 | ├── function → DictWrapper<{'icon': 'gear', 'title': '1: Provide Seniors', 'details': 'Quis aute iure reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquid ex ea commodi consequat. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat.', 'expanded': True}> 117 | │ ├── failure → DictWrapper<{'icon': 'exclamation', 'title': 'Streets not provided'}> 118 | │ │ ├── cause → DictWrapper<{'icon': 'tools', 'title': 'Decisions not provided'}> 119 | │ │ ├── effect → DictWrapper<{'icon': 'lightning', 'title': 'Spaces not provided'}> 120 | │ │ ╰── effect → DictWrapper<{'icon': 'lightning', 'title': 'Kings not provided'}> 121 | │ ╰── failure → DictWrapper<{'icon': 'exclamation', 'title': 'Entertainments not provided'}> 122 | │ ├── cause → DictWrapper<{'icon': 'tools', 'title': 'Programs not provided'}> 123 | │ ├── effect → DictWrapper<{'icon': 'lightning', 'title': 'Dirts not provided'}> 124 | │ ╰── effect → DictWrapper<{'icon': 'lightning', 'title': 'Dimensions not provided'}> 125 | ├── function → DictWrapper<{'icon': 'gear', 'title': '2: Provide Shots', 'details': 'Quis aute iure reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Quis aute iure reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum.', 'expanded': True}> 126 | │ ├── failure → DictWrapper<{'icon': 'exclamation', 'title': 'Trainers not provided'}> 127 | │ │ ├── cause → DictWrapper<{'icon': 'tools', 'title': 'Girlfriends not provided'}> 128 | │ │ ├── cause → DictWrapper<{'icon': 'tools', 'title': 'Noses not provided'}> 129 | │ │ ├── effect → DictWrapper<{'icon': 'lightning', 'title': 'Closets not provided'}> 130 | │ │ ╰── effect → DictWrapper<{'icon': 'lightning', 'title': 'Potentials not provided'}> 131 | │ ╰── failure → DictWrapper<{'icon': 'exclamation', 'title': 'Punches not provided'}> 132 | │ ├── cause → DictWrapper<{'icon': 'tools', 'title': 'Inevitables not provided'}> 133 | │ ├── cause → DictWrapper<{'icon': 'tools', 'title': 'Fronts not provided'}> 134 | │ ╰── effect → DictWrapper<{'icon': 'lightning', 'title': 'Worths not provided'}> 135 | ╰── function → DictWrapper<{'icon': 'gear', 'title': '3: Provide Shots', 'details': 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Quis aute iure reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.', 'expanded': True}> 136 | ╰── failure → DictWrapper<{'icon': 'exclamation', 'title': 'Recovers not provided'}> 137 | ├── cause → DictWrapper<{'icon': 'tools', 'title': 'Viruses not provided'}> 138 | ├── effect → DictWrapper<{'icon': 'lightning', 'title': 'Dirts not provided'}> 139 | ╰── effect → DictWrapper<{'icon': 'lightning', 'title': 'Readings not provided'}> 140 | 141 | 142 | **A few things to note** 143 | 144 | - The generated tree contains :class:`~common.DictWrapper` instances as ``node.data`` 145 | value. 146 | 147 | - Every ``node.data`` contains items from the structure definition except for 148 | the ones starting with a colon, for example ``":count"``. |br| 149 | The node items are merged with the default properties defined in the `types` 150 | section. 151 | 152 | - Randomizers are used to generate random data for each instance. 153 | They derive from the :class:`~tree_generator.Randomizer` base class. 154 | 155 | - The :class:`~tree_generator.TextRandomizer` and 156 | :class:`~tree_generator.BlindTextRandomizer` classes are used to generate 157 | random text using the `Fabulist `_ library. 158 | 159 | - :meth:`tree.Tree.build_random_tree` creates instances of :class:`~tree.Tree`, while 160 | :meth:`typed_tree.TypedTree.build_random_tree` creates instances of 161 | :class:`~typed_tree.TypedTree`. 162 | 163 | - The generated tree contains instances of the :class:`~common.DictWrapper` 164 | class by default, but can be overridden for each node type by adding a 165 | ``":factory": CLASS`` entry. 166 | 167 | .. note:: 168 | 169 | The random text generator is based on the `Fabulist `_ 170 | library and can use any of its providers to generate random data. |br| 171 | Make sure to install the `fabulist` package to use the text randomizers 172 | :class:`~tree_generator.TextRandomizer` and :class:`~tree_generator.BlindTextRandomizer`. 173 | Either install `fabulist` separately or install nutree with extras: 174 | ``pip install "nutree[random]"`` or ``pip install "nutree[all]"``. 175 | -------------------------------------------------------------------------------- /docs/sphinx/ug_search_and_navigate.rst: -------------------------------------------------------------------------------- 1 | ------------------- 2 | Search and Navigate 3 | ------------------- 4 | 5 | .. py:currentmodule:: nutree 6 | 7 | .. admonition:: TL;DR 8 | 9 | Nutree provides methods to search, navigate, and iterate tree structures. 10 | 11 | .. _navigate: 12 | 13 | Assuming we have a tree like this:: 14 | 15 | Tree<'fixture'> 16 | ├── 'A' 17 | │ ├── 'a1' 18 | │ │ ├── 'a11' 19 | │ │ ╰── 'a12' 20 | │ ╰── 'a2' 21 | ╰── 'B' 22 | ├── 'a11' <- a clone node here 23 | ╰── 'b1' 24 | ╰── 'b11' 25 | 26 | Navigate 27 | -------- 28 | 29 | Related nodes can be resolved using Node API methods, like 30 | :meth:`~nutree.node.Node.parent`, 31 | :meth:`~nutree.node.Node.children`, 32 | :meth:`~nutree.node.Node.first_child`, 33 | :meth:`~nutree.node.Node.last_child`, 34 | :meth:`~nutree.node.Node.first_sibling`, 35 | :meth:`~nutree.node.Node.prev_sibling`, 36 | :meth:`~nutree.node.Node.next_sibling`, 37 | :meth:`~nutree.node.Node.last_sibling`, 38 | :meth:`~nutree.node.Node.get_clones`, 39 | :meth:`~nutree.node.Node.get_siblings`, 40 | :meth:`~nutree.node.Node.get_parent_list`, 41 | and these Tree API methods 42 | :meth:`~nutree.tree.Tree.get_toplevel_nodes`, 43 | 44 | Examples:: 45 | 46 | records_node = tree["Records"] 47 | assert tree.first_child() is records_node 48 | 49 | assert len(records_node.children) == 2 50 | assert records_node.depth() == 1 51 | 52 | n = records_node.first_child() 53 | assert records_node.find("Let It Be") is n 54 | 55 | assert n.name == "Let It Be" 56 | assert n.depth() == 2 57 | assert n.parent is records_node 58 | assert n.prev_sibling() is None 59 | assert n.next_sibling().name == "Get Yer Ya-Ya's Out!" 60 | assert not n.children 61 | 62 | 63 | .. _searching: 64 | 65 | Search 66 | ------ 67 | 68 | Examples:: 69 | 70 | # Case sensitive (single match): 71 | assert tree.find("Records") is records_node 72 | assert tree.find("records") is None 73 | 74 | # 'Smart' search: 75 | assert tree["Records"] is records_node 76 | 77 | # Regular expressions 78 | res = tree.find_all(match=r"[GL]et.*") 79 | print(res) 80 | assert len(res) == 2 81 | 82 | res = tree.find_first(match=r"[GL]et.*") 83 | assert res.name == "Let It Be" 84 | 85 | res = tree.find_all(match=lambda n: "y" in n.name.lower()) 86 | assert len(res) == 1 87 | 88 | 89 | .. note:: 90 | ``tree[term]`` performs a 'smart' search: 91 | 92 | 1. If `term` is an integer, we look for the ``node_id``, 93 | 2. else if `term` is a string or integer, we look for the ``data_id``, 94 | 3. else if we search for ``calc_data_id(node.data) == term``. 95 | 4. If the search return more than one match, raise ``AmbiguousMatchError`` 96 | 97 | Using :meth:`~nutree.tree.Tree.find_first` or :meth:`~nutree.tree.Tree.find_all` 98 | may be more explicit (and faster). 99 | 100 | .. note:: 101 | ``tree.find("123")`` will search for ``calc_data_id(node.data) == "123"``. 102 | If a node was created with an explicit ``data_id``, this will not work. 103 | Instead, use ``tree.find(data_id="123")`` to search by key:: 104 | 105 | tree.add("A", data_id="123") 106 | assert tree.find("A") is None # not found 107 | assert tree.find("123") is None # not found 108 | assert tree.find(data_id="123") is not None # FOUND! 109 | 110 | 111 | .. _traversal: 112 | 113 | Traversal 114 | --------- 115 | 116 | .. rubric:: Iteration 117 | 118 | Iterators are the most performant and memory efficient way to traverse the tree. 119 | 120 | Iterators are available for the whole tree or by branch (i.e. starting at a node). 121 | Different traversal methods are supported. :: 122 | 123 | for node in tree: 124 | # Depth-first, pre-order by default 125 | ... 126 | 127 | for node in tree.iterator(method=IterMethod.POST_ORDER): 128 | ... 129 | 130 | # Walk a branch (not including the root node) 131 | for n in node: 132 | ... 133 | 134 | # Walk a branch (including the root node) 135 | for n in node.iterator(add_self=True): 136 | ... 137 | 138 | # Keep in mind that iterators are generators, so at times we may need 139 | # to materialize: 140 | res = list(node.iterator(add_self=True)) 141 | 142 | .. _iteration-methods: 143 | 144 | Available iteration methods (`IterMethod.MODE`):: 145 | 146 | PRE_ORDER # Depth-first, pre-order 147 | POST_ORDER # Depth-first, post-order 148 | LEVEL_ORDER # Breadth-first (aka level-order) 149 | LEVEL_ORDER_RTL # Breadth-first (aka level-order) right-to-left 150 | ZIGZAG # ZigZag order 151 | ZIGZAG_RTL # ZigZag order right-to-left 152 | RANDOM_ORDER # Random order traversal 153 | UNORDERED # Fastest traversal in unpredictable order. It may appear to 154 | # be the order of node insertion, but do not rely on it. 155 | 156 | .. note:: 157 | 158 | To avoid race conditions during iteration, we can enforce critical sections 159 | like so:: 160 | 161 | with tree: 162 | for node in tree: 163 | # Depth-first, pre-order by default 164 | ... 165 | 166 | or:: 167 | 168 | with tree: 169 | snapshot = tree.to_dict_list() 170 | ... 171 | 172 | 173 | .. rubric:: Visit 174 | 175 | The :meth:`~nutree.tree.Tree.visit` method is an alternative way to traverse tree 176 | structures with a little bit more control. 177 | In this case, a callback function is invoked for every node. 178 | 179 | The callback may return (or raise) :class:`~nutree.common.SkipBranch` to 180 | prevent visiting of the descendant nodes. |br| 181 | The callback may return (or raise) :class:`~nutree.common.StopTraversal` to 182 | stop traversal immediately. An optional return value may be passed to the 183 | constructor. |br| 184 | See `Iteration Callbacks `_ for details. 185 | 186 | :: 187 | 188 | from nutree import Tree, SkipBranch, StopTraversal 189 | 190 | def callback(node, memo): 191 | if node.name == "secret": 192 | # Prevent visiting the child nodes: 193 | return SkipBranch 194 | if node.data.foobar == 17: 195 | raise StopTraversal("found it") 196 | 197 | # `res` contains the value passed to the `StopTraversal` constructor 198 | res = tree.visit(callback) # res == "found it" 199 | 200 | The `memo` argument contains an empty dict by default, which is discarded after 201 | traversal. This may be handy to cache and pass along some calculated values 202 | during iteration. |br| 203 | It is also possible to pass-in the `memo` argument, in order to access the data 204 | after the call:: 205 | 206 | def callback(node, memo): 207 | if node.data.foobar > 10: 208 | memo.append(node) 209 | 210 | hits = [] 211 | tree.visit(callback, memo=hits) 212 | 213 | We could achieve the same using a closure if the callback is defined in the 214 | same scope as the `visit()` call:: 215 | 216 | hits = [] 217 | def callback(node, memo): 218 | if node.data.foobar > 10: 219 | hits.append(node) 220 | 221 | tree.visit(callback) 222 | 223 | .. rubric:: Custom Traversal 224 | 225 | If we need more control, here is an example implementation of a recursive 226 | traversal:: 227 | 228 | def my_visit(node): 229 | """Depth-first, pre-order traversal.""" 230 | print(node) 231 | for child in node.children: 232 | my_visit(child) 233 | return 234 | -------------------------------------------------------------------------------- /docs/sphinx/user_guide.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | User Guide 3 | ========== 4 | 5 | First, `Take the Tour `_ to get a quick overview of the features and 6 | capabilities of the library, if you haven't already. 7 | 8 | 9 | .. **Read the Details** 10 | 11 | .. :hidden: 12 | .. :maxdepth: 1: 13 | 14 | .. toctree:: 15 | 16 | :hidden: 17 | 18 | ug_basics 19 | ug_search_and_navigate 20 | ug_pretty_print 21 | ug_mutation 22 | ug_clones 23 | ug_objects 24 | ug_serialize 25 | ug_diff 26 | ug_graphs 27 | ug_randomize 28 | ug_advanced 29 | ug_benchmarks.md 30 | -------------------------------------------------------------------------------- /nutree/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Current version number. 3 | 4 | See https://www.python.org/dev/peps/pep-0440 5 | 6 | Examples 7 | Pre-releases (alpha, beta, release candidate): 8 | '3.0.0a1', '3.0.0b1', '3.0.0rc1' 9 | Final Release: 10 | '3.0.0' 11 | Developmental release (to mark 3.0.0 as 'used'. Don't publish this): 12 | '3.0.0.dev1' 13 | NOTE: 14 | When pywin32 is installed, number must be a.b.c for MSI builds? 15 | "3.0.0a4" seems not to work in this case! 16 | """ 17 | 18 | # flake8: noqa 19 | __version__ = "1.1.1-a1" 20 | 21 | from nutree.common import ( 22 | AmbiguousMatchError, 23 | DictWrapper, 24 | IterMethod, 25 | SelectBranch, 26 | SkipBranch, 27 | StopTraversal, 28 | TreeError, 29 | UniqueConstraintError, 30 | ) 31 | from nutree.diff import DiffClassification, diff_node_formatter 32 | from nutree.fs import load_tree_from_fs 33 | from nutree.node import Node 34 | from nutree.tree import Tree 35 | from nutree.typed_tree import TypedNode, TypedTree 36 | 37 | __all__ = [ # type: ignore 38 | Tree, 39 | Node, 40 | AmbiguousMatchError, 41 | diff_node_formatter, 42 | DiffClassification, 43 | DictWrapper, 44 | IterMethod, 45 | load_tree_from_fs, 46 | SelectBranch, 47 | SkipBranch, 48 | StopTraversal, 49 | TreeError, 50 | TypedNode, 51 | TypedTree, 52 | UniqueConstraintError, 53 | ] 54 | -------------------------------------------------------------------------------- /nutree/diff.py: -------------------------------------------------------------------------------- 1 | # (c) 2021-2024 Martin Wendt and contributors; see https://github.com/mar10/nutree 2 | # Licensed under the MIT license: https://www.opensource.org/licenses/mit-license.php 3 | """ 4 | Implement diff/merge algorithms. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | from enum import Enum 10 | from typing import TYPE_CHECKING, Any, Callable, Union 11 | 12 | if TYPE_CHECKING: # Imported by type checkers, but prevent circular includes 13 | from nutree.tree import Node, Tree 14 | 15 | 16 | class DiffClassification(Enum): 17 | ADDED = 1 18 | REMOVED = 2 19 | MOVED_HERE = 3 20 | MOVED_TO = 4 21 | 22 | 23 | #: Alias for DiffClassification 24 | DC = DiffClassification 25 | 26 | #: Callback for `tree.diff(compare=...)` 27 | #: Return `False` if nodes are considered equal, `False` if different, or any 28 | #: value to indicate a custom classification. 29 | DiffCompareCallbackType = Callable[["Node", "Node", "Node"], Union[bool, Any]] 30 | 31 | 32 | def _find_child(child_list: list[Node], child: Node) -> tuple[int, Node | None]: 33 | """Search for a node with the same data id and Return index and node.""" 34 | for i, c in enumerate(child_list): 35 | if c.data_id == child.data_id: 36 | return (i, c) 37 | return (-1, None) 38 | 39 | 40 | def _check_modified( 41 | c0: Node, c1: Node, c2: Node, compare: DiffCompareCallbackType | bool 42 | ) -> bool: 43 | """Test if nodes are different and set metadata if so.""" 44 | if not compare: 45 | return False 46 | if compare is True: 47 | if c0.data != c1.data: 48 | c2.set_meta("dc_modified", True) 49 | return True 50 | else: 51 | if compare(c0, c1, c2): 52 | c2.set_meta("dc_modified", True) 53 | return True 54 | return False 55 | 56 | 57 | def _copy_children(source: Node, dest: Node, add_set: set, meta: tuple | None) -> None: 58 | assert source.has_children() and not dest.has_children() 59 | for n in source.children: 60 | n_dest = dest.append_child(n) 61 | add_set.add(n_dest._node_id) 62 | if meta: 63 | n_dest.set_meta(*meta) 64 | if n._children: 65 | # meta is only set on top node 66 | _copy_children(n, n_dest, add_set, meta=None) 67 | return 68 | 69 | 70 | def diff_node_formatter(node: Node) -> str: 71 | """Use with :meth:`~nutree.tree.format` or :meth:`~nutree.tree.print` 72 | `repr=...` arguments.""" 73 | s = f"{node.name}" 74 | meta = node.meta 75 | 76 | if not meta: 77 | return s 78 | 79 | flags = [] 80 | dc = meta.get("dc") 81 | if dc is None: 82 | pass 83 | elif dc == DC.ADDED: 84 | flags.append("Added") # ☆ 🌟 85 | elif dc == DC.REMOVED: 86 | flags.append("Removed") # × ❌ 87 | elif dc == DC.MOVED_HERE: 88 | flags.append("Moved here") # ← 89 | elif dc == DC.MOVED_TO: 90 | flags.append("Moved away") # ×➡ 91 | elif dc: # pragma: no cover 92 | flags.append(f"{dc}") 93 | 94 | if modified := meta.get("dc_modified"): 95 | if isinstance(modified, str): 96 | flags.append(f"Modified ({modified})") 97 | else: 98 | flags.append("Modified") 99 | 100 | if meta.get("dc_renumbered"): # Child order has changed 101 | flags.append("Renumbered") 102 | 103 | if order := meta.get("dc_order"): # Node position changed 104 | ofs = order[1] - order[0] 105 | flags.append(f"Order {ofs:+d}") # ⇳ ⇵ 106 | 107 | # if meta.get("dc_cleared"): 108 | # flags.append("Children cleared") 109 | 110 | if flags: 111 | flags_s = "[" + "], [".join(flags) + "]" 112 | s += f" - {flags_s}" 113 | 114 | return s 115 | 116 | 117 | def diff_tree( 118 | t0: Tree[Any, Any], 119 | t1: Tree[Any, Any], 120 | *, 121 | compare: DiffCompareCallbackType | bool = True, 122 | ordered: bool = False, 123 | reduce: bool = False, 124 | ) -> Tree: 125 | from nutree import Tree 126 | 127 | t2 = Tree[Any, Any](f"diff({t0.name!r}, {t1.name!r})") 128 | added_nodes = set[int]() 129 | removed_nodes = set[int]() 130 | 131 | def _diff(p0: Node, p1: Node, p2: Node): 132 | p0_data_ids = set() 133 | 134 | # `p0.children` always returns an (empty) array 135 | for i0, c0 in enumerate(p0.children): 136 | p0_data_ids.add(c0._data_id) 137 | i1, c1 = _find_child(p1.children, c0) 138 | # preferably copy from T1 (which should have the current 139 | # modification status) 140 | if c1: 141 | c2 = p2.add(c1, data_id=c1._data_id) 142 | else: 143 | c2 = p2.add(c0, data_id=c0._data_id) 144 | 145 | if c1 is None: 146 | # t0 node is not found in t1 147 | c2.set_meta("dc", DC.REMOVED) 148 | removed_nodes.add(c2._node_id) 149 | else: 150 | # Found node with same data_id 151 | 152 | # Check if position changed 153 | if ordered and i0 != i1: 154 | # Mark parent node as renumbered 155 | p2.set_meta("dc_renumbered", True) 156 | # Mark child node as shifted 157 | c2.set_meta("dc_order", (i0, i1)) 158 | 159 | # Check if data changed 160 | _check_modified(c0, c1, c2, compare) 161 | 162 | if c0._children: 163 | if c1: 164 | _diff(c0, c1, c2) 165 | # if c1._children: 166 | # # c0 and c1 have children: Recursively visit peer nodes 167 | # _compare(c0, c1, c2) 168 | # else: 169 | # # c0 has children and c1 exists, but has no children 170 | # # TODO: copy children c0 to c2 171 | # c2.set_meta("dc_cleared", True) 172 | else: 173 | # c0 has children, but c1 does not even exist 174 | # TODO: Copy children from c0 to c2, but we need to check 175 | # if c1 is really removed or just moved-away 176 | pass 177 | elif c1: 178 | if c1._children: 179 | # c1 has children and c0 exists, but has no children 180 | _diff(c0, c1, c2) 181 | else: 182 | # Neither c0 nor c1 have children: Nothing to do 183 | pass 184 | 185 | # Collect t1 nodes that are NOT in t0: 186 | for c1 in p1.children: # `p1.children` always returns an (empty) array 187 | if c1._data_id not in p0_data_ids: 188 | idx = c1.get_index() # try to maintain the order 189 | c2 = p2.add(c1, data_id=c1._data_id, before=idx) 190 | c2.set_meta("dc", DC.ADDED) 191 | added_nodes.add(c2._node_id) 192 | if c1._children: 193 | # c1 has children, but c0 does not even exist 194 | # TODO: Copy children from c1 to c2, but we need to check 195 | # if c1 is really added or just moved-here 196 | _copy_children(c1, c2, added_nodes, ("dc", DC.ADDED)) 197 | else: 198 | # c1 does not have children and c0 does not exist: 199 | # We already marked a 'new', nothing more to do. 200 | pass 201 | 202 | return # def _compare() 203 | 204 | _diff(t0._root, t1._root, t2._root) 205 | 206 | # Re-classify: check added/removed nodes for move operations 207 | for nid in added_nodes: 208 | added_node = t2._node_by_id[nid] 209 | other_clones = added_node.get_clones() 210 | removed_clones = [n for n in other_clones if n.get_meta("dc") == DC.REMOVED] 211 | if removed_clones: 212 | added_node.set_meta("dc", DC.MOVED_HERE) 213 | for n in removed_clones: 214 | n.set_meta("dc", DC.MOVED_TO) 215 | if _check_modified(n, added_node, added_node, compare): 216 | n.set_meta("dc_modified", True) 217 | 218 | # Purge unchanged parts from tree 219 | if reduce: 220 | 221 | def pred(node): 222 | return bool( 223 | node.get_meta("dc") 224 | or node.get_meta("dc_modified") 225 | or node.get_meta("dc_order") 226 | ) 227 | 228 | t2.filter(predicate=pred) 229 | 230 | return t2 231 | -------------------------------------------------------------------------------- /nutree/dot.py: -------------------------------------------------------------------------------- 1 | # (c) 2021-2024 Martin Wendt; see https://github.com/mar10/nutree 2 | # Licensed under the MIT license: https://www.opensource.org/licenses/mit-license.php 3 | """ 4 | Functions and declarations to support 5 | `Graphviz `_. 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | from collections.abc import Iterator 11 | from pathlib import Path 12 | from typing import IO, TYPE_CHECKING, Any 13 | 14 | from nutree.common import MapperCallbackType, call_mapper 15 | 16 | if TYPE_CHECKING: # Imported by type checkers, but prevent circular includes 17 | from nutree.node import Node 18 | from nutree.tree import Tree 19 | 20 | try: 21 | import pydot 22 | except ImportError: # pragma: no cover 23 | pydot = None 24 | 25 | 26 | def node_to_dot( 27 | node: Node, 28 | *, 29 | add_self=False, 30 | unique_nodes=True, 31 | graph_attrs: dict | None = None, 32 | node_attrs: dict | None = None, 33 | edge_attrs: dict | None = None, 34 | node_mapper: MapperCallbackType | None = None, 35 | edge_mapper: MapperCallbackType | None = None, 36 | ) -> Iterator[str]: 37 | """Generate DOT formatted output line-by-line. 38 | 39 | https://graphviz.org/doc/info/attrs.html 40 | Args: 41 | mapper (method): 42 | add_self (bool): 43 | unique_nodes (bool): 44 | """ 45 | indent = " " 46 | name = node.tree.name 47 | used_keys = set() 48 | 49 | def _key(n: Node): 50 | return n._data_id if unique_nodes else n._node_id 51 | 52 | def _attr_str(attr_def: dict, mapper=None, node=None): 53 | if mapper: 54 | assert isinstance(attr_def, dict), "attr_def must be a dict" 55 | # if attr_def is None: 56 | # attr_def = {} 57 | assert node, "node required for mapper" 58 | call_mapper(mapper, node, attr_def) 59 | if not attr_def: 60 | return "" 61 | attr_str = " ".join(f'{k}="{v}"' for k, v in attr_def.items()) # noqa: B028 62 | return " [" + attr_str + "]" 63 | 64 | yield "# Generator: https://github.com/mar10/nutree/" 65 | yield f'digraph "{name}" {{' # noqa: B028 66 | 67 | if graph_attrs or node_attrs or edge_attrs: 68 | yield "" 69 | yield f"{indent}# Default Definitions" 70 | if graph_attrs: 71 | yield f"{indent}graph {_attr_str(graph_attrs)}" 72 | if node_attrs: 73 | yield f"{indent}node {_attr_str(node_attrs)}" 74 | if edge_attrs: 75 | yield f"{indent}edge {_attr_str(edge_attrs)}" 76 | 77 | yield "" 78 | yield f"{indent}# Node Definitions" 79 | 80 | if add_self: 81 | if node._parent: 82 | attr_def = {} 83 | else: # __root__ inherits tree name by default 84 | attr_def = {"label": f"{name}", "shape": "box"} 85 | 86 | attr_str = _attr_str(attr_def, node_mapper, node) 87 | yield f'{indent}"{_key(node)}"{attr_str}' 88 | 89 | for n in node: 90 | if unique_nodes: 91 | key = n._data_id 92 | if key in used_keys: 93 | continue 94 | used_keys.add(key) 95 | else: 96 | key = n._node_id 97 | 98 | attr_def = {"label": n.name} 99 | attr_str = _attr_str(attr_def, node_mapper, n) 100 | yield f'{indent}"{key}"{attr_str}' 101 | 102 | yield "" 103 | yield f"{indent}# Edge Definitions" 104 | for n in node: 105 | if not add_self and n._parent is node: 106 | continue 107 | attr_def = {} 108 | attr_str = _attr_str(attr_def, edge_mapper, n) 109 | yield f'{indent}"{_key(n._parent)}" -> "{_key(n)}"{attr_str}' 110 | 111 | yield "}" 112 | 113 | 114 | def tree_to_dotfile( 115 | tree: Tree[Any, Any], 116 | target: IO[str] | str | Path, 117 | *, 118 | format=None, 119 | add_root=True, 120 | unique_nodes=True, 121 | graph_attrs: dict | None = None, 122 | node_attrs: dict | None = None, 123 | edge_attrs: dict | None = None, 124 | node_mapper: MapperCallbackType | None = None, 125 | edge_mapper: MapperCallbackType | None = None, 126 | ) -> None: 127 | if isinstance(target, str): 128 | target = Path(target) 129 | 130 | if isinstance(target, Path): 131 | if format: 132 | dot_path = target.with_suffix(".gv") 133 | else: 134 | dot_path = target 135 | 136 | # print("write", dot_path) 137 | with open(dot_path, "w") as fp: 138 | tree_to_dotfile( 139 | tree=tree, 140 | target=fp, 141 | add_root=add_root, 142 | unique_nodes=unique_nodes, 143 | graph_attrs=graph_attrs, 144 | node_attrs=node_attrs, 145 | edge_attrs=edge_attrs, 146 | node_mapper=node_mapper, 147 | edge_mapper=edge_mapper, 148 | ) 149 | 150 | if format: 151 | if not pydot: # pragma: no cover 152 | raise RuntimeError("Need pydot installed to convert DOT output.") 153 | # print("convert", dot_path, format, target) 154 | pydot.call_graphviz( 155 | "dot", 156 | [ 157 | "-o", 158 | target.with_suffix(f".{format}"), 159 | f"-T{format}", 160 | dot_path, 161 | ], 162 | working_dir=target.parent, 163 | ) 164 | # https://graphviz.org/docs/outputs/ 165 | # check_call(f"dot -h") 166 | # check_call(f"dot -O -T{format} {target}") 167 | # check_call(f"dot -o{target}.{format} -T{format} {target}") 168 | 169 | # Remove DOT file that was created as input for the conversion 170 | # TODO: 171 | # dot_path.unlink() 172 | return 173 | 174 | # `target` is suppoesed to be an open, writable filelike 175 | if format: # pragma: no cover 176 | raise RuntimeError("Need a filepath to convert DOT output.") 177 | 178 | with tree: 179 | for line in tree.to_dot( 180 | add_root=add_root, 181 | unique_nodes=unique_nodes, 182 | graph_attrs=graph_attrs, 183 | node_attrs=node_attrs, 184 | edge_attrs=edge_attrs, 185 | node_mapper=node_mapper, 186 | edge_mapper=edge_mapper, 187 | ): 188 | target.write(line + "\n") 189 | return 190 | -------------------------------------------------------------------------------- /nutree/fs.py: -------------------------------------------------------------------------------- 1 | # (c) 2021-2024 Martin Wendt; see https://github.com/mar10/nutree 2 | # Licensed under the MIT license: https://www.opensource.org/licenses/mit-license.php 3 | """ 4 | Methods and classes to support file system related functionality. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | from datetime import datetime 10 | from operator import attrgetter, itemgetter 11 | from pathlib import Path 12 | 13 | from nutree.tree import Node, Tree 14 | 15 | 16 | class FileSystemEntry: 17 | def __init__( 18 | self, 19 | name: str, 20 | *, 21 | is_dir: bool = False, 22 | size: int | None = None, 23 | mdate: float | None = None, 24 | ): 25 | self.name = name 26 | self.is_dir = is_dir 27 | if is_dir: 28 | assert size is None 29 | size = 0 30 | else: 31 | assert size is not None 32 | self.size = int(size) 33 | self.mdate = float(mdate) if mdate is not None else None 34 | 35 | def __repr__(self): 36 | if self.is_dir: 37 | return f"[{self.name}]" 38 | assert self.mdate is not None 39 | mdt = datetime.fromtimestamp(self.mdate).isoformat(sep=" ", timespec="seconds") 40 | return f"{self.name!r}, {self.size:,} bytes, {mdt}" 41 | 42 | 43 | class FileSystemTree(Tree[FileSystemEntry]): 44 | DEFAULT_KEY_MAP = {} # don't replace 's' with 'str' 45 | 46 | @classmethod 47 | def serialize_mapper(cls, node: Node, data: dict): 48 | """Callback for use with :meth:`~nutree.tree.Tree.save`.""" 49 | inst = node.data 50 | if inst.is_dir: 51 | data.update({"n": inst.name, "d": True}) 52 | else: 53 | data.update({"n": inst.name, "s": inst.size, "m": inst.mdate}) 54 | return data 55 | 56 | @classmethod 57 | def deserialize_mapper(cls, parent: Node, data: dict): 58 | """Callback for use with :meth:`~nutree.tree.Tree.load`.""" 59 | # v = data["v"] 60 | if "d" in data: 61 | return FileSystemEntry(data["n"], is_dir=True) 62 | return FileSystemEntry(data["n"], size=data["s"], mdate=data["m"]) 63 | 64 | 65 | def load_tree_from_fs(path: str | Path, *, sort: bool = True) -> FileSystemTree: 66 | """Scan a filesystem folder and store as tree. 67 | 68 | Args: 69 | sort: Pass true to sort alphabetical and files before directories. 70 | Especially useful when comparing unit test fixtures. 71 | """ 72 | path = Path(path) 73 | tree = FileSystemTree(str(path)) 74 | 75 | def visit(node: Node, pth: Path): 76 | if sort: 77 | dirs = [] 78 | files = [] 79 | for c in pth.iterdir(): 80 | if c.is_dir(): 81 | o = FileSystemEntry(f"{c.name}", is_dir=True) 82 | dirs.append((c, o)) 83 | elif c.is_file(): 84 | stat = c.stat() 85 | o = FileSystemEntry(c.name, size=stat.st_size, mdate=stat.st_mtime) 86 | files.append(o) 87 | # Files first, sorted by name 88 | for o in sorted(files, key=attrgetter("name")): 89 | node.add(o) 90 | # Followed by dirs, sorted by path 91 | for c, o in sorted(dirs, key=itemgetter(0)): 92 | pn = node.add(o) 93 | visit(pn, c) 94 | return 95 | 96 | for c in pth.iterdir(): 97 | if c.is_dir(): 98 | o = FileSystemEntry(f"{c.name}", is_dir=True) 99 | pn = node.add(o) 100 | visit(pn, c) 101 | elif c.is_file(): 102 | stat = c.stat() 103 | o = FileSystemEntry(c.name, size=stat.st_size, mdate=stat.st_mtime) 104 | node.add(o) 105 | 106 | visit(tree._root, path) 107 | return tree 108 | -------------------------------------------------------------------------------- /nutree/mermaid.py: -------------------------------------------------------------------------------- 1 | # (c) 2021-2024 Martin Wendt; see https://github.com/mar10/nutree 2 | # Licensed under the MIT license: https://www.opensource.org/licenses/mit-license.php 3 | """ 4 | Functions and declarations to support 5 | `Mermaid `_ exports. 6 | """ 7 | # ruff: noqa: E731 # do not assign a lambda expression, use a def 8 | 9 | from __future__ import annotations 10 | 11 | import io 12 | from collections.abc import Iterable, Iterator 13 | from pathlib import Path 14 | from subprocess import CalledProcessError, check_output 15 | from typing import IO, TYPE_CHECKING, Callable, Literal 16 | 17 | from nutree.common import DataIdType 18 | 19 | if TYPE_CHECKING: # Imported by type checkers, but prevent circular includes 20 | from nutree.node import Node 21 | 22 | MermaidDirectionType = Literal["LR", "RL", "TB", "TD", "BT"] 23 | MermaidFormatType = Literal["svg", "pdf", "png"] 24 | 25 | #: Callback to map a node to a Mermaid node definition string. 26 | #: The callback is called with the following arguments: 27 | #: `node`. 28 | MermaidNodeMapperCallbackType = Callable[["Node"], str] 29 | 30 | #: Callback to map a node to a Mermaid edge definition string. 31 | #: The callback is called with the following arguments: 32 | #: `from_id`, `from_node`, `to_id`, `to_node`. 33 | MermaidEdgeMapperCallbackType = Callable[[int, "Node", int, "Node"], str] 34 | 35 | DEFAULT_DIRECTION: MermaidDirectionType = "TD" 36 | 37 | #: Default Mermaid node shape templates 38 | #: See https://mermaid.js.org/syntax/flowchart.html#node-shapes 39 | DEFAULT_ROOT_SHAPE: str = '(["{node.name}"])' 40 | DEFAULT_NODE_SHAPE: str = '["{node.name}"]' 41 | #: Default Mermaid edge shape templates 42 | #: See https://mermaid.js.org/syntax/flowchart.html#links-between-nodes 43 | DEFAULT_EDGE: str = "{from_id} --> {to_id}" 44 | DEFAULT_EDGE_TYPED: str = '{from_id}-- "{to_node.kind}" -->{to_id}' 45 | 46 | 47 | def _node_to_mermaid_flowchart_iter( 48 | *, 49 | node: Node, 50 | as_markdown: bool = True, 51 | direction: MermaidDirectionType = "TD", 52 | title: str | bool | None = True, 53 | add_root: bool = True, 54 | unique_nodes: bool = True, 55 | headers: Iterable[str] | None = None, 56 | root_shape: str | None = None, 57 | node_mapper: MermaidNodeMapperCallbackType | str | None = None, 58 | edge_mapper: MermaidEdgeMapperCallbackType | str | None = None, # type: ignore[reportRedeclaration] 59 | ) -> Iterator[str]: 60 | """Generate Mermaid formatted output line-by-line. 61 | 62 | https://mermaid.js.org/syntax/flowchart.html 63 | Args: 64 | node_mapper (method): 65 | See https://mermaid.js.org/syntax/flowchart.html#node-shapes 66 | edge_mapper (method): 67 | add_self (bool): 68 | unique_nodes (bool): 69 | """ 70 | id_to_idx = {} 71 | 72 | def _id(n: Node) -> DataIdType: 73 | return n._data_id if unique_nodes else n._node_id 74 | 75 | if root_shape is None: 76 | root_shape = DEFAULT_ROOT_SHAPE 77 | 78 | if node_mapper is None: 79 | node_mapper = lambda node: DEFAULT_NODE_SHAPE.format(node=node) 80 | elif isinstance(node_mapper, str): 81 | node_templ = node_mapper 82 | node_mapper = lambda node: node_templ.format(node=node) 83 | 84 | if isinstance(edge_mapper, str): 85 | edge_templ = edge_mapper 86 | 87 | def edge_mapper(from_id, from_node, to_id, to_node): 88 | return edge_templ.format( 89 | from_id=from_id, from_node=from_node, to_id=to_id, to_node=to_node 90 | ) 91 | 92 | elif edge_mapper is None: 93 | 94 | def edge_mapper(from_id, from_node, to_id, to_node): 95 | kind = getattr(to_node, "kind", None) 96 | templ = DEFAULT_EDGE_TYPED if kind else DEFAULT_EDGE 97 | return templ.format( 98 | from_id=from_id, 99 | from_node=from_node, 100 | to_id=to_id, 101 | to_node=to_node, 102 | kind=kind, 103 | ) 104 | elif not callable(edge_mapper): # pragma: no cover 105 | raise ValueError("edge_mapper must be str or callable") 106 | 107 | if as_markdown: 108 | yield "```mermaid" 109 | 110 | if title: 111 | yield "---" 112 | yield f"title: {node.name if title is True else title}" 113 | yield "---" 114 | 115 | yield "" 116 | yield "%% Generator: https://github.com/mar10/nutree/" 117 | yield "" 118 | 119 | yield f"flowchart {direction}" 120 | 121 | if headers: 122 | yield "" 123 | yield "%% Headers:" 124 | yield from headers 125 | 126 | yield "" 127 | yield "%% Nodes:" 128 | if add_root: 129 | id_to_idx[_id(node)] = 0 130 | yield "0" + root_shape.format(node=node) 131 | 132 | idx = 1 133 | for n in node: 134 | key = _id(n) 135 | if key in id_to_idx: 136 | continue # we use the initial clone instead 137 | id_to_idx[key] = idx 138 | 139 | shape = node_mapper(n) 140 | yield f"{idx}{shape}" 141 | idx += 1 142 | 143 | yield "" 144 | yield "%% Edges:" 145 | for n in node: 146 | if not add_root and n._parent is node: 147 | continue # Skip root edges 148 | parent_key = _id(n._parent) 149 | key = _id(n) 150 | 151 | parent_idx = id_to_idx[parent_key] 152 | idx = id_to_idx[key] 153 | yield edge_mapper(parent_idx, n._parent, idx, n) 154 | 155 | if as_markdown: 156 | yield "```" 157 | return 158 | 159 | 160 | def node_to_mermaid_flowchart( 161 | node: Node, 162 | target: IO[str] | str | Path, 163 | *, 164 | as_markdown: bool = True, 165 | direction: MermaidDirectionType = "TD", 166 | title: str | bool | None = True, 167 | format: MermaidFormatType | None = None, 168 | mmdc_options: dict | None = None, 169 | add_root: bool = True, 170 | unique_nodes: bool = True, 171 | headers: Iterable[str] | None = None, 172 | root_shape: str | None = None, 173 | node_mapper: MermaidNodeMapperCallbackType | str | None = None, 174 | edge_mapper: MermaidEdgeMapperCallbackType | str | None = None, 175 | ) -> None: 176 | """Write a Mermaid flowchart to a file or stream.""" 177 | if format: 178 | as_markdown = False 179 | 180 | if mmdc_options is None: 181 | mmdc_options = {} 182 | 183 | def _write(fp): 184 | for line in _node_to_mermaid_flowchart_iter( 185 | node=node, 186 | as_markdown=as_markdown, 187 | direction=direction, 188 | title=title, 189 | add_root=add_root, 190 | unique_nodes=unique_nodes, 191 | headers=headers, 192 | root_shape=root_shape, 193 | node_mapper=node_mapper, 194 | edge_mapper=edge_mapper, 195 | ): 196 | fp.write(line + "\n") 197 | 198 | if isinstance(target, io.StringIO): 199 | if format: 200 | raise RuntimeError("Need a filepath to convert Mermaid output.") 201 | _write(target) 202 | return 203 | 204 | if isinstance(target, str): 205 | target = Path(target) 206 | 207 | if not isinstance(target, Path): 208 | raise ValueError(f"target must be a Path, str, or StringIO: {target}") 209 | 210 | mm_path = target.with_suffix(".tmp") if format else target 211 | 212 | with mm_path.open("w") as fp: 213 | _write(fp) 214 | 215 | if format: 216 | # Convert Mermaid output using mmdc 217 | # See https://github.com/mermaid-js/mermaid-cli 218 | 219 | # Make sure the source markdown stream is flushed 220 | # fp.close() 221 | 222 | mmdc_options["-i"] = str(mm_path) 223 | mmdc_options["-o"] = str(target) 224 | mmdc_options["-e"] = format 225 | 226 | cmd = ["mmdc"] 227 | for k, v in mmdc_options.items(): 228 | cmd.extend((k, v)) 229 | 230 | try: 231 | check_output(cmd) 232 | except CalledProcessError as e: # pragma: no cover 233 | raise RuntimeError( 234 | f"Could not convert Mermaid output using {cmd}.\n" 235 | f"Error: {e.output.decode()}" 236 | ) from e 237 | except FileNotFoundError as e: # pragma: no cover 238 | raise RuntimeError( 239 | f"Could not convert Mermaid output using {cmd}.\n" 240 | "Mermaid CLI (mmdc) not found.\n" 241 | "Please install it with `npm install -g mermaid.cli`." 242 | ) from e 243 | return 244 | -------------------------------------------------------------------------------- /nutree/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/nutree/py.typed -------------------------------------------------------------------------------- /nutree/rdf.py: -------------------------------------------------------------------------------- 1 | # (c) 2021-2024 Martin Wendt; see https://github.com/mar10/nutree 2 | # Licensed under the MIT license: https://www.opensource.org/licenses/mit-license.php 3 | """ 4 | Functions and declarations to implement `rdflib `_. 5 | """ 6 | # pragma: exclude-file-from-coverage 7 | 8 | # pyright: reportOptionalCall=false 9 | # pyright: reportInvalidTypeForm=false 10 | # pyright: reportGeneralTypeIssues=false 11 | # pyright: reportOptionalMemberAccess=false 12 | # pyright: reportArgumentType=false 13 | 14 | # mypy: disable-error-code="arg-type, assignment, misc, return-value" 15 | 16 | from __future__ import annotations 17 | 18 | from typing import TYPE_CHECKING, Any, Callable, Union 19 | 20 | from nutree.common import IterationControl 21 | 22 | if TYPE_CHECKING: # Imported by type checkers, but prevent circular includes 23 | from nutree.node import Node 24 | from nutree.tree import Tree 25 | 26 | # Export some common rdflib attributes, so they can be accessed as 27 | # `from nutree.rdf import Literal` without having to `import rdflib` 28 | # (which may not be available): 29 | try: 30 | import rdflib 31 | from rdflib import Graph, IdentifiedNode, Literal, URIRef 32 | from rdflib.namespace import RDF, XSD, DefinedNamespace, Namespace 33 | 34 | except ImportError: 35 | rdflib = None 36 | Graph = IdentifiedNode = Literal = URIRef = None 37 | RDF = XSD = DefinedNamespace = Namespace = None 38 | 39 | 40 | RDFMapperCallbackType = Callable[[Graph, IdentifiedNode, "Node"], Union[None, bool]] 41 | 42 | 43 | if rdflib: 44 | 45 | class NUTREE_NS(DefinedNamespace): 46 | """ 47 | nutree vocabulary 48 | """ 49 | 50 | _fail = True 51 | 52 | # diff_meta: 53 | index: URIRef 54 | has_child: URIRef 55 | kind: URIRef 56 | name: URIRef # 57 | system_root: URIRef 58 | 59 | _NS = Namespace("http://wwwendt.de/namespace/nutree/rdf/0.1/") 60 | 61 | else: # rdflib unavailable # pragma: no cover 62 | NUTREE_NS = None # type: ignore 63 | 64 | 65 | def _make_graph() -> Graph: 66 | if not rdflib: # pragma: no cover 67 | raise RuntimeError("Need rdflib installed.") 68 | graph = Graph() 69 | 70 | graph.bind("nutree", NUTREE_NS) 71 | graph.bind("rdf", RDF) 72 | return graph 73 | 74 | 75 | def _add_child_node( 76 | graph: Graph, 77 | parent_graph_node: IdentifiedNode | None, 78 | tree_node: Node, 79 | index: int, 80 | node_mapper: RDFMapperCallbackType | None, 81 | ) -> IdentifiedNode | IterationControl | bool: 82 | """""" 83 | graph_node = Literal(tree_node.data_id) 84 | 85 | # Mapper can call `graph.add()` 86 | if node_mapper: 87 | try: 88 | res = node_mapper(graph, graph_node, tree_node) 89 | if isinstance(res, (IterationControl, StopIteration)): 90 | return res 91 | except (IterationControl, StopIteration) as e: 92 | return e # SkipBranch, SelectBranch, StopTraversal, StopIteration 93 | else: 94 | res = None 95 | 96 | if parent_graph_node: 97 | graph.add((parent_graph_node, NUTREE_NS.has_child, graph_node)) 98 | 99 | if res is False: 100 | # node_mapper wants to prevent adding standard attributes? 101 | return False 102 | 103 | # Add standard attributes 104 | if hasattr(tree_node, "kind"): 105 | graph.add((graph_node, NUTREE_NS.kind, Literal(tree_node.kind))) 106 | graph.add((graph_node, NUTREE_NS.name, Literal(tree_node.name))) 107 | if index >= 0: 108 | graph.add((graph_node, NUTREE_NS.index, Literal(index, datatype=XSD.integer))) 109 | 110 | # if add_diff_meta: 111 | # pass 112 | 113 | return graph_node 114 | 115 | 116 | def _add_child_nodes( 117 | graph: Graph, 118 | graph_node: IdentifiedNode, 119 | tree_node: Node, 120 | node_mapper: RDFMapperCallbackType | None = None, 121 | ) -> None: 122 | """""" 123 | for index, child_tree_node in enumerate(tree_node._children or ()): 124 | cgn = _add_child_node( 125 | graph, 126 | parent_graph_node=graph_node, 127 | tree_node=child_tree_node, 128 | index=index, 129 | node_mapper=node_mapper, 130 | ) 131 | if len(child_tree_node.children) > 0: 132 | _add_child_nodes(graph, cgn, child_tree_node, node_mapper) 133 | 134 | return 135 | 136 | 137 | def node_to_rdf( 138 | tree_node: Node, 139 | *, 140 | add_self: bool = True, 141 | node_mapper: RDFMapperCallbackType | None = None, 142 | ) -> Graph: 143 | """Generate DOT formatted output line-by-line.""" 144 | graph = _make_graph() 145 | 146 | if add_self: 147 | root_graph_node = _add_child_node( 148 | graph, 149 | parent_graph_node=None, 150 | tree_node=tree_node, 151 | index=-1, 152 | node_mapper=node_mapper, 153 | ) 154 | else: 155 | root_graph_node = None 156 | 157 | _add_child_nodes( 158 | graph, 159 | graph_node=root_graph_node, 160 | tree_node=tree_node, 161 | node_mapper=node_mapper, 162 | ) 163 | 164 | return graph 165 | 166 | 167 | def tree_to_rdf( 168 | tree: Tree[Any, Any], 169 | *, 170 | node_mapper: RDFMapperCallbackType | None = None, 171 | ) -> Graph: 172 | graph = _make_graph() 173 | 174 | root_graph_node = URIRef(NUTREE_NS.system_root) 175 | graph.add((root_graph_node, NUTREE_NS.name, Literal(tree.name))) 176 | 177 | _add_child_nodes( 178 | graph, graph_node=root_graph_node, tree_node=tree._root, node_mapper=node_mapper 179 | ) 180 | return graph 181 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # --- Ruff Settings ------------------------------------------------------------ 2 | [tool.ruff] 3 | target-version = "py39" 4 | src = ["nutree", "tests"] 5 | 6 | [tool.ruff.lint] 7 | select = [ 8 | "B", # bugbear 9 | "E", # pycodestyle 10 | "F", # pyflakes 11 | "G", # flake8-logging-format 12 | "I", # isort 13 | "UP", # pyupgrade 14 | "T", # print, ... 15 | # "D", # pydocstyle 16 | ] 17 | ignore = [ 18 | # We need the old syntax for python <= 3.9 19 | "UP006", # Use `list` instead of `List` for type annotations (since Py39) 20 | "UP007", # Use `X | Y` for type annotations (since Py310) 21 | "E721", # Do not compare types, use `isinstance()` 22 | ] 23 | 24 | [tool.ruff.lint.per-file-ignores] 25 | "*.ipynb" = [ # Jupyter Notebooks 26 | "T20", # print statement 27 | "E402", # module level import not at top of file 28 | ] 29 | 30 | # [tool.ruff.lint.isort] 31 | # case-sensitive = true 32 | 33 | # [tool.ruff.pydocstyle] 34 | # convention = "google" 35 | 36 | # --- Pyright Settings --------------------------------------------------------- 37 | [tool.pyright] 38 | typeCheckingMode = "standard" 39 | reportMissingImports = "none" 40 | include = ["nutree", "tests"] 41 | 42 | # https://github.com/microsoft/pyright/blob/main/docs/configuration.md#sample-pyprojecttoml-file 43 | reportUnnecessaryTypeIgnoreComment = true 44 | 45 | # --- Mypy Settings ------------------------------------------------------------ 46 | [tool.mypy] 47 | warn_return_any = true 48 | warn_unused_configs = true 49 | # warn_unused_ignores = true 50 | # warn_redundant_casts = true 51 | # ignore_missing_imports = true 52 | 53 | [[tool.mypy.overrides]] 54 | module = [ 55 | "fabulist", 56 | "pydot", 57 | "pytest", 58 | "pympler", 59 | "pympler.asizeof", 60 | "rdflib", 61 | "rdflib.namespace", 62 | ] 63 | ignore_missing_imports = true 64 | 65 | # --- Pytest and Coverage Settings --------------------------------------------- 66 | [tool.pytest.ini_options] 67 | # addopts = "-ra -q --cov=nutree --cov-report=html" 68 | addopts = "-ra -q --cov=nutree" 69 | # addopts = "--cov=nutree --cov-report=html --cov-report=term-missing" 70 | 71 | markers = [ 72 | "benchmarks: include slow benchmarks (enable with '-m benchmarks')", 73 | # "slow: marks tests as slow (deselect with '-m \"not slow\"')", 74 | # "serial", 75 | ] 76 | 77 | [tool.coverage.run] 78 | # branch = true 79 | omit = [ 80 | "tests/*", 81 | "setup.py", 82 | # nutree/leaves_cli.py 83 | # nutree/cli_common.py 84 | # nutree/monitor/* 85 | ] 86 | 87 | [tool.coverage.report] 88 | precision = 1 89 | # show_missing = true 90 | sort = "Name" 91 | exclude_lines = [ 92 | "pragma: no cover", 93 | "raise NotImplementedError", 94 | "if __name__ == .__main__.:", 95 | "if TYPE_CHECKING:", 96 | ] 97 | exclude_also = [ 98 | # 1. Exclude an except clause of a specific form: 99 | # "except ValueError:\\n\\s*assume\\(False\\)", 100 | # 2. Comments to turn coverage on and off: 101 | # "no cover: start(?s:.)*?no cover: stop", 102 | # 3. A pragma comment that excludes an entire file: 103 | # "\\A(?s:.*# pragma: exclude file.*)\\Z", 104 | "\\A(?s:.*# pragma: exclude-file-from-coverage.*)\\Z", 105 | ] 106 | 107 | [tool.coverage.html] 108 | directory = "build/coverage" 109 | 110 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # "package_data": { 2 | # # If any package contains *.txt files, include them: 3 | # # "": ["*.css", "*.html", "*.ico", "*.js"], 4 | # "": ["*.tmpl"], 5 | # "nutree.monitor": ["htdocs/*.*"], 6 | # }, 7 | # "install_requires": install_requires, 8 | # "setup_requires": setup_requires, 9 | # "tests_require": tests_require, 10 | # "py_modules": [], 11 | # "zip_safe": False, 12 | # "extras_require": {}, 13 | # "cmdclass": {"test": ToxCommand, "sphinx": SphinxCommand}, 14 | # "entry_points": {"console_scripts": ["nutree = nutree.leaves_cli:run"]}, 15 | # "options": {}, 16 | 17 | [metadata] 18 | # GitHub dependants needs name in setup.py? 19 | # name = nutree 20 | version = attr: nutree.__version__ 21 | author = Martin Wendt 22 | author_email = nutree@wwwendt.de 23 | maintainer = Martin Wendt 24 | maintainer_email = nutree@wwwendt.de 25 | url = https://github.com/mar10/nutree/ 26 | project_urls = 27 | Bug Tracker = https://github.com/mar10/nutree/issues 28 | Source Code = https://github.com/mar10/nutree 29 | Documentation = https://nutree.readthedocs.io/ 30 | Download = https://github.com/mar10/nutree/releases/latest 31 | description = A Python library for tree data structures with an intuitive, yet powerful, API. 32 | long_description = file: README.md 33 | long_description_content_type = text/markdown 34 | keywords = tree, data structure, digraph, graph, nodes, hierarchy, treelib 35 | license = MIT 36 | license_files = LICENSE.txt 37 | classifiers = 38 | # Development Status :: 3 - Alpha 39 | # Development Status :: 4 - Beta 40 | Development Status :: 5 - Production/Stable 41 | Environment :: Console 42 | Intended Audience :: Developers 43 | License :: OSI Approved :: MIT License 44 | Operating System :: OS Independent 45 | Programming Language :: Python 46 | Programming Language :: Python :: 3 47 | Programming Language :: Python :: 3 :: Only 48 | ; Programming Language :: Python :: 3.7 # EOL 2023-06-27 49 | ; Programming Language :: Python :: 3.8 # EOL 2024-10 50 | Programming Language :: Python :: 3.9 51 | Programming Language :: Python :: 3.10 52 | Programming Language :: Python :: 3.11 53 | Programming Language :: Python :: 3.12 54 | Programming Language :: Python :: 3.13 55 | Topic :: Software Development :: Libraries :: Python Modules 56 | 57 | [options] 58 | package_dir = 59 | = . 60 | packages = find: 61 | zip_safe = False 62 | 63 | # scripts = 64 | # bin/first.py 65 | # bin/second.py 66 | 67 | install_requires = 68 | typing_extensions>=4.0 69 | # typing_extensions>=4.0; python_version<='3.10' 70 | 71 | # [options.package_data] 72 | # * = *.txt, *.rst 73 | # hello = *.msg 74 | 75 | [options.extras_require] 76 | graph = pydot; rdflib; graphviz 77 | # pdf = ReportLab>=1.2; RXP 78 | # rest = docutils>=0.3; pack ==1.1, ==1.3 79 | random = fabulist 80 | all = pydot; rdflib; graphviz; fabulist 81 | 82 | [options.packages.find] 83 | where = . 84 | include_package_data = True 85 | exclude = 86 | tests 87 | 88 | [options.data_files] 89 | . = CHANGELOG.md 90 | # /etc/my_package = 91 | # site.d/00_default.conf 92 | # host.d/00_default.conf 93 | # data = data/img/logo.png, data/svg/icon.svg 94 | 95 | [options.package_data] 96 | nutree = 97 | py.typed 98 | 99 | [options.entry_points] 100 | console_scripts = 101 | # nutree = nutree.leaves_cli:run 102 | 103 | [bdist_wheel] 104 | # set universal = 1 if Python 2 and 3 are supported 105 | universal = false 106 | 107 | # [check-manifest] 108 | # ignore = 109 | # docs/sphinx-build 110 | # docs/sphinx-build/* 111 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup 3 | 4 | setup( 5 | name="nutree", # GitHub dependants needs it in setup.py? 6 | # See setup.cfg 7 | ) 8 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # see also pytest.ini 2 | import pytest 3 | from benchman import BenchmarkManager 4 | 5 | 6 | def pytest_addoption(parser): 7 | parser.addoption("--benchmarks", action="store_true") 8 | 9 | 10 | @pytest.fixture(scope="session") 11 | def benchman() -> BenchmarkManager: 12 | return BenchmarkManager.singleton() 13 | -------------------------------------------------------------------------------- /tests/fixtures/file_1.txt: -------------------------------------------------------------------------------- 1 | # Testfile 1 2 | -------------------------------------------------------------------------------- /tests/fixtures/folder_1/file_1_1.txt: -------------------------------------------------------------------------------- 1 | # Testfile 1.1 2 | -------------------------------------------------------------------------------- /tests/make_sample_files.py: -------------------------------------------------------------------------------- 1 | # (c) 2021-2024 Martin Wendt; see https://github.com/mar10/nutree 2 | # Licensed under the MIT license: https://www.opensource.org/licenses/mit-license.php 3 | """ 4 | Run this to generate sample images and files. 5 | """ 6 | # pragma: no cover 7 | # ruff: noqa: T201, T203 `print` found 8 | 9 | from __future__ import annotations 10 | 11 | from pathlib import Path 12 | 13 | from nutree.diff import DiffClassification, diff_node_formatter 14 | from nutree.node import Node 15 | 16 | from tests import fixture 17 | 18 | 19 | def write_str_diff_png(): 20 | tree_0 = fixture.create_tree_simple(name="T0", print=True) 21 | 22 | tree_1 = fixture.create_tree_simple(name="T1", print=False) 23 | 24 | tree_1["a2"].add("a21") 25 | tree_1["a11"].remove() 26 | tree_1.add_child("C") 27 | tree_1["b1"].move_to(tree_1["C"]) 28 | tree_1.print() 29 | 30 | tree_2 = tree_0.diff(tree_1, reduce=False) 31 | 32 | tree_2.print(repr=diff_node_formatter) 33 | 34 | def node_mapper(node: Node, attr_def: dict): 35 | dc = node.get_meta("dc") 36 | if dc == DiffClassification.ADDED: 37 | attr_def["color"] = "#00c000" 38 | elif dc == DiffClassification.REMOVED: 39 | attr_def["color"] = "#c00000" 40 | 41 | def edge_mapper(node: Node, attr_def: dict): 42 | # https://renenyffenegger.ch/notes/tools/Graphviz/examples/index 43 | # https://graphs.grevian.org/reference 44 | # https://graphviz.org/doc/info/attrs.html 45 | dc = node.get_meta("dc") 46 | if dc in (DiffClassification.ADDED, DiffClassification.MOVED_HERE): 47 | attr_def["color"] = "#00C000" 48 | elif dc in (DiffClassification.REMOVED, DiffClassification.MOVED_TO): 49 | attr_def["style"] = "dashed" 50 | attr_def["color"] = "#C00000" 51 | # attr_def["label"] = "X" 52 | 53 | # # attr_def["label"] = "\E" 54 | # # attr_def["label"] = "child of" 55 | # attr_def["penwidth"] = 1.0 56 | # # attr_def["weight"] = 1.0 57 | 58 | tree_2.to_dotfile( 59 | Path(__file__).parent / "temp/tree_diff.png", 60 | format="png", 61 | # add_root=False, 62 | # unique_nodes=False, 63 | graph_attrs={"label": "Diff T0/T1"}, 64 | node_attrs={"style": "filled", "fillcolor": "#e0e0e0"}, 65 | edge_attrs={}, 66 | node_mapper=node_mapper, 67 | edge_mapper=edge_mapper, 68 | ) 69 | 70 | 71 | def write_object_diff_png(): 72 | tree_0 = fixture.create_tree_objects(name="T0", print=True) 73 | tree_1 = tree_0.copy(name="T1") 74 | 75 | # Modify 2nd tree 76 | bob_node = tree_1.find(match=".*Bob.*") 77 | assert bob_node 78 | dave_node = tree_1.find(match=".*Dave.*") 79 | assert dave_node 80 | dev_node = tree_1.find(match=".*Development.*") 81 | assert dev_node 82 | mkt_node = tree_1.find(match=".*Marketing.*") 83 | assert mkt_node 84 | alice_node = tree_1.find(match=".*Alice.*") 85 | assert alice_node 86 | 87 | newman = fixture.Person("Newman", age=67, guid="{567-567}") 88 | 89 | bob_node.remove() 90 | alice_node.move_to(mkt_node, before=True) 91 | dev_node.add(newman, data_id=newman.guid) 92 | # tree_1 contains nodes thar reference the same data objects as tree_0 93 | # In order to simulate a change, we need to instantiate a new Person object 94 | # and patch the node. 95 | dave_0 = dave_node.data 96 | dave_1 = fixture.Person(dave_0.name, guid=dave_0.guid, age=55) 97 | dave_node._data = dave_1 98 | 99 | alice_0 = alice_node.data 100 | alice_1 = fixture.Person("Alicia", guid=alice_0.guid, age=23) 101 | alice_node._data = alice_1 102 | 103 | tree_1.print(repr="{node}") 104 | 105 | tree_2 = tree_0.diff(tree_1, reduce=False) 106 | 107 | tree_2.print(repr=diff_node_formatter) 108 | 109 | UNIQUE_NODES = True 110 | 111 | def node_mapper(node: Node, attr_def: dict): 112 | # https://graphviz.org/docs/nodes/ 113 | 114 | dc = node.get_meta("dc") 115 | if isinstance(node.data, fixture.OrgaUnit): 116 | attr_def["label"] = str(node.data) 117 | 118 | if ( 119 | UNIQUE_NODES 120 | and node.get_meta("dc_modified") 121 | and dc == DiffClassification.MOVED_TO 122 | ): 123 | # We only display the first node that the iterator hits. 124 | # If it is modified, we want to make sure, we display the label of 125 | # the new status 126 | for n in node.get_clones(): 127 | if n.get_meta("dc") == DiffClassification.MOVED_HERE: 128 | attr_def["label"] = str(n.data) 129 | 130 | if isinstance(node.data, fixture.Department): 131 | attr_def["shape"] = "box" 132 | 133 | if dc == DiffClassification.ADDED: 134 | attr_def["color"] = "#00c000" 135 | attr_def["fillcolor"] = "#d0f8d0" 136 | elif dc == DiffClassification.REMOVED: 137 | attr_def["color"] = "#c00000" 138 | attr_def["fillcolor"] = "#f8d0d0" 139 | 140 | if node.get_meta("dc_modified"): 141 | attr_def["fillcolor"] = "#fff0d0" # "gold" "#FFD700" 142 | 143 | def edge_mapper(node: Node, attr_def: dict): 144 | # https://renenyffenegger.ch/notes/tools/Graphviz/examples/index 145 | # https://graphviz.org/docs/edges 146 | dc = node.get_meta("dc") 147 | if dc in (DiffClassification.ADDED, DiffClassification.MOVED_HERE): 148 | attr_def["color"] = "#00C000" 149 | elif dc in (DiffClassification.REMOVED, DiffClassification.MOVED_TO): 150 | attr_def["style"] = "dashed" 151 | attr_def["color"] = "#C00000" 152 | # attr_def["label"] = "X" 153 | 154 | if node.get_meta("dc_modified"): 155 | attr_def["arrowhead"] = "diamond" 156 | 157 | tree_2.to_dotfile( 158 | Path(__file__).parent / "temp/tree_diff_obj.png", 159 | format="png", 160 | # add_root=False, 161 | unique_nodes=UNIQUE_NODES, 162 | graph_attrs={"label": "Diff T0/T1"}, 163 | node_attrs={"style": "filled", "fillcolor": "#ffffff"}, 164 | edge_attrs={}, 165 | node_mapper=node_mapper, 166 | edge_mapper=edge_mapper, 167 | ) 168 | 169 | 170 | if __name__ == "__main__": 171 | write_object_diff_png() 172 | -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | # Silence `PytestDeprecationWarning` 3 | junit_family = legacy 4 | 5 | # See also pyproject.toml 6 | 7 | ; testpaths = 8 | ; tests 9 | ; src 10 | # minversion = 6.0 11 | # addopts = -ra -q 12 | 13 | ; markers = 14 | ; benchmarks: include slow benchmarks (enable with '-m benchmarks') 15 | ; # slow: marks tests as slow (deselect with '-m "not slow"') 16 | ; # serial 17 | 18 | -------------------------------------------------------------------------------- /tests/temp/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mar10/nutree/7d856e5afaf44398acba505c7accd43741f2b0ad/tests/temp/.gitkeep -------------------------------------------------------------------------------- /tests/test_clones.py: -------------------------------------------------------------------------------- 1 | # (c) 2021-2024 Martin Wendt; see https://github.com/mar10/nutree 2 | # Licensed under the MIT license: https://www.opensource.org/licenses/mit-license.php 3 | """ """ 4 | # ruff: noqa: T201, T203 `print` found 5 | 6 | import pytest 7 | from nutree import AmbiguousMatchError, Node, Tree 8 | from nutree.common import DictWrapper, UniqueConstraintError 9 | 10 | from . import fixture 11 | 12 | 13 | class TestClones: 14 | def setup_method(self): 15 | self.tree = Tree("fixture") 16 | 17 | def teardown_method(self): 18 | self.tree = None 19 | 20 | def test_clones(self): 21 | """ """ 22 | tree = fixture.create_tree_simple() 23 | 24 | # Add another 'a1' below 'B' 25 | tree["B"].add("a1") 26 | 27 | print(tree.format(repr="{node.data}")) 28 | 29 | assert tree.count == 9 30 | assert tree.count_unique == 8 31 | 32 | # Not allowed to add two clones to same parent 33 | with pytest.raises(UniqueConstraintError): 34 | tree["B"].add("a1") 35 | 36 | # tree[data] expects single matches 37 | with pytest.raises(KeyError): 38 | tree["not_existing"] 39 | with pytest.raises(AmbiguousMatchError): 40 | tree["a1"] 41 | 42 | # # Not allowed to add two clones to same parent 43 | with pytest.raises(UniqueConstraintError): 44 | tree.add("A") 45 | with pytest.raises(UniqueConstraintError): 46 | tree.add(tree["A"]) 47 | 48 | res = tree.find("a1") 49 | assert res 50 | assert res.data == "a1" 51 | assert res.is_clone() 52 | assert len(res.get_clones()) == 1 53 | assert len(res.get_clones(add_self=True)) == 2 54 | 55 | res = tree.find("not_existing") 56 | assert res is None 57 | 58 | assert not tree["a2"].is_clone() 59 | 60 | res = tree.find_all("a1") 61 | 62 | assert res[0].is_clone() 63 | assert res[1].is_clone() 64 | 65 | assert len(res) == 2 66 | assert isinstance(res[0], Node) 67 | assert res[0] == res[1] # nodes are equal 68 | assert res[0] == res[1].data # nodes are equal 69 | assert res[0] is not res[1] # but not identical 70 | assert res[0].data == res[1].data # node.data is equal 71 | assert res[0].data is res[1].data # and identical 72 | 73 | res = tree.find_all("not_existing") 74 | assert res == [] 75 | 76 | assert tree._self_check() 77 | 78 | def test_clones_typed(self): 79 | """ 80 | TypedTree<'fixture'> 81 | ├── function → func1 82 | │ ├── failure → fail1 83 | │ │ ├── cause → cause1 84 | │ │ ├── cause → cause2 85 | │ │ ├── effect → eff1 86 | │ │ ╰── effect → eff2 87 | │ ╰── failure → fail2 88 | ╰── function → func2 89 | """ 90 | tree = fixture.create_typed_tree_simple() 91 | 92 | assert tree.count == 8 93 | assert tree.count_unique == 8 94 | 95 | fail1 = tree["fail1"] 96 | # Not allowed to add two clones to same parent 97 | with pytest.raises(UniqueConstraintError): 98 | fail1.add("cause1", kind="cause") 99 | fail1.add("cause1", kind="other") 100 | tree.print() 101 | assert tree.count == 9 102 | assert tree.count_unique == 8 103 | 104 | def test_dict(self): 105 | """ """ 106 | tree = fixture.create_tree_simple() 107 | d = {"a": 1, "b": 2} 108 | # Add another 'a1' below 'B' 109 | n1 = tree["A"].add(DictWrapper(d)) 110 | 111 | with pytest.raises(UniqueConstraintError): 112 | # Not allowed to add two clones to same parent 113 | tree["A"].add(DictWrapper(d)) 114 | 115 | n2 = tree["B"].add(DictWrapper(d)) 116 | 117 | tree.print(repr="{node}") 118 | 119 | assert tree.count == 10 120 | assert tree.count_unique == 9 121 | assert n1.data._dict is d 122 | assert n2.data._dict is d 123 | n1.data["a"] = 42 124 | assert n2.data["a"] == 42 125 | with pytest.raises(TypeError, match="unhashable type: 'dict'"): 126 | _ = tree.find(d) 127 | 128 | n = tree.find(DictWrapper(d)) 129 | assert n 130 | assert n is n1 131 | assert n.is_clone() 132 | 133 | assert len(tree.find_all(DictWrapper(d))) == 2 134 | assert n1.get_clones() == [n2] 135 | assert n1.get_clones(add_self=True) == [n1, n2] 136 | 137 | tree._self_check() 138 | -------------------------------------------------------------------------------- /tests/test_diff.py: -------------------------------------------------------------------------------- 1 | # (c) 2021-2024 Martin Wendt; see https://github.com/mar10/nutree 2 | # Licensed under the MIT license: https://www.opensource.org/licenses/mit-license.php 3 | """ """ 4 | 5 | from nutree import diff_node_formatter 6 | 7 | from . import fixture 8 | 9 | 10 | class TestDiff: 11 | def test_diff(self): 12 | tree_0 = fixture.create_tree_simple(name="T0", print=True) 13 | 14 | tree_1 = fixture.create_tree_simple(name="T1", print=False) 15 | 16 | tree_1["a2"].add("a21") 17 | tree_1["a11"].remove() 18 | tree_1.add_child("C") 19 | tree_1["b1"].move_to(tree_1["C"]) 20 | tree_1.print() 21 | 22 | tree_2 = tree_0.diff(tree_1, compare=False) 23 | 24 | tree_2.print(repr=diff_node_formatter) 25 | 26 | assert fixture.check_content( 27 | tree_2, 28 | """ 29 | Tree<"diff('T0', 'T1')"> 30 | ├── A 31 | │ ├── a1 32 | │ │ ├── a11 - [Removed] 33 | │ │ ╰── a12 34 | │ ╰── a2 35 | │ ╰── a21 - [Added] 36 | ├── B 37 | │ ╰── b1 - [Moved away] 38 | ╰── C - [Added] 39 | ╰── b1 - [Moved here] 40 | ╰── b11 41 | """, 42 | repr=diff_node_formatter, 43 | ) 44 | 45 | tree_2 = tree_0.diff(tree_1, ordered=True) 46 | 47 | assert fixture.check_content( 48 | tree_2, 49 | """ 50 | Tree<"diff('T0', 'T1')"> 51 | ├── A 52 | │ ├── a1 - [Renumbered] 53 | │ │ ├── a11 - [Removed] 54 | │ │ ╰── a12 - [Order -1] 55 | │ ╰── a2 56 | │ ╰── a21 - [Added] 57 | ├── B 58 | │ ╰── b1 - [Moved away] 59 | ╰── C - [Added] 60 | ╰── b1 - [Moved here] 61 | ╰── b11 62 | """, 63 | repr=diff_node_formatter, 64 | ) 65 | 66 | tree_2 = tree_0.diff(tree_1, reduce=True) 67 | 68 | assert fixture.check_content( 69 | tree_2, 70 | """ 71 | Tree<"diff('T0', 'T1')"> 72 | ├── A 73 | │ ├── a1 74 | │ │ ╰── a11 - [Removed] 75 | │ ╰── a2 76 | │ ╰── a21 - [Added] 77 | ├── B 78 | │ ╰── b1 - [Moved away] 79 | ╰── C - [Added] 80 | ╰── b1 - [Moved here] 81 | """, 82 | repr=diff_node_formatter, 83 | ) 84 | 85 | def test_diff_objects(self): 86 | tree_0 = fixture.create_tree_objects(name="T0", print=True) 87 | tree_1 = tree_0.copy(name="T1") 88 | 89 | # Modify 2nd tree 90 | bob_node = tree_1.find(match=".*Bob.*") 91 | assert bob_node 92 | dave_node = tree_1.find(match=".*Dave.*") 93 | assert dave_node 94 | dev_node = tree_1.find(match=".*Development.*") 95 | assert dev_node 96 | mkt_node = tree_1.find(match=".*Marketing.*") 97 | assert mkt_node 98 | alice_node = tree_1.find(match=".*Alice.*") 99 | assert alice_node 100 | 101 | newman = fixture.Person("Newman", age=67, guid="{567-567}") 102 | 103 | bob_node.remove() 104 | alice_node.move_to(mkt_node, before=True) 105 | dev_node.add(newman, data_id=newman.guid) 106 | # tree_1 contains nodes thar reference the same data objects as tree_0 107 | # In order to simulate a change, we need to instantiate a new Person object 108 | # and patch the node. 109 | dave_0 = dave_node.data 110 | dave_1 = fixture.Person(dave_0.name, guid=dave_0.guid, age=55) 111 | dave_node._data = dave_1 112 | 113 | alice_0 = alice_node.data 114 | alice_1 = fixture.Person("Alicia", guid=alice_0.guid, age=23) 115 | alice_node._data = alice_1 116 | 117 | tree_1.print(repr="{node}") 118 | 119 | def compare_cb(node_0, node_1, node_2): 120 | if node_0.data.name != node_1.data.name: 121 | return True 122 | if getattr(node_0.data, "age", None) != getattr(node_1.data, "age", None): 123 | return True 124 | return False 125 | 126 | tree_2 = tree_0.diff(tree_1, compare=compare_cb) 127 | 128 | tree_2.print(repr=diff_node_formatter) 129 | 130 | assert fixture.check_content( 131 | tree_2, 132 | """ 133 | Tree<*> 134 | ├── Department 135 | │ ├── Person - [Added] 136 | │ ├── Person - [Moved away], [Modified] 137 | │ ╰── Person - [Removed] 138 | ╰── Department 139 | ├── Person - [Moved here], [Modified] 140 | ├── Person 141 | ╰── Person - [Modified] 142 | """, 143 | repr=diff_node_formatter, 144 | ) 145 | 146 | tree_2 = tree_0.diff(tree_1, ordered=True) 147 | 148 | assert fixture.check_content( 149 | tree_2, 150 | """ 151 | Tree<*> 152 | ├── Department 153 | │ ├── Person - [Added] 154 | │ ├── Person - [Moved away], [Modified] 155 | │ ╰── Person - [Removed] 156 | ╰── Department - [Renumbered] 157 | ├── Person - [Moved here], [Modified] 158 | ├── Person - [Order +1] 159 | ╰── Person - [Modified], [Order +1] 160 | """, 161 | repr=diff_node_formatter, 162 | ) 163 | 164 | tree_2 = tree_0.diff(tree_1, reduce=True) 165 | 166 | assert fixture.check_content( 167 | tree_2, 168 | """ 169 | Tree<*> 170 | ├── Department 171 | │ ├── Person - [Added] 172 | │ ├── Person - [Moved away], [Modified] 173 | │ ╰── Person - [Removed] 174 | ╰── Department 175 | ├── Person - [Moved here], [Modified] 176 | ╰── Person - [Modified] 177 | """, 178 | repr=diff_node_formatter, 179 | ) 180 | -------------------------------------------------------------------------------- /tests/test_dot.py: -------------------------------------------------------------------------------- 1 | # (c) 2021-2024 Martin Wendt; see https://github.com/mar10/nutree 2 | # Licensed under the MIT license: https://www.opensource.org/licenses/mit-license.php 3 | """ """ 4 | # ruff: noqa: T201, T203 `print` found 5 | 6 | from pathlib import Path 7 | 8 | from nutree import Node 9 | from nutree.diff import DiffClassification, diff_node_formatter 10 | 11 | from . import fixture 12 | 13 | 14 | class TestDot: 15 | def test_dot_default(self): 16 | """Save/load as object tree with clones.""" 17 | 18 | tree = fixture.create_tree_simple(clones=True, name="Root") 19 | 20 | res = list(tree.to_dot()) 21 | assert len(res) == 25 22 | res = "\n".join(res) 23 | print(res) 24 | assert 'digraph "Root" {' in res 25 | assert '"__root__" [label="Root" shape="box"]' in res 26 | assert '[label="b11"]' in res 27 | assert res.count('"__root__" -> ') == 2 28 | 29 | def test_dot_attrs(self): 30 | """Save/load as object tree with clones.""" 31 | 32 | tree = fixture.create_tree_simple(clones=True, name="Root") 33 | 34 | res = tree.to_dot( 35 | unique_nodes=False, 36 | graph_attrs={"label": "Simple Tree"}, 37 | node_attrs={"shape": "box"}, 38 | edge_attrs={"color": "red"}, 39 | ) 40 | res = list(res) 41 | assert len(res) == 31 42 | res = "\n".join(res) 43 | print(res) 44 | assert 'graph [label="Simple Tree"]' in res 45 | assert 'node [shape="box"]' in res 46 | assert 'edge [color="red"]' in res 47 | assert '"0" [label="Root" shape="box"]' in res 48 | assert '"0" -> ' in res 49 | 50 | def test_dot_diff(self): 51 | tree_0 = fixture.create_tree_simple(name="T0", print=True) 52 | 53 | tree_1 = fixture.create_tree_simple(name="T1", print=False) 54 | 55 | tree_1["a2"].add("a21") 56 | tree_1["a11"].remove() 57 | tree_1.add_child("C") 58 | tree_1["b1"].move_to(tree_1["C"]) 59 | tree_1.print() 60 | 61 | tree_2 = tree_0.diff(tree_1, reduce=False) 62 | 63 | tree_2.print(repr=diff_node_formatter) 64 | 65 | def node_mapper(node: Node, attr_def: dict): 66 | dc = node.get_meta("dc") 67 | if dc == DiffClassification.ADDED: 68 | attr_def["color"] = "#00c000" 69 | elif dc == DiffClassification.REMOVED: 70 | attr_def["color"] = "#c00000" 71 | 72 | def edge_mapper(node: Node, attr_def: dict): 73 | # https://renenyffenegger.ch/notes/tools/Graphviz/examples/index 74 | # https://graphs.grevian.org/reference 75 | # https://graphviz.org/doc/info/attrs.html 76 | dc = node.get_meta("dc") 77 | if dc in (DiffClassification.ADDED, DiffClassification.MOVED_HERE): 78 | attr_def["color"] = "#00C000" 79 | elif dc in (DiffClassification.REMOVED, DiffClassification.MOVED_TO): 80 | attr_def["style"] = "dashed" 81 | attr_def["color"] = "#C00000" 82 | attr_def["label"] = "X" 83 | # # attr_def["label"] = "\E" 84 | # # attr_def["label"] = "child of" 85 | # attr_def["color"] = "green" 86 | # # attr_def["style"] = "dashed" 87 | # attr_def["penwidth"] = 1.0 88 | # # attr_def["weight"] = 1.0 89 | 90 | # tree_2.to_dotfile( 91 | # "/Users/martin/Downloads/tree_diff.png", 92 | # format="png", 93 | # # add_root=False, 94 | # # unique_nodes=False, 95 | # graph_attrs={"label": "Diff T0/T1"}, 96 | # node_attrs={"style": "filled", "fillcolor": "#e0e0e0"}, 97 | # edge_attrs={}, 98 | # node_mapper=node_mapper, 99 | # edge_mapper=edge_mapper, 100 | # ) 101 | # raise 102 | 103 | res = [ 104 | line 105 | for line in tree_2.to_dot( 106 | graph_attrs={"label": "Diff T0/T1"}, 107 | node_attrs={"style": "filled", "fillcolor": "#e0e0e0"}, 108 | edge_attrs={}, 109 | node_mapper=node_mapper, 110 | edge_mapper=edge_mapper, 111 | ) 112 | ] 113 | res = "\n".join(res) 114 | print(res) 115 | assert 'node [style="filled" fillcolor="#e0e0e0"]' in res 116 | assert '[label="C" color="#00c000"]' in res 117 | 118 | def test_serialize_dot(self): 119 | """Save/load as object tree with clones.""" 120 | 121 | tree = fixture.create_tree_simple(clones=True, name="Root") 122 | 123 | with fixture.WritableTempFile("w", suffix=".gv") as temp_file: 124 | tree.to_dotfile(temp_file.name) 125 | 126 | buffer = Path(temp_file.name).read_text() 127 | 128 | # print(buffer) 129 | assert '"__root__" [label="Root" shape="box"]' in buffer 130 | assert '"__root__" -> ' in buffer 131 | 132 | def test_serialize_png(self): 133 | """Save/load as object tree with clones.""" 134 | 135 | tree = fixture.create_tree_simple(clones=True, name="Root") 136 | 137 | with fixture.WritableTempFile("w", suffix=".gv") as temp_file: 138 | tree.to_dotfile(temp_file.name, format="png") 139 | 140 | target_path = Path(temp_file.name).with_suffix(".png") 141 | 142 | assert target_path.exists() 143 | -------------------------------------------------------------------------------- /tests/test_fixtures.py: -------------------------------------------------------------------------------- 1 | # (c) 2021-2024 Martin Wendt; see https://github.com/mar10/nutree 2 | # Licensed under the MIT license: https://www.opensource.org/licenses/mit-license.php 3 | """ """ 4 | # ruff: noqa: T201, T203 `print` found 5 | # pyright: reportRedeclaration=false 6 | # pyright: reportOptionalMemberAccess=false 7 | 8 | from . import fixture 9 | 10 | 11 | class TestBasics: 12 | def test_generate_tree(self): 13 | tree = fixture.generate_tree([3, 4, 2]) 14 | tree.print() 15 | assert tree.count == 39 16 | -------------------------------------------------------------------------------- /tests/test_fs.py: -------------------------------------------------------------------------------- 1 | # (c) 2021-2024 Martin Wendt; see https://github.com/mar10/nutree 2 | # Licensed under the MIT license: https://www.opensource.org/licenses/mit-license.php 3 | """ """ 4 | # ruff: noqa: T201, T203 `print` found 5 | # pyright: reportRedeclaration=false 6 | # pyright: reportOptionalMemberAccess=false 7 | 8 | import shutil 9 | from pathlib import Path 10 | 11 | from nutree.fs import FileSystemTree, load_tree_from_fs 12 | 13 | from . import fixture 14 | 15 | 16 | class TestFS: 17 | def test_fs_serialize(self): 18 | KEEP_FILES = False 19 | path = Path(__file__).parent / "fixtures" 20 | 21 | tree = load_tree_from_fs(path) 22 | 23 | with fixture.WritableTempFile("r+t", suffix=".json") as temp_file: 24 | tree.save( 25 | temp_file.name, 26 | mapper=tree.serialize_mapper, 27 | ) 28 | 29 | if KEEP_FILES: # save to tests/temp/... 30 | shutil.copy( 31 | temp_file.name, 32 | Path(__file__).parent / "temp/test_serialize_fs.json", 33 | ) 34 | 35 | tree_2 = FileSystemTree.load(temp_file.name, mapper=tree.deserialize_mapper) 36 | 37 | assert "[folder_1]" in fixture.canonical_repr(tree) 38 | assert len(tree) == 3 39 | assert fixture.trees_equal(tree, tree_2, ignore_tree_name=True) 40 | 41 | def test_fs_serialize_unsorted(self): 42 | path = Path(__file__).parent / "fixtures" 43 | tree = load_tree_from_fs(path, sort=False) 44 | assert "[folder_1]" in fixture.canonical_repr(tree) 45 | assert len(tree) == 3 46 | 47 | # NOTE: these tests are not very useful, since they depend on the file system 48 | # and the file system is not under our control (e.g. line endings, file sizes, etc.) 49 | # Especially on GitHub Actions we have no control over the file system and 50 | # timestamps, so we cannot compare the output of `load_tree_from_fs` with a 51 | # fixture. 52 | # We should only test the serialization/deserialization here. 53 | 54 | # @pytest.mark.skipif(os.name == "nt", reason="windows has different eol size") 55 | # def test_fs_linux(self): 56 | # path = Path(__file__).parent / "fixtures" 57 | 58 | # # We check for unix line endings/file sizes (as used on travis) 59 | # tree = load_tree_from_fs(path) 60 | # assert fixture.check_content( 61 | # tree, 62 | # """ 63 | # FileSystemTree<*> 64 | # ├── 'file_1.txt', 13 bytes, 2022-04-14 21:35:21 65 | # ╰── [folder_1] 66 | # ╰── 'file_1_1.txt', 15 bytes, 2022-04-14 21:35:21 67 | # """, 68 | # ) 69 | 70 | # tree = load_tree_from_fs(path, sort=False) 71 | # assert "[folder_1]" in fixture.canonical_repr(tree) 72 | 73 | # @pytest.mark.skipif(os.name != "nt", reason="windows has different eol size") 74 | # def test_fs_windows(self): 75 | # path = Path(__file__).parent / "fixtures" 76 | # # Cheap test only, 77 | # tree = load_tree_from_fs(path) 78 | # assert "[folder_1]" in fixture.canonical_repr(tree) 79 | -------------------------------------------------------------------------------- /tests/test_mermaid.py: -------------------------------------------------------------------------------- 1 | # (c) 2021-2024 Martin Wendt; see https://github.com/mar10/nutree 2 | # Licensed under the MIT license: https://www.opensource.org/licenses/mit-license.php 3 | """ """ 4 | # ruff: noqa: T201, T203 `print` found 5 | 6 | import shutil 7 | from pathlib import Path 8 | 9 | import pytest 10 | 11 | from . import fixture 12 | 13 | 14 | class TestMermaid: 15 | def test_serialize_mermaid_defaults(self): 16 | """Save/load as object tree with clones.""" 17 | KEEP_FILES = not fixture.is_running_on_ci() and False 18 | tree = fixture.create_tree_simple(clones=True, name="Root") 19 | 20 | with fixture.WritableTempFile("w", suffix=".md") as temp_file: 21 | tree.to_mermaid_flowchart(temp_file.name) 22 | if KEEP_FILES: # save to tests/temp/... 23 | shutil.copy( 24 | temp_file.name, 25 | Path(__file__).parent / "temp/test_mermaid_1.md", 26 | ) 27 | 28 | buffer = Path(temp_file.name).read_text() 29 | # print(buffer) 30 | 31 | assert buffer.startswith("```mermaid\n") 32 | assert "title: Root" in buffer 33 | assert '0(["Root"])' in buffer 34 | assert '8["b11"]' in buffer 35 | assert "7 --> 8" in buffer 36 | 37 | def test_serialize_mermaid_mappers(self): 38 | """Save/load as object tree with clones.""" 39 | KEEP_FILES = not fixture.is_running_on_ci() and False 40 | tree = fixture.create_tree_simple(clones=True, name="Root") 41 | 42 | # def node_mapper(n: Node) -> str: 43 | # return f"{n.name}" 44 | 45 | # def edge_mapper( 46 | # from_id: int, from_node: Node, to_id: int, to_node: Node 47 | # ) -> str: 48 | # return f"{from_id}-- child -->{to_id}" 49 | 50 | with fixture.WritableTempFile("w", suffix=".md") as temp_file: 51 | tree.to_mermaid_flowchart( 52 | temp_file.name, 53 | # add_root=False, 54 | title=False, 55 | headers=["classDef default fill:#f9f,stroke:#333,stroke-width:1px;"], 56 | root_shape='[["{node.name}"]]', 57 | node_mapper='[/"{node.name}"/]', 58 | edge_mapper='{from_id}-. "{to_node.get_index()}" .->{to_id}', 59 | # unique_nodes=False, 60 | # format="png", 61 | # mmdc_options={"--theme": "forest"}, 62 | ) 63 | if KEEP_FILES: # save to tests/temp/... 64 | shutil.copy( 65 | temp_file.name, 66 | Path(__file__).parent / "temp/test_mermaid_2.md", 67 | ) 68 | 69 | buffer = Path(temp_file.name).read_text() 70 | # print(buffer) 71 | 72 | assert buffer.startswith("```mermaid\n") 73 | assert "title: Root" not in buffer 74 | assert '0[["Root"]]' in buffer 75 | assert '8[/"b11"/]' in buffer 76 | assert '7-. "1" .->8' in buffer 77 | assert "classDef default fill" in buffer 78 | 79 | def test_serialize_mermaid_typed(self): 80 | """Save/load as object tree with clones.""" 81 | KEEP_FILES = not fixture.is_running_on_ci() and False 82 | tree = fixture.create_typed_tree_simple(clones=True, name="Root") 83 | 84 | with fixture.WritableTempFile("w", suffix=".md") as temp_file: 85 | tree.to_mermaid_flowchart( 86 | temp_file.name, 87 | title="Typed Tree", 88 | direction="LR", 89 | # add_root=False, 90 | # node_mapper=lambda node: f"{node}", 91 | ) 92 | if KEEP_FILES: # save to tests/temp/... 93 | shutil.copy( 94 | temp_file.name, 95 | Path(__file__).parent / "temp/test_mermaid_3.md", 96 | ) 97 | 98 | buffer = Path(temp_file.name).read_text() 99 | print(buffer) 100 | 101 | assert buffer.startswith("```mermaid\n") 102 | assert "title: Typed Tree" in buffer 103 | assert '0(["Root"])' in buffer 104 | assert '8["func2"]' in buffer 105 | assert '0-- "function" -->8' in buffer 106 | 107 | # @pytest.mark.xfail(reason="mmdc may not be installed") 108 | def test_serialize_mermaid_png(self): 109 | """Save/load as typed object tree with clones.""" 110 | if not shutil.which("mmdc"): 111 | raise pytest.skip("mmdc not installed") 112 | 113 | KEEP_FILES = not fixture.is_running_on_ci() and False 114 | FORMAT = "png" 115 | 116 | tree = fixture.create_typed_tree_simple(clones=True, name="Root") 117 | 118 | with fixture.WritableTempFile("w", suffix=f".{FORMAT}") as temp_file: 119 | tree.to_mermaid_flowchart( 120 | temp_file.name, 121 | title="Typed Tree", 122 | direction="LR", 123 | format=FORMAT, 124 | ) 125 | if KEEP_FILES: # save to tests/temp/... 126 | shutil.copy( 127 | temp_file.name, 128 | Path(__file__).parent / f"temp/test_mermaid_4.{FORMAT}", 129 | ) 130 | -------------------------------------------------------------------------------- /tests/test_rdf.py: -------------------------------------------------------------------------------- 1 | # (c) 2021-2024 Martin Wendt; see https://github.com/mar10/nutree 2 | # Licensed under the MIT license: https://www.opensource.org/licenses/mit-license.php 3 | """ """ 4 | # ruff: noqa: T201, T203 `print` found 5 | # pyright: reportAttributeAccessIssue=false 6 | 7 | from nutree.typed_tree import TypedTree 8 | 9 | from . import fixture 10 | 11 | 12 | class TestRDF: 13 | def test_tree(self): 14 | tree = fixture.create_tree_simple() 15 | 16 | g = tree.to_rdf_graph() 17 | 18 | turtle_fmt = g.serialize() 19 | tree.print() 20 | print(turtle_fmt) 21 | 22 | assert "nutree:kind" not in turtle_fmt 23 | assert 'nutree:name "b1" .' in turtle_fmt 24 | 25 | def test_typed_tree(self): 26 | tree = TypedTree("Pencil") 27 | 28 | func = tree.add("Write on paper", kind="function") 29 | fail = func.add("Wood shaft breaks", kind="failure") 30 | fail.add("Unable to write", kind="effect") 31 | fail.add("Injury from splinter", kind="effect") 32 | fail.add("Wood too soft", kind="cause") 33 | 34 | fail = func.add("Lead breaks", kind="failure") 35 | fail.add("Cannot erase (dissatisfaction)", kind="effect") 36 | fail.add("Lead material too brittle", kind="cause") 37 | 38 | func = tree.add("Erase text", kind="function") 39 | 40 | assert fixture.check_content( 41 | tree, 42 | """ 43 | TypedTree<*> 44 | +- function → Write on paper 45 | | +- failure → Wood shaft breaks 46 | | | +- effect → Unable to write 47 | | | +- effect → Injury from splinter 48 | | | `- cause → Wood too soft 49 | | `- failure → Lead breaks 50 | | +- effect → Cannot erase (dissatisfaction) 51 | | `- cause → Lead material too brittle 52 | `- function → Erase text 53 | """, 54 | ) 55 | 56 | eff1 = tree["Unable to write"] 57 | eff2 = tree["Injury from splinter"] 58 | cause1 = tree["Wood too soft"] 59 | 60 | assert eff1.first_sibling() is eff1 61 | assert eff1.last_sibling() is eff2 62 | assert eff1.last_sibling(any_kind=True) is cause1 63 | 64 | assert cause1.get_index() == 0 65 | assert cause1.get_index(any_kind=True) == 2 66 | 67 | assert len(list(tree.iterator(kind="effect"))) == 3 68 | 69 | # tree.print() 70 | # print() 71 | 72 | g = tree.to_rdf_graph() 73 | 74 | turtle_fmt = g.serialize() 75 | print(turtle_fmt) 76 | print() 77 | assert 'nutree:kind "failure"' in turtle_fmt 78 | assert 'nutree:name "Wood shaft breaks' in turtle_fmt 79 | 80 | # Basic triple matching: All cause types 81 | # Note that Literal will be `None` if rdflib is not available 82 | from nutree.rdf import NUTREE_NS, Literal 83 | 84 | cause_kind = Literal("cause") 85 | n = 0 86 | for s, _p, o in g.triples((None, NUTREE_NS.kind, cause_kind)): 87 | name = g.value(s, NUTREE_NS.name) 88 | print(f"{name} is a {o}") 89 | n += 1 90 | print() 91 | assert n == 2 92 | 93 | # SPARQL query: 94 | 95 | query = """ 96 | PREFIX nutree: 97 | 98 | SELECT ?data_id ?kind ?name 99 | WHERE { 100 | BIND("cause" as ?kind) 101 | 102 | ?data_id nutree:kind ?kind ; 103 | nutree:name ?name . 104 | } 105 | """ 106 | 107 | qres = g.query(query) 108 | n = 0 109 | for row in qres: 110 | print(f"{row.data_id} {row.name} is a {row.kind}") 111 | n += 1 112 | assert n == 2 113 | 114 | # tree.print() 115 | # raise 116 | 117 | node = tree["Wood shaft breaks"] 118 | 119 | g = node.to_rdf_graph() 120 | print(g.serialize()) 121 | 122 | calls = 0 123 | 124 | def node_mapper(graph, graph_node, tree_node): 125 | nonlocal calls 126 | calls += 1 127 | 128 | g = node.to_rdf_graph(add_self=False, node_mapper=node_mapper) 129 | print(g.serialize()) 130 | 131 | assert calls == 3 132 | # raise 133 | -------------------------------------------------------------------------------- /tests/test_tree_generator.py: -------------------------------------------------------------------------------- 1 | """ 2 | Generic tree generator for test data. 3 | """ 4 | 5 | import datetime 6 | 7 | import pytest 8 | from nutree.common import DictWrapper 9 | from nutree.tree import Tree 10 | from nutree.tree_generator import ( 11 | BlindTextRandomizer, 12 | DateRangeRandomizer, 13 | RangeRandomizer, 14 | SampleRandomizer, 15 | SparseBoolRandomizer, 16 | TextRandomizer, 17 | ValueRandomizer, 18 | fab, 19 | ) 20 | from nutree.typed_tree import TypedTree 21 | 22 | from tests import fixture 23 | 24 | 25 | class TestBase: 26 | def test_simple(self): 27 | _cb_count = 0 28 | 29 | def _calback(data): 30 | nonlocal _cb_count 31 | assert data["title"].startswith("Failure ") 32 | _cb_count += 1 33 | 34 | structure_def = { 35 | "name": "fmea", 36 | #: Types define the default properties of the nodes 37 | "types": { 38 | #: Default properties for all node types 39 | "*": {":factory": DictWrapper}, 40 | #: Specific default properties for each node type 41 | "function": {"icon": "bi bi-gear"}, 42 | "failure": {"icon": "bi bi-exclamation-triangle"}, 43 | "cause": {"icon": "bi bi-tools"}, 44 | "effect": {"icon": "bi bi-lightning"}, 45 | }, 46 | #: Relations define the possible parent / child relationships between 47 | #: node types and optionally override the default properties. 48 | "relations": { 49 | "__root__": { 50 | "function": { 51 | ":count": 30, 52 | "title": "Function {hier_idx}", 53 | "date": DateRangeRandomizer( 54 | datetime.date(2020, 1, 1), datetime.date(2020, 12, 31) 55 | ), 56 | "date2": DateRangeRandomizer( 57 | datetime.date(2020, 1, 1), 365, probability=0.5 58 | ), 59 | "value": ValueRandomizer("foo", probability=0.5), 60 | "expanded": SparseBoolRandomizer(probability=0.5), 61 | "state": SampleRandomizer(["open", "closed"], probability=0.5), 62 | }, 63 | }, 64 | "function": { 65 | "failure": { 66 | ":count": RangeRandomizer(1, 3), 67 | ":callback": _calback, 68 | "title": "Failure {hier_idx}", 69 | }, 70 | }, 71 | "failure": { 72 | "cause": { 73 | ":count": RangeRandomizer(1, 3, probability=0.5), 74 | "title": "Cause {hier_idx}", 75 | }, 76 | "effect": { 77 | ":count": RangeRandomizer(1, 3), 78 | "title": "Effect {hier_idx}", 79 | }, 80 | }, 81 | }, 82 | } 83 | tree = Tree.build_random_tree(structure_def) 84 | tree.print() 85 | assert type(tree) is Tree 86 | assert tree.calc_height() == 3 87 | assert _cb_count >= 3 88 | 89 | tree2 = TypedTree.build_random_tree(structure_def) 90 | tree2.print() 91 | assert type(tree2) is TypedTree 92 | assert tree2.calc_height() == 3 93 | 94 | # Save and load with DictWrapper mappers 95 | with fixture.WritableTempFile("r+t") as temp_file: 96 | tree.save( 97 | temp_file.name, 98 | compression=True, 99 | mapper=DictWrapper.serialize_mapper, 100 | ) 101 | tree3 = Tree.load(temp_file.name, mapper=DictWrapper.deserialize_mapper) 102 | tree3.print() 103 | assert fixture.trees_equal(tree, tree3) 104 | 105 | def test_fabulist(self): 106 | if not fab: 107 | pytest.skip("fabulist not installed") 108 | 109 | structure_def = { 110 | "name": "fmea", 111 | #: Types define the default properties of the nodes 112 | "types": { 113 | #: Default properties for all node types (optional, default 114 | #: is DictWrapper) 115 | "*": {":factory": DictWrapper}, 116 | #: Specific default properties for each node type 117 | "function": {"icon": "bi bi-gear"}, 118 | "failure": {"icon": "bi bi-exclamation-triangle"}, 119 | "cause": {"icon": "bi bi-tools"}, 120 | "effect": {"icon": "bi bi-lightning"}, 121 | }, 122 | #: Relations define the possible parent / child relationships between 123 | #: node types and optionally override the default properties. 124 | "relations": { 125 | "__root__": { 126 | "function": { 127 | ":count": 3, 128 | "title": TextRandomizer( 129 | [ 130 | "{idx}: Provide $(Noun:plural)", 131 | ] 132 | ), 133 | "details": BlindTextRandomizer(dialect="ipsum"), 134 | "expanded": True, 135 | }, 136 | }, 137 | "function": { 138 | "failure": { 139 | ":count": RangeRandomizer(1, 3), 140 | "title": TextRandomizer("$(Noun:plural) not provided"), 141 | }, 142 | }, 143 | "failure": { 144 | "cause": { 145 | ":count": RangeRandomizer(1, 3), 146 | "title": TextRandomizer("$(Noun:plural) not provided"), 147 | }, 148 | "effect": { 149 | ":count": RangeRandomizer(1, 3), 150 | "title": TextRandomizer("$(Noun:plural) not provided"), 151 | }, 152 | }, 153 | }, 154 | } 155 | tree = TypedTree.build_random_tree(structure_def) 156 | tree.print() 157 | 158 | assert type(tree) is TypedTree 159 | 160 | 161 | class TestRandomizers: 162 | def test_range(self): 163 | r = RangeRandomizer(1, 3) 164 | for v in (r.generate() for _ in range(100)): 165 | assert isinstance(v, int) 166 | assert 1 <= v <= 3 167 | 168 | r = RangeRandomizer(1.0, 3.0) 169 | for v in (r.generate() for _ in range(100)): 170 | assert isinstance(v, float) 171 | assert 1 <= v <= 3 172 | -------------------------------------------------------------------------------- /tests/test_typed_tree.py: -------------------------------------------------------------------------------- 1 | # (c) 2021-2024 Martin Wendt; see https://github.com/mar10/nutree 2 | # Licensed under the MIT license: https://www.opensource.org/licenses/mit-license.php 3 | """ """ 4 | # ruff: noqa: T201, T203 `print` found 5 | # pyright: reportOptionalMemberAccess=false 6 | 7 | import re 8 | from pathlib import Path 9 | 10 | from nutree.typed_tree import ANY_KIND, TypedNode, TypedTree, _SystemRootTypedNode 11 | 12 | from . import fixture 13 | 14 | 15 | class TestTypedTree: 16 | # def met 17 | def test_add_child(self): 18 | tree = TypedTree("fixture") 19 | 20 | # --- Empty tree 21 | assert not tree, "empty tree is falsy" 22 | assert tree.count == 0 23 | assert len(tree) == 0 24 | assert f"{tree}" == "TypedTree<'fixture'>" 25 | assert isinstance(tree._root, _SystemRootTypedNode) 26 | 27 | # --- 28 | func = tree.add("func1", kind="function") 29 | 30 | assert isinstance(func, TypedNode) 31 | print(f"{func}") 32 | assert ( 33 | re.sub(r"data_id=[-\d]+>", "data_id=*>", f"{func}") 34 | == "TypedNode" 35 | ) 36 | 37 | fail1 = func.add("fail1", kind="failure") 38 | 39 | fail1.add("cause1", kind="cause") 40 | fail1.add("cause2", kind="cause") 41 | 42 | fail1.add("eff1", kind="effect") 43 | fail1.add("eff2", kind="effect") 44 | 45 | func.add("fail2", kind="failure") 46 | 47 | func2 = tree.add("func2", kind="function") 48 | 49 | assert fixture.check_content( 50 | tree, 51 | """ 52 | TypedTree<*> 53 | +- function → func1 54 | | +- failure → fail1 55 | | | +- cause → cause1 56 | | | +- cause → cause2 57 | | | +- effect → eff1 58 | | | `- effect → eff2 59 | | `- failure → fail2 60 | `- function → func2 61 | """, 62 | ) 63 | assert tree.last_child(ANY_KIND).name == "func2" 64 | 65 | assert len(fail1.children) == 4 66 | assert fail1.get_children(kind=ANY_KIND) == fail1.children 67 | 68 | assert len(fail1.get_children(kind="cause")) == 2 69 | assert fail1.get_children(kind="unknown") == [] 70 | 71 | assert fail1.has_children(kind="unknown") is False 72 | assert fail1.has_children(kind=ANY_KIND) is True 73 | assert fail1.has_children(kind="cause") is True 74 | 75 | assert fail1.first_child(kind="cause").name == "cause1" 76 | assert fail1.first_child(kind="effect").name == "eff1" 77 | assert fail1.first_child(kind=ANY_KIND).name == "cause1" 78 | assert fail1.first_child(kind="unknown") is None 79 | 80 | assert fail1.last_child(kind="cause").name == "cause2" 81 | assert fail1.last_child(kind="effect").name == "eff2" 82 | assert fail1.last_child(kind=ANY_KIND).name == "eff2" 83 | assert fail1.last_child(kind="unknown") is None 84 | 85 | cause1 = tree["cause1"] 86 | cause2 = tree["cause2"] 87 | eff1 = tree["eff1"] 88 | eff2 = tree["eff2"] 89 | 90 | assert cause2.get_siblings(any_kind=True, add_self=True) == fail1.get_children( 91 | kind=ANY_KIND 92 | ) 93 | assert cause2.get_siblings() != fail1.get_children(kind=ANY_KIND) 94 | assert cause2.get_siblings(add_self=True) == fail1.get_children(kind="cause") 95 | 96 | assert cause2.parent is fail1 97 | 98 | assert tree.count == 8 99 | assert tree.count_descendants() == 8 100 | tree.print() 101 | assert tree.count_descendants(leaves_only=True) == 6 102 | assert tree.count_descendants(kind="cause") == 2 103 | assert tree.count_descendants(leaves_only=True, kind="failure") == 1 104 | assert tree.system_root.count_descendants(kind="failure") == 2 105 | 106 | assert len(list(tree.iter_by_type("cause"))) == 2 107 | assert len(list(tree.iterator(kind="cause"))) == 2 108 | assert len(list(tree.iterator())) == 8 109 | 110 | assert len(list(tree.system_root.iterator(add_self=True))) == 9 111 | assert len(list(tree.system_root.iterator(kind="cause"))) == 2 112 | assert len(list(tree.system_root.iterator(kind="cause", add_self=True))) == 2 113 | 114 | assert cause2.get_children("undefined") == [] 115 | 116 | assert cause2.first_child(ANY_KIND) is None 117 | assert cause2.first_child("undefined") is None 118 | 119 | assert cause2.last_child(ANY_KIND) is None 120 | assert cause2.last_child("undefined") is None 121 | 122 | assert cause2.first_sibling() is cause1 123 | assert cause2.first_sibling(any_kind=True) is cause1 124 | 125 | assert cause2.last_sibling() is cause2 126 | assert cause2.last_sibling(any_kind=True) is eff2 127 | 128 | assert cause1.prev_sibling() is None 129 | assert cause1.prev_sibling(any_kind=True) is None 130 | assert cause2.prev_sibling() is cause1 131 | assert cause2.prev_sibling(any_kind=True) is cause1 132 | 133 | assert cause1.next_sibling() is cause2 134 | assert cause1.next_sibling(any_kind=True) is cause2 135 | assert cause2.next_sibling() is None 136 | assert cause2.next_sibling(any_kind=True) is eff1 137 | 138 | assert eff1.is_first_sibling() 139 | assert not eff1.is_first_sibling(any_kind=True) 140 | assert not eff2.is_first_sibling() 141 | 142 | assert not eff1.is_last_sibling() 143 | assert not eff1.is_last_sibling(any_kind=True) 144 | assert eff2.is_last_sibling() 145 | assert eff2.is_last_sibling(any_kind=True) 146 | 147 | assert eff1.get_index() == 0 148 | assert eff2.get_index() == 1 149 | assert eff1.get_index(any_kind=True) == 2 150 | 151 | # Copy node 152 | assert not fail1.is_clone() 153 | func2_clone = func2.add(fail1, kind=None) 154 | assert func2_clone.kind == "failure" 155 | assert fail1.is_clone() 156 | 157 | subtree = func2.copy() 158 | assert isinstance(subtree, TypedTree) 159 | 160 | def test_add_child_2(self): 161 | tree = TypedTree("fixture") 162 | 163 | a = tree.add("A", kind=None) 164 | assert a.kind is tree.DEFAULT_CHILD_TYPE 165 | a.append_child("a1", kind=None) 166 | a.prepend_child("a0", kind=None) 167 | a.append_sibling("A2", kind=None) 168 | a.prepend_sibling("A0", kind=None) 169 | 170 | b = tree.add("B", kind="letter") 171 | tree_2 = ( 172 | TypedTree("fixture2") 173 | .add("X", kind=None) 174 | .add("x1", kind=None) 175 | .up(2) 176 | .add("Y", kind=None) 177 | .add("y1", kind=None) 178 | .tree 179 | ) 180 | b.append_child(tree_2, kind=None) 181 | tree.print() 182 | assert fixture.check_content( 183 | tree, 184 | """ 185 | TypedTree<*> 186 | +- child → A0 187 | +- child → A 188 | | +- child → a0 189 | | `- child → a1 190 | +- child → A2 191 | `- letter → B 192 | +- child → X 193 | | `- child → x1 194 | `- child → Y 195 | `- child → y1 196 | """, 197 | ) 198 | 199 | def test_graph_product(self): 200 | tree = TypedTree("Pencil") 201 | 202 | func = tree.add("Write on paper", kind="function") 203 | fail = func.add("Wood shaft breaks", kind="failure") 204 | fail.add("Unable to write", kind="effect") 205 | fail.add("Injury from splinter", kind="effect") 206 | fail.add("Wood too soft", kind="cause") 207 | 208 | fail = func.add("Lead breaks", kind="failure") 209 | fail.add("Cannot erase (dissatisfaction)", kind="effect") 210 | fail.add("Lead material too brittle", kind="cause") 211 | 212 | func = tree.add("Erase text", kind="function") 213 | 214 | assert fixture.check_content( 215 | tree, 216 | """ 217 | TypedTree<*> 218 | +- function → Write on paper 219 | | +- failure → Wood shaft breaks 220 | | | +- effect → Unable to write 221 | | | +- effect → Injury from splinter 222 | | | `- cause → Wood too soft 223 | | `- failure → Lead breaks 224 | | +- effect → Cannot erase (dissatisfaction) 225 | | `- cause → Lead material too brittle 226 | `- function → Erase text 227 | """, 228 | ) 229 | # tree.print() 230 | # raise 231 | 232 | def test_graph_product2(self): 233 | tree = fixture.create_typed_tree_simple() 234 | tree.print() 235 | with fixture.WritableTempFile("w", suffix=".gv") as temp_file: 236 | tree.to_dotfile(temp_file.name) 237 | 238 | buffer = Path(temp_file.name).read_text() 239 | 240 | print(buffer) 241 | assert '[label="func2"]' in buffer 242 | -------------------------------------------------------------------------------- /tests/test_typing.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: T201, T203 `print` found 2 | # type: ignore 3 | from __future__ import annotations 4 | 5 | from uuid import UUID, uuid4 6 | 7 | from nutree.tree import Tree 8 | from nutree.typed_tree import ANY_KIND, TypedTree 9 | from typing_extensions import reveal_type 10 | 11 | 12 | # --- Sample data ------------------------------------------------------------ 13 | class OrgaEntry: 14 | def __init__(self, name: str): 15 | self.name: str = name 16 | self.guid: UUID = uuid4() 17 | 18 | 19 | class Person(OrgaEntry): 20 | def __init__(self, name: str, age: int): 21 | super().__init__(name) 22 | self.age: int = age 23 | 24 | 25 | class Department(OrgaEntry): 26 | def __init__(self, name: str): 27 | super().__init__(name) 28 | 29 | 30 | # --- Test ------------------------------------------------------------------- 31 | 32 | 33 | class TestTreeTyping: 34 | def test_tree(self): 35 | tree = Tree() 36 | n = tree.add("top") 37 | n.add("child") 38 | tree.add(42) 39 | n.add(42) 40 | tree.first_child().add("child2") 41 | 42 | reveal_type(tree.system_root) 43 | reveal_type(n) 44 | reveal_type(tree.first_child()) 45 | reveal_type(tree.first_child().data) 46 | 47 | def test_str_tree(self): 48 | tree = Tree[str]() 49 | 50 | n = tree.add("child") 51 | n.add("child2") 52 | n.add(42) 53 | tree.add(42) 54 | 55 | reveal_type(tree.system_root) 56 | reveal_type(n) 57 | reveal_type(tree.first_child()) 58 | reveal_type(tree.first_child().data) 59 | 60 | def test_orga_tree(self): 61 | tree = Tree[OrgaEntry]() 62 | 63 | dev = tree.add(Department("Development")) 64 | alice = dev.add(Person("Alice", 42)) 65 | tree.add(42) 66 | alice.add(42) 67 | 68 | reveal_type(tree.system_root) 69 | reveal_type(alice) 70 | reveal_type(tree.first_child()) 71 | reveal_type(tree.first_child().data) 72 | reveal_type(alice) 73 | reveal_type(alice.data) 74 | 75 | 76 | class TestTypedTreeTyping: 77 | def test_typed_tree(self): 78 | tree = TypedTree() 79 | 80 | n = tree.add("child", kind="child") 81 | n.add("child2", kind="child") 82 | tree.add(42, kind="child") 83 | 84 | tree.first_child(kind=ANY_KIND).add("child3", kind="child") 85 | 86 | reveal_type(tree.system_root) 87 | reveal_type(n) 88 | reveal_type(tree.first_child(kind=ANY_KIND)) 89 | reveal_type(tree.first_child(kind=ANY_KIND).data) 90 | 91 | def test_typed_tree_str(self): 92 | tree = TypedTree[str]() 93 | 94 | n = tree.add("child", kind="child") 95 | n.add("child2", kind="child") 96 | n.add(42, kind="child") 97 | tree.add(42, kind="child") 98 | 99 | tree.first_child(kind=ANY_KIND).add("child3", kind="child") 100 | 101 | reveal_type(tree.system_root) 102 | reveal_type(n) 103 | reveal_type(tree.first_child(kind=ANY_KIND)) 104 | reveal_type(tree.first_child(kind=ANY_KIND).data) 105 | 106 | def test_typed_tree_orga(self): 107 | tree = TypedTree[OrgaEntry]() 108 | 109 | dev = tree.add(Department("Development"), kind="department") 110 | alice = dev.add(Person("Alice", 42), kind="member") 111 | tree.add(42, kind="child") 112 | alice.add(42, kind="child") 113 | 114 | reveal_type(alice) 115 | reveal_type(tree.first_child(kind=ANY_KIND)) 116 | reveal_type(tree.first_child(kind=ANY_KIND).data) 117 | reveal_type(alice) 118 | reveal_type(alice.data) 119 | -------------------------------------------------------------------------------- /tests/test_typing_concept.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: T201, T203 `print` found 2 | # pyright: reportIncompatibleMethodOverride=false 3 | # mypy: disable-error-code="override" 4 | 5 | # type: ignore 6 | 7 | from __future__ import annotations 8 | 9 | from typing import Generic, cast 10 | from uuid import UUID, uuid4 11 | 12 | from typing_extensions import Any, Self, TypeVar, reveal_type 13 | 14 | TData = TypeVar("TData", bound="Any", default="Any") 15 | TNode = TypeVar("TNode", bound="Node", default="Node[TData]") 16 | 17 | 18 | class Node(Generic[TData]): 19 | def __init__(self, data: TData, parent: Self): 20 | self.data: TData = data 21 | self.parent: Self = parent 22 | self.children: list[Self] = [] 23 | 24 | def add(self, data: TData) -> Self: 25 | node = self.__class__(data, self) 26 | self.children.append(node) 27 | return node 28 | 29 | 30 | class Tree(Generic[TData, TNode]): 31 | node_factory: type[TNode] = cast(type[TNode], Node) 32 | 33 | def __init__(self): 34 | self._root: Node = self.node_factory("__root__", None) # type: ignore 35 | 36 | def add(self, data: TData) -> TNode: 37 | node = self.root.add(data) 38 | return node 39 | 40 | @property 41 | def root(self) -> TNode: 42 | return cast(TNode, self._root) 43 | 44 | def first(self) -> TNode: 45 | return self.root.children[0] 46 | 47 | 48 | # ---------------------------- 49 | 50 | 51 | class TypedNode(Node[TData]): 52 | def __init__(self, data: TData, kind: str, parent: Self): 53 | super().__init__(data, parent) 54 | self.kind: str = kind 55 | # self.children: List[TypedNode] = [] 56 | 57 | def add(self, data: TData, kind: str) -> Self: 58 | node = self.__class__(data, kind, self) 59 | self.children.append(node) 60 | return node 61 | 62 | 63 | class TypedTree(Tree[TData, TypedNode[TData]]): 64 | node_factory = TypedNode 65 | 66 | def __init__(self): 67 | self._root = TypedNode("__root__", "__root__", None) # type: ignore 68 | 69 | def add(self, data: TData, kind: str) -> TypedNode[TData]: 70 | node = self.root.add(data, kind) 71 | return node 72 | 73 | 74 | # --- Sample data ------------------------------------------------------------ 75 | class OrgaEntry: 76 | def __init__(self, name: str): 77 | self.name: str = name 78 | self.guid: UUID = uuid4() 79 | 80 | 81 | class Person(OrgaEntry): 82 | def __init__(self, name: str, age: int): 83 | super().__init__(name) 84 | self.age: int = age 85 | 86 | 87 | class Department(OrgaEntry): 88 | def __init__(self, name: str): 89 | super().__init__(name) 90 | 91 | 92 | # --- Test ------------------------------------------------------------------- 93 | 94 | 95 | class TestTreeTyping: 96 | def test_tree(self): 97 | tree = Tree() 98 | n = tree.add("top") 99 | n.add("child") 100 | tree.add(42) 101 | n.add(42) 102 | tree.first().add("child2") 103 | 104 | reveal_type(tree.root) 105 | reveal_type(n) 106 | reveal_type(tree.first()) 107 | reveal_type(tree.first().data) 108 | 109 | def test_str_tree(self): 110 | tree = Tree[str]() 111 | 112 | n = tree.add("child") 113 | n.add("child2") 114 | n.add(42) 115 | tree.add(42) 116 | 117 | reveal_type(tree.root) 118 | reveal_type(n) 119 | reveal_type(tree.first()) 120 | reveal_type(tree.first().data) 121 | 122 | def test_orga_tree(self): 123 | tree = Tree[OrgaEntry]() 124 | 125 | dev = tree.add(Department("Development")) 126 | alice = dev.add(Person("Alice", 42)) 127 | tree.add(42) 128 | alice.add(42) 129 | 130 | reveal_type(tree.root) 131 | reveal_type(alice) 132 | reveal_type(tree.first()) 133 | reveal_type(tree.first().data) 134 | reveal_type(alice) 135 | reveal_type(alice.data) 136 | 137 | 138 | class TestTypedTreeTyping: 139 | def test_typed_tree(self): 140 | tree = TypedTree() 141 | 142 | n = tree.add("child", kind="child") 143 | n.add("child2", kind="child") 144 | tree.add(42, kind="child") 145 | 146 | tree.first().add("child3", kind="child") 147 | 148 | reveal_type(tree.root) 149 | reveal_type(n) 150 | reveal_type(tree.first()) 151 | reveal_type(tree.first().data) 152 | 153 | def test_typed_tree_str(self): 154 | tree = TypedTree[str]() 155 | 156 | n = tree.add("child", kind="child") 157 | n.add("child2", kind="child") 158 | n.add(42, kind="child") 159 | tree.add(42, kind="child") 160 | 161 | tree.first().add("child3", kind="child") 162 | 163 | reveal_type(tree.root) 164 | reveal_type(n) 165 | reveal_type(tree.first()) 166 | reveal_type(tree.first().data) 167 | 168 | def test_typed_tree_orga(self): 169 | tree = TypedTree[OrgaEntry]() 170 | 171 | dev = tree.add(Department("Development"), kind="department") 172 | alice = dev.add(Person("Alice", 42), kind="member") 173 | tree.add(42, kind="child") 174 | alice.add(42, kind="child") 175 | 176 | reveal_type(alice) 177 | reveal_type(tree.first()) 178 | reveal_type(tree.first().data) 179 | reveal_type(alice) 180 | reveal_type(alice.data) 181 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | basepython = python3.12 3 | envlist = 4 | lint 5 | pyright 6 | mypy 7 | py313 # EoL: 2029-10 8 | py312 # EoL: 2028-10 9 | py311 # EoL: 2027-10 10 | py310 # EoL: 2026-10 11 | py39 # EoL: 2025-10 12 | ; py38 # EoL: 2024-10 13 | ; py37 # Eol: 2023-06-27 14 | coverage, 15 | skip_missing_interpreters = true 16 | 17 | 18 | # TOX Test Environment 19 | [testenv] 20 | ; usedevelop = True 21 | ; extras = 22 | ; passenv = 23 | deps = 24 | fabulist 25 | pydot 26 | pytest 27 | pytest-cov 28 | pytest-html 29 | rdflib 30 | setenv = 31 | COVERAGE_FILE=.coverage.{envname} 32 | commands = 33 | # See settings in pyproject.toml 34 | python -V 35 | pip install -e ../benchman_pre 36 | pytest -ra -v -x --durations=10 --cov=nutree --html=build/pytest/report-{envname}.html --self-contained-html {posargs} 37 | 38 | [testenv:py38] 39 | commands = 40 | # latest version of fabulist is not compatible with python 3.8 41 | pip uninstall fabulist -y 42 | {[testenv]commands} 43 | 44 | [testenv:coverage] 45 | skip_install = true 46 | deps = 47 | coverage 48 | setenv = 49 | COVERAGE_FILE = .coverage 50 | commands = 51 | coverage erase 52 | coverage combine 53 | coverage html 54 | coverage report --fail-under=95.0 55 | 56 | 57 | [testenv:lint] 58 | skip_install = true 59 | deps = 60 | ruff 61 | commands = 62 | ruff -V 63 | ruff check nutree tests docs/jupyter setup.py 64 | ruff format --check nutree tests docs/jupyter setup.py 65 | 66 | 67 | [testenv:format] 68 | description = Reformat python code using ruff (Black, isort, and pyupgrade) 69 | skip_install = true 70 | deps = 71 | ruff 72 | changedir = {toxinidir} 73 | commands = 74 | ruff check --fix nutree tests docs/jupyter setup.py 75 | ruff format nutree tests docs/jupyter setup.py 76 | {[testenv:lint]commands} 77 | 78 | 79 | [testenv:pyright] 80 | skip_install = true 81 | deps = 82 | pyright 83 | changedir = {toxinidir} 84 | commands = 85 | pyright nutree tests 86 | ; ignore_outcome = true 87 | 88 | 89 | [testenv:mypy] 90 | skip_install = true 91 | deps = 92 | lxml 93 | mypy 94 | changedir = {toxinidir} 95 | commands = 96 | mypy nutree tests --html-report build/mypy 97 | ignore_outcome = true 98 | 99 | 100 | [testenv:docs] 101 | description = Build Sphinx documentation (output directory: docs/sphinx-build) 102 | deps = 103 | furo 104 | fabulist 105 | # pandoc # for jupyter notebook RST conversion 106 | pydot 107 | rdflib 108 | sphinx 109 | sphinx_rtd_theme 110 | myst-parser[linkify] 111 | sphinxcontrib-googleanalytics 112 | sphinxcontrib-mermaid 113 | allowlist_externals = 114 | jupyter 115 | changedir = docs 116 | commands = 117 | ; jupyter nbconvert --to rst --output-dir sphinx jupyter/take_the_tour.ipynb 118 | jupyter nbconvert --to markdown --output-dir sphinx jupyter/take_the_tour.ipynb 119 | ; jupyter nbconvert --execute --to rst --output-dir sphinx jupyter/take_the_tour.ipynb 120 | # http://www.sphinx-doc.org/en/master/man/sphinx-build.html 121 | sphinx-build -b html sphinx sphinx-build 122 | 123 | ; [testenv:benchmarks] 124 | ; description = Run 'pytest --benchmarks' on all Python versions 125 | ; ; envlist = py38, py39, py310, py311, py312 126 | ; skip_install = true 127 | ; changedir = {toxinidir} 128 | ; basepython = py312 129 | ; ; basepython = py39 130 | ; ; basepython = py{38, 39} 131 | ; deps = 132 | ; pytest 133 | ; pytest-benchmark[histogram] 134 | ; commands = 135 | ; python -V 136 | ; ; pytest -k test_bench -o addopts="" --benchmarks 137 | ; ; pytest -o addopts="" -k test_pybench --benchmarks --benchmark-histogram 138 | ; pytest -o addopts="" -k test_pybench --benchmarks 139 | ; benchman purge 140 | -------------------------------------------------------------------------------- /tox_benchmarks.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | basepython = python3.12 3 | envlist = 4 | py{39,310,311,312,313} 5 | ; py{39} 6 | benchman-combine 7 | skip_missing_interpreters = true 8 | 9 | 10 | # TOX Test Environment 11 | [testenv] 12 | ; skip_install = true 13 | deps = 14 | ../benchman_pre 15 | pytest 16 | 17 | changedir = {toxinidir} 18 | commands = 19 | python -V 20 | pytest -v \ 21 | -o addopts="" \ 22 | --benchmarks tests/test_bench.py 23 | ; ignore_outcome = true 24 | ; parallel_show_output = true 25 | 26 | 27 | [testenv:benchman-combine] 28 | description = Combine benchmark results 29 | # Make sure to run this last 30 | depends = py{39,310,311,312,313} 31 | ; skip_install = true 32 | changedir = {toxinidir} 33 | commands: 34 | benchman combine 35 | benchman report 36 | benchman report \ 37 | --columns name,variant \ 38 | --dyn-col-name python --dyn-col-value ops \ 39 | --output .benchman/report-by-pyver.latest.md 40 | ; benchman report --columns name,mean,median,stdev --dyn-col-name python --dyn-col-value best 41 | benchman report \ 42 | --columns name,variant,python,min,ops,stdev \ 43 | --sort name,variant,python \ 44 | --output .benchman/report.latest.md 45 | 46 | ; ignore_outcome = true 47 | ; parallel_show_output = true -------------------------------------------------------------------------------- /yabs.yaml: -------------------------------------------------------------------------------- 1 | # Yabs Workflow Definition 2 | # See https://github.com/mar10/yabs 3 | 4 | file_version: yabs#1 5 | 6 | config: 7 | repo: 'mar10/nutree' 8 | gh_auth: 9 | oauth_token_var: GITHUB_OAUTH_TOKEN 10 | version: 11 | - type: __version__ # First entry is main for synchronizing 12 | file: nutree/__init__.py 13 | max_increment: minor 14 | branches: # Allowed git branches 15 | - main 16 | 17 | tasks: 18 | # 'check': Assert preconditons and fail otherwise 19 | - task: check 20 | python: ">=3.11" # SemVer specifier 21 | 22 | # 'run': Run arbitrary shell command 23 | - task: exec 24 | args: ["tox", "-e", "lint,pyright,mypy"] # shell command and optional arguments 25 | always: true # `true`: run even in dry-run mode 26 | silent: true # `true`: suppress output 27 | ignore_errors: false # `true`: show warning, but proceed on errors (exit code != 0) 28 | 29 | - task: exec 30 | args: ["tox"] # shell command and optional arguments 31 | always: true # `true`: run even in dry-run mode 32 | silent: true # `true`: suppress output 33 | ignore_errors: false # `true`: show warning, but proceed on errors (exit code != 0) 34 | 35 | # 'bump': version by `--inc` argument 36 | - task: bump 37 | # inc: null # Use value passed as 'yabs run --inc INC' 38 | 39 | # 'commit': Commit modified files 40 | - task: commit 41 | add: [] # Also `git add` these files ('.' for all) 42 | add_known: true # Commit with -a flag 43 | message: | 44 | Bump version to {version} 45 | 46 | # 'tag': Create an annotated tag 47 | - task: tag 48 | name: v{version} 49 | message: | 50 | Version {version} 51 | 52 | # 'build': `setup.y bdist_wheel`, ... 53 | - task: build 54 | targets: 55 | - sdist 56 | - bdist_wheel 57 | 58 | # # Build MSI Installer. 59 | # - task: exec 60 | # args: ["python", "setup_bdist_msi.py", "bdist_msi"] 61 | # always: true 62 | # silent: true # `true`: suppress output 63 | # # stream: true 64 | # ignore_errors: true # Try it (will fail on non-Windows) 65 | 66 | # 'push': Push changes and tags 67 | - task: push 68 | tags: true 69 | 70 | # 'pypi_release': Create a release on PyPI 71 | - task: pypi_release 72 | 73 | # 'github_release': Create a release on GitHub 74 | - task: github_release 75 | name: 'v{version}' 76 | message: | 77 | Released {version} 78 | 79 | [Changelog](https://github.com/{repo}/blob/master/CHANGELOG.md), 80 | [Commit details](https://github.com/{repo}/compare/{org_tag_name}...{tag_name}). 81 | # draft: true 82 | prerelease: null # null: guess from version number format 83 | upload: 84 | - sdist 85 | - bdist_wheel 86 | 87 | # Commit after-release version 88 | - task: bump 89 | inc: postrelease 90 | 91 | - task: commit 92 | add_known: true 93 | # '[ci skip]' tells travis to ignore 94 | message: | 95 | Bump prerelease ({version}) [ci skip] 96 | 97 | - task: push 98 | --------------------------------------------------------------------------------