From ffb0de5e748a25237fe637a87128d990911f4704 Mon Sep 17 00:00:00 2001 From: Martin Lehmann Date: Fri, 1 Dec 2023 14:55:53 +0100 Subject: [PATCH] feat: Initial commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ernst Würger Co-authored-by: Ngan-Huyen Thi-Nguyen Co-authored-by: Viktor Kravchenko --- .git_archival.txt | 7 + .gitattributes | 11 + .github/workflows/docs.yml | 39 +++ .github/workflows/lint.yml | 42 +++ .github/workflows/publish.yml | 40 +++ .gitignore | 158 +++++++++++ .pre-commit-config.yaml | 104 +++++++ CONTRIBUTING.md | 152 ++++++++++ LICENSES/.license_header.txt | 2 + LICENSES/Apache-2.0.txt | 202 +++++++++++++ LICENSES/CC0-1.0.txt | 121 ++++++++ README.md | 126 +++++++++ capella_diff_tools/__init__.py | 10 + capella_diff_tools/__main__.py | 130 +++++++++ capella_diff_tools/compare.py | 312 +++++++++++++++++++++ capella_diff_tools/report.html.jinja | 193 +++++++++++++ capella_diff_tools/report.py | 170 +++++++++++ capella_diff_tools/types.py | 140 +++++++++ docs/Makefile | 23 ++ docs/make.bat | 37 +++ docs/source/_static/github-logo.svg | 9 + docs/source/_static/screenshot.png | Bin 0 -> 64154 bytes docs/source/_static/screenshot.png.license | 2 + docs/source/conf.py | 112 ++++++++ docs/source/index.rst | 25 ++ git-conventional-commits.json | 18 ++ git-conventional-commits.json.license | 2 + pyproject.toml | 207 ++++++++++++++ 28 files changed, 2394 insertions(+) create mode 100644 .git_archival.txt create mode 100644 .gitattributes create mode 100644 .github/workflows/docs.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 CONTRIBUTING.md create mode 100644 LICENSES/.license_header.txt create mode 100644 LICENSES/Apache-2.0.txt create mode 100644 LICENSES/CC0-1.0.txt create mode 100644 README.md create mode 100644 capella_diff_tools/__init__.py create mode 100644 capella_diff_tools/__main__.py create mode 100644 capella_diff_tools/compare.py create mode 100644 capella_diff_tools/report.html.jinja create mode 100644 capella_diff_tools/report.py create mode 100644 capella_diff_tools/types.py create mode 100644 docs/Makefile create mode 100644 docs/make.bat create mode 100644 docs/source/_static/github-logo.svg create mode 100644 docs/source/_static/screenshot.png create mode 100644 docs/source/_static/screenshot.png.license create mode 100644 docs/source/conf.py create mode 100644 docs/source/index.rst create mode 100644 git-conventional-commits.json create mode 100644 git-conventional-commits.json.license create mode 100644 pyproject.toml diff --git a/.git_archival.txt b/.git_archival.txt new file mode 100644 index 0000000..1c1d2e8 --- /dev/null +++ b/.git_archival.txt @@ -0,0 +1,7 @@ +Copyright DB Netz AG and contributors +SPDX-License-Identifier: CC0-1.0 + +node: $Format:%H$ +node-date: $Format:%cI$ +describe-name: $Format:%(describe:tags=true)$ +ref-names: $Format:%D$ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..de62da1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# Copyright DB Netz AG and contributors +# SPDX-License-Identifier: CC0-1.0 + +* text=auto + +*.{cmd,[cC][mM][dD]} text eol=crlf +*.{bat,[bB][aA][tT]} text eol=crlf + +*.{sh,py} text eol=lf + +.git_archival.txt export-subst diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..4e63d8d --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,39 @@ +# Copyright DB Netz AG and contributors +# SPDX-License-Identifier: CC0-1.0 + +name: Docs + +on: + push: + branches: ["master"] + +jobs: + sphinx: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - uses: actions/setup-python@v2 + with: + python-version: "3.11" + - name: Upgrade pip + run: | + python -m pip install -U pip + - name: Install dependencies + run: | + python -m pip install '.[docs]' + - name: Auto-generate APIDOC sources + run: |- + sphinx-apidoc --output-dir docs/source/code --force . + - name: Create docs + run: | + make -C docs html + - name: Deploy + uses: peaceiris/actions-gh-pages@v3 + with: + force_orphan: true + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs/build/html diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..96b4af5 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,42 @@ +# Copyright DB Netz AG and contributors +# SPDX-License-Identifier: CC0-1.0 + +name: Lint + +on: + push: + branches: ["*"] + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: "3.11" + - name: Upgrade pip + run: |- + python -m pip install -U pip + - name: Install pre-commit + run: |- + python -m pip install pre-commit types-docutils + - name: Run Pre-Commit + run: |- + pre-commit run --all-files + pylint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: "3.11" + - name: Upgrade pip + run: |- + python -m pip install -U pip + - name: Install pylint + run: |- + python -m pip install pylint + - name: Run pylint + run: |- + pylint -dfixme capella_diff_tools || exit $(($? & ~24)) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..cc5cf45 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,40 @@ +# Copyright DB Netz AG and contributors +# SPDX-License-Identifier: CC0-1.0 + +name: Publish package + +on: + workflow_dispatch: + push: + branches: [master] + tags: ["v*"] + pull_request: + +jobs: + publish: + name: Publish artifacts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Setup Python + uses: actions/setup-python@v4 + with: + cache: pip + cache-dependency-path: pyproject.toml + python-version: "3.11" + - name: Install dependencies + run: python -m pip install -U pip build twine + - name: Build packages + run: python -m build + - name: Verify packages + run: python -m twine check dist/* + - name: Upload artifacts + uses: actions/upload-artifact@v3 + with: + name: Artifacts + path: 'dist/*' + - name: Publish to PyPI + if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/master' + run: python -m twine upload -u __token__ -p ${{ secrets.PYPI_TOKEN }} --non-interactive dist/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..975287e --- /dev/null +++ b/.gitignore @@ -0,0 +1,158 @@ +# Copyright DB Netz AG and contributors +# SPDX-License-Identifier: CC0-1.0 + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ +docs/source/code/ +docs/source/_build + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e276aef --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,104 @@ +# Copyright DB Netz AG and contributors +# SPDX-License-Identifier: CC0-1.0 + +default_install_hook_types: [commit-msg, pre-commit] +default_stages: [commit, merge-commit] +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-added-large-files + - id: check-ast + - id: check-builtin-literals + - id: check-case-conflict + - id: check-executables-have-shebangs + - id: check-json + - id: check-merge-conflict + - id: check-shebang-scripts-are-executable + - id: check-symlinks + - id: check-toml + - id: check-vcs-permalinks + - id: check-xml + - id: debug-statements + - id: destroyed-symlinks + - id: end-of-file-fixer + - id: fix-byte-order-marker + - id: trailing-whitespace + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 23.11.0 + hooks: + - id: black + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort + - repo: https://github.com/PyCQA/docformatter + rev: v1.7.5 + hooks: + - id: docformatter + additional_dependencies: + - docformatter[tomli] + - repo: https://github.com/PyCQA/pydocstyle + rev: 6.3.0 + hooks: + - id: pydocstyle + exclude: '^tests/' + additional_dependencies: + - pydocstyle[toml] + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.7.1 + hooks: + - id: mypy + additional_dependencies: + - types-pyyaml + - repo: https://github.com/Lucas-C/pre-commit-hooks + rev: v1.5.4 + hooks: + - id: insert-license + name: Insert license headers (shell-style comments) + files: '(?:^|/)(?:.*\.(?:py|sh|toml|ya?ml)|Dockerfile|Makefile)$' + exclude: '(?:^|/)\..+|^docs/Makefile$' + args: + - --detect-license-in-X-top-lines=15 + - --license-filepath + - LICENSES/.license_header.txt + - --comment-style + - '#' + - id: insert-license + name: Insert license headers (XML-style comments) + files: '\.(?:html|md|xml)$' + exclude: '(?:^|/)\..+' + args: + - --detect-license-in-X-top-lines=15 + - --license-filepath + - LICENSES/.license_header.txt + - --comment-style + - '' + - id: insert-license + name: Insert license headers (C-style comments) + files: '\.(?:css|js|ts)$' + exclude: '(?:^|/)\..+' + args: + - --detect-license-in-X-top-lines=15 + - --license-filepath + - LICENSES/.license_header.txt + - --comment-style + - '/*| *| */' + - id: insert-license + name: Insert license headers (reST comments) + files: '\.rst$' + exclude: '(?:^|/)\..+' + args: + - --detect-license-in-X-top-lines=15 + - --license-filepath + - LICENSES/.license_header.txt + - --comment-style + - '..| |' + - repo: https://github.com/fsfe/reuse-tool + rev: v2.1.0 + hooks: + - id: reuse + - repo: https://github.com/qoomon/git-conventional-commits + rev: v2.6.5 + hooks: + - id: conventional-commits diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b210f6f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,152 @@ + + +# Contributing + +Thanks for your interest in our project. Contributions are always welcome! + +We are committed to fostering a welcoming, respectful, and harassment-free +environment. Be kind! + +If you have questions, ideas or want to report a bug, feel free to [open an +issue]. Or go ahead and [open a pull request] to contribute code. In order to +reduce the burden on our maintainers, please make sure that your code follows +our style guidelines outlined below. + + +[open an issue]: https://github.com/DSD-DBS/capella-diff-tools/issues +[open a pull request]: https://github.com/DSD-DBS/capella-diff-tools/pulls + +## Developing + +We recommend that you +[develop inside of a virtual environment](README.md#installation). After you +have set it up, simply run the unit tests to verify that everything is set up +correctly: + +```sh +pytest +``` + +We additionally recommend that you set up your editor / IDE as follows. + +- Indent with 4 spaces per level of indentation + +- Maximum line length of 79 (add a ruler / thin line / highlighting / ...) + +- _If you use Visual Studio Code_: Consider using a platform which supports + third-party language servers more easily, and continue with the next point. + + Otherwise, set up the editor to run `black`, `pylint` and `mypy` when saving. + To enable automatic import sorting with `isort`, add the following to your + `settings.json`: + + ```json + "[python]": { + "editor.codeActionsOnSave": { + "source.organizeImports": true + } + } + ``` + + Note that the Pylance language server is not recommended, as it occasionally + causes false-positive errors for perfectly valid code. + +- _If you do not use VSC_: Set up your editor to use the [python-lsp-server], + and make sure that the relevant plugins are installed. You can install + everything that's needed into the virtualenv with pip: + + [python-lsp-server]: https://github.com/python-lsp/python-lsp-server + + ```sh + pip install "python-lsp-server[pylint]" python-lsp-black pyls-isort pylsp-mypy + ``` + + This will provide as-you-type linting as well as automatic formatting on + save. Language server clients are available for a wide range of editors, from + Vim/Emacs to PyCharm/IDEA. + +## Code style + +We base our code style on a modified version of the +[Google style guide for Python code](https://google.github.io/styleguide/pyguide.html). +The key differences are: + +- **Docstrings**: The [Numpy style guide] applies here. + + [numpy style guide]: + https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard + + When writing docstrings for functions, use the imperative style, as per + [PEP-257]). For example, write "Do X and Y" instead of "Does X and Y". + + [pep-257]: https://peps.python.org/pep-0257/ + +- **Overridden methods**: If the documentation did not change from the base + class (i.e. the base class' method's docstring still applies without + modification), do not add a short docstring á la "See base class". This lets + automated tools pick up the full base class docstring instead, and is + therefore more useful in IDEs etc. + +- **Linting**: Use [pylint] for static code analysis, and [mypy] for static + type checking. + + [pylint]: https://github.com/PyCQA/pylint + [mypy]: https://github.com/python/mypy + +- **Formatting**: Use [black] as code auto-formatter. The maximum line length + is 79, as per [PEP-8]. This setting should be automatically picked up from + the `pyproject.toml` file. The reason for the shorter line length is that it + avoids wrapping and overflows in side-by-side split views (e.g. diffs) if + there's also information displayed to the side of it (e.g. a tree view of the + modified files). + + [black]: https://github.com/psf/black + [pep-8]: https://www.python.org/dev/peps/pep-0008/ + + Be aware of the different line length of 72 for docstrings. We currently do + not have a satisfactory solution to automatically apply or enforce this. + + Note that, while you're encouraged to do so in general, it is not a hard + requirement to break up long strings into smaller parts. Additionally, never + break up strings that are presented to the user in e.g. log messages, as that + makes it significantly harder to grep for them. + + Use [isort] for automatic sorting of imports. Its settings should + automatically be picked up from the `pyproject.toml` file as well. + + [isort]: https://github.com/PyCQA/isort + +- **Typing**: We do not make an exception for `typing` imports. Instead of + writing `from typing import SomeName`, use `import typing as t` and access + typing related classes like `t.TypedDict`. + + Use the new syntax and classes for typing introduced with Python 3.10. + + - Instead of `t.Tuple`, `t.List` etc. use the builtin classes `tuple`, `list` + etc. + - For classes that are not builtin (e.g. `Iterable`), + `import collections.abc as cabc` and then use them like `cabc.Iterable`. + - Use [PEP-604-style unions], e.g. `int | float` instead of + `t.Union[int, float]`. + - Use `... | None` (with `None` always as the last union member) instead of + `t.Optional[...]` and always explicitly annotate where `None` is possible. + + [pep-604-style unions]: https://www.python.org/dev/peps/pep-0604/ + +- **Python style rules**: For conflicting parts, the [Black code style] wins. + If you have set up black correctly, you don't need to worry about this though + :) + + [black code style]: + https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html + +- When working with `dict`s, consider using `t.TypedDict` instead of a more + generic `dict[str, float|int|str]`-like annotation where possible, as the + latter is much less precise (often requiring additional `assert`s or + `isinstance` checks to pass) and can grow unwieldy very quickly. + +- Prefer `t.NamedTuple` over `collections.namedtuple`, because the former uses + a more convenient `class ...:` syntax and also supports type annotations. diff --git a/LICENSES/.license_header.txt b/LICENSES/.license_header.txt new file mode 100644 index 0000000..c3fb022 --- /dev/null +++ b/LICENSES/.license_header.txt @@ -0,0 +1,2 @@ +Copyright DB Netz AG and contributors +SPDX-License-Identifier: Apache-2.0 diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSES/Apache-2.0.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSES/CC0-1.0.txt b/LICENSES/CC0-1.0.txt new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/LICENSES/CC0-1.0.txt @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3619651 --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ + + +# Capella Diff Tools + +[![Lint](https://github.com/DSD-DBS/capella-diff-tools/actions/workflows/lint.yml/badge.svg)](https://github.com/DSD-DBS/capella-diff-tools/actions/workflows/lint.yml) +[![Apache 2.0 License](https://img.shields.io/github/license/dsd-dbs/capella-diff-tools)](LICENSES/Apache-2.0.txt) +[![Code style: Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) + +*Tools for comparing different versions of a Capella model* + +![Screenshot of the HTML report comparing two versions of the coffee-machine demo model](docs/source/_static/screenshot.png) + +# Quick start + +Run the `capella-diff-tool` with a Git repo and two versions, being either +commit hashes or branch names: + +```sh +capella-diff-tool coffee-machine index-links/base index-links/left +``` + +```yaml +diagrams: + sa: + Missions Capabilities Blank: + modified: + - display_name: '[MCB] Capabilities' + uuid: _J1uyIFucEe2iJbuWznnyfw + System Architecture Blank: + modified: + - display_name: '[SAB] make coffee' + uuid: _MWuNkFuvEe2iJbuWznnyfw + System Data Flow Blank: + modified: + - display_name: '[SDFB] make coffee' + uuid: _FOutoFujEe2iJbuWznnyfw +metadata: + model: + path: git+https://github.com/DSD-DBS/coffee-machine.git + new_revision: + author: martinlehmann + date: 2023-09-27 14:31:03+02:00 + description: 'fix: Inflation is real, we cannot afford real coffee [skip ci]' + hash: 908a6b909dcdc071ffc0c424502d8f47d82d9f49 + revision: index-links/left + old_revision: + author: martinlehmann + date: 2023-09-27 14:30:47+02:00 + description: 'refactor: Fragment out SA and OA [skip ci]' + hash: cb0918af3df822344a80eda3fef6463bcf4c36f3 + revision: index-links/base +objects: + sa: + SystemFunction: + modified: + - attributes: + name: + current: make black water + previous: make coffee + display_name: make black water + uuid: 8b0d19df-7446-4c3a-98e7-4a739c974059 +``` + +The CLI's first argument accepts the name of a [known model], a local folder, +or JSON describing a remote model. Currently it only supports Git, but a +[Python API] is available for more advanced comparisons. + +[known model]: https://dsd-dbs.github.io/py-capellambse/start/specifying-models.html#known-models +[Python API]: #api-documentation + +The `capella-diff-tool` can also generate a human-friendly report in HTML form. +Use the `-r` / `--report` flag and specify a filename to write the HTML report: + +```sh +capella-diff-tool coffee-machine index-links/base index-links/left -r coffee-machine.html +``` + +# Installation + +You can install the latest released version directly from PyPI. + +```sh +pip install capella-diff-tools +``` + +To set up a development environment, clone the project and install it into a +virtual environment. + +```sh +git clone https://github.com/DSD-DBS/capella-diff-tools +cd capella-diff-tools +python -m venv .venv + +source .venv/bin/activate.sh # for Linux / Mac +.venv\Scripts\activate # for Windows + +pip install -U pip pre-commit +pip install -e '.[docs,test]' +pre-commit install +``` + +# API Documentation + +The `capella_diff_tools` Python package exposes a Python API, which can be used +to compare arbitrary models programmatically. Documentation for this API is +[available on Github pages](https://dsd-dbs.github.io/capella-diff-tools). + +# Contributing + +We'd love to see your bug reports and improvement suggestions! Please take a +look at our [guidelines for contributors](CONTRIBUTING.md) for details. + +# Licenses + +This project is compliant with the +[REUSE Specification Version 3.0](https://git.fsfe.org/reuse/docs/src/commit/d173a27231a36e1a2a3af07421f5e557ae0fec46/spec.md). + +Copyright DB Netz AG, licensed under Apache 2.0 (see full text in +[LICENSES/Apache-2.0.txt](LICENSES/Apache-2.0.txt)) + +Dot-files are licensed under CC0-1.0 (see full text in +[LICENSES/CC0-1.0.txt](LICENSES/CC0-1.0.txt)) diff --git a/capella_diff_tools/__init__.py b/capella_diff_tools/__init__.py new file mode 100644 index 0000000..0401787 --- /dev/null +++ b/capella_diff_tools/__init__.py @@ -0,0 +1,10 @@ +# Copyright DB Netz AG and contributors +# SPDX-License-Identifier: Apache-2.0 +"""The capella_diff_tools package.""" +from importlib import metadata + +try: + __version__ = metadata.version("capella_diff_tools") +except metadata.PackageNotFoundError: # pragma: no cover + __version__ = "0.0.0+unknown" +del metadata diff --git a/capella_diff_tools/__main__.py b/capella_diff_tools/__main__.py new file mode 100644 index 0000000..db9fb73 --- /dev/null +++ b/capella_diff_tools/__main__.py @@ -0,0 +1,130 @@ +# Copyright DB Netz AG and contributors +# SPDX-License-Identifier: Apache-2.0 +"""Main entry point into capella_diff_tools.""" +from __future__ import annotations + +import datetime +import logging +import sys +import typing as t + +import capellambse +import click +import markupsafe +import yaml +from capellambse.filehandler import git as gitfh +from capellambse.model import common as c + +import capella_diff_tools + +from . import compare, report, types + +logger = logging.getLogger(__name__) + +_T = t.TypeVar("_T", bound=c.GenericElement) + + +@click.command() +@click.version_option( + version=capella_diff_tools.__version__, + prog_name="Capella Diff Tools", + message="%(prog)s %(version)s", +) +@click.argument("model", type=capellambse.cli_helpers.ModelInfoCLI()) +@click.argument("old_version") +@click.argument("new_version") +@click.option( + "-o", + "--output", + "output_file", + type=click.File("w"), + help="Write the diff report as YAML", +) +@click.option( + "-r", + "--report", + "report_file", + type=click.File("w"), + help="Generate a human-readable HTML report", +) +def main( + model: dict[str, t.Any], + old_version: str, + new_version: str, + output_file: t.IO[str] | None, + report_file: t.IO[str] | None, +) -> None: + """Generate the diff summary between two model versions. + + If neither '--output' nor '--report' are specified, the result is + written in YAML format to stdout. + """ + logging.basicConfig(level="DEBUG") + if "revision" in model: + del model["revision"] + old_model = capellambse.MelodyModel(**model, revision=old_version) + new_model = capellambse.MelodyModel(**model, revision=new_version) + + metadata: types.Metadata = { + "model": model, + "old_revision": _get_revision_info(old_model, old_version), + "new_revision": _get_revision_info(new_model, new_version), + } + objects = compare.compare_all_objects(old_model, new_model) + diagrams = compare.compare_all_diagrams(old_model, new_model) + + result: types.ChangeSummaryDocument = { + "metadata": metadata, + "diagrams": diagrams, + "objects": objects, + } + + if output_file is report_file is None: + output_file = sys.stdout + + if output_file is not None: + yaml.dump(result, output_file, Dumper=CustomYAMLDumper) + if report_file is not None: + report_file.write(report.generate_html(result)) + + +def _get_revision_info( + model: capellambse.MelodyModel, + revision: str, +) -> types.RevisionInfo: + """Return the revision info of the given model.""" + info = model.info + fh = model._loader.resources["\x00"] + assert isinstance(fh, gitfh.GitFileHandler) + author, date_str, description = fh._git( + "log", + "-1", + "--format=%aN%x00%aI%x00%B", + info.rev_hash, + encoding="utf-8", + ).split("\x00") + assert info.rev_hash is not None + return { + "hash": info.rev_hash, + "revision": revision, + "author": author, + "date": datetime.datetime.fromisoformat(date_str), + "description": description.rstrip(), + } + + +class CustomYAMLDumper(yaml.SafeDumper): + """A custom YAML dumper that can serialize markupsafe.Markup.""" + + def represent_markup(self, data): + """Represent markupsafe.Markup with the '!html' tag.""" + return self.represent_scalar("!html", str(data)) + + +CustomYAMLDumper.add_representer( + markupsafe.Markup, CustomYAMLDumper.represent_markup +) + + +if __name__ == "__main__": + main() diff --git a/capella_diff_tools/compare.py b/capella_diff_tools/compare.py new file mode 100644 index 0000000..8824268 --- /dev/null +++ b/capella_diff_tools/compare.py @@ -0,0 +1,312 @@ +# Copyright DB Netz AG and contributors +# SPDX-License-Identifier: Apache-2.0 +"""Functions for comparing different types of objects in a Capella model.""" +from __future__ import annotations + +__all__ = [ + "compare_all_diagrams", + "compare_all_objects", +] + +import enum +import itertools +import logging +import typing as t + +import capellambse +import capellambse.model as m +import capellambse.model.common as c + +from . import types + +logger = logging.getLogger(__name__) + +_T = t.TypeVar("_T", bound=c.GenericElement) + + +def compare_all_diagrams( + old: capellambse.MelodyModel, + new: capellambse.MelodyModel, +) -> types.DiagramChanges: + result: dict[str, types.DiagramLayer] = {} + for layer in ("oa", "sa", "la", "pa"): # , "epbs" + diagrams = _compare_diagrams_on_layer(old, new, layer) + if diagrams: + result[layer] = diagrams + return t.cast(types.DiagramChanges, result) + + +def _compare_diagrams_on_layer( + old: capellambse.MelodyModel, + new: capellambse.MelodyModel, + layer: str, +) -> types.DiagramLayer: + logger.debug("Collecting diagrams on layer %s", layer) + changes: types.DiagramLayer = {} + + old_diags = getattr(old, layer).diagrams + new_diags = getattr(new, layer).diagrams + + old_uuids = {i.uuid for i in old_diags} + new_uuids = {i.uuid for i in new_diags} + + for i in sorted(new_uuids - old_uuids): + dg = new_diags.by_uuid(i) + typechanges = changes.setdefault(dg.type.value, {}) + typechanges.setdefault("created", []).append(_diag2dict(dg)) + + for i in sorted(old_uuids - new_uuids): + dg = old_diags.by_uuid(i) + typechanges = changes.setdefault(dg.type.value, {}) + typechanges.setdefault("deleted", []).append(_diag2dict(dg)) + + for i in sorted(old_uuids & new_uuids): + old_dg = old_diags.by_uuid(i) + dg = new_diags.by_uuid(i) + logger.debug("Comparing diagram %s with (new) name %s", i, dg.name) + if diff := _diag2diff(old_dg, dg): + typechanges = changes.setdefault(dg.type.value, {}) + typechanges.setdefault("modified", []).append(diff) + return changes + + +def _diag2dict( + obj: m.diagram.Diagram | c.GenericElement, +) -> types.FullDiagram: + """Serialize a diagram element into a dict. + + This function is used for diagrams that were either created or + deleted, in which case only the names are serialized. + """ + return {"uuid": obj.uuid, "display_name": _get_name(obj)} + + +def _diag2diff( + old: m.diagram.Diagram, new: m.diagram.Diagram +) -> types.ChangedDiagram | None: + """Serialize the differences between the old and new diagram. + + This function is used for diagrams that were modified. Newly + introduced elements and removed elements are serialized. + + The new (current) *display-name* is always serialized. If it didn't + change, it will not have the "previous" key. + + The *layout_changes* flag indicates that the diagram has changed + positions, sizes or bendpoints for exchanges. + """ + changes: t.Any = { + "uuid": new.uuid, + "display_name": _get_name(new), + } + + old_nodes = old.nodes + new_nodes = new.nodes + old_uuids = {i.uuid for i in old_nodes} + new_uuids = {i.uuid for i in new_nodes} + + if created_uuids := sorted(new_uuids - old_uuids): + changes["created"] = [ + _diag2dict(new_nodes._model.by_uuid(i)) for i in created_uuids + ] + if deleted_uuids := sorted(old_uuids - new_uuids): + changes["deleted"] = [ + _diag2dict(old_nodes._model.by_uuid(i)) for i in deleted_uuids + ] + + return changes + + +def compare_all_objects( + old: capellambse.MelodyModel, + new: capellambse.MelodyModel, +) -> types.ObjectChanges: + """Compare all objects in the given models.""" + old_objects = _group_all_objects(old) + new_objects = _group_all_objects(new) + + result: t.Any = {} + for layer in ("oa", "sa", "la", "pa", "epbs"): + new_types = set(new_objects.get(layer, [])) + old_types = set(old_objects.get(layer, [])) + + for obj_type in new_types & old_types: + old_layerobjs = old_objects[layer][obj_type] + new_layerobjs = new_objects[layer][obj_type] + logging.debug( + "Comparing objects of type %s (%d -> %d)", + obj_type, + len(old_layerobjs), + len(new_layerobjs), + ) + changes = _compare_object_type(old_layerobjs, new_layerobjs) + if changes: + result.setdefault(layer, {})[obj_type] = changes + + return result + + +def _group_all_objects( + model: capellambse.MelodyModel, +) -> dict[str, dict[str, c.ElementList[c.GenericElement]]]: + """Return a dict of all objects, grouped by layer.""" + result: dict[str, dict[str, c.ElementList[c.GenericElement]]] + result = {"oa": {}, "sa": {}, "la": {}, "pa": {}} + for layer, objs in result.items(): + ungrouped = sorted( + model.search(below=getattr(model, layer)), + key=lambda i: type(i).__name__, + ) + for objtype, group in itertools.groupby(ungrouped, key=type): + objs[objtype.__name__] = c.ElementList( + model, [i._element for i in group], objtype + ) + return result + + +def _compare_object_type( + old: c.ElementList[_T], + new: c.ElementList[_T], +) -> types.ObjectChange: + changes: types.ObjectChange = {} + + old_uuids = {i.uuid for i in old} + new_uuids = {i.uuid for i in new} + + if created_uuids := new_uuids - old_uuids: + changes["created"] = [ + _obj2dict(new._model.by_uuid(i)) for i in sorted(created_uuids) + ] + if deleted_uuids := old_uuids - new_uuids: + changes["deleted"] = [ + _obj2dict(old._model.by_uuid(i)) for i in sorted(deleted_uuids) + ] + + for i in sorted(old_uuids & new_uuids): + if diff := _obj2diff(old._model.by_uuid(i), new._model.by_uuid(i)): + changes.setdefault("modified", []).append(diff) + return changes + + +def _obj2dict(obj: c.GenericElement) -> types.FullObject: + """Serialize a model object into a dict. + + This function is used for objects that were either created or + deleted, in which case all available attributes are serialized. + """ + attributes: dict[str, t.Any] = {} + for attr in dir(type(obj)): + acc = getattr(type(obj), attr, None) + if isinstance(acc, c.AttributeProperty): + val = getattr(obj, attr) + if val is None: + continue + attributes[attr] = _serialize_obj(val) + return { + "uuid": obj.uuid, + "display_name": _get_name(obj), + "attributes": attributes, + } + + +def _obj2diff( + old: c.GenericElement, new: c.GenericElement +) -> types.ChangedObject | None: + """Serialize the differences between the old and new object. + + This function is used for objects that were modified. Only the + attributes that were changed are serialized. + + The new (current) *name* is always serialized. If it didn't change, + it will not have the "previous" key. + """ + attributes: dict[str, types.ChangedAttribute] = {} + for attr in dir(type(old)): + if not isinstance( + getattr(type(old), attr, None), + (c.AttributeProperty, c.AttrProxyAccessor, c.LinkAccessor), + ): + continue + + try: + old_val = getattr(old, attr, None) + except TypeError as err: + if isinstance(err.args[0], str) and err.args[0].startswith( + f"Mandatory XML attribute {attr!r} not found on " + ): + logger.warning( + "Mandatory attribute %r not found on old version of %s %r", + attr, + type(old).__name__, + old.uuid, + ) + old_val = None + else: + raise + try: + new_val = getattr(new, attr, None) + except TypeError as err: + if isinstance(err.args[0], str) and err.args[0].startswith( + f"Mandatory XML attribute {attr!r} not found on " + ): + logger.warning( + "Mandatory attribute %r not found on new version of %s %r", + attr, + type(new).__name__, + new.uuid, + ) + new_val = None + else: + raise + + if isinstance(old_val, c.GenericElement) and isinstance( + new_val, c.GenericElement + ): + if old_val.uuid != new_val.uuid: + attributes[attr] = { + "previous": _serialize_obj(old_val), + "current": _serialize_obj(new_val), + } + elif isinstance(old_val, c.ElementList) and isinstance( + new_val, c.ElementList + ): + if [i.uuid for i in old_val] != [i.uuid for i in new_val]: + attributes[attr] = { + "previous": _serialize_obj(old_val), + "current": _serialize_obj(new_val), + } + elif old_val != new_val: + attributes[attr] = { + "previous": _serialize_obj(old_val), + "current": _serialize_obj(new_val), + } + + if not attributes: + return None + return { + "uuid": old.uuid, + "display_name": _get_name(new), + "attributes": attributes, + } + + +def _serialize_obj(obj: t.Any) -> t.Any: + if isinstance(obj, c.GenericElement): + return {"uuid": obj.uuid, "display_name": _get_name(obj)} + elif isinstance(obj, c.ElementList): + return [{"uuid": i.uuid, "display_name": _get_name(i)} for i in obj] + elif isinstance(obj, (enum.Enum, enum.Flag)): + return obj.name + return obj + + +def _get_name(obj: m.diagram.Diagram | c.GenericElement) -> str: + """Return the object's name. + + If the object doesn't own a name, its type is returned instead. + """ + try: + name = obj.name + except AttributeError: + name = "" + return name or f"[{type(obj).__name__}]" diff --git a/capella_diff_tools/report.html.jinja b/capella_diff_tools/report.html.jinja new file mode 100644 index 0000000..b5173cc --- /dev/null +++ b/capella_diff_tools/report.html.jinja @@ -0,0 +1,193 @@ +{#- + Copyright DB Netz AG and contributors + SPDX-License-Identifier: Apache-2.0 + -#} + + + + + +

