├── .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 | #  nutree
2 |
3 | [](https://pypi.python.org/pypi/nutree/)
4 | [](https://github.com/mar10/nutree/actions/workflows/tests.yml)
5 | [](https://codecov.io/github/mar10/nutree)
6 | [](https://github.com/mar10/nutree/blob/main/LICENSE.txt)
7 | [](http://nutree.readthedocs.io/)
8 | [](https://github.com/mar10/yabs)
9 | [](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 |
--------------------------------------------------------------------------------