Model Change Assessment Report

+

This report provides an analysis of changes to the following model repository:

+

+ Repository: {{ data["metadata"]["model"]["path"] }}
+ Entry point: {{ data["metadata"]["model"]["entrypoint"] }} +

+

The review of changes covers the following commits:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PreviousCurrent
Author{{ data["metadata"]["old_revision"]["author"] }}{{ data["metadata"]["new_revision"]["author"] }}
Revision{{ data["metadata"]["old_revision"]["revision"] }}{{ data["metadata"]["new_revision"]["revision"] }}
Date & time of commit{{ data["metadata"]["old_revision"]["date"] }}{{ data["metadata"]["new_revision"]["date"] }}
Commit message{{ data["metadata"]["old_revision"]["description"] }}{{ data["metadata"]["new_revision"]["description"] }}
Commit ID (hash){{ data["metadata"]["old_revision"]["hash"] }}{{ data["metadata"]["new_revision"]["hash"] }}
+ +{% macro pretty_stats(stats) %} +( + {% if stats.created %}+{{stats["created"]}} / {% endif %} + {% if stats.deleted %}-{{stats["deleted"]}} / {% endif %} + {% if stats.modified %}Δ{{stats["modified"]}}{% endif %} +) +{% endmacro %} + + +{% macro display_basic_changes(key, objects, color) %} + {% if key in objects %} +

{{key | upper}} ({{ objects[key] | length }})

+
+
    + {% for obj in objects[key] %} +
  • {{obj["display_name"]}}
  • + {% endfor %} +
+
+ {% endif %} +{% endmacro %} + + +{% macro spell_changes_out(changes) %} +
+ {{ display_basic_changes("created", changes, "#009900") }} + {% if "modified" in changes %} +

MODIFIED ({{ changes["modified"] | length }})

+
+ {% for obj in changes["modified"] %} +

{{ obj["display_name"] }}

+
+
    + {% for change in obj["attributes"] %} +
  • {{ change }}: + {% if "diff" in obj["attributes"][change] %} + {{ obj["attributes"][change]["diff"] }} + {% else %} + {{ obj["attributes"][change]["previous"] }} -> {{ obj["attributes"][change]["current"] }} + {% endif %} +
  • + {% endfor %} +
+ {{ display_basic_changes("introduced", obj, "#009900") }} + {{ display_basic_changes("removed", obj, "red") }} +
+ {% endfor %} +
+ {% endif %} + {{ display_basic_changes("deleted", changes, "red") }} +
+{% endmacro %} + +

Object Changes {{ pretty_stats(data["objects"].stats) }}

+
+ {% set LAYER = {"oa": "Operational Analysis", "sa": "System Analysis", "la": "Logical Architecture", "pa": "Physical Architecture"}%} + {% for layer in ["oa", "sa", "la", "pa"] %} + {% set layer_data = data["objects"][layer] %} + {% if layer_data and layer_data.stats %} +

{{LAYER[layer]}} {{ pretty_stats(layer_data.stats) }}

+ + +
+ {% for obj_type in data["objects"][layer] if obj_type != "stats" %} + {% set obj_type_items = data["objects"][layer][obj_type] %} + {% if obj_type_items.stats %} +

{{obj_type}} {{pretty_stats(obj_type_items.stats)}}

+ {{ spell_changes_out(obj_type_items) }} + {% endif %} + {% endfor %} +
+ {% endif %} + {% endfor %} +
+ +

Diagram changes

+
+ + {% for layer in ["oa", "sa", "la", "pa"] %} + {% set layer_data = data["diagrams"][layer] %} + {% if layer_data and layer_data.stats %} +

{{LAYER[layer]}} {{ pretty_stats(layer_data.stats) }}

+
+ {% for diag_type, diags in data.diagrams[layer].items() %} + {% if diags.stats %} +

{{diag_type}} {{pretty_stats(diags.stats)}}

+ {{ spell_changes_out(diags) }} + {% endif %} + {% endfor %} +
+ {% endif %} + {% endfor %} +
+ + diff --git a/capella_diff_tools/report.py b/capella_diff_tools/report.py new file mode 100644 index 0000000..e3af53a --- /dev/null +++ b/capella_diff_tools/report.py @@ -0,0 +1,170 @@ +# Copyright DB Netz AG and contributors +# SPDX-License-Identifier: Apache-2.0 +from __future__ import annotations + +__all__ = [ + "generate_html", +] + +import copy +import typing as t + +import click +import diff_match_patch +import jinja2 +import markupsafe +import yaml + +from . import types + +ENV = jinja2.Environment( + trim_blocks=True, + lstrip_blocks=True, + loader=jinja2.PackageLoader(__name__.rsplit(".", 1)[0], "."), +) + + +class _CustomLoader(yaml.SafeLoader): + def construct_html(self, node): + data = self.construct_scalar(node) + return markupsafe.Markup(data) + + +_CustomLoader.add_constructor("!html", _CustomLoader.construct_html) + + +def _diff_text(previous, current): + dmp = diff_match_patch.diff_match_patch() + diff = dmp.diff_main("\n".join(previous), "\n".join(current)) + dmp.diff_cleanupSemantic(diff) + return dmp.diff_prettyHtml(diff) + + +def _diff_objects(previous, current): + return ( + f"{previous['display_name']}" + f" → {current['display_name']}" + ) + + +def _diff_lists(previous, current): + out = [] + previous = {item["uuid"]: item for item in previous} + for item in current: + if item["uuid"] not in previous: + out.append(f"
  • {item}
  • ") + elif item["uuid"] in previous: + if item["display_name"] != previous[item["uuid"]]["display_name"]: + out.append( + f"
  • {_diff_objects(previous[item['uuid']], item)}
  • " + ) + else: + out.append(f"
  • {item['display_name']}
  • ") + current = {item["uuid"]: item for item in current} + for item in previous: + if item not in current: + out.append(f"
  • {previous[item]['display_name']}
  • ") + return "
      " + "".join(out) + "
    " + + +def _traverse_and_diff(data): + """Traverse the data and perform diff on text fields. + + This function recursively traverses the data and performs an HTML + diff on every "name" and "description" field that has child keys + "previous" and "current". The result is stored in a new child key + "diff". + """ + updates = {} + for key, value in data.items(): + if ( + isinstance(value, dict) + and "previous" in value + and "current" in value + ): + prev_type = type(value["previous"]) + curr_type = type(value["current"]) + if prev_type == curr_type == str: + diff = _diff_text( + value["previous"].splitlines(), + value["current"].splitlines(), + ) + updates[key] = {"diff": diff} + elif prev_type == curr_type == dict: + diff = _diff_objects(value["previous"], value["current"]) + updates[key] = {"diff": diff} + elif prev_type == curr_type == list: + diff = _diff_lists(value["previous"], value["current"]) + updates[key] = {"diff": diff} + + elif isinstance(value, list): + for item in value: + _traverse_and_diff(item) + elif isinstance(value, dict): + _traverse_and_diff(value) + for key, value in updates.items(): + data[key].update(value) + return data + + +def _compute_diff_stats(data): + """Compute the diff stats for the data. + + This function collects the diff stats for the data, i.e. how many + items each were created, modified or deleted. The results are + aggregated for each category and subcategory. + """ + stats = {} + if "created" in data: + stats["created"] = len(data["created"]) + if "modified" in data: + stats["modified"] = len(data["modified"]) + if "deleted" in data: + stats["deleted"] = len(data["deleted"]) + if not stats: + for value in data.values(): + if isinstance(value, dict): + child_stats = _compute_diff_stats(value) + if "created" in child_stats: + stats["created"] = ( + stats.get("created", 0) + child_stats["created"] + ) + if "modified" in child_stats: + stats["modified"] = ( + stats.get("modified", 0) + child_stats["modified"] + ) + if "deleted" in child_stats: + stats["deleted"] = ( + stats.get("deleted", 0) + child_stats["deleted"] + ) + data["stats"] = stats + return stats + + +def generate_html(data: types.ChangeSummaryDocument) -> markupsafe.Markup: + data = copy.deepcopy(data) + _compute_diff_stats(data) + template_data = _traverse_and_diff(data) + + template = ENV.get_template("report.html.jinja") + html = template.render(data=template_data) + return markupsafe.Markup(html) + + +@click.command() +@click.argument("filename", type=click.File("r")) +@click.option( + "-r", + "--report", + "report_file", + type=click.File("w"), + help="File to write the HTML report to.", +) +def main(filename: t.IO[str], report_file: t.IO[str]) -> None: + data = yaml.load(filename, Loader=_CustomLoader) + html = generate_html(data) + report_file.write(html) + + +if __name__ == "__main__": + main() diff --git a/capella_diff_tools/types.py b/capella_diff_tools/types.py new file mode 100644 index 0000000..4a1710d --- /dev/null +++ b/capella_diff_tools/types.py @@ -0,0 +1,140 @@ +# Copyright DB Netz AG and contributors +# SPDX-License-Identifier: Apache-2.0 +"""Types for annotating functions in the diff tool.""" +from __future__ import annotations + +import datetime +import typing as t + +import typing_extensions as te + + +class ChangeSummaryDocument(te.TypedDict): + metadata: Metadata + diagrams: DiagramChanges + objects: ObjectChanges + + +class Metadata(te.TypedDict): + model: dict[str, t.Any] + """The 'modelinfo' used to load the models, sans the revision key.""" + new_revision: RevisionInfo + old_revision: RevisionInfo + + +class RevisionInfo(te.TypedDict, total=False): + hash: te.Required[str] + """The revision hash.""" + revision: str + """The original revision passed to the diff tool.""" + author: str + """The author of the revision, in "Name " format.""" + date: datetime.datetime + """The time and date of the revision.""" + description: str + """The description of the revision, i.e. the commit message.""" + + +class DiagramChanges(te.TypedDict, total=False): + oa: DiagramLayer + """Changes on diagrams from the OperationalAnalysis layer.""" + sa: DiagramLayer + """Changes on diagrams from the SystemAnalysis layer.""" + la: DiagramLayer + """Changes on diagrams from the LogicalAnalysis layer.""" + pa: DiagramLayer + """Changes on diagrams from the PhysicalAnalysis layer.""" + epbs: DiagramLayer + """Changes on diagrams from the EPBS layer.""" + + +DiagramLayer: te.TypeAlias = "dict[str, DiagramChange]" + + +class DiagramChange(te.TypedDict, total=False): + created: list[FullDiagram] + """Diagrams that were created.""" + deleted: list[FullDiagram] + """Diagrams that were deleted.""" + modified: list[ChangedDiagram] + """Diagrams that were changed.""" + + +class BaseObject(te.TypedDict): + uuid: str + display_name: str + """Name for displaying in the frontend. + + This is usually the ``name`` attribute of the "current" version of + the object. + """ + + +class FullDiagram(BaseObject, te.TypedDict): + """A diagram that was created or deleted.""" + + +class ChangedDiagram(BaseObject, te.TypedDict): + layout_changes: t.Literal[True] + """Whether the layout of the diagram changed. + + This will always be true if there were any semantic changes to the + diagram. + """ + # FIXME layout_changes cannot be False + # If there are semantic changes, the layout will change, too. + # If there are no layout changes, there cannot be any semantic + # changes. + # Therefore, if there are no layout changes, there are no + # changes at all, and the diagram will not be listed as + # changed. + introduced: te.NotRequired[list[BaseObject]] + """Objects that were introduced to the diagram.""" + removed: te.NotRequired[list[BaseObject]] + """Objects that were removed from the diagram.""" + changed: te.NotRequired[list[BaseObject]] + """Objects that were changed on the diagram. + + This does not consider layout changes. See :attr:`layout_changes`. + """ + + +class ObjectChanges(te.TypedDict, total=False): + oa: ObjectLayer + """Changes to objects from the OperationalAnalysis layer.""" + sa: ObjectLayer + """Changes to objects from the SystemAnalysis layer.""" + la: ObjectLayer + """Changes to objects from the LogicalAnalysis layer.""" + pa: ObjectLayer + """Changes to objects from the PhysicalAnalysis layer.""" + epbs: ObjectLayer + """Changes to objects from the EPBS layer.""" + + +ObjectLayer: te.TypeAlias = "dict[str, ObjectChange]" + + +class ObjectChange(te.TypedDict, total=False): + created: list[FullObject] + """Contains objects that were created.""" + deleted: list[FullObject] + """Contains objects that were deleted.""" + modified: list[ChangedObject] + + +class FullObject(BaseObject, te.TypedDict): + attributes: dict[str, t.Any] + """All attributes that the object has (or had).""" + + +class ChangedObject(BaseObject, te.TypedDict): + attributes: dict[str, ChangedAttribute] + """The attributes that were changed.""" + + +class ChangedAttribute(te.TypedDict): + previous: t.Any + """The old value of the attribute.""" + current: t.Any + """The new value of the attribute.""" diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..fdfe666 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,23 @@ +# Copyright DB Netz AG and contributors +# SPDX-License-Identifier: CC0-1.0 + +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..ab614db --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,37 @@ +@ECHO OFF +REM Copyright DB Netz AG and contributors +REM SPDX-License-Identifier: CC0-1.0 + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/_static/github-logo.svg b/docs/source/_static/github-logo.svg new file mode 100644 index 0000000..a407b96 --- /dev/null +++ b/docs/source/_static/github-logo.svg @@ -0,0 +1,9 @@ + + + + + diff --git a/docs/source/_static/screenshot.png b/docs/source/_static/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..2593d503ec37ca90963bbbed027a4c6374d9477e GIT binary patch literal 64154 zcmbTd1Dqu>w=cTe?w;1PZQJgiwr$(CJ=4>+ZQHhOPTRI$&xd>N@7#0ldGDq6+F41Z zlB}evvetj4e#yy*!a-v~0{{Rxaj_o?006Mb7s){af8~@}^H~D`_+iFs;wIwalmN)D zJOBXxi=Y4>08jwX7x$L{K>y_bCV>9MKYV2a1AzcQzGA;G0{Pi zAmD%26Y|9a|BDC3|4MuX$bTH4%b$M$-y}rDMZOyP+xXYt=Ndo=00|Bb0S*QU0RaI8 z1qls{0tX8N1B-!#jDUiJiHD1WiH(gTICMq=Yy!sr&*8HhfCveA0QwS^5CDt_1cC_k*$=?`>L(b`Kf*sn z`70<0C>S^dBos8vSAm9ae@6-g7!(8y3>5V1toPS>04O3D5&?q%II_Gx1fe|&qkl{e zB#~fE530h<6)}^6LjV-?cQkYiOcGKuatcaj7FITP4o;yT!Xlz#;u4BV$||aA>KcYd z#wMm_<`#}l&MvNQ?jC_b!6BhPe}%=y#U~^tC8wn3=H(X@78RG2*4EWGG&VK2wD$J( z4-5_skBrXF%`Yr2Ew8L@@9ggF9~>SXpIqPE-rYYuK0UwumFusZ{|^6@>_5qc_$3!G zC@2Uh#9z69fL;FzjtB}yzyOXUAP=E$k4(tu4~ZfelT*_JMZ~0Vg=*k11O1(td7I?= zuW0{}?Ejr$0sp@w`(J|nhg_=wSP-DE$pb+I@BtoQ$aDRm{}LtDFH9_TW99YX;DDEC zPSs@JLWI!WLB2KBA?0OSm*>L~GQN}c&gAn`e%zww@05_kCrlzD=PekgVYh7)k}CDr zY(MGhZ!O?D(`@aDT?u~TLy(9-_OPG6D8;!|Yn-G;mOY?PCFBaZaOj<>r|om^;)R%j zOq*5lHC|6vzy-fA<`hU71dN@_Pdso~PdD=4+`P+j44)yOd;(Cmo-)UJNe?8~$PW{D z09BS)CCVn)Mm1AQ;o;K|o&xx=asFx^=N~!wZk#XAsT6b-=P8drf+(z<0da2=M(Sqf z=G!eSOl1~mNyz4Bv&>B-c@AS4Jst>F9<$#3Xa1nTkob=%4c$+gnhRFjSNjQ{fEBoe zi;M%2QFL+Ke#KiPI|q0VSkJloODydTw~)oTN4D>5HPXb$B<0n+CJZ5tp%6SG6dh-f zm*iD^v+c$e>!iW%?qE^N_gtZk6iC(1`pw`Y>_cO7coE_dzn#Z|>bum+!Gae{9!SE= z+>3JsgMUpzvEQLT=61WrtAg0>daK-OI5o_XCXUdh&LmJ57yzsb-ezuK5x;8CooSaW z(RM5Jrc)B5PG-&&uH)a{7d|>Ab!ozmhCl&Yno(=KB$@hs8Q5}jc5a{+HaZu11IVlUd)IJ+4-T;v&QTef%=UGEd%hPjx)>#p7gRhOSz&7!PX;$-=BL9%RP zxE%H*h{YYvd3~x#U17TmVpA@qs$yc$kucMHcn!~YnW`8{Dcf~~#%h)uHm)y>A_w$e zks3&k2V~nTe;wZ`_ZHQJFO`pD+t}p#Vfs$fQ8ui!Xs-rqnKRAAMqPa!Wqz=U^JdwM z7^sn_Q~4RM<_Sz{jbEa47wr32Q53k8Watw zADB=5VBe24@nr~dY|UdU;dhCys5@myX!dxdAB|Fu&20?R3l7Ds>`B%A#R%zQ=}V-ydj z+D75GM19EQ;tXPYnu^L#rdRrrtu$X!g|`z`Z22xsN76>g0jZ7H=1b}j|LRkoFS$^o`x%Z zdm05PIDS<9t|-iM#_J)_BEF1*v@*$Xi{NX~z!!W@>s)m%ewZmP$ZeKI4qZtv!awpR zUfS2=qH9zeTzR2g9z6tF3%QcW2v`YdCfdiTyX1#!xt>h2G1A8wuMq}yMUupJSBkg| z;Y*TOoW1|qAWmzbMiyXc0z>Rb3pqT@7gPQk1+l#T`|4H;p>a*vnsg%%I;98O&c{&0 z&fH#eQ&*cf);%fB^@hG#3)Y?=gVXfaUf0qE_2vrFl-Dmyb_1zGo}3#l2N~XwtU|Zm zA*Iw+_CGCA=W^T+=Y)C^Wf9AVjiMUHMFGs77}Q0L5qWKm-Us{k2U21LP3ey5>32C^ zs6{jlLExUTi7K(HWaEq@q>M5Uy77kS+0Y(E;q&HbvF4|>H;bCiPDz%2wGw4n@iEhx zZz@@DBrbq>D!SCVG1!>kF@gwJ2BJCiw*e(w@VSpMZa8PBUSogtPJVlsOD#b2Y0pk< zU*Bz0ScA054uR$24}3pSe3wc3*}HCHF!aNVTYeYqS>mWO#H^BSalkrXkjdrTPzzW&;WXPf!!S75}`k9=xThcw3xCD1uMQWuAC+E z-Es_p$EhuaPd;1H^Oep(+;du5_@bt(?4U8d{&7=^69CPccFW&vt!WmbEu*`&AmLeK zERvE7HVrTo`DTb@-HDKERLN`cG?@ZJx*jP`h2Dg_(uoE&!`jL_eDoq5Lqr&dILC`G znnJ!0O`XjN?Rnn^A<-}(xXJenqe?2a&z|vEKLJYzG&aK4CF?Ai3EX37FDgjdetzXj zF_9idhSwDqlON-&tzM9yfaZGZA^YcKKfzA`_Xld8x^xlgVxNF8ijtqaFbd$mB1O$h zvZWVHXO_+zZ{M732zg<8-%8s@(fe$O88yzxiehz3dqJXLs|MU{S&DiPf%R+}fiVOLYLoVdjc z*nuAc8(}f#&EQAkNviVHaw)e%V$zx2aWZM^I;u&>F$P za0RIMUi?FeDhw=Zn^v{7W`qw-F zoc}faMRO1@2cG~ZUkyMRYl0!mpAJhkpXIU!T2|oGJF-*d3NN-o&ExBp5vwG~Smu@# z0qQex6P^~*hSKJ)I=X7@Zq0E9!*PsFaEbfV$PM%S&29nGUj?HkjOGvHrh2mn47V}U zS@sIFNqRZJtAe0b<$n0j5|s-?N9=_ehLVWxMGAF@mp6|oOIX(#EmOJ{stfhY2RFKM z_3Exx%X8#hHR*;tb^>n}9qF(1EyS!UjpwcCL?Vzxqv2lJu@G~_AI_kdxJedjYsU{W zHk2~M-6M)I&q!vNo3TpO3)GE4w@|NwdbIX!RZ`cf^-U_5ND_7ZGb4}K>;#>m9q`DS z*)3{EtA^qzA-(JEv^uN|;bq9NqPr7NxyL^vf9u;IK3?cj>s<_m+lrK z*iI|WD*%^!Sf?vO88!*qNdiL%dSoqTCfbQoI}m~~$z^seYAF4_6$uQBpSqM0-5yK_M2*OnnwbR9Ba%~aH{UPG-G$Y z5he*C(H1~vk*7uEtn3qV`ZxJ*_N6!4ckh&IxEZZ(3e&Bz2?L)8vwrKg`hk_^I`POf zZOSq&ufc6>+V+OXUA(W_n^cA!#vDTjBW3`yDf1K~-;ltYb z9Kfq$48It(dI<6ldfsKjE3LT|2v3VI@-fN{D_Wvr+q`y1WgjoC7R_6;9MciSE}B3u z{>Ju0h1NZo6abCP14Yy(K>->^ZpxSauqM0(Z5R7-~%FxxP(mcSXXnapAsblh?~W72!P1tVglF@ z6E2vRSY~GUvHQPoQYzOY>*VPLazLk$SzNIXu3tXxsWgmp21Ovm*j^<1%9uhW&rQ;SwEy@nJ9y6ouLv6|oPBRfuY!lcdh z2X5*ojn5l6vMf6Z*Yod=hj4gX3Wu!Aex!Zp=Le}iEcA3fJ=YdS#hn^Iec7w9QGUc; zT)Pr8BzVp<3}Pp?nxyARTT69$ef(LLBEaYRXd0M)J5*HQH10skT(sBYpr)XC!y_8I5zjffrn=1wj> z#M6w#q0%N&tJino6$EU(Kj3{~I= z_{vNP3dYg?-iwJ@3KN>aY z$D^jLS(~W7L&*?}t!G+5q`gLYVfi!Q&_*)cbhf=xlBK=wI=C~TKx?%;%kbu0fv}Qe zq?c}EH{GRT!&B-zne?1hJ!q1&b4 zhHt3pZ}H~H8yfSVSa+HOlCG)nbR)8j(*dCAdv}sZlr8G8-zP)BwR_ia`8xQ&#cexi zN?MRFuvkOZ!e18oB4~(HA4OZzWYQGoX!aeQEn#)cttwH&e7`|7m`vqpT6EIR<;Mv* z&moJ&s9;oF#Wa4PgzK?#3;_N>cZ$*kXFp>dgmOl{%-1Fr6@Do9Z7TY@)*Oa6B zIKi`AbEUb_)5VNyZufpQeG9boo)*2vUEod1*48EqJipgwH79D6Y-o-&`=zu8hOmZy zoC(jKjUdkU?PpM-S-)vsBKq6W>klxDx1$G&KGtpj`zq>>f%ZVjx!GN@)|T0GmfvwS zVZCa}zv;t+_}oSJ15-q{o2? zC{h_Pj$T9fI6XMU{AUv4?ga1F$9A1Eo6E+AGFy* zdslrR{>E!u7S^}le|wlAU6Aplvn{d!to(1c_P=lO{}c<_DS4#J0WVmdqB~=$G&SFH z*9wd~Fwzg@QGhC^k^_gNjPU zN%W(z(9muygBEr$0m5=fHgQYiQ^ub?Zq?3=F{824M-*MUnUXpp5H@=*5k1OM5zxv18WYHwb(j6o<>k}ErTl>WhW)c6fAi1G3ppn97 zM5~p3()B|u5hi|I*Alni{T8e7)4F|-HPwx+H?gQO#4a1nHmCKwO+56%&X}khFIyIi z9ua?9zgHHJFi?Ig_y5_w8<@JmAmW%jy{C6QuW+VSv&y*P_65c{XP>wR_Lr1u3A;76 zw$Bbm5rKRIAds0&VL*F2Xe5bWvo=55*WA5>uamYa$^QqBFKu)zR zH7pZnwuE8PYPlaLcjO)YY7$5!!e9O%4rtEbO(-hO)r&3XPR4?_nj0dKzpsHLtTT<^ z9PvAC`%gl$-xXBS-pVfNHD{e!a(3TJEH^o;(A0ohIlghg#oQHF(zNR&G36^rTt8=O zu*dkE`PumR9;Qt4beMrOQv>zjBVY34AA(}r*=wnWGkYt10u-7&(GOyf7NQvwhEh7j z*2O{X5U3`2Zik2icT3$7B43wl=*6P@{$Hi`7Sit!Znc7cuH0{6^7 za~A+$v7TcAW6*#;qRpBHH>1{9Zu=gpK1>Yxydfl@pc7FU*?oDPHIA^4<8yVX2Ou@N zO-8SESl2y+2B+CjmL9A-MM8K20fMn}tf(#zZ=?SbbNf$t8;%s-Y1;eDyZK$uM@!cR z-l88izn%afa2^u?oudSR0Km^TjAo&=o-nGXQP*m7t0OG3z=a;q*T#e`CO|E85ia9~ zDhoS&0SOE zdq2#N!KiRmoh;$ZR0ta%W7eH8@Xrd?)K^2|N^#93=kel+D+WlD{WnVdJd!zgm`3q4b5WA#qOX=}2+8Du)WFZ%5U8X^Fz zKcxcAXijTGRNol8qjeS`$qFt*#VCuk{9_w>{W}vg0P+{ff_kzKkmgNZ$|H!-8LQjS zir(NCJXD6mxs(6O%^vP90V?VMV5r~4_A#l|9KbZYeW;JD9PB3er!ShI{i7*H7%`S_QO>XBAIZa~5nDoAQ^YN(-02@(E!WF0 zK$iV3%*taa6H6R-9z*T|2~rJVf5h84pJr=EH=}GaUtY1YXjv>b6tSHS*K(Yi?Im9T zb6Ec>IvK8cBlyFFr7~JRjBx-32eI!AhPVgJyezd4Spy89&iDC5#n*UoH=KS$=>Q4t^2Fv=M9- znUJ%D&(%yWkLTm)RKrOQoXSmA(k%C9eh!h6vOK{c3V`VuTJ4RYps!vOxmcZh8sxe; z9QiLQL1hvKm}r@yzQ|OGo5Zz*ATYuQ2f+%>8A2JnI!L{iL>AoC$_~*!%u$xz`>pYX z;#*e+8=AV&Zi^sWL5o`*V)PI##c3%0U!bFZ!%F{z9KHtd-{8>~yW=HXx@n$Cv4u0f zDOEjWMgZ?ZHPZfzVGe(2X?h-0da>GRUej}m@q~)lC3Qb#%%3Ube$T&W07c;l$G3{W zS@)X-h_rd8oOKm7?Bce_qC)ofbfljnBbb4FNI zw_Tfeo0eOkTvOwuj;CMtdzxK<3Zma7mSB`0r_uMfGF3TuIxlU@q7i3jxT`Uv&|QlEH9=Jmp1foibneWu<+)y z7HBbA7S^bJWqDeR3E7GCNM3?4fJ5 zKN5-J`}|PzpAhVHr^%i67m!oAHQT00oZ8U=X9}XR76YZkVd76fCK!sUwCwhK&(_O@ z(-(B*0Q>Lf>~rOe$Q9$u-3)FZDf$EO`S-}~D2n^afwB`*cl_F)(Epc!5~$#@zA_=4 z#HEEH4q&ifpR3M9PK`=*6pw*8`-3zegeOE16j$yO4PVA3#2__3 zq}*)~o7C7OO@toRyCfMmb7wX?Ck@b*}T&LAZ@l{p61rrHaO!}+Q#Ls4rEV9(7%Km3P3A%QhsdJR}z9-S2i zlCq|M9vGq{??x|rpPkg^D_e?4GyUxy2`bxb3?c73x2o)XZ+0izqqX_6E^2&RF;ZDU zNp9x|Mi96f977*FjBu4ry`+@mp(!P9nRG;Wn@`@8I^UaZeUmS33YP~}G8b|z$p*%v zMhluql`GnkYkAd`=}DS=&>IM<1AtW8)J46&JKI>xgH~PQu0JHnsx@ZIBqXB2jQ-xr z9}Iqa!Co!3STvoT%`$mh?6(^(5>9vXcugTj6tZZY_$)0l81t0I#cl(dwZ|g@T+UVN zu`q8ql3!^;C^iWS%*mq)c5J<^L?SeD)B1YsO;J^P4r1wN39RCtDNneqjTwAubKkdh zy~l3Ojx|LgAB1G-@ST>?0JT_SV-Py?u%2(RyPie+S6=AAG@a@07Cww<(G{`}(}hp9 zA1WWc{W;$nS95$D(e!v;>>$_C5QNf%@;ol`0fY8>%y1(xf|Mx|uhVAHoO#xH?e1^@ zz~41O1o$T-f1|N+umW!z>>b|9@dJk=L!WCIS?cdLoK#c%PQSk`3eQJvTD|cteq5Hm zU54XzH}+j$Sm}0?KP1b`{d{L*+gKWBld(>y)ESf4b>e`p@xArNlFe~n7Ol((8uv+5 zsWXekV3g>QTF3c9S$M7zr0m^pD$@Z@nc&yP<8Hqi5^Xvxg{hyMOD1!x5LGTTN;)q* zF!*wY-mQTg34bhLNC=ZF?*?`J0%okzAF|txc)bH^Bh_BlX6agoXAdeH=1o>-s`uF^ z|GcN$3i;Aw^jP-;>Tu)>0!+`@Jrd8;KIcj?qlyvSOq(~2^7fwGaF^GjDGevTU$6RX zPS;tGEJ38vtNHlt=;U@G^EZ z4bkZupo1c)DvMASB1O`(?_igCON1ZR;=!cc3n*E!o%dU8PTnDB#{hx#yEi*x0tPSX z89-w9h4nc_poB|Tc!7P>1qcaF1=|@zBqQ0$ET`ub^hC=?dgcXCg34BHNIaxkj`SV~ z$uaU}N)Etqd+oGH9YrfOXjUIiscTzoa7$YUK4_~3&RC>4vNh)HO zVzM5FBV^Zt=ifi=W*YK(>(wzqfl?dQ7|g)l5RuL}dzjQB&v5-%lh^DkBU0B|ns{u4 zlXrV8?M?r=rfLJh>z*3@QP?c_W4P=XJ;k~4l*uUG)E8z!M2k`0XZ<~`>5?zfJtZ|` zsw^HLDz80MEL3y>Pg@zNZ7q!=4*0eoU4$mD@j)&e=?v%k=x(`uxM zJ9gY|}as z!%>Yc&b2Vt_#{it>3x4t#Z_4_`hM>qVeL^U7r73`T~I-MYVR^cI!IjO zJ}Z~+`^P){Lu&5~Nwx^!rvRb(WCf&i5hE=yFk+<4Jf1gMT%T;@wlo#drgOOHGSYAc&>({THWvES7{_I>+hS-G5 zg=>Gdr27dsS0bm)3IMV2^s$sweKVSjB=n_N<% zP*k94e5auDjfLyM)q0%WA$||$@}t#jw_$#j-~KplkNk75xGjm!{o`(^G>0_36;1=! z+v=QdNRW|o(*xGWTt~xPXSGh0O%}&F^Kp%i$w~>!t`NOv?x!hghGZknhSzI@R6@cY zanK?0k0N0I$p-(iM!Hh zlP^`{?dnNKuXxEd!(>@>wIHUST%+T0&U6?lU7#VFAMl^m-h5gBK&Z%XlEY0Acr&Tn zQU)YW2o!b|D$vNX+TE2%2`8h?@iG*$E`oL=dBbHPz0Xq)F9UE!N+u`=eWknJox7qJc!0H}(;&t%~ zZiN<&Q1$FofmDG_|m6a@+Ove^7-dc4hmYBAJsOx@wlunwn z*o&28K4!64>>b#eNaJh|#Z4;iw7A%)ShG18Nharf9JEcd$Q_w=gi35L4QWMcvM5Z5 zKHX-RPX-mN*aaXUL~T#v809AB*9vDL;2hiYgmmWX#!j$q)TwEq0uRxo68Gn0QeVx&t+$kY zc9gC4Q<|Qo#yt+hq~+VuHV96+6RH|0Aeu995W&j9LLvHzF#;Gc+pIXGvS&VLH-jaC zfgi8;@?_zd2T&XWf#H7wmEp1VOf#7-i35k~7DLh{7N3qJu!aUV!D9HXu3kIVvy8); z!e+54?hWIJdZTW?z!6SNMU({YSW7@AUbXd@rW(~Wa{#P-^R{!K%yn@nR%(Tv;P|?e z2j*fkIze9e^n5IYVBz#r$yKBEGiv0!GO~MJ7e%ej=pB#)Oi1 zx;+WaSG|#kzDgsf`0eV-Xf($IGh(vcdpjna7)fFgd9gf;ZCD|KWV;|JeWPZ7IPNV3 zsWwG$OUM+tOq2z5$?EOFn~&X68P)4yUXpL}gQxi?I6x%UWnb#5+$K;73P4FkljlO- zKa1D)ye#2q6QTNYpk94hVfQ=}DJ>(dfH6`c2<{hss<#q0(5=LX4Fe3$Z-^egSoZE& zHKNx5q4RTl&tB~wqW&Bl{|r76XE~ZK*S%!q*@;f*4(8h(BOkC6MwJvPl89Y>gEA(b zaUF95vr(kps07vnj@Z4x7^_rc%*FS9h0C4$+o|1fOKBu+Ttp-)3mJxinx=OPS4cy3 zH=2jqVree|rE^o?jUtnW1PLg(ibZPq@eI!ur3$^qw0if`rOClCVgR6|*mT46EFjDE z{ZFAxz5dr@_1pN(1&(Lqkuk9xK91EUSx)&^e(duwUKfrURUm(osqyt@BH^sv(~UEX z$YbJ+BtY(n-9>3?6$nu|JwbWKPAV$;lELJsx_{!NmCWJLCFvZpt(@KwO)ZGg27BI;^gz^oQ$PMS)t%F9ZW`}0qig}QiJ{r zi@I&!jxrcf?nt1?dvw~K3zfC(@D$XkTKj{+S9yK1eCHNCI!2QXl*o)$?qb0MsOZ|- zu!vB?Fw+T@?ChLN<0^8N4d|;btjdV( z80rpTR=lG=X_2ap_s`Ti_{}--Dv_P9srbuj;FQ^XFT;3tCf8-xN968gKKEKt<-?KZ zAfj4Wc|0ENJJi>!jm5cM!-_h{?hQo@pe91^J*}CClgy0K3@W!`H{LW+ky;q}9SGng zFMudPI@`zqTOI(uJvxHvBtneDrth;=TNjkB6&S{k*~sQ?8O&RzO-2Pq*y1sV#Q7=B z3wNH%s2DrGq#hqWy}9*p|2~M)SX-_I+YJzw1}KmaLdSO+#0{PFk;Lk!@AhM-(LEW zx5sE6uKwqGFSRt=O&my+ZIiENt#+$ee2x_Ev~n(8d@of1f}je2Yj#^&7?3Ur2B1%a z{L*qF)!qE(z}@iuPB)|ZFr8NIe$f!n(aC-(A5fG70IhmsNRJe}91Ep|1>;66y?!wF zf`~Mg(qewrnFP5COOaD8E%p73D%YC%dEALZFJ*y`~HOGfb+NoX%9ltQ=+4Quu`$ zrAdJ9*_k_<7K#huJ5a08at@cu z5C;9>_%LsX-s+s&H?5Rm3Zn`Q+015- zXLl69!%sU8g@YN71@i+Gm>P-5_7W0|31iM=U}W(=SIaR#2n1!s{e|b39SiG=gFdc^ z$P-%{NRoz2RA?wUh?z|j)h+orW^#EQ2i+v{(loS%7M81UyHEq0G>q+i}YX#~kE6>$=F2mAWoMpYh>20K9KU`Q}0^mj%0KQ7mAJ=;gv+k7w)eS7J^a|Pk_m6rs9VbNxEQXcsP5o) z9`ugxp3jpMlwgY~on@qyhQxYs9m8AHOrn(h*_=~Ki)IEQUpbC$UJ?Kw@fYuK(l00$ zZ5*%^nfRFKw}&uIPClxlk0*i>TMMmIQik6Cq>>5>V%4Uh;oz>U_LJMgb{`Q&8#L{k zJ^A)#`<|s4c~T(1Nnge308K@^6L!ci?a4UNu+!5SF8KtA*l&*n+jrf?nRdii@dpT? zq@pYW>ww_P%7YQi%Q^(4(6KP?>w7NZhLxD^|0ExKe4J1fTD^9P*vduFV#4qk4%@vx8658 zFfsat4#;i2f((d#wwJrOy_dpDX~xvX^>o|Le@^SL4IL~p+V32Zk^Sjocb%ooR8-ZL zK~Wd$XS~PZT5oTdFm^@sP_eR~q(%q?m`SV72VH2EHh z*njq^KXyLf^dO!`Rcqk&xlwQTTeqS|+2x3eNC!5-#bqJv-Je3?mqCAfxhYUl6U|NP zlcrro39<6L(PE*(v;U!Ok)$_iYSts=iSnE-Fsbu~X(V%L28&VuF`G~7$3m;nlF*KB z7X>xt=$KE&Hyp3mb$=SFE({*G{+G4_Ya%0O!F2wDLEf?oX#J3_2kRw=5`^2ZtC9kti{F$T*cKq4!HFXQkVIrc z2OrTC&JcE2fdK6-uuWlWg@a%tRry%okJUAbca*-?S|PpifI*s&lBL#x99>(Ok`7yRb=Oqm>KRRuhExa7ipW#k>6Y$CZp<&>D;0U6w$*6?%$}9qpoEp-poLUJWg^E zU45D}iL~>(Ar&esv%Eg-T-PQCPqZ}TbYhcYV&hZ$keRxjk0~6HrJE>lLxmNw1?Vf|&!$HEXl@hR2lsl;-F2Fb(32r&hIb1%R=Z;ZM zg?qlS^U6N;Fj9@c6AdooZfV?V+q52y@HvX6)6>r=qM(_Wpu%9TwI)qtx9cqqRP)G| zO&0#{eVD{^&WWptX!&xT9Na?J&Bewtg;7$37&CfnOeB!&j1pzN3h?^cD@9Oqxz>MG zq>2v~Oo7-Vt3cA=QxwtRj7;ihxG)jsO(gXLZHUKPnoEP(FnXeFH8jH*mI<8Axf)w4 z1>o7_pO4;7*B4Z^-N7PrLwV2=4TO(U(UzLy{}(-UfJ=#7t4-^2SmDzTo_HQ*&N1V# znY$2fjPM0ZEwG;PmyxE;Mb{ww)5YRO#!hOO=(|v{qK+Hnv4z?-HTGuXi&AA$#k+Z0 z+`S|(onCCl_eoYYTwoe!ycdu&@KzgA)Ua{x@dkglhc~^y`PW86R1iV`~{-yBG&)+5+X^6LsyFyI6LZ{{s z&yjIm4%QZqWP)RhGXbdI>2ehMdL6rJ%slRGK9rkv0092!YE#TJ>k~IlG%;X5e}Ju6 z8W_$d(~WRJkZ5}j0MbTvC^O8gJDSxetp+-k^;$yPN3dMv_6(zE$p-` zaHDogkLyo&nBC6ip}eK{H01@K;V-D7?$VGhd`~a>el`cqmwRRM!%}mAp-OA#J!Dv6 zr$fME>KR@=6o}IdwRlcOLAE*GPX=Bs2Q9S-?+d2xQI?uxxf~g6*&ZFmcu$;IRY(>; z>Ucbir{jpKW4ErN;-HXQ^-54pD^WC#NE1w@xsQ*N={T% zQjwG){P^QlQLU}_>v~eIj zC(tdc#(+Hj{K56i?c){unKC6raLe+^|U4RVY7zD_1}b5?@;?x|?5 zlwY_pZha`y3ESyNc4hD6 zkbh-6vkcSYBXybbNb0;ilPLm%0{ZAxaqb|aSs$~}!Ci-*&T?)ieYq?w-5NNyTWva< ztbYP8j(tMyh^DP4>6FQ68>>(7xI0wt9~zyf7<94dSib)@PrQzQ=NExPL2DQa* zdh-C9U_|daT&ENbA0xEpeHx&AqU)w_ZoB)m-A&xzAF&euQCeK)Z3AK%;a^9p<^Yvl zYny)c?!^Z$XsX3!-xLilSaW!1{!pCsrJyIB7=tu2Z z#zm@O9++TeZCH*Rvs*vADY6RO;^OdPj(l=cwGdzZsY#XyiLT3Ru?lDShaXUqu{NNV z(Odyk8Qb;cN;+jKA-WaZgFNN408#d1UwJO^oE__1lJ<3DDCuZ(hxa8&NjT&M!v+eMkC9la(YUdZH++Z00hEnbU# z@5B(sc+^CtQCeF9mUC=sImS=LUA(X)Y$sdK>0>^QQg9sV@wK$0QYs3El-l&C>geUO z)naObr4>tuswv0BRJjv|&<&SDkDBxHyV46M{!|WqV-{|BEZzgKrkBd8BIFhn-)x zKHWE{F80fFbaaBPm9a4$c&fv$k_-bK7eOT#!LNNE;uD)&Raq7v&+wt_h3`cZ=b!G- ztqw~uTTTPGA=yTRj4lF`^WGCxb2QZpADR;)%+Ka50R=P0>o-|wZB3#OP+#urn8e=M zbE7R5Uf#0opdqw42MdpLt~G?K^fQ)eDU2l3&n03-)A7q9>A*n2yd53l&dEtS@&l+K z1E^>%VHWE^mqhLJbnQ?$duHX{abVEr^(_;vq+j@u0r(R@Lk(Zim%8?kBzemJkofBG z-x3m}f3NvJ5cHnVcgEMA*kv`b%we!X|1f3$Q@X;OJPgPe0*04!jx@$(V! zYr#V?D(6FCmC@Wps34_IZ#|{FHjo~P;kAq`#?}DN~2k; z-@mpZnav)AQ@q$+;Lzd8$An7gw8m}D{@kb~@4%j31_ZE@vlf?Q{}zW4#f%m-cc0e5 zCr{C3&Q%rl)$;uSi?SK+ubc36QGD%&M^?mk<4JzpViuKJG3*kZtwsLY#sTP7;=YEV z{Sai1lWRz6d|qDQYO2D1cb0HXU_GIb8u7lo6(^KC;W73a`VPv$NEd*Iz#dt>j_QZ7 zX_}Lxli-2@ei!IClFb9CJo6tk01k^(9CSSZ=6l&Y76VF!{o1YoQ+P9y;z$Rka4J(h z&xP=M@`|TgrPXM+sK%zLsLU#0afinxb_mjhn6c|iRDRP%!2o+1qbr612k2%z;p+|q z`|4uID-U_MRPy5EPpvH&1POBm!)`UU^Mf*aFw>44q;4PB@kd}|ziQ+wDTS>V;N>p( zTsJ)q4BT~KG_XHxiyHaIKN;YmK@%Jg4Zb0yp+49D(K;XtlJNb=U2QYO_|h7E3jz(! zt73Qhe=@ssp#modSV|6l+=yAIs>>NlO?>+ykG@MV(1O-etk#f zscId!!Pisx1`nEH__hnjfzRu?{BlCx3He3F+FE-|W|!R1icBm0Bkru=yhh=l{(m^% z-1f7sPg%uE(r z%*+fHGudKhX4dxi&n&!sGqbzh(NPiAU6s{UnR(8=c}}JkOicQ>i8aY!;Ogh)Sn=eB zJ8oj87Dc=gd)?_@-`k3#{)!A9lo3prmta-#a*-AHKH9sAT(xja#y;_l7h6&HP97*q zn*fQ8k|fFB=|M-*{bl*)dC8jlb>DS>Jxt1YfT_q+(JAK|*qS zq2F+SW-YF%5QKD~UKYjH@obOI-aIm+S`z-sr+xzl`{ql~@|-PxY!~y#-qdI#gC_Di z-$tj!gMo$qGf%AA*U!62tamaKTn|ooj8106G)OLAj7nbKrv?y9|GI`*`GPchv;?t0 z97J_(ZZW*j{N^_EcOvMOtPKujqD5kecS7}cXeS9&N&eXun##YfHJyUu@_&s!P}lWc zS;1vsK*2J3Usw>UyI(<7ZM@f7(M-*0sj_-;e-hfy>Ucy%;9ZR7qq+ z>B6##$b_9se@-$+EPJt3dLC3A!3E?o8h7{Vog3Yu&~*-1`_cECF{_B`cgCk?F(k2I zOSB^0kF!wg<~fTj1$zluLv>lhw2i??Blg$YzJ<%I?13;nVUYo4-4_EnWAi&YVfU{J zu8R2(#2tUG9)@?3HqPN`%II^c_{c{Y$T_JV_hJI22!9r4NE^iIwKRwql>JJS5u?LN z{1R@C$+)fF?U#9_6oqFuUD{W28K<@6`tHG{G%!yk?J!WeI}CK!=-pR-qS{%WE2Z3@ zcs()K5ew5x$ij`!!rk({x(?cS*PKynkC*R(vFOT<-tA))iFNg9K0Ymn*K8VY{Yu{Y zwKmo!yRCJz;bF-H2{Zj`IIQ<)6IgOBO8h76+x?-Ttzbw@9Rn{e67e)naQd78$0376 z<_?;Ui2KV_?+L=Za3b*%j{Ea*xR{bCk5}cxbvYs#nCp?Ao>R)8LR&-mtX(R1m#tji zwi>X+ylp0n8lv*iARNez=oF4PSvW-LQl{2|jIppTJV^QQ;wFMJ))vSS!`d)BEW<`( zwtOy}+`5*q4a834h`?sLr%F)pa>?Ia%sB;aTwCgNoV=Z75CDh*$tXC}_u9vg6 zIUNUTCYH162PMJ-I=C3=)G2t{ik{QYk^ZqcLA+hQZ@YRY`yQNW?NJX6#$a)0CIoQs zaLdf9&22b#guWeDRKk+e-Sq`7Dwv*D2hmpH${8$#I7BUy*iI3>z&mI|BA&@(?CbpkYP(0 zVcWaM&Tr9s8?Q1Zk_6qSZj_6#^+(0+15P|8Jh_z{%*jU4;j64ENKT?xW@3S)QS0GW zsR>(0l(Tx9eDEiDg zXHAiguzPxFL#AOXiGMxl5d29>ptfp8cmv3 z07>#Vw<^7Z3ON{nYp~k6`J6I?L0fO${ZVUIASH(%`q|M(W4}mrRJ}L6+xo-DiBAik zYv~hTHJ-*<2w3tRr_^kNp~>1!Wa2S=p8nE?0%*`hHr0oIdG@D+<0wb>uCwfcHAY+5nn)IHfk$1o; zV@~A_rL>msR5C6;_W0K$K#xNy%1=Y#KQoDx67f=+;|H7k<{CvK+n(bD^>RGRzuPSg-B-{ z4XT*p-db8lbwyB*cgCnvqc@7%Mtwp;L_A)&1h0o0veCuEuuy$P+Bva*y=Zr0uq9Pc zggUQuxa_wR2yyzEo+ccos1Xoo%t+KTmhmQ7k)5dBV1WLBt2qIQ{fii-+%Nc$C|lCo zFR}Ej&MXo@QAsW=NLM@Zn*Wo(^ChX$)azZBx2V{r@fQRlXzbnc(;+Isn4{DnF4~M_|=lz^2U(t()k~ru!SqU3iVy$PNE^1Oh;XR5%XJq0#BVVeUIaUBO*g2Ob!7=X0O z6-b7B6Dc}bv$UIotkdeHzu<;X=h9eN5%#d|@S0rkTdI75G%u0}S{Ux|7Ult?fDdDJ zvOjc@9`18AcQe5<_nJaCn{9C3lZL@TGhG_v4g1Hbi56oXKR@f3sKOnAXjxe~+kF3S zQj%s)l?*v3#~@Ya-9h;?bb;DE=faY0>Q^0v*AAXMuA+=kr>t8-37`5`$?vspizqJ_ z1N+yd5A_Sg^-nFTbFPHyTvhE9?e|_~wB(16Mb(t!Pbc;UMlpCoflhj1CcLSzvYC+I zApUW(YON5}=+7E&qF-KP2ElG9F~J2`?c2eF0@rTT7LXZEjgYh6)Z?IxZ~)hSG%$BL ztXLWwPUrU0$N0EESNJG4NIP~b@T96Vj@enO<6(9Xf^Py>KTp+?^C)6~2H%%6Yj*q+ zTP;Q_k$80W(+{Gp@X>LV#C~8{x%kJW&4cpe@Uv~RwHh8@Ogk|FjXqdXfr8E z_>;)@=JBidZ3V8Xg8-~2EF*?hsuVGRd4zws_jcsc#^|S~e?5VG^_E-DODA3wKS|uZ z7>9OBxR;=v&i(;=#EerKPvcT+<3lhazzAgvZ&p*rBue{FXH4lb6oH!?nZwieT*Udk zbo2YFN^|xVirR+DxZ%o-BNK7XM*B^TFLe$WAbQ!yz-XP3uE7i~ZRIVbgI@5Lu3#(o z!Z*wCaU@h@+pXI(eL~Zc9}DtXl-}M+)$A-QJCv_7d(k-Et#X60(8M`ZoXqBGkJse} zErMb)QI*q^PQ2CE;^BPf^IuYE;INLe3nfx4rqXrHNl-yZC>w|`D9H_iUMpHzUc#@V zlE;PBHMw&(QA(R7CVK4KGrA1rGHcL0Kim!=h4)iln(Z;*1#wmpL7c~rPWINfnyo6A zqR~1R{VAPWtkKO zuAPjYFG|>i@A-Q-j@BDqcd?jc=m|O}=?T;(Ha8zE`Nfm3?CIb@2t6JB54^`wSg9%b zR7j$%#PqoaW2Ps&Q_g8f#0=!S3w(i|!_t~Qlz%{4`*AsU!5_-Jj86;$NK0d4hxwbDKoTvVQplA9gfF7eENGP=3Kmd z!br{`AraJHVUR-W9$NclWsNhN+Q1L3le5i>qtUomJyq62GAKwd;b-#Q zNA}X)juVAJ)WsY7LPY6`?{cPuty~`1LjC;5dap1%a5x*<6+8Ca+GsQ65L1*w4F}cG z;NVp~aQAaYCvmfom-}sKpDVahe9h`+zg+EGV$8S(;g4g^n%G302U!yjNm`H+LZP~h zT^j@*oJh}}v=~y%?xdxpd|zwRYG=$lmYF64iBZI5($3+K2ushv&ha*t5Z8GbPhM1I zp=OS%6CM$38pi2Z6qwLJX*E1igcA>P4w7uKdlqbDUM=!a#9{hPn^Uan# zddvFhccsgmD1P(^SKb4T)@zdmR+f}vcVo>FWSSJ*V=V`x> z*qq;iHPJ3vX{r^UX3rHwt-rTYA*CSAmEZn>S{K8s-Nxo|l)B<*1F{f=Lug9$RqhT{KU3u8IDLdM&f8?a4f`6)x=!O6aQ&DC$-`Q7GY5UqEq`Vm@$TW)fE zM@6wmL4J0;ozlN0IVR0_5_E;xo>Z9pfa~O0o zfZwjo+x2`HQ7H=h)%Ef9XY6Eq)BH)Q7}n?;yY4El_6p*+rC#7O=s$dx*IF*%{aI~k^ZzjMaGeig5jsx@4oCHQ0jR?0MpfBA;YijXLtNE1UAEA zAcU<|7YdMf+~D>$P#ihM{cIZAJ-467nIp_ndW+!uichJ0*tB`@cWt+Z7^Kq>r=i2s zS~8!xM>;<=G`#UHVJiE>a091tFEQ4`l-0suM|zI~GL4ykDu{^b%J6bYPg9;{YHT7t zCdvo{Jf4rk#@W0k84tTdeuE zJRUM4SVGVYWp1sIh@JbllKP%oF4VDiodgdHn-5(fwe``$+TQpNh(GF3dVc1UZf@_p zgY8>Wa}Mr;g0eErSt{(Iyf#9iE$ZeC1~B@g**bhU2~rKNS>FZ%ZsK#|WjR@_m;E6} z+PiC_iU>Md#`iJqoj7S8=O=K$@3M`4bD1q6E0!kpSjsVteWgNa$BSdB)M}1Z6&5(% znlM#V34QIVX{Mfe%u{A^g8}RJ>t;8Wg-U$>{l|&-mF*K7lwT0oLNk&yOf}Y=%@k*Y z(Tnwq^%p1mu$bJ`aIZ2Vy?2_8SEet@YBw z_q;P0WFqeERI=Y5sRFO3uan&MDZRP*b~0^#W6b_nvXt*!TUI0Jh9Ar zT(d*WhZiBoaKUY1-r9p1FlI`W9r)GZrtL!F2N(ub{& zaxpPQf&=;*&L6K;CBE`7ynm zO#U)Q=Uw1INDn+T?4S>R)$36n+-s8G93?%JaK|_m6%Gcx#d8s%wFipbgOe-E{%&a2 zyW>eb)n^DGzo+r-bO{_N-EX~_^uRF>OQDZw_qpC}gqaEd%MB-{GF9P899zmP`Y>O5 z?0<`&1NEy}B;v0Q&IKZ=RVIG1w$IwctRGI)Ar`bN*+V~82{f~ zBiq<&>`A*t-)4yVHrXfV71Sr!&dolljhIye3h>Q8w4HWlW%0_47GK&xMlG;e3@9HO zoh8UHb&5IUr*VZA&(p(TPqgsXy z<=W7j>2M{UioYYx4{u#3l0;B|@@V&*=NkghbJVr_x@G=b{%#l`XirwVcRy00!JUx0 z(BCbu$cGQlEHbBrS*m3&LS#~_ivNk7QZq?1%14r@ddO8$EtUBe z-OLch9GF$Sc_lZkCc6*Iq3#T{QS~Eop>9CI@ppo`+oTFWL{DZR-gzo>Izd`cfJW@R znskeT5@K#O+KNQs7kp9!Y*PKdfHWJRXr9+M_&IqtiiCR8;A)t9!g!PjCgkQ5K%$}@ zayLg3bR~v8x$zODw&>c3qVDEAnw$`>pHNO+ zm_NC^B*-vYS}e-R81r2nKxdxt=Opb47Q%T@heTRj^s61@s{mz+xi` zO=7_rUrkE;XxVKJx^Sy^OlT;(`x&z?pi?;r0ZNiZUp*6T~l<1#1b->)Kf$PAb*1W1ZCL6XQAE% z;}85P>g0*c&{%w~@k#j@kXYJd$MsZGQzeh)*L`K>td!u)@S^pA9s9v~Q5nIV578To zdYgBbZmpHF*ilQ;yhBE<;ofL6jT(K<{-3x|N(t`TbJtScgoxiwfub=4+xlg~=sjVx zwjWLBPSgvt=v`;uyCQsL;-ME8HDx-V|E0kF!((9K3IIPxrlX-!JM&stSto0ctwu~n z^?r93T~-QDj|uat?Vff&t)Fdiez0SzU^+Xj-Ijmcf8u&8FYRx$P;Kt$$U0!$DPOPt zTEs-XsZcv4E(1Q=58gla3D7@6Bv_Y1^V8E*>;(W+3qqI}q7}J7NFeu#n^!FvV00!j zEl_ehd;G->kRW+R-#MsNIDh-apzHS47!benFMp`O&ldn%?i(?u(%i}7(K|cb4;F5N z7wft(N{^b>J#Xac&`Ve%|0aIKU@!xa)aq*w#tMd*ZYejbnlE$QzoZWPiUZAxibyFCSgc@Bl39NiRl+adrdI5t@ij+#*(Q zV@B)4WwQwn3%YWAbcoiX4a2fB! zXVqujg6f)3av}HS`t4m>spS+;_5IYJEFMv(T~t8&N5TA51#CAlwTg70K)>fGm|sVs zGYrT+pnTs*Od+k+_~0R(4J$69vPUtb?d0Jko#({aC`9?IZanH=FYQ*BeG4*R8l)k| za>kHY(B_5~_a;dR*O7xT$06rJysn9t^wXw%IUfaiRFp;zx_6`%p00XPjA7;IEE_2$ z?X1P3Pf3sn5sX<|L=FY1g2 z@WZKr%_t3q;pC-xl>&?j+k!+@Q^Fvj$JD2ouA2@X9QxO{en=Q0cW}QvnuH50y_!ey z4wj=FD|Rnq6pm7pb(F7pPg@T{W99(Jn$J{00|WT63IFwx&fy}Bg|WEtH6P5+;1SDKqDX+tNH!Xwkc6S}j1~eT(BdK} zDYg2U=Ek4dN#g9jsF4N-s|*1mYF-2rXJ3lOZ{ET*|N7mW&PhNwZ$AA15ASHc<7ZG? zUY=7F|3UXiv8G~Qvw^T6n$WO)t4xwG0UTvcKpR(7ianLg?>>MWmIIkV3r%Owp%v0k zoU9KA9Yi;IJ$I2jWnS$!f~8)tADA~$0mP4|tNw?A8^wZkeL2b>;C`i1u&^UY-Nc%r z5U5r(uCC?O>7+^RzV#QY@y>J44Qla|WD)R&47OS9zH-VU7CnLEr}cW z5-4I2?n8v}L}g+A5|9cK7a>EBD1D!ah|P)|M2Gg9*xGCl1M!^?w3fDeW}i17KXfo4 zijn-=FX|lC&h7(11|4+K?r!wo6T~1;GwJ(78dA@QX(y_(rChSQ-F=p}4N^dr5Hx%h zSsaKL>zSb0rdTgU)n*MB;TAd+N`b$47Hmz%cQGn*moFenTefB;kd~-39ag?-rm;KE zidnd>XW$|F^y1k;tK(g2I}%pMbZsFif@5luEYYc9tz)e!CHW|8Sz=y-?)1Z6;TQhx zRnH5!ADT2V$c7b@2A{!_$$?C_mh4CWmD&VWQAY?$2KgVnFM<_9sd&0^>WK_k#v%#0 z-IdO}^~J(0?{~}nW4Vh8fek2K-Rg?-Lh%pZ;qsPPr0H)fA#u!$;tXOR$)hMqYCQh>I$-C9O|7Pg2_GK z$UlR{SFZc{|1hu~OhM)&rGM<$Tyj%}0`cN>mE<17`a%TPQ7Jc>@2&;mot?Z0phmg9 zxbR4=fkC0uex(c!@RvQ*F#A^Tewyw16DK0Car8HOKoy~?f5Gc;6Y6bU10|mqliT_8 z4)|ko<6)Pyh>7TDnPa?9c2||c?_>JEPd`{$7&tDa{WkvUXd@X#-aj8ZBm@*Z4tSZv zmz73Jh2p)8HGbmg2%O3zqh?@70brqqwjeA`=YqD{DPEsv>(p%9tFV7-!Y6cGN?l=s z9B>xPx{;88;t)t*(-Fr=5(5$*NqC`H+Y1!+O>ZnA&o$g-%)b4uGUJ`_tb6-eZNv@j zGN+agMec4s)lVc!GtRA4U)}>Fx<0#{>CC6w$GGRu<-4lEIG>q?<9>F!maj@#5XT9@ zlldSDd2J?o`8wSpVIa#BwFl`wQY)8uwAKE?iI)h_LVWQ?SES0lss7;3Ik2KvOVP37 z@EyuLzvE^pUJ3f@qV@qxV>$r0jzBro_6HB6b>=hLCsK3lGdBj9rw4rY5=|EtQex9C zH;qxJ*@`8;!mWJvOZ6Hzy!kJ-qm0go1RE&pO9s1`W=(-K5@B+={FAe75Sj#_u=#1+ zz)V5G!%M-Va?iN*A+!0te3C#T)_h>NYP~%>qKu6ZcfI<2Ss)^Z(DOs_)LY|4%HlSI zXHB7PUtq+19yJg~t^R9Bl{i9cIy9g?)P)Jeol#mZ$34GIsVPyH=DTg|Ty-(w8Qh?K zp*|CxByjSHwj~;rf7&W`4T1nrt=`Ucww~%&RV#7G8dlXRkfolUOxof=c~>LwI-|1l zLJh6z^IvT_f3YS^aqWFp*UIeyTAs5IZOd7=*O0qv?8@#G>fERF`1kaM`Vh#aVe5rz zX0EOaWsHSiNa+7F0@zYXHRq%*9sx>5E0B- z?RH%r%q#1E_3cVbI%jq3ySCjbyBwr|CyGfxFCT7OW8S!uKmG`|?nk3dG*2m?+gq)Rtf{OnT$ER`f;v3~ z7Hd?+>Dq6(vUf(f^8VcoS4H2mcJyoNP|B^6Pfmx+Q|r0Cp%i!8OCHS^o?EBn-INT> z*JD)fGCMIY5TY_anFb^qk;)=gj4pvrnggLXq7+0t)v0}FWhSx+e^8jHZj_2fCWyD7 zJ%ZWYf*&V4#)W)d9?dK;>fXjas_i%RCjL?3yUT}!{2WK!E6v^hpot9y z(EiH%;*qcW{?T<~g3(22EzQH_eEa$0OH?Yk1aIhDM3U!3_F0ri`0GJ$&uQwpgYn~= zBWay<9LCM1garo#KNXiW{sxrV)EnQg-I~qfaI{Eo7vk$0Et}%o&$c3kdXG1cG<#iq zl|Mmmj>J$9Vg))Qb}Dp=A5;#6Wc5 z1Eq!fSTb`QUQl7EqxdAPx(jbj1_J7zS&hgs+?Nn)Ks-~QVi6*MM0_q+$p0Tj=l@Z{ z`M(J)|A(IQ!|0-FCx7^Z&#&8*?a&5Q9#5HH6B26x`E+rP>#m|+YwEoIZt`5T7Q+e; z3{Ip#0Iw7(Wg>lpc%Jlg#KX&a*2O_T-#MF!3E$1%Da-YobV5`?+;rH_L8zh>f}l-& zII1A5fiY~^-plyQ4P&tM<=V<>m3FwvJH04 zd?qJ2(Ru*~hXv1c^;5srY5XX75#(K1#*w=7lP<%uw2cZ`QSu-3%R4ycgVI3`74v6- zqooN$1uc~Ybr1>>2&H5L7X1bd`X9GnFpB&8UGF}me)O!d%M^_DKW%bjPx1v;D56IA zCBpdgUOKX;Z9{Aq&5EnTnaA51vVQ;5+X^D2Bt6RrQF|7;q(;CSLB>U@-re?MMQ8jq zWz8lxW0w9)PzE^yvw=V5cqg(~Td$hwtE!zzUvTiIUd$WRKL!6vNQ6;@j3r(g(p`iI zLk+0#3M5kU!Y_J*O9njNwp3X#eFEHkagE@6LL=)p1!V(&SV%+gBE6!+@*>ce^+@@r z?|c>ofXzD0bpj}JATEICb4?x&YU9Oh4gxry8R+d6D-daRG`LV+;`8l;95=2~(-Q(y z`x%0X3imt8IA{Vf^1RJ6c$hB&s$D)J3%L82EBv`k`b*^DWqAKQe;=xlHU1p1Zz!K5 z%PJ`@fE+lYHF6I`B=%SVdZ+?qf-cB)w%jV{6kWS##55*62{27%OtQcAE0&E+JJLNh<<_&MDocMI1(xXGhl&tvrzj(_mdc| zF~Be6M54@6?`3U!NRGlUuqlP=J#<#YPgeFM#_AhT8{DWC!VgKTe!p3yeiOFl(BcCH zE-T1oww+m+kN>^KpBU_O@*m58DmWh*5z?C{SZ^SVoIf-}gysHWT z4b0#!fq1B#xBw7#otxXbEQN>I2nJj`{^Z>Iy0!CJe@S;{r|kvdCMURmln zlyGSIYd;$%`Des{>#ci7l9gW6mk_8F@Iz_AGwACHTJAe=?j3N(+)$VX zFDMiN@I;N9ShB7uF-wE9(K3GUB+SNVcy;&)NkCj))+%0o`u z!ixK5n=m>-nJ+`O`QzY31dw(vP~Uj7N5=(j4o*>HXj>BK>{5A;ITBmXh_ z*TZN0)urDH8IKCfio3l&fS}yDHDDD7G3)^=WW_IoMccV4N$Q6g^?^Wf#O7wdWiFop z)p*YcI*F=(K3!%ffQ<Ln z%hZBxaKiMSSU)@6@{BMX!|&RAsP@muuD137U|VC{9s6n{XJ3sZ%}z3ya7d5(CHfj#>NGmop0UVSg^n~ zz2LzJ$sx>@t3dUjkR&HgN986j#AIhM-0~j>ScBy7X%taUTB~yitXP9Y-^d58;;j)1 zw;uoXp9Zx1{06qKha`V$a$FraOV=&er+=Z#hYpjz{^Oon z=}91G6BcL^#>_O4{}Z(Q8{+&M9{s1#@;}k*zl5p(1-9A1A^CTJ`wx+9 zLm0Q#$S*N4aBTjDxYSk;Ud7H@RSgD4YzoOgpxV1B8B$KIh4cIO7W$5%Jp1Nl$6Olb5>DGCSM!%=C zqw@u`#kozlw9R5Y1j6rIXIJOD+w86*6VhfAXr&~45Br1Jqn(bId%Lsab&>=FX*W!6U#hl_6Upo_3OrO@1dUDA)3Ckd3tT8cgE;D$+`W-@0DZCNdd2y2YBS*0@8ZbseS|veY^s)m{#&+OoeuE4=2# z%OWGozBL6S9-Wd3Up1SdEyK zq=>}6j+_&$uE=>(EG27BOClBND?4R(ZK_uM*3GmO0ml!Yn6vJICN@%Xa%7ooVlv3h zTSq42b-aogImOAq{(d{FvZMqxyPxP{MQln96%aIcV@ecLPKtF)%T=00KFG(@%FX6@ z(>l?)@RL)yfF}D~qkj(#QIcxd_d)-n5B@2#9omxBgYeh(Ss<5xIwF)zr^{^crz5oH zhu;T`3?Cetu>~9k?Y=sPiS)T}&_6{YBi_Db_tmZL0E~Fj% zR^D{lz5lVh&YJV{;K0=Y!%1a)tOgygB(^zreH;}SbGz+oX9x_`UFY-FpL?974Zpzu zD$U((HJB{SyJCF1n3YIp%=^M?!NKXO@7HoU@l!g2KHMjfBE|XME~T)}$Pj^wnw^PM z3Nh+_GG>_!)k3$P-G;~cBYi`LSZ_mTeHl?R^Wj(}0Cu;Hfv2^onHsEz5wHEEuTTo- z!0_}^|N&y>gXWehx7>E?1L#?2M;x4&HvuxifG za9Qmu#ndzwMJ8~AF+@D(pHr3kFv1%VSztG(vG%Z%2Ed5pT@3d6aiALV=U&$= zJuKq^Apar$n>HRS8|C%*BnrUqTV@EZ{G*^CG8dx#3LCA0J`fsz1eIim$t7+u8p3|N zA;_2)myLIFzTsxJ08(2PPHtdvQH?YozBJ-m67P@gh8OB=&ndT+qM8gg09vR*qrMj} z=v%1jddxOzhHm)-t&@U{*Krbyox?20{bw3F>Nc~?R|5H5ZUhaNVsc6Eda zqcJ)WW<`T&ks`%;r@%N3107DpJd%_6Fouc)bw>MY@g8(3UEZCj&3gT?EFKpXsM|dx z4vd?fR-^y}(yZ7Z0C5%P(>YX6=t zBi|oYSsB)n7&FC@`YS-?bK<&Fxf>bWXHpemKtm<2Vw+i4h7L!9u4eBZJt+jwltc8_ zb=ybX;#;>*pg4e60xt_C0wDmBU9$3cD`9!u5Q)ry62{ab6JB2?vIte^eQ14%F&<>^ ztjSB)MwUn0#6zRp@o(6gj!?H&%kCj_>o2n#Lxl=R28DT`VMofTR z|6;cDA%cI>Yx)(Ahm-0`=v~wGPDmp04ja>l|a&CC`_9j9qDLs>-!2gud_`G5ZgiUFq*JT#$ zVR7{}Ok`lfwzio_)TjXLn4<0zuT<&Ox28GU7dG4VODR*0&EJGtzT7D*11`gV+SH}c zluhjHLek7ltiK{DresN{ZCBLPAV%nGwf`9dlZPrT{eg+9NUxM4gEu`_8HuMX$%fc# zSPKiZ9U7^UBjr0Fr#>G;3;e5T%iwxY5Eg`4*~|oka$cV&q-yn)IQ?ObhHk zL3?vq#POGDXqpQtTW|tnzyE@C{Uj{WQJ5o-hPs=*?liikm_s4tn9SjAnrOHos{3`q zF7EGHgWhlIn0s~WzG~go)~G?Ks_BP%LGr{kPH%3e5bg@9!u-Nj&(mk7OP*iZ=PV`8 z-p6n}%M!N(D7n}-Cb8>fC{DQ=y?+Dk_tzbnNl6t`xNkQkT7(?X zy>WBr+-4*1jc=^@ok=VNohF0wT7>DN^V5EZioZr)Wa*t!N+-A#Eqi|d(kk|FnOD)D zF^p4WcE(?yE13x{mi|?1X~IQ~njhQs)1{ND2;Uo-@&FkTATh4w{K*LBXFfZlodJ|| zs$&DjYd$M1ouT3IyF<_+MRlH49F3kq0~ac=d+P{GF^;op#{Rg*&Rpr1%wSzr0;qz~ zp=24qJ#~Fx;^X7*@9#hERbFA(;64Y{jQ$sSk$?E!|2LWPpODx8#~h0{4TxqxQ$|Kb zfvlfHE{}2}6K^oqk(04q>`weJ?PRL|aV7^;cph;Cl}T*#$)l0P_Q%DYN&ljRzBOV< z5#t`XJj+0~f3NKHI>6~6$iKi&r@V<{v;ICfV%1hkS-NyW-06ysrEXaQnO29x|5~4+?z9Nb~C@sT>t_Zd$4Rg@+Asz6@-YosM{B%=tU;UKy1I5k| zJMSWhx`nO0Jn$wgP(cF4^@FV~w!OT(_@O~&X?x6a|FvWrcjK+xL>6~_Ru@}66KdlmqcCnuo1d4`B%ELn)DBL422)gCKsCwkwA-kmQl zR8Ad2`9k4b)@2iFes9uqRa2YFV{YOTuhxOkd4Hem!eai7eC6Qie1rFXvdA?eBjY`b zHRJm>KWDlBgt9;!oO-EkW<0_MBb_b>0GiZNcwd zu&bPbwNSTjP(-HUkTy4RU_h^wxDNMjm%FE4BE64N)1EOKYpSH9WR(@-r}Yy7N+Z1; zSrjpPIp3*DUnaiq=0|^qbjEFQwboBKNT2O{4|0A!@S0m4{PbqWNs-_d`a$N=+|7Dp zGx2=59rYD7ZvqTi!MwDGl0VHwip_*Y02A^$M_gEokY&+dqx#V1iF(9(tY=} zjM@J7TxDf$Ge1GAS#PeWEBZ8wg4DvXrKs|||IfEI8)rsuAF}x0)%2js>^*f|#KOUS z>K*O`wuMUjIvgkvgkKYGYiRJ<%(Mt{K$9fhUHy*uv=S&~u3;T18`3fF z3YNMkR;--O@qN^<+_6t?P=WbL0*4IBr5DMuDzY<7tH5 z;W-4xL?Kv7fY^y?W$Zbl`__5#a&oQO!&z2y1h=n#(7GwiLEi(TJB#sh&nq8)tlvC} zem)Wq=Zz#v3pQtNXGbXR9Z>FRu=>5l0iR&P;0EsdJI3k}I6F3k6yEOFLlemC6N4E# z+#+Q73_zvEl#~Fg@3>R{3(|v2Z#Bl|Huhl-;(ylyd^x1p*j55nrUW6&qZ!B3$;uzt zVufd`pP1q|-nKlDTBoQLp!vRr&zzb5986*AODkBwC-t3E_GwU0hm%LcX15V!st)}- z+i8JEs;db9tJ5N|PbDe2)l70#jDd0^8BY>@D$WD1>TGaHP%1c(RUOz9Xx*Z1vJzY$ z<90OS0T<4!S@K$_%D^5sS+e1nd7|PoKK(Sv|t4o~*ZzRPBnx+B)_N&0I$rPmEgyp^Hz4BOg)`EtU`(=tf zg6-a3r$=(l@JQ8uq&NbH;Y)Dp2X49EZoAv;fiWO*FsZ72y}&i2~D%P?;}ewlU*Hq|5Wy(9aIk?YZ+Uz-N_L%#D^#(9^9K%{oZV( z5yOuM^yyLw7|+m~PvWDM+H1giR6mr4ry*>&-FKEp!2K#tUf-%%ZBJUZ}}sp-VN<@P@@4P9=>~z2nJKpTBdgj6aZ&Bx6JDFj&}{B;QwWA9SV&Jet2`l172)SOg!@%Lsl;3zE_q`xC8Fh{B?7qNG6=BT ze1zu=0Lby$bORsvfK{pl0IS>0Mi>HL$f_@Shc@+cDi_S7CMh?|S4?EU_mZR3WMuC2 zSL3{pX2t%d#JTsk4wngE*V^>6)JD$ymLS!|+;W=>ISJN!H@DT%1Snv--G#cf$z^=w zR>?T5b)u2*tl?7*Qm^D%n2A4uQu;BN$m5iy_xC7x293Dnw3^p<{#klpeT*? zBA&_qVogItTUS%NPmO*0GgyhFZ14a$oOyBHx+oK*)KcZouMj|57Ie&Eg?K`lPP=Og zsb3KdO$$qy(yx3eDiZa82j2E(o3*w#Ww)=1z*TXQGBQ$+A}ix;lDWqTEUTqfV8#Vc zX;Gf2q{g!PqBg5wnOVuV($R>r-;T~f9U&nsm+l>H#o}8p7*gy|M-zYBL9R3pe`OnG zC*=O+wZf*Dgd<~1*?kJKBv$QQR9!Lu2>kD?v^xWTQR5+Ebmi4TzO4w^I=W$i2dJKw zrJ-)(`xR?Ud@wEhcd`u(`IQV7mo5F;G-J@=!cEw>!OKu7{5C>&ZWd5kI02cRCI%5E z*}oxA=y8-*m*GYjS@oTj=9X#Yb|B|Bg^YH#(6WhxEB?M3*I8Hu<4w)$ViQ=bCvfCN_c3jq}nlInZz9OsbE5TUOybo z&o6t&it)3u`)CB zn(|aqauEQvpe%!0dCvI==KRP{vFA6~W?;S65dM(2|hle>v}@MU8B%=e;uj;&K{VQI1%1gp8w)W5pW~D6M9Q@x4i6~#U zwbGqbtnJUD)ifxmux`AIRy$NbP-SY3si9bz@2-y(nI6}E4+|f*f0@r*bfv$4Xs|b{#3sB6b z#6ai-$v%mAxV|f2%QkrLPs?@k7MOiU1EcTKpiciX2W4)1b08;|;R-H&RKEgAF2ffd z65vPR*h3;MjxwPh^<5+p;Tr1ToM2I}A03%c*36da*4$}sKm~w5v`}A%Y0(sACRfrk zv^fj*nd!~$9As7!32#3|zodoyBzN9&9??n<=$jUA<;g&nc13`r~L6VzZIgI%tqXkj_U4@wd&Jkt*>|WDjW7^PRu4|Xml^0Xe3EoYCBTE(kZ?wH-R2)wnEjW+>!4uqFg1c)VxVyW% zyF-BB1b26Lhr!+5-Q68#=l^!!epor@?T4+?bLMnUpX#dasp_g*_uikSYZC|aG0@P% z?xwGDjSyWPAb7gGY6+_}$H_g266u_nl1*5;wj!ti_|4t{nLPgm)5rY+xzHupmYY}N zE6))Hy^-bkDpUc#P!gWH`aFnb209Pi2b>LsPuH}Ke5EEXxsH#li;@VMQOHoeCP_T6 zFBy{~pdP$1ZkP`nja%KrPpb1VBYDuUl~h_

    |!YVV~EnA5%XN8PA)`pwR7&y;@bF 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 S`dR?wk`R;qQX!%j@V@|BD2AQ@ literal 0 HcmV?d00001 diff --git a/docs/source/_static/screenshot.png.license b/docs/source/_static/screenshot.png.license new file mode 100644 index 0000000..c3fb022 --- /dev/null +++ b/docs/source/_static/screenshot.png.license @@ -0,0 +1,2 @@ +Copyright DB Netz AG and contributors +SPDX-License-Identifier: Apache-2.0 diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..3841a87 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,112 @@ +# Copyright DB Netz AG and contributors +# SPDX-License-Identifier: Apache-2.0 +"""Configuration file for Sphinx.""" + + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys +import tomllib + +sys.path.insert(0, os.path.abspath("../..")) + +import capella_diff_tools + +# -- Project information ----------------------------------------------------- + +with open("../../pyproject.toml", "rb") as f: + _metadata = tomllib.load(f)["project"] + +project = "Capella Diff Tools" +author = _metadata["authors"][0]["name"] +copyright = f"{author} and the {_metadata['name']} contributors" + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.napoleon", + "sphinx_copybutton", +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +# exclude_patterns = [] + + +# -- General information about the project ----------------------------------- + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. + +# The full version, including alpha/beta/rc tags. +version = capella_diff_tools.__version__ +rst_epilog = f""" +.. |Project| replace:: {project} +.. |Version| replace:: {version} +""" + + +# -- Options for copy-button ------------------------------------------------- +copybutton_here_doc_delimiter = "EOT" +copybutton_line_continuation_character = "\\" + + +# -- Options for auto-doc ---------------------------------------------------- +autoclass_content = "class" + + +# -- Options for napoleon ---------------------------------------------------- +napoleon_google_docstring = False +napoleon_include_init_with_doc = True + + +# -- Options for Intersphinx output ------------------------------------------ +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), +} + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. + +html_theme = "furo" +html_theme_options = { + "footer_icons": [ + { + "name": "GitHub", + "url": "https://github.com/DSD-DBS/capella-diff-tools", + "html": '', + "class": "", + }, + ], +} + + +# -- Extra options for Furo theme -------------------------------------------- + +pygments_style = "tango" +pygments_dark_style = "monokai" + + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ["_static"] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..819e174 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,25 @@ +.. + Copyright DB Netz AG and contributors + SPDX-License-Identifier: Apache-2.0 + +Welcome to Capella Diff Tools's documentation! +============================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + +.. toctree:: + :maxdepth: 3 + :caption: API reference + + code/modules + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/git-conventional-commits.json b/git-conventional-commits.json new file mode 100644 index 0000000..525cbf0 --- /dev/null +++ b/git-conventional-commits.json @@ -0,0 +1,18 @@ +{ + "convention" : { + "commitTypes": [ + "build", + "chore", + "ci", + "docs", + "feat", + "fix", + "merge", + "perf", + "refactor", + "revert", + "test" + ], + "commitScopes": [] + } +} diff --git a/git-conventional-commits.json.license b/git-conventional-commits.json.license new file mode 100644 index 0000000..95e8b6e --- /dev/null +++ b/git-conventional-commits.json.license @@ -0,0 +1,2 @@ +Copyright DB Netz AG and contributors +SPDX-License-Identifier: CC0-1.0 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f22c5ee --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,207 @@ +# Copyright DB Netz AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +[build-system] +requires = ["setuptools>=64", "setuptools_scm[toml]>=3.4", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +dynamic = ["version"] + +name = "capella-diff-tools" +description = "Tools for comparing different versions of a Capella model" +readme = "README.md" +requires-python = ">=3.11, <3.13" +license = { text = "Apache-2.0" } +authors = [ + { name = "DB Netz AG" }, +] +keywords = [] +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: Apache Software License", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + "capellambse>=0.5.39", + "click", + "diff-match-patch>=20230430", +] + +[project.urls] +Homepage = "https://github.com/DSD-DBS/capella-diff-tools" +Documentation = "https://dsd-dbs.github.io/capella-diff-tools" + +[project.optional-dependencies] +docs = [ + "furo", + "sphinx", + "sphinx-copybutton", + "tomli; python_version<'3.11'", +] + +test = [ + "pytest", + "pytest-cov", +] + +[project.scripts] +capella-diff-tool = "capella_diff_tools.__main__:main" + +[tool.black] +line-length = 79 +target-version = ["py311"] + +[tool.coverage.run] +branch = true +command_line = "-m pytest" +source = ["capella_diff_tools"] + +[tool.coverage.report] +exclude_also = [ + 'if t\.TYPE_CHECKING:', + 'class .*\bt\.Protocol\):', + '@abc\.abstractmethod', + '@t\.overload', +] +skip_covered = true + +[tool.docformatter] +wrap-descriptions = 72 +wrap-summaries = 79 + +[tool.isort] +profile = 'black' +line_length = 79 + +[tool.mypy] +check_untyped_defs = true +no_implicit_optional = true +show_error_codes = true +warn_redundant_casts = true +warn_unreachable = true +python_version = "3.11" + +[[tool.mypy.overrides]] +module = ["tests.*"] +allow_incomplete_defs = true +allow_untyped_defs = true + +[[tool.mypy.overrides]] +# Untyped third party libraries +module = [ + # ... +] +ignore_missing_imports = true + +[tool.pydocstyle] +convention = "numpy" +add-select = [ + "D212", # Multi-line docstring summary should start at the first line + "D402", # First line should not be the function’s “signature” + "D417", # Missing argument descriptions in the docstring +] +add-ignore = [ + "D1", # Missing docstring in public module/class/function/... + "D201", # No blank lines allowed before function docstring # auto-formatting + "D202", # No blank lines allowed after function docstring # auto-formatting + "D203", # 1 blank line required before class docstring # auto-formatting + "D204", # 1 blank line required after class docstring # auto-formatting + "D211", # No blank lines allowed before class docstring # auto-formatting + "D213", # Multi-line docstring summary should start at the second line +] + +[tool.pylint.format] +ignore-long-lines = '^\s*(?:(?:__ |\.\. __: )?https?://[^ ]+$|def test_.*|[A-Za-z0-9_\.]+(?: ?:)?$)' + +[tool.pylint.master] +max-line-length = 79 + +[tool.pylint.messages_control] +disable = [ + "broad-except", + "global-statement", + "import-outside-toplevel", + "invalid-name", + "missing-class-docstring", + "missing-function-docstring", + "missing-module-docstring", + "no-else-break", + "no-else-continue", + "no-else-raise", + "no-else-return", + "protected-access", + "redefined-builtin", + "too-few-public-methods", + "too-many-ancestors", + "too-many-arguments", + "too-many-boolean-expressions", + "too-many-branches", + "too-many-instance-attributes", + "too-many-lines", + "too-many-locals", + "too-many-public-methods", + "too-many-return-statements", + "too-many-statements", + + # Auto-formatting + "bad-indentation", + "inconsistent-quotes", + "missing-final-newline", + "mixed-line-endings", + "multiple-imports", + "multiple-statements", + "trailing-newlines", + "trailing-whitespace", + "unexpected-line-ending-format", + "ungrouped-imports", + "wrong-import-order", + "wrong-import-position", + + # Handled by mypy + "arguments-differ", + "assignment-from-no-return", + "import-error", + "missing-kwoa", + "no-member", + "no-value-for-parameter", + "redundant-keyword-arg", + "signature-differs", + "syntax-error", + "too-many-function-args", + "unbalanced-tuple-unpacking", + "undefined-variable", + "unexpected-keyword-arg", +] +enable = [ + "c-extension-no-member", + "deprecated-pragma", + "use-symbolic-message-instead", + "useless-suppression", +] + +[tool.pytest.ini_options] +addopts = """ + --import-mode=importlib + --strict-config + --strict-markers +""" +testpaths = ["tests"] +xfail_strict = true + +[tool.setuptools] +platforms = ["any"] +zip-safe = false + +[tool.setuptools.package-data] +"*" = ["py.typed"] + +[tool.setuptools.packages.find] +include = ["capella_diff_tools", "capella_diff_tools.*"] + +[tool.setuptools_scm] +# This section must exist for setuptools_scm to work