diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..153aa61d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: CC0-1.0 + +# “.gitattributes Best Practices - Muhammad Rehan Saeed” {{{ +# +# Set default behavior to automatically normalize line endings. +* text=auto + +# Force batch scripts to always use CRLF line endings so that if a repo is accessed +# in Windows via a file share from Linux, the scripts will work. +*.{cmd,[cC][mM][dD]} text eol=crlf +*.{bat,[bB][aA][tT]} text eol=crlf + +# Force bash scripts to always use LF line endings so that if a repo is accessed +# in Unix via a file share from Windows, the scripts will work. +*.sh text eol=lf +# }}} + +# vim:set fdm=marker: diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..07b31c18 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: CC0-1.0 + +* @ewuerger + +/capellambse_context_diagrams/*.js @MoritzWeber0 diff --git a/.github/workflows/build-test-publish.yml b/.github/workflows/build-test-publish.yml new file mode 100644 index 00000000..d759c8c9 --- /dev/null +++ b/.github/workflows/build-test-publish.yml @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 + +name: Build + +on: + push: + branches: ["*"] + pull_request: [master] + tags: ["v*.*.*"] + +jobs: + test: + name: Test with Python ${{matrix.python_version}} on ${{matrix.os}} + runs-on: ${{matrix.os}} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + python_version: ["3.9", "3.10"] + include: + - os: windows-latest + python_version: "3.9" + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{matrix.python_version}} + uses: actions/setup-python@v2 + with: + python-version: ${{matrix.python_version}} + - uses: actions/setup-node@v3 + with: + node-version: 16 + - run: npm -v + - uses: actions/cache@v2 + with: + path: ~/.cache/pip + key: ${{runner.os}}-pip-${{hashFiles('setup.cfg')}} + restore-keys: | + ${{runner.os}}-pip- + ${{runner.os}}- + - name: Upgrade Pip + run: |- + python -m pip install -U pip + - name: Install test dependencies + run: |- + python -m pip install '.[test]' + - name: Run unit tests + run: |- + python -m pytest --cov-report=term --cov=capellambse_context_diagrams --rootdir=. + + publish: + name: Publish artifacts + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: "3.9" + - name: Install dependencies + run: |- + python -m pip install -U pip + python -m pip install 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@v2 + with: + name: Artifacts + path: "dist/*" + - name: Publish to PyPI (release only) + if: startsWith(github.ref, 'refs/tags/v') + run: python -m twine upload -u __token__ -p ${{ secrets.PYPI_TOKEN }} --non-interactive dist/* diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..f6ec5cad --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 + +name: Docs + +on: + push: + branches: ["master"] + +jobs: + documentation: + name: Build documentation + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + - name: Set up Python runtime + uses: actions/setup-python@v4 + with: + python-version: 3.10.5 + - name: Install Python dependencies + run: | + pip install '.[docs]' + - name: Deploy documentation + run: | + mkdocs gh-deploy --force diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..57f421ba --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.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.9" + - 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.9" + - name: Upgrade pip + run: |- + python -m pip install -U pip + - name: Install pylint + run: |- + python -m pip install pylint==2.13.9 + - name: Run pylint + run: |- + pylint -dfixme -- capellambse_context_diagrams || exit $(($? & ~24)) diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..67f7ae9a --- /dev/null +++ b/.gitignore @@ -0,0 +1,307 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: CC0-1.0 + +# Templates from +# vim:set fdm=marker fmr=#region,#endregion: + +#region Global/JetBrains.gitignore +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser +#endregion + +#region Global/Linux.gitignore +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* +#endregion + +#region Global/VisualStudioCode.gitignore +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ +#endregion + +#region Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk +#endregion + +#region Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk +#endregion + +#region Python.gitignore +# 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/ + +# 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 + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__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/ +#endregion diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..dae17360 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,72 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: CC0-1.0 + +exclude: '^(versioneer\.py|.*/_version\.py)$' +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.2.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: check-yaml + - 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 + rev: 22.3.0 + hooks: + - id: black + - repo: https://github.com/PyCQA/isort + rev: 5.10.1 + hooks: + - id: isort + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.942 + hooks: + - id: mypy + - repo: https://github.com/Lucas-C/pre-commit-hooks + rev: v1.1.13 + hooks: + - id: insert-license + name: Insert Licence for Python, YAML and Dockerfiles + files: '\.py$|\.yaml$|^Dockerfile$|^Makefile$' + exclude: '^\.' + args: + - --license-filepath + - license_header.txt + - --comment-style + - '#' + - id: insert-license + name: Insert Licence for HTML files + files: '\.html$|\.md$' + exclude: '^\.|^docs/credits\.md$' + args: + - --license-filepath + - license_header.txt + - --comment-style + - '' + - id: insert-license + name: Insert Licence for CSS files + files: '\.css$' + exclude: '^\.' + args: + - --license-filepath + - license_header.txt + - --comment-style + - '/*| *| */' + - repo: https://github.com/fsfe/reuse-tool + rev: v1.0.0 + hooks: + - id: reuse diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..b95ac374 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,178 @@ + + +# Introduction + +First off, thank you for considering contributing to capellambse-context-diagrams. It's people like you that make capellambse-context-diagrams and therefore [capellambse](https://github.com/DSD-DBS/py-capellambse) such great tools. + +Please take a moment to review this document in order to make the contribution process easy and effective for everyone involved. + +Following these guidelines helps to communicate that you respect the time of the developers managing and developing this open source project. In return, they should reciprocate that respect in addressing your issue, assessing changes, and helping you finalize your pull requests. + +## Opening an issue + +GitHub's issue tracker is the preferred channel for [bug reports](#bug-reports), [features requests](#feature-requests), submitting [pull requests](#pull-requests) and improving the [documentation](#extending-the-documentation), but please respect the following restrictions: + +* Please do not use the issue tracker for personal support requests. + - For internals (collegues from DB): You can reach the maintainers via our internal messaging tool or via email and ask for guidance. + + - For externals: You can reach the founder of this extension via [email](mailto:ernst.wuerger@deutschebahn.com?subject=[capellambse-context-diagrams]%20My%20problem). + +* Please **do not** derail or troll issues. Keep the discussion on topic and + respect the opinions of others. + +## Contributions we are especially looking for + +### Extending the Documentation + +If you find paragraphs or sections unintuitive or you have a more clear way of explaining and showing the capabilities of a feature an issue can be opened. + +You can install needed libraries via + +```bash +pip install "[.docs]" +``` + +and see your changes to the markdown files live by executing + +```bash +mkdocs serve +``` + +command in the root directory. Keep in mind that the code reference is generated from the docstrings via [mkdocsstrings](https://mkdocstrings.github.io/). This is a plugin for [mkdocs](https://www.mkdocs.org/). Here we are using [material for mkdocs](https://squidfunk.github.io/mkdocs-material/) which gives a modern theme for mkdocs. + +### Bug reports + +A bug is a _demonstrable problem_ that is caused by the code in the repository. +Good bug reports are extremely helpful - thank you! + +Guidelines for bug reports: + +1. **Use the GitHub issue search** — check if the issue has already been + reported. + +2. **Check if the issue has been fixed** — try to reproduce it using the + latest `master` branch in the repository. + +3. **Isolate the problem** — ideally create a reduced test case. + +A good bug report shouldn't leave others needing to chase you up for more +information. Please try to be as detailed as possible in your report. What is +your environment? What steps will reproduce the issue? What OS experiences the +problem? What would you expect to be the outcome? All these details will help +people to fix any potential bugs. + +Example: + +> Short and descriptive example bug report title +> +> A summary of the issue and the browser/OS environment in which it occurs. If +> suitable, include the steps required to reproduce the bug. +> +> 1. This is the first step +> 2. This is the second step +> 3. Further steps, etc. +> +> `` - a link to the reduced test case +> +> Any other information you want to share that is relevant to the issue being +> reported. This might include the lines of code that you have identified as +> causing the bug, and potential solutions (and your opinions on their +> merits). + +### Feature requests + +Feature requests are welcome. But take a moment to find out whether your idea +fits with the scope and aims of the project. You might want to check if your feature is already on the [menue](https://github.com/DSD-DBS/capellambse-context-diagrams/projects?type=beta). + +A frequently appearing kind of requested feature is to add `.context_diagram` to more ModelObjects. Please check if the class-type of your requested ModelObject is not [already implemented](https://dsd-dbs.github.io/capellambse-context-diagrams/#features). Then it is important to describe how the context of this specific ModelObject is formed and how it shall be collected. There are 3 stages in building context diagrams: + + 1. Collect the context data. + 2. Layouting via elkjs, size and position calculation. + 3. Serialization of ELKOutputData to aird elements. + +For the 2nd stage it is most certainly useful to use the interactive JSON editor from the [ELK demonstrator](https://rtsys.informatik.uni-kiel.de/elklive/) to check the positioning and routing. There you can test and see the behavior of configurations for ELKInput elements. + +It's up to *you* to make a strong case to convince the project's developers of the merits of your feature. Please provide as much detail and context as possible. + +## Known limitations and future challenges + +Dealing with hierarchical diagrams/graphs is complex and needs further research for the right configuration parameters for ELK such that the orthogonal routing is working as intended. The edges that connect a subcomponent (A) with a component (B) outside of component (C) (parent of A) need to be global edges (i.e. listed in the edges array in the group component their source and target are located). If there is no joint parent component to be found for source and target of an edge this edge is a global edge. It should be listed in the first edges container. + +# Ground Rules + +Introducing a new feature also shares responsibility to add tests and documentation. Tests will help to maintain the correctness of your feature and the latter will explain the intent and showcase the capabilities of your contributions. Very often the examples given in the documentation are stemming from the respective test case. Here we are using pytest to automate unit and integration tests. + +Example: + +> Integration test for capability context diagrams seen in `tests/test_capability_diagrams.py`: +> +> ```python +> # SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +> # SPDX-License-Identifier: Apache-2.0 +> +> import capellambse +> import pytest +> from capellambse.model.layers import ctx, oa +> +> TEST_TYPES = (oa.OperationalCapability, ctx.Capability, ctx.Mission) +> +> +> @pytest.mark.parametrize( +> "uuid", +> [ +> pytest.param( +> "da08ddb6-92ba-4c3b-956a-017424dbfe85", id="OperationalCapability" +> ), +> pytest.param("9390b7d5-598a-42db-bef8-23677e45ba06", id="Capability"), +> pytest.param("5bf3f1e3-0f5e-4fec-81d5-c113d3a1b3a6", id="Mission"), +> ], +> ) +> def test_context_diagrams(model: capellambse.MelodyModel, uuid: str) -> None: +> obj = model.by_uuid(uuid) +> +> diag = obj.context_diagram +> +> assert isinstance(obj, TEST_TYPES) +> assert diag.nodes +> ``` +> +> The OperationalCapability, Capability and Mission were added to the test model (`tests/data/ContextDiagram.capella`) via Capella 5.2 and the new `.context_diagram` generation is tested. +> +> Documentation can look like this `docs/index.md`: +> ```markdown +> - ??? example "🔥Brand-new🔥 [`ctx.Mission`][capellambse.model.layers.ctx.Mission] (MCB) 🔥Brand-new🔥" +> +> ``` py +> import capellambse +> +> model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") +> diag = model.by_uuid("5bf3f1e3-0f5e-4fec-81d5-c113d3a1b3a6").context_diagram +> diag.render("svgdiagram").save_drawing(True) +> ``` +>
+> +>
Context diagram of Mission Top secret with type [MCB]
+>
+> ``` +> +> Make sure to work urself into [material for mkdocs](https://squidfunk.github.io/mkdocs-material/reference/) as we are using several extensions like admonitions and figure markdown. The used diagram SVG should be added to the `docs/gen_images.py` script such that mkdocs generates it while building the documentation. + +To meet needed prerequisites execute +```bash +pip install "[.test]" +``` + +You can then run all tests in the terminal by executing + +```bash +pytest +``` + +or if you are using VSCode you can use the integrated test functionality via task profiles. + +Additionally we want to keep being REUSE-compliant (i.e. license compliant). We are using the [reuse.software](https://reuse.software/tutorial/) python tool to check for compliancy and add license headers where they are missing. + +We are also using pre-commit hooks as seen in the `.pre-commit-config.yaml`. It is wise to install pre-commit such that your commits are bullit-proof against the checks that are also executed in the workflow on GitHub. diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt new file mode 100644 index 00000000..d6456956 --- /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 00000000..0e259d42 --- /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/LICENSES/EPL-2.0.txt b/LICENSES/EPL-2.0.txt new file mode 100644 index 00000000..e48e0963 --- /dev/null +++ b/LICENSES/EPL-2.0.txt @@ -0,0 +1,277 @@ +Eclipse Public License - v 2.0 + + THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + +1. DEFINITIONS + +"Contribution" means: + + a) in the case of the initial Contributor, the initial content + Distributed under this Agreement, and + + b) in the case of each subsequent Contributor: + i) changes to the Program, and + ii) additions to the Program; + where such changes and/or additions to the Program originate from + and are Distributed by that particular Contributor. A Contribution + "originates" from a Contributor if it was added to the Program by + such Contributor itself or anyone acting on such Contributor's behalf. + Contributions do not include changes or additions to the Program that + are not Modified Works. + +"Contributor" means any person or entity that Distributes the Program. + +"Licensed Patents" mean patent claims licensable by a Contributor which +are necessarily infringed by the use or sale of its Contribution alone +or when combined with the Program. + +"Program" means the Contributions Distributed in accordance with this +Agreement. + +"Recipient" means anyone who receives the Program under this Agreement +or any Secondary License (as applicable), including Contributors. + +"Derivative Works" shall mean any work, whether in Source Code or other +form, that is based on (or derived from) the Program and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. + +"Modified Works" shall mean any work in Source Code or other form that +results from an addition to, deletion from, or modification of the +contents of the Program, including, for purposes of clarity any new file +in Source Code form that contains any contents of the Program. Modified +Works shall not include works that contain only declarations, +interfaces, types, classes, structures, or files of the Program solely +in each case in order to link to, bind by name, or subclass the Program +or Modified Works thereof. + +"Distribute" means the acts of a) distributing or b) making available +in any manner that enables the transfer of a copy. + +"Source Code" means the form of a Program preferred for making +modifications, including but not limited to software source code, +documentation source, and configuration files. + +"Secondary License" means either the GNU General Public License, +Version 2.0, or any later versions of that license, including any +exceptions or additional permissions as identified by the initial +Contributor. + +2. GRANT OF RIGHTS + + a) Subject to the terms of this Agreement, each Contributor hereby + grants Recipient a non-exclusive, worldwide, royalty-free copyright + license to reproduce, prepare Derivative Works of, publicly display, + publicly perform, Distribute and sublicense the Contribution of such + Contributor, if any, and such Derivative Works. + + b) Subject to the terms of this Agreement, each Contributor hereby + grants Recipient a non-exclusive, worldwide, royalty-free patent + license under Licensed Patents to make, use, sell, offer to sell, + import and otherwise transfer the Contribution of such Contributor, + if any, in Source Code or other form. This patent license shall + apply to the combination of the Contribution and the Program if, at + the time the Contribution is added by the Contributor, such addition + of the Contribution causes such combination to be covered by the + Licensed Patents. The patent license shall not apply to any other + combinations which include the Contribution. No hardware per se is + licensed hereunder. + + c) Recipient understands that although each Contributor grants the + licenses to its Contributions set forth herein, no assurances are + provided by any Contributor that the Program does not infringe the + patent or other intellectual property rights of any other entity. + Each Contributor disclaims any liability to Recipient for claims + brought by any other entity based on infringement of intellectual + property rights or otherwise. As a condition to exercising the + rights and licenses granted hereunder, each Recipient hereby + assumes sole responsibility to secure any other intellectual + property rights needed, if any. For example, if a third party + patent license is required to allow Recipient to Distribute the + Program, it is Recipient's responsibility to acquire that license + before distributing the Program. + + d) Each Contributor represents that to its knowledge it has + sufficient copyright rights in its Contribution, if any, to grant + the copyright license set forth in this Agreement. + + e) Notwithstanding the terms of any Secondary License, no + Contributor makes additional grants to any Recipient (other than + those set forth in this Agreement) as a result of such Recipient's + receipt of the Program under the terms of a Secondary License + (if permitted under the terms of Section 3). + +3. REQUIREMENTS + +3.1 If a Contributor Distributes the Program in any form, then: + + a) the Program must also be made available as Source Code, in + accordance with section 3.2, and the Contributor must accompany + the Program with a statement that the Source Code for the Program + is available under this Agreement, and informs Recipients how to + obtain it in a reasonable manner on or through a medium customarily + used for software exchange; and + + b) the Contributor may Distribute the Program under a license + different than this Agreement, provided that such license: + i) effectively disclaims on behalf of all other Contributors all + warranties and conditions, express and implied, including + warranties or conditions of title and non-infringement, and + implied warranties or conditions of merchantability and fitness + for a particular purpose; + + ii) effectively excludes on behalf of all other Contributors all + liability for damages, including direct, indirect, special, + incidental and consequential damages, such as lost profits; + + iii) does not attempt to limit or alter the recipients' rights + in the Source Code under section 3.2; and + + iv) requires any subsequent distribution of the Program by any + party to be under a license that satisfies the requirements + of this section 3. + +3.2 When the Program is Distributed as Source Code: + + a) it must be made available under this Agreement, or if the + Program (i) is combined with other material in a separate file or + files made available under a Secondary License, and (ii) the initial + Contributor attached to the Source Code the notice described in + Exhibit A of this Agreement, then the Program may be made available + under the terms of such Secondary Licenses, and + + b) a copy of this Agreement must be included with each copy of + the Program. + +3.3 Contributors may not remove or alter any copyright, patent, +trademark, attribution notices, disclaimers of warranty, or limitations +of liability ("notices") contained within the Program from any copy of +the Program which they Distribute, provided that Contributors may add +their own appropriate notices. + +4. COMMERCIAL DISTRIBUTION + +Commercial distributors of software may accept certain responsibilities +with respect to end users, business partners and the like. While this +license is intended to facilitate the commercial use of the Program, +the Contributor who includes the Program in a commercial product +offering should do so in a manner which does not create potential +liability for other Contributors. Therefore, if a Contributor includes +the Program in a commercial product offering, such Contributor +("Commercial Contributor") hereby agrees to defend and indemnify every +other Contributor ("Indemnified Contributor") against any losses, +damages and costs (collectively "Losses") arising from claims, lawsuits +and other legal actions brought by a third party against the Indemnified +Contributor to the extent caused by the acts or omissions of such +Commercial Contributor in connection with its distribution of the Program +in a commercial product offering. The obligations in this section do not +apply to any claims or Losses relating to any actual or alleged +intellectual property infringement. In order to qualify, an Indemnified +Contributor must: a) promptly notify the Commercial Contributor in +writing of such claim, and b) allow the Commercial Contributor to control, +and cooperate with the Commercial Contributor in, the defense and any +related settlement negotiations. The Indemnified Contributor may +participate in any such claim at its own expense. + +For example, a Contributor might include the Program in a commercial +product offering, Product X. That Contributor is then a Commercial +Contributor. If that Commercial Contributor then makes performance +claims, or offers warranties related to Product X, those performance +claims and warranties are such Commercial Contributor's responsibility +alone. Under this section, the Commercial Contributor would have to +defend claims against the other Contributors related to those performance +claims and warranties, and if a court requires any other Contributor to +pay any damages as a result, the Commercial Contributor must pay +those damages. + +5. NO WARRANTY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT +PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED 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. Each Recipient is solely responsible for determining the +appropriateness of using and distributing the Program and assumes all +risks associated with its exercise of rights under this Agreement, +including but not limited to the risks and costs of program errors, +compliance with applicable laws, damage to or loss of data, programs +or equipment, and unavailability or interruption of operations. + +6. DISCLAIMER OF LIABILITY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT +PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS +SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST +PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE +EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + +7. GENERAL + +If any provision of this Agreement is invalid or unenforceable under +applicable law, it shall not affect the validity or enforceability of +the remainder of the terms of this Agreement, and without further +action by the parties hereto, such provision shall be reformed to the +minimum extent necessary to make such provision valid and enforceable. + +If Recipient institutes patent litigation against any entity +(including a cross-claim or counterclaim in a lawsuit) alleging that the +Program itself (excluding combinations of the Program with other software +or hardware) infringes such Recipient's patent(s), then such Recipient's +rights granted under Section 2(b) shall terminate as of the date such +litigation is filed. + +All Recipient's rights under this Agreement shall terminate if it +fails to comply with any of the material terms or conditions of this +Agreement and does not cure such failure in a reasonable period of +time after becoming aware of such noncompliance. If all Recipient's +rights under this Agreement terminate, Recipient agrees to cease use +and distribution of the Program as soon as reasonably practicable. +However, Recipient's obligations under this Agreement and any licenses +granted by Recipient relating to the Program shall continue and survive. + +Everyone is permitted to copy and distribute copies of this Agreement, +but in order to avoid inconsistency the Agreement is copyrighted and +may only be modified in the following manner. The Agreement Steward +reserves the right to publish new versions (including revisions) of +this Agreement from time to time. No one other than the Agreement +Steward has the right to modify this Agreement. The Eclipse Foundation +is the initial Agreement Steward. The Eclipse Foundation may assign the +responsibility to serve as the Agreement Steward to a suitable separate +entity. Each new version of the Agreement will be given a distinguishing +version number. The Program (including Contributions) may always be +Distributed subject to the version of the Agreement under which it was +received. In addition, after a new version of the Agreement is published, +Contributor may elect to Distribute the Program (including its +Contributions) under the new version. + +Except as expressly stated in Sections 2(a) and 2(b) above, Recipient +receives no rights or licenses to the intellectual property of any +Contributor under this Agreement, whether expressly, by implication, +estoppel or otherwise. All rights in the Program not expressly granted +under this Agreement are reserved. Nothing in this Agreement is intended +to be enforceable by any entity that is not a Contributor or Recipient. +No third-party beneficiary rights are created under this Agreement. + +Exhibit A - Form of Secondary Licenses Notice + +"This Source Code may also be made available under the following +Secondary Licenses when the conditions for such availability set forth +in the Eclipse Public License, v. 2.0 are satisfied: {name license(s), +version(s), and exceptions or additional permissions here}." + + Simply including a copy of this Agreement, including this Exhibit A + is not sufficient to license the Source Code under Secondary Licenses. + + If it is not possible or desirable to put the notice in a particular + file, then You may include the notice in a location (such as a LICENSE + file in a relevant directory) where a recipient would be likely to + look for such a notice. + + You may add additional accurate notices of copyright ownership. diff --git a/README.md b/README.md new file mode 100644 index 00000000..47e556f1 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ + + +# Context Diagram extension for capellambse + +This extension of [py-capellambse](https://github.com/DSD-DBS/py-capellambse) enables generation of views (diagrams) that describe an element context (from a user-defined perspective). This allows systems engineers to do less layouting work and at the same time get diagrams with optimal layouts into the model-derived documents. + +The contents of an element context (what elements make it to the context) depend on the element of interest and are selected based on a hand-picked set of rules. However in many cases end user can further customize what and how needs to be in the context view. + +The generated views are delivered as SVG images and do not persist in the model itself. This approach enables generation of large number of views at scale (in parallel) in the document production pipeline and also saves quite some XML space in the models. When you rely on generated views for documentation the models can stay lite as they only need to have the engineering / design views (that dont need to have a nice layout). + +The layout work is done by [elkjs'](https://github.com/kieler/elkjs) Layered algorithm. + +## Generate **Context Diagrams** from your model data! + +When the extension is installed you get additional method `.context_diagram` available on those model elements that are already covered by context view definitions. + +
+ +
Context diagram of Left
+
+ + +
+ +
Interface context diagram of Left to right
+
+ +Have a look at our [documentation](https://dsd-dbs.github.io/capellambse-context-diagrams/) to get started and see the capabilities of this extension. + +--- + +Special thanks goes to the developers and maintainers of [Eclipse Layout Kernel™](https://www.eclipse.org/elk/). + +# Licenses + +Copyright and license information added and maintained via the reuse tool from [Reuse Software](https://reuse.software/). + +***Copyright 2022 DB Netz AG, own contributions licensed under Apache 2.0 (see full text in [LICENSES/Apache-2.0](https://github.com/DSD-DBS/capellambse-context-diagrams/blob/master/LICENSES/Apache-2.0.txt))*** + +***Copyright (c) 2021 Kiel University and others, ELK/Sprotty contributions ([elkgraph-json.js](https://github.com/DSD-DBS/capellambse-context-diagrams/blob/master/capellambse_context_diagrams/elkgraph-json.js) & [elkgraph-to-sprotty.js](https://github.com/DSD-DBS/capellambse-context-diagrams/blob/master/capellambse_context_diagrams/elkgraph-to-sprotty.js)) licensed under EPL-2.0*** + +***Dot-files licensed under CC0-1.0 (see full text in [LICENSES/CC0-1.0](https://github.com/DSD-DBS/capellambse-context-diagrams/blob/master/LICENSES/CC0-1.0.txt))*** diff --git a/capellambse_context_diagrams/__init__.py b/capellambse_context_diagrams/__init__.py new file mode 100644 index 00000000..c622c073 --- /dev/null +++ b/capellambse_context_diagrams/__init__.py @@ -0,0 +1,147 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 + +"""The Context Diagrams model extension. + +This extension adds a new property to many model elements called +`context_diagram`, which allows access automatically generated diagrams +of an element's "context". + +The context of an element is defined as the collection of the element +itself, its ports, the exchanges that flow into or out of the ports, as +well as the ports on the other side of the exchange and the ports' +direct parent elements. + +The element of interest uses the regular styling (configurable via +function), other elements use a white background color to distinguish +them. +""" +from __future__ import annotations + +import logging + +from . import context + +logger = logging.getLogger(__name__) + +ATTR_NAME = "context_diagram" + + +def init() -> None: + """Initialize the extension.""" + register_classes() + register_interface_context() + # register_functional_context() XXX: Future + + +def register_classes() -> None: + """Add the `context_diagram` property to the relevant model objects.""" + from capellambse.model.layers import ctx, la, oa, pa + from capellambse.model.modeltypes import DiagramType + + patch_styles() + supported_classes: list[ + tuple[type[common.GenericElement], DiagramType] + ] = [ + (oa.Entity, DiagramType.OAB), + (oa.OperationalActivity, DiagramType.OAIB), + (oa.OperationalCapability, DiagramType.OCB), + (ctx.Mission, DiagramType.MCB), + (ctx.Capability, DiagramType.MCB), + (ctx.SystemComponent, DiagramType.SAB), + (ctx.SystemFunction, DiagramType.SDFB), + (la.LogicalComponent, DiagramType.LAB), + (la.LogicalFunction, DiagramType.LDFB), + (pa.PhysicalComponent, DiagramType.PAB), + (pa.PhysicalFunction, DiagramType.PDFB), + ] + class_: type[common.GenericElement] + for class_, dgcls in supported_classes: + common.set_accessor( + class_, ATTR_NAME, context.ContextAccessor(dgcls.value) + ) + + +def patch_styles() -> None: + """Add missing default styling to default styles. + + See Also + -------- + [capstyle.get_style][capellambse.aird.capstyle.get_style] : Default + style getter. + """ + from capellambse.aird import COLORS, CSSdef, capstyle + + cap: dict[str, CSSdef] = { + "fill": [COLORS["_CAP_Entity_Gray_min"], COLORS["_CAP_Entity_Gray"]], + "stroke": COLORS["dark_gray"], + "text_fill": COLORS["black"], + } + capstyle.STYLES["Missions Capabilities Blank"].update( + {"Box.Capability": cap, "Box.Mission": cap} + ) + capstyle.STYLES["Operational Capabilities Blank"][ + "Box.OperationalCapability" + ] = cap + + +def register_interface_context() -> None: + """Add the `context_diagram` property to interface model objects.""" + from capellambse.model.crosslayer import fa + from capellambse.model.layers import ctx, la, oa, pa + from capellambse.model.modeltypes import DiagramType + + common.set_accessor( + oa.CommunicationMean, + ATTR_NAME, + context.InterfaceContextAccessor( + { + oa.EntityPkg: DiagramType.OAB.value, + oa.Entity: DiagramType.OAB.value, + } + ), + ) + common.set_accessor( + fa.ComponentExchange, + ATTR_NAME, + context.InterfaceContextAccessor( + { + ctx.SystemComponentPkg: DiagramType.SAB.value, + ctx.SystemComponent: DiagramType.SAB.value, + la.LogicalComponentPkg: DiagramType.LAB.value, + la.LogicalComponent: DiagramType.LAB.value, + pa.PhysicalComponentPkg: DiagramType.PAB.value, + pa.PhysicalComponent: DiagramType.PAB.value, + }, + ), + ) + + +def register_functional_context() -> None: + """Add the `functional_context_diagram` attribute to `ModelObject`s. + + !!! bug "Full of bugs" + + The functional context diagrams will be available soon. + """ + from capellambse.model.layers import ctx, la, oa, pa + from capellambse.model.modeltypes import DiagramType + + supported_classes: list[ + tuple[type[common.GenericElement], DiagramType] + ] = [ + (oa.Entity, DiagramType.OAB), + (ctx.SystemComponent, DiagramType.SAB), + (la.LogicalComponent, DiagramType.LAB), + (pa.PhysicalComponent, DiagramType.PAB), + ] + class_: type[common.GenericElement] + for class_, dgcls in supported_classes: + common.set_accessor( + class_, + f"functional_{ATTR_NAME}", + context.FunctionalContextAccessor(dgcls.value), + ) + + +from capellambse.model import common diff --git a/capellambse_context_diagrams/_elkjs.py b/capellambse_context_diagrams/_elkjs.py new file mode 100644 index 00000000..5067f6e1 --- /dev/null +++ b/capellambse_context_diagrams/_elkjs.py @@ -0,0 +1,322 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 + +""" +ELK data model implemented as `typings.TypedDict`s and subprocess +callers to check if elkjs can be installed via npm. The high level +function is [call_elkjs][capellambse_context_diagrams._elkjs.call_elkjs]. +""" +from __future__ import annotations + +import collections.abc as cabc +import json +import logging +import os +import shutil +import subprocess +import typing as t +from pathlib import Path + +import capellambse +import typing_extensions as te + +__all__ = [ + "call_elkjs", + "ELKInputChild", + "ELKInputData", + "ELKInputEdge", + "ELKInputLabel", + "ELKInputPort", + "ELKOutputChild", + "ELKOutputData", + "ELKOutputEdge", + "ELKOutputLabel", + "ELKOutputNode", + "ELKOutputPort", + "ELKPoint", + "ELKSize", +] + +log = logging.getLogger(__name__) + +NODE_HOME = Path(capellambse.dirs.user_cache_dir, "elkjs", "node_modules") +PATH_TO_ELK_JS = Path(__file__).parent / "elk.js" +REQUIRED_NPM_PKG_VERSIONS: t.Dict[str, str] = { + "elkjs": "0.8.1", +} +"""npm package names and versions required by this Python module.""" + +LayoutOptions = dict[str, t.Union[str, int, float]] +LAYOUT_OPTIONS: LayoutOptions = { + "algorithm": "layered", + "edgeRouting": "ORTHOGONAL", + "elk.direction": "RIGHT", + "hierarchyHandling": "INCLUDE_CHILDREN", + "layered.edgeLabels.sideSelection": "ALWAYS_DOWN", + "layered.nodePlacement.strategy": "BRANDES_KOEPF", + "spacing.labelNode": "0.0", +} +""" +Available (and possibly useful) Global Options to configure ELK layouting. + +See Also +-------- +[get_global_layered_layout_options][capellambse_context_diagrams._elkjs.get_global_layered_layout_options] : + A function that instantiates this class with well-tested settings. +""" +LABEL_LAYOUT_OPTIONS = {"nodeLabels.placement": "OUTSIDE, V_BOTTOM, H_CENTER"} +"""Options for labels to configure ELK layouting.""" + + +class ELKInputData(te.TypedDict): + """Data that can be fed to ELK.""" + + id: str + layoutOptions: cabc.MutableMapping[str, t.Union[str, int, float]] + children: cabc.MutableSequence[ELKInputChild] # type: ignore + edges: cabc.MutableSequence[ELKInputEdge] + + +class ELKInputChild(te.TypedDict, total=False): + """Children of either `ELKInputData` or `ELKInputChild`.""" + + id: te.Required[str] + layoutOptions: cabc.MutableMapping[str, t.Union[str, int, float]] + children: cabc.MutableSequence[ELKInputChild] # type: ignore + edges: cabc.MutableSequence[ELKInputEdge] + + labels: te.NotRequired[cabc.MutableSequence[ELKInputLabel]] + ports: cabc.MutableSequence[ELKInputPort] + + width: t.Union[int, float] + height: t.Union[int, float] + + +class ELKInputLabel(te.TypedDict, total=False): + """Label data that can be fed to ELK.""" + + text: te.Required[str] + layoutOptions: cabc.MutableMapping[str, t.Union[str, int, float]] + width: t.Union[int, float] + height: t.Union[int, float] + + +class ELKInputPort(t.TypedDict): + """Connector data that can be fed to ELK.""" + + id: str + width: t.Union[int, float] + height: t.Union[int, float] + + layoutOptions: cabc.MutableMapping[str, t.Any] + + +class ELKInputEdge(te.TypedDict): + """Exchange data that can be fed to ELK""" + + id: str + sources: cabc.MutableSequence[str] + targets: cabc.MutableSequence[str] + labels: te.NotRequired[cabc.MutableSequence[ELKInputLabel]] + + +class ELKPoint(t.TypedDict): + """Point data in ELK.""" + + x: t.Union[int, float] + y: t.Union[int, float] + + +class ELKSize(t.TypedDict): + """Size data in ELK.""" + + width: t.Union[int, float] + height: t.Union[int, float] + + +class ELKOutputData(t.TypedDict): + """Data that comes from ELK.""" + + id: str + type: t.Literal["graph"] + children: cabc.MutableSequence[ELKOutputChild] # type: ignore + + +class ELKOutputNode(t.TypedDict): + """Node that comes out of ELK.""" + + id: str + type: t.Literal["node"] + children: cabc.MutableSequence[ELKOutputChild] # type: ignore + + position: ELKPoint + size: ELKSize + + +class ELKOutputPort(t.TypedDict): + """Port that comes out of ELK.""" + + id: str + type: t.Literal["port"] + children: cabc.MutableSequence[ELKOutputLabel] + + position: ELKPoint + size: ELKSize + + +class ELKOutputLabel(t.TypedDict): + """Label that comes out of ELK.""" + + id: str + type: t.Literal["label"] + text: str + + position: ELKPoint + size: ELKSize + + +class ELKOutputEdge(t.TypedDict): + """Edge that comes out of ELK.""" + + id: str + type: t.Literal["edge"] + sourceId: str + targetId: str + routingPoints: cabc.MutableSequence[ELKPoint] + children: cabc.MutableSequence[ELKOutputLabel] + + +ELKOutputChild = t.Union[ # type: ignore + ELKOutputNode, ELKOutputPort, ELKOutputLabel, ELKOutputEdge +] +""" +Type alias for `ELKOutputNode`, `ELKOutputPort`, `ELKOutputLabel` or +`ELKOutputEdge`. +""" + + +class NodeJSError(RuntimeError): + """An error happened during node execution.""" + + +class ExecutableNotFoundError(NodeJSError, FileNotFoundError): + """The required executable could not be found in the PATH.""" + + +class NodeInstallationError(NodeJSError): + """Installation of the node.js package failed.""" + + +def _find_node_and_npm() -> None: + """Find executables for ``node`` and ``npm``. + + Raises + ------ + NodeJSError + When ``node`` or ``npm`` cannot be found in any of the + directories registered in the environment variable ``PATH``. + """ + for i in ("node", "npm"): + if shutil.which(i) is None: + raise ExecutableNotFoundError(i) + + +def _get_installed_npm_pkg_versions() -> t.Dict[str, str]: + """Read installed npm packages and versions. + + Returns + ------- + dict + Dictionary with installed npm package name (key), package + version (val) + """ + installed_npm_pkg_versions: t.Dict[str, str] = {} + package_lock_file_path: Path = NODE_HOME.parent / "package-lock.json" + if not package_lock_file_path.is_file(): + return installed_npm_pkg_versions + package_lock: t.Dict[str, t.Any] = json.loads( + package_lock_file_path.read_text() + ) + if "packages" not in package_lock: + return installed_npm_pkg_versions + pkg_rel_path: str + pkg_info: t.Dict[str, str] + for pkg_rel_path, pkg_info in package_lock["packages"].items(): + if not pkg_rel_path.startswith("node_modules/"): + continue + if "version" not in pkg_info: + log.warning( + "Broken NPM lock file at %r: cannot find version of %s", + str(package_lock_file_path), + pkg_rel_path, + ) + continue + pkg_name: str = pkg_rel_path.replace("node_modules/", "") + installed_npm_pkg_versions[pkg_name] = pkg_info["version"] + return installed_npm_pkg_versions + + +def _install_npm_package(npm_pkg_name: str, npm_pkg_version: str) -> None: + log.debug("Installing package %r into %s", npm_pkg_name, NODE_HOME) + proc = subprocess.run( + [ + "npm", + "install", + "--prefix", + str(NODE_HOME.parent), + f"{npm_pkg_name}@{npm_pkg_version}", + ], + executable=shutil.which("npm"), + capture_output=True, + check=False, + text=True, + ) + if proc.returncode: + log.getChild("node").error("%s", proc.stderr) + raise NodeInstallationError(npm_pkg_name) + + +def _install_required_npm_pkg_versions() -> None: + if not NODE_HOME.is_dir(): + NODE_HOME.mkdir(parents=True) + installed = _get_installed_npm_pkg_versions() + for pkg_name, pkg_version in REQUIRED_NPM_PKG_VERSIONS.items(): + if installed.get(pkg_name) != pkg_version: + _install_npm_package(pkg_name, pkg_version) + + +def call_elkjs(elk_dict: ELKInputData) -> ELKOutputData: + """Call into elk.js to auto-layout the ``diagram``. + + Parameters + ---------- + elk_dict + The diagram data, sans layouting information + + Returns + ------- + layouted_diagram + The diagram data, augmented with layouting information + """ + _find_node_and_npm() + _install_required_npm_pkg_versions() + + proc = subprocess.run( + ["node", str(PATH_TO_ELK_JS)], + executable=shutil.which("node"), + capture_output=True, + check=False, + input=json.dumps(elk_dict), + text=True, + env={**os.environ, "NODE_PATH": str(NODE_HOME)}, + ) + if proc.returncode: + log.getChild("node").error("%s", proc.stderr) + raise NodeJSError("elk.js process failed") + + return json.loads(proc.stdout) + + +def get_global_layered_layout_options() -> LayoutOptions: + """Return optimal ELKLayered configuration.""" + return LAYOUT_OPTIONS diff --git a/capellambse_context_diagrams/collectors/__init__.py b/capellambse_context_diagrams/collectors/__init__.py new file mode 100644 index 00000000..962d422d --- /dev/null +++ b/capellambse_context_diagrams/collectors/__init__.py @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 + +""" +Functionality for collection of model data from an instance of +[`MelodyModel`][capellambse.model.MelodyModel] and conversion of it into +[`_elkjs.ELKInputData`][capellambse_context_diagrams._elkjs.ELKInputData]. +""" +from __future__ import annotations + +import logging +import typing as t + +from .. import _elkjs, context +from . import default, generic, portless + +__all__ = ["get_elkdata"] +logger = logging.getLogger(__name__) + + +def get_elkdata( + diagram: context.ContextDiagram, params: dict[str, t.Any] = None +) -> _elkjs.ELKInputData: + """ + High level collector function to collect needed data for ELK + + Parameters + ---------- + diagram + The [`ContextDiagram`][capellambse_context_diagrams.context.ContextDiagram] + instance to get the + [`_elkjs.ELKInputData`][capellambse_context_diagrams._elkjs.ELKInputData] + for. + params + Optional render params dictionary. + + Returns + ------- + elkdata + The data that can be fed into elkjs. + """ + + if diagram.type in generic.PORTLESS_DIAGRAM_TYPES: + collector = portless.collector + else: + collector = default.collector + + return collector(diagram, params) diff --git a/capellambse_context_diagrams/collectors/default.py b/capellambse_context_diagrams/collectors/default.py new file mode 100644 index 00000000..d664cf89 --- /dev/null +++ b/capellambse_context_diagrams/collectors/default.py @@ -0,0 +1,170 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 +""" +Collection of [`ELKInputData`][capellambse_context_diagrams._elkjs.ELKInputData] +on diagrams that involve ports. +""" +from __future__ import annotations + +import collections.abc as cabc +import typing as t + +from capellambse import helpers +from capellambse.model import common +from capellambse.model.crosslayer import cs, fa + +from .. import _elkjs, context +from . import generic, makers + + +def collector( + diagram: context.ContextDiagram, params: dict[str, t.Any] = None +) -> _elkjs.ELKInputData: + """Collect context data from ports of centric box.""" + data = generic.collector(diagram) + ports = port_collector(diagram.target) + centerbox = data["children"][0] + centerbox["ports"] = [makers.make_port(i.uuid) for i in ports] + connections = port_exchange_collector(ports) + for ex in connections: + try: + generic.exchange_data_collector( + generic.ExchangeData(ex, data, diagram.filters, params) + ) + except AttributeError: + continue + + stack_heights: dict[str, float | int] = { + "input": -makers.NEIGHBOR_VMARGIN, + "output": -makers.NEIGHBOR_VMARGIN, + } + already_made = {centerbox["id"]} + for i, ports, side in port_context_collector(connections, ports): + _, label_height = helpers.get_text_extent(i.name) + height = max( + label_height + 2 * makers.LABEL_VPAD, + makers.PORT_PADDING + + (makers.PORT_SIZE + makers.PORT_PADDING) * len(ports), + ) + if i.uuid not in already_made: + box = makers.make_box(i, height=height) + box["ports"] = [makers.make_port(j.uuid) for j in ports] + elif i.uuid != centerbox["id"]: + box = next(b for b in data["children"] if b["id"] == i.uuid) + else: # Circle + continue + + stack_heights[side] += makers.NEIGHBOR_VMARGIN + height + data["children"].append(box) + + centerbox["height"] = max(centerbox["height"], *stack_heights.values()) + return data + + +def port_collector( + target: common.GenericElement | common.ElementList, +) -> list[common.GenericElement]: + """Savely collect ports from `target`.""" + + def __collect(target): + all_ports: list[common.GenericElement] = [] + for attr in generic.CONNECTOR_ATTR_NAMES | {"ports"}: + try: + ports = getattr(target, attr) + if ports and isinstance( + ports[0], + (fa.FunctionPort, fa.ComponentPort, cs.PhysicalPort), + ): + all_ports.extend(ports) + except AttributeError: + pass + return all_ports + + if isinstance(target, cabc.Iterable): + assert not isinstance(target, common.GenericElement) + all_ports: list[common.GenericElement] = [] + for obj in target: + all_ports.extend(__collect(obj)) + else: + all_ports = __collect(target) + return all_ports + + +def port_exchange_collector( + ports: t.Iterable[common.GenericElement], +) -> list[common.GenericElement]: + """Savely collect exchanges from `ports`.""" + exchanges: list[common.GenericElement] = [] + for i in ports: + try: + exchanges.extend(getattr(i, "exchanges")) + except AttributeError: + pass + return exchanges + + +class ContextInfo(t.NamedTuple): + """ContextInfo data.""" + + element: common.GenericElement + """An element of context.""" + ports: list[common.GenericElement] + """The context element's relevant ports. + + This list only contains ports that at least one of the exchanges + passed into ``collect_exchanges`` sees. + """ + side: t.Literal["input", "output"] + """Whether this is an input or output to the element of interest.""" + + +def port_context_collector( + exchanges: t.Iterable[common.GenericElement], + local_ports: t.Container[common.GenericElement], +) -> t.Iterator[ContextInfo]: + """Collect the context objects. + + Parameters + ---------- + exchanges + The exchanges to look at to find new elements. + local_ports + Connectors/Ports lookup where `exchanges` is checked against. + If an exchange connects via a port from `local_ports` it is + collected. + + Returns + ------- + contexts + An iterator over + [`ContextDiagram.ContextInfo`s][capellambse_context_diagrams.context.ContextDiagram]. + """ + + ctx: dict[str, ContextInfo] = {} + side: t.Literal["input", "output"] + for exchange in exchanges: + try: + source, target = generic.collect_exchange_endpoints(exchange) + except AttributeError: + continue + + if source in local_ports: + port = target + side = "output" + elif target in local_ports: + port = source + side = "input" + else: + continue + + try: + owner = port.owner # type: ignore[attr-defined] + except AttributeError: + continue + + info = ContextInfo(owner, [], side) + info = ctx.setdefault(owner.uuid, info) + if port not in info.ports: + info.ports.append(port) + + return iter(ctx.values()) diff --git a/capellambse_context_diagrams/collectors/exchanges.py b/capellambse_context_diagrams/collectors/exchanges.py new file mode 100644 index 00000000..f576a7f5 --- /dev/null +++ b/capellambse_context_diagrams/collectors/exchanges.py @@ -0,0 +1,301 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import abc +import collections.abc as cabc +import logging +import operator +import typing as t + +from capellambse import helpers +from capellambse.model import common +from capellambse.model.modeltypes import DiagramType as DT + +from .. import _elkjs, context +from . import generic, makers + +logger = logging.getLogger(__name__) + + +class ExchangeCollector(metaclass=abc.ABCMeta): + """Base class for context collection on Exchanges.""" + + intermap = { + DT.OAB: ("source", "target", "allocated_interactions", "activities"), + DT.SAB: ( + "source.parent", + "target.parent", + "allocated_functional_exchanges", + "allocated_functions", + ), + DT.LAB: ( + "source.parent", + "target.parent", + "allocated_functional_exchanges", + "functions", + ), + DT.PAB: ( + "source.parent", + "target.parent", + "allocated_functional_exchanges", + "functions", + ), + } + + def __init__( + self, + diagram: context.InterfaceContextDiagram + | context.FunctionalContextDiagram, + data: _elkjs.ELKInputData, + ) -> None: + self.diagram = diagram + self.data: _elkjs.ELKInputData = data + self.obj = self.diagram.target + src, trg, alloc_fex, fncs = self.intermap[diagram.type] + self.get_source = operator.attrgetter(src) + self.get_target = operator.attrgetter(trg) + self.get_alloc_fex = operator.attrgetter(alloc_fex) + self.get_functions = operator.attrgetter(fncs) + + def get_functions_and_exchanges( + self, comp: common.GenericElement, interface: common.GenericElement + ) -> tuple[ + list[common.GenericElement], + list[common.GenericElement], + list[common.GenericElement], + ]: + """Return `Function`s, incoming and outgoing + `FunctionalExchange`s for given `Component` and `interface`. + """ + functions, outgoings, incomings = [], [], [] + alloc_functions = self.get_functions(comp) + for fex in self.get_alloc_fex(interface): + source = self.get_source(fex) + if source in alloc_functions: + if fex not in outgoings: + outgoings.append(fex) + if source not in functions: + functions.append(source) + + target = self.get_target(fex) + if target in alloc_functions: + if fex not in incomings: + incomings.append(fex) + if target not in functions: + functions.append(target) + + return functions, incomings, outgoings + + def make_ports_and_update_children_size( + self, + data: _elkjs.ELKInputChild, + exchanges: t.Sequence[_elkjs.ELKInputEdge], + ) -> None: + """Adjust size of functions and make ports.""" + stack_height = -makers.NEIGHBOR_VMARGIN + for child in data["children"]: + inputs, outputs = [], [] + obj = self.obj._model.by_uuid(child["id"]) + for ex in exchanges: + source, target = ex["sources"][0], ex["targets"][0] + port_ids = [p.uuid for p in obj.inputs + obj.outputs] + if source in port_ids: + outputs.append(source) + elif target in port_ids: + inputs.append(target) + + if self.diagram.type not in generic.PORTLESS_DIAGRAM_TYPES: + child["ports"] = [ + makers.make_port(i) for i in set(inputs + outputs) + ] + + childnum = max(len(inputs), len(outputs)) + height = max( + child["height"] + 2 * makers.LABEL_VPAD, + makers.PORT_PADDING + + (makers.PORT_SIZE + makers.PORT_PADDING) * childnum, + ) + child["height"] = height + stack_height += makers.NEIGHBOR_VMARGIN + height + + data["height"] = stack_height + + @abc.abstractmethod + def collect(self) -> cabc.MutableSequence[_elkjs.ELKInputEdge]: + return NotImplemented + + +def get_elkdata_for_exchanges( + diagram: context.InterfaceContextDiagram + | context.FunctionalContextDiagram, + collector_type: type[ExchangeCollector], +) -> _elkjs.ELKInputData: + """Return exchange data for ELK.""" + data = generic.collector(diagram) + collector = collector_type(diagram, data) + data["edges"] = collector.collect() + for comp in data["children"]: + collector.make_ports_and_update_children_size(comp, data["edges"]) + + return data + + +class InterfaceContextCollector(ExchangeCollector): + """Collect necessary + [`_elkjs.ELKInputData`][capellambse_context_diagrams._elkjs.ELKInputData] + for building the interface context. + """ + + left: common.GenericElement + """Source or target Component of the interface.""" + right: common.GenericElement + """Source or target Component of the interface.""" + outgoing_edges: list[common.GenericElement] + incoming_edges: list[common.GenericElement] + + def __init__( + self, + diagram: context.InterfaceContextDiagram, + data: _elkjs.ELKInputData, + ) -> None: + super().__init__(diagram, data) + self.data["children"] = [] + self.get_left_and_right() + + def get_left_and_right(self) -> None: + made_children: set[str] = set() + + def get_capella_order( + comp: common.GenericElement, functions: list[common.GenericElement] + ) -> list[common.GenericElement]: + return [fnc for fnc in comp.functions if fnc in functions] + + def make_boxes( + comp: common.GenericElement, functions: list[common.GenericElement] + ) -> None: + if comp.uuid not in made_children: + box = makers.make_box(comp) + box["children"] = [ + makers.make_box(c) + for c in functions + if c in self.get_functions(comp) + ] + self.data["children"].append(box) + made_children.add(comp.uuid) + + try: + comp = self.get_source(self.obj) + functions, incs, outs = self.get_functions_and_exchanges( + comp, self.obj + ) + inc_port_ids = set(ex.target.uuid for ex in incs) + out_port_ids = set(ex.source.uuid for ex in outs) + port_spread = len(out_port_ids) - len(inc_port_ids) + + _comp = self.get_target(self.obj) + _functions, _, _ = self.get_functions_and_exchanges( + _comp, self.obj + ) + _inc_port_ids = set(ex.target.uuid for ex in outs) + _out_port_ids = set(ex.source.uuid for ex in incs) + _port_spread = len(_out_port_ids) - len(_inc_port_ids) + functions = get_capella_order(comp, functions) + _functions = get_capella_order(_comp, _functions) + if port_spread >= _port_spread: + self.left = comp + self.right = _comp + self.outgoing_edges = outs + self.incoming_edges = incs + left_functions = functions + right_functions = _functions + else: + self.left = _comp + self.right = comp + self.outgoing_edges = incs + self.incoming_edges = outs + left_functions = _functions + right_functions = functions + + make_boxes(self.left, left_functions) + make_boxes(self.right, right_functions) + except AttributeError: + pass + + def collect(self) -> cabc.MutableSequence[_elkjs.ELKInputEdge]: + """Return all allocated `FunctionalExchange`s in the context.""" + functional_exchanges: list[_elkjs.ELKInputEdge] = [] + try: + for ex in self.incoming_edges + self.outgoing_edges: + try: + src, tgt = generic.collect_exchange_endpoints(ex) + except AttributeError: + continue + + width, height = helpers.extent_func(ex.name) + swap = ex in self.incoming_edges + functional_exchanges.append( + _elkjs.ELKInputEdge( + id=ex.uuid, + sources=[tgt.uuid] if swap else [src.uuid], + targets=[src.uuid] if swap else [tgt.uuid], + labels=[ + _elkjs.ELKInputLabel( + text=ex.name, + width=width + 2 * makers.LABEL_HPAD, + height=height + 2 * makers.LABEL_VPAD, + ) + ], + ) + ) + + if not functional_exchanges: + logger.warning( + "There are no FunctionalExchanges allocated to '%s'.", + self.obj.name, + ) + except AttributeError: + pass + + return functional_exchanges + + +class FunctionalContextCollector(ExchangeCollector): + def collect(self) -> cabc.MutableSequence[_elkjs.ELKInputEdge]: + functional_exchanges: list[common.GenericElement] = [] + all_functions: list[common.GenericElement] = [] + made_children: set[str] = {self.obj.uuid} + try: + for interface in self.obj.exchanges: + if self.get_source(interface) == self.obj: + comp = self.get_target(interface) + else: + comp = self.get_source(interface) + + functions, inc, outs = self.get_functions_and_exchanges( + self.obj, interface + ) + if comp.uuid not in made_children: + box = makers.make_box(comp) + box["children"] = [makers.make_box(c) for c in functions] + self.data["children"].append(box) + made_children.add(comp.uuid) + + all_functions.extend(functions) + functional_exchanges.extend(inc + outs) + + self.data["children"][0]["children"] = [ + makers.make_box(c) + for c in all_functions + if c in self.obj.functions + ] + except AttributeError: + pass + + for ex in functional_exchanges: + generic.exchange_data_collector( + generic.ExchangeData(ex, self.data, set()) + ) + + return self.data["edges"] diff --git a/capellambse_context_diagrams/collectors/generic.py b/capellambse_context_diagrams/collectors/generic.py new file mode 100644 index 00000000..f1183bb1 --- /dev/null +++ b/capellambse_context_diagrams/collectors/generic.py @@ -0,0 +1,162 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 +""" +Functionality for collection of model data from an instance of [`MelodyModel`][capellambse.model.MelodyModel] +and conversion of it into [`_elkjs.ELKInputData`][capellambse_context_diagrams._elkjs.ELKInputData]. +""" + +from __future__ import annotations + +import collections.abc as cabc +import logging +import typing as t + +from capellambse import helpers +from capellambse.model import common +from capellambse.model.crosslayer import interaction +from capellambse.model.modeltypes import DiagramType as DT + +from .. import _elkjs, context, filters +from . import makers + +logger = logging.getLogger(__name__) + +SourceAndTarget = tuple[common.GenericElement, common.GenericElement] + + +CONNECTOR_ATTR_NAMES = {"inputs", "outputs"} +"""Attribute of GenericElements for receiving connections.""" +PORTLESS_DIAGRAM_TYPES = {DT.OAB, DT.OAIB, DT.OCB, DT.MCB} +"""Supported diagram types without connectors (i.e. ports).""" +MARKER_SIZE = 3 +"""Default size of marker-ends in pixels.""" +MARKER_PADDING = makers.PORT_PADDING +"""Default padding of markers in pixels.""" + + +def collector( + diagram: context.ContextDiagram, *, width: int | float = makers.EOI_WIDTH +) -> _elkjs.ELKInputData: + """Returns `ELKInputData` with only centerbox in children and config.""" + return { + "id": diagram.uuid, + "layoutOptions": _elkjs.get_global_layered_layout_options(), + "children": [makers.make_box(diagram.target, width=width)], + "edges": [], + } + + +def collect_exchange_endpoints( + e: common.GenericElement, +) -> tuple[common.GenericElement, common.GenericElement]: + """Safely collect exchange endpoints from `e`.""" + return e.source, e.target + + +class ExchangeData(t.NamedTuple): + """Exchange data for ELK.""" + + exchange: common.GenericElement + """An exchange from the capellambse model.""" + elkdata: _elkjs.ELKInputData + """The collected elkdata to add the edges in there.""" + filter_iterable: cabc.Iterable[str] + """ + A string that maps to a filter label adjuster + callable in + [`FILTER_LABEL_ADJUSTERS`][capellambse_context_diagrams.filters.FILTER_LABEL_ADJUSTERS]. + """ + params: dict[str, t.Any] | None = None + """Optional dictionary of additional render params.""" + + +def exchange_data_collector( + data: ExchangeData, + endpoint_collector: cabc.Callable[ + [common.GenericElement], SourceAndTarget + ] = collect_exchange_endpoints, +) -> tuple[common.GenericElement, common.GenericElement]: + """Return source and target port from `exchange`. + + Additionally inflate `elkdata["children"]` with input data for ELK. + You can handover a filter name that corresponds to capellambse + filters. This will apply filter functionality from + [`filters.FILTER_LABEL_ADJUSTERS`][capellambse_context_diagrams.filters.FILTER_LABEL_ADJUSTERS]. + + Parameters + ---------- + data + Instance of [`ExchangeData`][capellambse_context_diagrams.collectors.generic.ExchangeData] + storing all needed elements for collection. + endpoint_collector + Optional collector function for Exchange endpoints. Defaults to + [`collect_exchange_endpoints`][capellambse_context_diagrams.collectors.generic.collect_exchange_endpoints]. + + Returns + ------- + source, target + A tuple consisting of the exchange's source and target elements. + """ + source, target = endpoint_collector(data.exchange) + label = collect_label(data.exchange) + for filter in data.filter_iterable: + try: + label = filters.FILTER_LABEL_ADJUSTERS[filter]( + data.exchange, label + ) + except KeyError: + logger.exception( + "There is no filter labelled: '%s' in filters.FILTER_LABEL_ADJUSTERS", + filter, + ) + + params = (data.params or {}).copy() + # Remove simple render parameters from params + no_edgelabels: bool = params.pop("no_edgelabels", False) + + render_adj: dict[str, t.Any] = {} + for name, value in params.items(): + try: + filters.RENDER_ADJUSTERS[name](value, data.exchange, render_adj) + except KeyError: + logger.exception( + "There is no render parameter solver labelled: '%s' in filters.RENDER_ADJUSTERS", + name, + ) + + data.elkdata["edges"].append( + { + "id": render_adj.get("id", data.exchange.uuid), + "sources": [render_adj.get("sources", source.uuid)], + "targets": [render_adj.get("targets", target.uuid)], + }, + ) + if label and not no_edgelabels: + width, height = helpers.extent_func(label) + data.elkdata["edges"][-1]["labels"] = [ + { + "text": render_adj.get("labels_text", label), + "width": render_adj.get( + "labels_width", width + 2 * makers.LABEL_HPAD + ), + "height": render_adj.get( + "labels_height", height + 2 * makers.LABEL_VPAD + ), + } + ] + + return source, target + + +def collect_label(obj: common.GenericElement) -> str | None: + """Return the label of a given object. + + The label usually comes from the `.name` attribute. Special handling + for [`interaction.AbstractCapabilityExtend`][capellambse.model.crosslayer.interaction.AbstractCapabilityExtend] + and [interaction.AbstractCapabilityInclude`][capellambse.model.crosslayer.interaction.AbstractCapabilityInclude]. + """ + if isinstance(obj, interaction.AbstractCapabilityExtend): + return "« e »" + elif isinstance(obj, interaction.AbstractCapabilityInclude): + return "« i »" + return "" if obj.name.startswith("(Unnamed") else obj.name diff --git a/capellambse_context_diagrams/collectors/makers.py b/capellambse_context_diagrams/collectors/makers.py new file mode 100644 index 00000000..aad4c8e9 --- /dev/null +++ b/capellambse_context_diagrams/collectors/makers.py @@ -0,0 +1,105 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from capellambse import helpers +from capellambse.model import common, layers +from capellambse.svg.decorations import icon_padding, icon_size + +from .. import _elkjs + +PORT_SIZE = 10 +"""Default size of ports in pixels.""" +PORT_PADDING = 2 +"""Default padding of ports in pixels.""" +LABEL_HPAD = 15 +"""Horizontal padding left and right of the label.""" +LABEL_VPAD = 5 +"""Vertical padding above and below the label.""" +NEIGHBOR_VMARGIN = 20 +"""Vertical space between two neighboring boxes.""" +EOI_WIDTH = 150 +"""The width of the element of interest.""" +MIN_SYMBOL_WIDTH = 30 +"""Minimum width of symbols.""" +MIN_SYMBOL_HEIGHT = 17 +"""Minimum height of symbols.""" +MAX_SYMBOL_WIDTH = 150 +"""Maximum width of symbols.""" +MAX_SYMBOL_HEIGHT = 135 +"""Maximum height of symbols.""" +SYMBOL_RATIO = MIN_SYMBOL_WIDTH / MIN_SYMBOL_HEIGHT +"""Width and height ratio of symbols.""" +FAULT_PAD = 10 +"""Height adjustment for labels.""" +BOX_TO_SYMBOL = ( + layers.ctx.Capability, + layers.oa.OperationalCapability, + layers.ctx.Mission, + layers.ctx.SystemComponent, +) +""" +Types that need to be converted to symbols during serialization if +`display_symbols_as_boxes` attribute is `False`. +""" + + +def make_box( + obj: common.GenericElement, + *, + width: int | float = 0, + height: int | float = 0, + no_symbol: bool = False, +) -> _elkjs.ELKInputChild: + """Return an + [`ELKInputChild`][capellambse_context_diagrams._elkjs.ELKInputChild]. + """ + labels = [make_label(obj)] + if not no_symbol and is_symbol(obj): + if height < MIN_SYMBOL_HEIGHT: + height = MIN_SYMBOL_HEIGHT + elif height > MAX_SYMBOL_HEIGHT: + height = MAX_SYMBOL_HEIGHT + width = height * SYMBOL_RATIO + labels[0]["layoutOptions"] = { + "nodeLabels.placement": "OUTSIDE, V_BOTTOM, H_CENTER" + } + else: + icon = icon_size + icon_padding * 2 + height = max( + height, + sum(label["height"] for label in labels) + icon, + ) + width = max(width, max(label["width"] for label in labels) + icon) + + return {"id": obj.uuid, "labels": labels, "width": width, "height": height} + + +def is_symbol(obj: common.GenericElement) -> bool: + """Check if given `obj` is rendered as a Symbol instead of a Box.""" + return isinstance(obj, BOX_TO_SYMBOL) + + +def make_label(obj: common.GenericElement) -> _elkjs.ELKInputLabel: + """Return an + [`ELKInputLabel`][capellambse_context_diagrams._elkjs.ELKInputLabel]. + """ + label_width, label_height = helpers.get_text_extent(obj.name) + return { + "text": obj.name, + "width": label_width + 2 * LABEL_HPAD, + "height": label_height + 2 * LABEL_VPAD, + } + + +def make_port(uuid: str) -> _elkjs.ELKInputPort: + """Return an + [`ELKInputPort`][capellambse_context_diagrams._elkjs.ELKInputPort]. + """ + return { + "id": uuid, + "width": 10, + "height": 10, + "layoutOptions": {"borderOffset": -8}, + } diff --git a/capellambse_context_diagrams/collectors/portless.py b/capellambse_context_diagrams/collectors/portless.py new file mode 100644 index 00000000..3f22cac3 --- /dev/null +++ b/capellambse_context_diagrams/collectors/portless.py @@ -0,0 +1,169 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 + +""" +Collection of [`ELKInputData`][capellambse_context_diagrams._elkjs.ELKInputData] +on diagrams that don't involve ports or any connectors. +""" +from __future__ import annotations + +import typing as t + +from capellambse.model import common, layers + +from .. import _elkjs, context +from . import generic, makers + +SOURCE_ATTR_NAMES = frozenset(("parent",)) +TARGET_ATTR_NAMES = frozenset(("involved", "capability")) + + +def collector( + diagram: context.ContextDiagram, params: dict[str, t.Any] = None +) -> _elkjs.ELKInputData: + """Collect context data from exchanges of centric box. + + This is the special context collector for the operational + architecture layer diagrams (diagrams where elements don't exchange + via ports/connectors). + """ + data = generic.collector(diagram) + centerbox = data["children"][0] + connections = list(get_exchanges(diagram.target)) + for ex in connections: + try: + generic.exchange_data_collector( + generic.ExchangeData(ex, data, diagram.filters, params), + collect_exchange_endpoints, + ) + except AttributeError: + continue + + stack_heights: dict[str, float | int] = { + "input": -makers.NEIGHBOR_VMARGIN, + "output": -makers.NEIGHBOR_VMARGIN, + } + contexts = context_collector(connections, diagram.target) + already_made = {centerbox["id"]} + for i, exchanges, side in contexts: + var_height = generic.MARKER_PADDING + ( + generic.MARKER_SIZE + generic.MARKER_PADDING + ) * len(exchanges) + if not diagram.display_symbols_as_boxes and makers.is_symbol( + diagram.target + ): + height = max(makers.MIN_SYMBOL_HEIGHT, var_height) + else: + height = var_height + + if i.uuid not in already_made: + box = makers.make_box( + i, height=height, no_symbol=diagram.display_symbols_as_boxes + ) + elif i.uuid != centerbox["id"]: + box = next(b for b in data["children"] if b["id"] == i.uuid) + box["height"] = height + else: # Circle + continue + + stack_heights[side] += makers.NEIGHBOR_VMARGIN + height + data["children"].append(box) + + centerbox["height"] = max(centerbox["height"], *stack_heights.values()) + centerbox["width"] = ( + max(label["width"] for label in centerbox["labels"]) + + 2 * makers.LABEL_HPAD + ) + if not diagram.display_symbols_as_boxes and makers.is_symbol( + diagram.target + ): + data["layoutOptions"]["spacing.labelNode"] = 5.0 + centerbox["width"] = centerbox["height"] * makers.SYMBOL_RATIO + return data + + +def collect_exchange_endpoints( + e: common.GenericElement, +) -> tuple[common.GenericElement, common.GenericElement]: + """Safely collect exchange endpoints from `e`.""" + + def _get( + e: common.GenericElement, attrs: t.FrozenSet[str] + ) -> common.GenericElement: + for attr in attrs: + try: + obj = getattr(e, attr) + assert isinstance(obj, common.GenericElement) + return obj + except AttributeError: + continue + raise AttributeError() + + try: + return _get(e, SOURCE_ATTR_NAMES), _get(e, TARGET_ATTR_NAMES) + except AttributeError: + pass + return generic.collect_exchange_endpoints(e) + + +class ContextInfo(t.NamedTuple): + """ContextInfo data.""" + + element: common.GenericElement + """An element of context.""" + connections: list[common.GenericElement] + """The context element's relevant exchanges.""" + side: t.Literal["input", "output"] + """Whether this is an input or output to the element of interest.""" + + +def context_collector( + exchanges: t.Iterable[common.GenericElement], + obj_oi: common.GenericElement, +) -> t.Iterator[ContextInfo]: + ctx: dict[str, ContextInfo] = {} + side: t.Literal["input", "output"] + for exchange in exchanges: + try: + source, target = collect_exchange_endpoints(exchange) + except AttributeError: + continue + + if source == obj_oi: + obj = target + side = "output" + else: + obj = source + side = "input" + + info = ContextInfo(obj, [], side) + info = ctx.setdefault(obj.uuid, info) + if exchange not in info.connections: + info.connections.append(exchange) + + return iter(ctx.values()) + + +def get_exchanges( + obj: common.GenericElement, +) -> t.Iterator[common.GenericElement]: + """Yield exchanges safely. + + Yields exchanges from `.exchanges` or `.extends`, `.includes` and + `.inheritance` (exclusively for Capabilities). + """ + is_op_capability = isinstance(obj, layers.oa.OperationalCapability) + is_capability = isinstance(obj, layers.ctx.Capability) + if is_op_capability or is_capability: + exchanges = obj.includes + obj.extends + obj.generalizes + elif isinstance(obj, layers.ctx.Mission): + exchanges = obj.involvements + obj.exploitations + else: + exchanges = obj.exchanges + + if is_op_capability: + exchanges += obj.entity_involvements + elif is_capability: + exchanges += obj.component_involvements + obj.incoming_exploitations + + yield from exchanges diff --git a/capellambse_context_diagrams/context.py b/capellambse_context_diagrams/context.py new file mode 100644 index 00000000..f08b44cb --- /dev/null +++ b/capellambse_context_diagrams/context.py @@ -0,0 +1,304 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 +""" +Definitions of Custom Accessor- and Diagram-Classtypes based on +[`Accessor`][capellambse.model.common.accessors.Accessor] and [`AbstractDiagram`][capellambse.model.diagram.AbstractDiagram]. +""" +from __future__ import annotations + +import collections.abc as cabc +import json +import logging +import typing as t + +from capellambse import aird +from capellambse.model import common, diagram, modeltypes + +from . import _elkjs, filters, serializers, styling +from .collectors import exchanges, get_elkdata + +logger = logging.getLogger(__name__) + + +class ContextAccessor(common.Accessor): + """Provides access to the context diagrams.""" + + def __init__(self, dgcls: str) -> None: + self._dgcls = dgcls + + @t.overload + def __get__(self, obj: None, objtype=None) -> common.Accessor: + ... + + @t.overload + def __get__( + self, + obj: common.T, + objtype: type[common.T] | None = None, + ) -> ContextDiagram: + ... + + def __get__( + self, + obj: common.T | None, + objtype: type | None = None, + ) -> common.Accessor | ContextDiagram: + """Make a ContextDiagram for the given model object.""" + del objtype + if obj is None: # pragma: no cover + return self + assert isinstance(obj, common.GenericElement) + return self._get(obj, ContextDiagram) + + def _get( + self, + obj: common.GenericElement, + diagram_class: type[ContextDiagram], + diagram_id: str = "{}_context", + ) -> common.Accessor | ContextDiagram: + try: + cache = getattr( + obj._model, ".".join((__name__, diagram_class.__qualname__)) + ) + except AttributeError: + cache = {} + setattr( + obj._model, + ".".join((__name__, diagram_class.__qualname__)), + cache, + ) + diagram_id = diagram_id.format(obj.uuid) + try: + return cache[diagram_id] + except KeyError: + pass + + new_diagram = diagram_class(self._dgcls, obj) + new_diagram.filters.add(filters.NO_UUID) + cache[diagram_id] = new_diagram + return new_diagram + + +class InterfaceContextAccessor(ContextAccessor): + """Provides access to the interface context diagrams.""" + + def __init__( # pylint: disable=super-init-not-called + self, + diagclass: dict[type[common.GenericElement], str], + ) -> None: + self.__dgclasses = diagclass + + def __get__( # type: ignore + self, + obj: common.T | None, + objtype: type | None = None, + ) -> common.Accessor | ContextDiagram: + """Make a ContextDiagram for the given model object.""" + del objtype + if obj is None: # pragma: no cover + return self + assert isinstance(obj, common.GenericElement) + assert isinstance(obj.parent, common.GenericElement) + self._dgcls = self.__dgclasses[obj.parent.__class__] + return self._get(obj, InterfaceContextDiagram, "{}_interface_context") + + +class FunctionalContextAccessor(ContextAccessor): + def __get__( # type: ignore + self, + obj: common.T | None, + objtype: type | None = None, + ) -> common.Accessor | ContextDiagram: + """Make a ContextDiagram for the given model object.""" + del objtype + if obj is None: # pragma: no cover + return self + assert isinstance(obj, common.GenericElement) + return self._get( + obj, FunctionalContextDiagram, "{}_functional_context" + ) + + +class ContextDiagram(diagram.AbstractDiagram): + """ + An automatically generated context diagram. + + Attributes + ---------- + target + The `common.GenericElement` from which the context is collected + from. + styleclass + The diagram class (for e.g. [LAB]). + render_styles + Dictionary with the `ElkChildType` in str format as keys and + `styling.Styler` functions as values. An exanple is given by: + [`styling.BLUE_ACTOR_FNCS`][capellambse_context_diagrams.styling.BLUE_ACTOR_FNCS] + display_symbols_as_boxes + Display objects that are normally displayed as symbol as a + simple box instead, with the symbol being the box' icon. This + avoids the object of interest to become one giant, oversized + symbol in the middle of the diagram, and instead keeps the + symbol small and only enlarges the surrounding box. + serializer + The serializer builds an `aird.Diagram` via + [`serializers.DiagramSerializer.make_diagram`][capellambse_context_diagrams.serializers.DiagramSerializer.make_diagram] + by converting every + [`_elkjs.ELKOutputChild`][capellambse_context_diagrams._elkjs.ELKOutputChild] + into an `aird.Box`, `aird.Edge` or `aird.Circle`. + filters + A list of filter names that are applied during collection of + context. Currently this is only done in + [`collectors.exchange_data_collector`][capellambse_context_diagrams.collectors.generic.exchange_data_collector]. + """ + + @property + def uuid(self) -> str: # type: ignore + """Returns diagram UUID.""" + return f"{self.target.uuid}_context" + + @property + def name(self) -> str: # type: ignore + """Returns the diagram name.""" + return f"Context of {self.target.name}" + + @property + def type(self) -> modeltypes.DiagramType: + """Return the type of this diagram.""" + try: + return modeltypes.DiagramType(self.styleclass) + except ValueError: # pragma: no cover + logger.warning("Unknown diagram type %r", self.styleclass) + return modeltypes.DiagramType.UNKNOWN + + @property + def nodes(self) -> common.MixedElementList: + """Return a list of all nodes visible in this diagram.""" + adiagram = self.render(None) + assert isinstance(adiagram, aird.Diagram) + allids = {e.uuid for e in iter(adiagram)} + assert None not in allids + elems = [] + for elemid in allids: + assert elemid is not None + try: + elem = self._model._loader[elemid] + except (KeyError, StopIteration): # pragma: no cover + continue + else: + # Filter out visual-only elements that live in the + # .aird / .airdfragment files + frag = self._model._loader.find_fragment(elem) + if frag.suffix not in {".aird", ".airdfragment"}: + elems.append(elem) + + return common.MixedElementList( + self._model, elems, common.GenericElement + ) + + class FilterSet(cabc.MutableSet): + """A set that stores filter_names and invalidates diagram cache.""" + + def __init__( + self, + diagram: diagram.AbstractDiagram, # pylint: disable=redefined-outer-name + ) -> None: + self._set: set[str] = set() + self._diagram = diagram + + def add(self, value: str) -> None: + if value not in filters.FILTER_LABEL_ADJUSTERS: + logger.error("The filter '%s' is not yet supported.", value) + return + if value not in self._set: + self._set.add(value) + + def discard(self, value: str) -> None: + if value in self._set: + self._diagram.invalidate_cache() + return self._set.discard(value) + + def __contains__(self, x: object) -> bool: + return self._set.__contains__(x) + + def __iter__(self) -> cabc.Iterator[str]: + return self._set.__iter__() + + def __len__(self) -> int: + return self._set.__len__() + + def __init__( + self, + class_: str, + obj: common.GenericElement, + *, + render_styles: dict[str, styling.Styler] | None = None, + display_symbols_as_boxes: bool = False, + ) -> None: + super().__init__(obj._model) + self.target = obj + self.styleclass = class_ + + self.render_styles = render_styles or styling.BLUE_ACTOR_FNCS + self.serializer = serializers.DiagramSerializer(self) + self.filters: cabc.MutableSet[str] = self.FilterSet(self) + self.display_symbols_as_boxes = display_symbols_as_boxes + + def _create_diagram( + self, + params: dict[str, t.Any], + elkdata: _elkjs.ELKInputData | None = None, + ) -> aird.Diagram: + try: + data = elkdata or get_elkdata(self, params) + layout = _elkjs.call_elkjs(data) + except json.JSONDecodeError as error: + logger.error(json.dumps(data, indent=4)) + raise error + return self.serializer.make_diagram(layout) + + +class InterfaceContextDiagram(ContextDiagram): + """An automatically generated Context Diagram exclusively for + ComponentExchanges. + """ + + @property + def name(self) -> str: # type: ignore + return f"Interface Context of {self.target.name}" + + def _create_diagram( + self, + params: dict[str, t.Any], + elkdata: _elkjs.ELKInputData | None = None, + ) -> aird.Diagram: + return super()._create_diagram( + params, + elkdata + or exchanges.get_elkdata_for_exchanges( + self, exchanges.InterfaceContextCollector + ), + ) + + +class FunctionalContextDiagram(ContextDiagram): + """An automatically generated Context Diagram exclusively for + Components. + """ + + @property + def name(self) -> str: # type: ignore + return f"Interface Context of {self.target.name}" + + def _create_diagram( + self, + params: dict[str, t.Any], + elkdata: _elkjs.ELKInputData | None = None, + ) -> aird.Diagram: + return super()._create_diagram( + params, + elkdata + or exchanges.get_elkdata_for_exchanges( + self, exchanges.FunctionalContextCollector + ), + ) diff --git a/capellambse_context_diagrams/elk.js b/capellambse_context_diagrams/elk.js new file mode 100644 index 00000000..278a5532 --- /dev/null +++ b/capellambse_context_diagrams/elk.js @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +// SPDX-License-Identifier: Apache-2.0 + +const elkgraphsprotty = require("./elkgraph-to-sprotty"); +const ELK = require("elkjs"); +const elk = new ELK(); + +var fs = require("fs"); +var stdinBuffer = fs.readFileSync(0); + +const elk_stdin = JSON.parse(stdinBuffer.toString()); + +elk + .layout(elk_stdin) + .then((res) => + console.log( + JSON.stringify(new elkgraphsprotty.ElkGraphJsonToSprotty().transform(res)) + ) + ) + .catch(console.error); diff --git a/capellambse_context_diagrams/elkgraph-json.js b/capellambse_context_diagrams/elkgraph-json.js new file mode 100644 index 00000000..c80219d6 --- /dev/null +++ b/capellambse_context_diagrams/elkgraph-json.js @@ -0,0 +1,19 @@ +/******************************************************************************* + * SPDX-FileCopyrightText: Copyright (c) 2021 Kiel University and others. + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ +"use strict"; +exports.__esModule = true; +exports.isExtended = exports.isPrimitive = void 0; +function isPrimitive(edge) { + return edge.source !== undefined && edge.target !== undefined; +} +exports.isPrimitive = isPrimitive; +function isExtended(edge) { + return edge.sources !== undefined && edge.targets !== undefined; +} +exports.isExtended = isExtended; diff --git a/capellambse_context_diagrams/elkgraph-to-sprotty.js b/capellambse_context_diagrams/elkgraph-to-sprotty.js new file mode 100644 index 00000000..fbf78cd3 --- /dev/null +++ b/capellambse_context_diagrams/elkgraph-to-sprotty.js @@ -0,0 +1,242 @@ +/******************************************************************************* + * SPDX-FileCopyrightText: Copyright (c) 2021 Kiel University and others. + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ +"use strict"; +exports.__esModule = true; +exports.ElkGraphJsonToSprotty = void 0; +var elkgraph_json_1 = require("./elkgraph-json"); +var ElkGraphJsonToSprotty = /** @class */ (function () { + function ElkGraphJsonToSprotty() { + this.nodeIds = new Set(); + this.edgeIds = new Set(); + this.portIds = new Set(); + this.labelIds = new Set(); + this.sectionIds = new Set(); + } + ElkGraphJsonToSprotty.prototype.transform = function (elkGraph) { + var _a, _b; + var _this = this; + var sGraph = { + type: "graph", + id: elkGraph.id || "root", + children: [], + }; + if (elkGraph.children) { + var children = elkGraph.children.map(function (n) { + return _this.transformElkNode(n); + }); + (_a = sGraph.children).push.apply(_a, children); + } + if (elkGraph.edges) { + var sEdges = elkGraph.edges.map(function (e) { + return _this.transformElkEdge(e); + }); + (_b = sGraph.children).push.apply(_b, sEdges); + } + return sGraph; + }; + ElkGraphJsonToSprotty.prototype.transformElkNode = function (elkNode) { + var _a, _b, _c, _d; + var _this = this; + this.checkAndRememberId(elkNode, this.nodeIds); + var sNode = { + type: "node", + id: elkNode.id, + position: this.pos(elkNode), + size: this.size(elkNode), + children: [], + }; + // children + if (elkNode.children) { + var sNodes = elkNode.children.map(function (n) { + return _this.transformElkNode(n); + }); + (_a = sNode.children).push.apply(_a, sNodes); + } + // ports + if (elkNode.ports) { + var sPorts = elkNode.ports.map(function (p) { + return _this.transformElkPort(p); + }); + (_b = sNode.children).push.apply(_b, sPorts); + } + // labels + if (elkNode.labels) { + var sLabels = elkNode.labels + .filter(function (l) { + return l.text !== undefined; + }) + .map(function (l) { + return _this.transformElkLabel(l); + }); + (_c = sNode.children).push.apply(_c, sLabels); + } + // edges + if (elkNode.edges) { + var sEdges = elkNode.edges.map(function (e) { + return _this.transformElkEdge(e); + }); + (_d = sNode.children).push.apply(_d, sEdges); + } + return sNode; + }; + ElkGraphJsonToSprotty.prototype.transformElkPort = function (elkPort) { + var _a; + var _this = this; + this.checkAndRememberId(elkPort, this.portIds); + var sPort = { + type: "port", + id: elkPort.id, + position: this.pos(elkPort), + size: this.size(elkPort), + children: [], + }; + // labels + if (elkPort.labels) { + var sLabels = elkPort.labels + .filter(function (l) { + return l.text !== undefined; + }) + .map(function (l) { + return _this.transformElkLabel(l); + }); + (_a = sPort.children).push.apply(_a, sLabels); + } + return sPort; + }; + ElkGraphJsonToSprotty.prototype.transformElkLabel = function (elkLabel) { + // For convenience, and since labels do not have to be referenced by other elements, + // we allow their ids to be generated on-the-fly + this.checkAndRememberId(elkLabel, this.labelIds, true); + return { + type: "label", + id: elkLabel.id, + text: elkLabel.text, + position: this.pos(elkLabel), + size: this.size(elkLabel), + }; + }; + /** + * Due to ELK issue #553 the computed layout of primitive edges is not transferred + * back in the correct way. Instead of using the primitive edge format the edge sections + * of the extended edge format are returned. + */ + ElkGraphJsonToSprotty.prototype.isBugged = function (elkEdge) { + return elkEdge.sections !== undefined && elkEdge.sections.length > 0; + }; + ElkGraphJsonToSprotty.prototype.transferSectionBendpoints = function ( + section, + sEdge + ) { + var _a; + this.checkAndRememberId(section, this.sectionIds); + sEdge.routingPoints.push(section.startPoint); + if (section.bendPoints) { + (_a = sEdge.routingPoints).push.apply(_a, section.bendPoints); + } + sEdge.routingPoints.push(section.endPoint); + }; + ElkGraphJsonToSprotty.prototype.transformElkEdge = function (elkEdge) { + var _a, _b; + var _this = this; + this.checkAndRememberId(elkEdge, this.edgeIds); + var sEdge = { + type: "edge", + id: elkEdge.id, + sourceId: "", + targetId: "", + routingPoints: [], + children: [], + }; + if (elkgraph_json_1.isPrimitive(elkEdge)) { + sEdge.sourceId = elkEdge.source; + sEdge.targetId = elkEdge.target; + // Workaround for ELK issue #553 + if (this.isBugged(elkEdge)) { + var section = elkEdge.sections[0]; + this.transferSectionBendpoints(section, sEdge); + } else { + if (elkEdge.sourcePoint) sEdge.routingPoints.push(elkEdge.sourcePoint); + if (elkEdge.bendPoints) + (_a = sEdge.routingPoints).push.apply(_a, elkEdge.bendPoints); + if (elkEdge.targetPoint) sEdge.routingPoints.push(elkEdge.targetPoint); + } + } else if (elkgraph_json_1.isExtended(elkEdge)) { + sEdge.sourceId = elkEdge.sources[0]; + sEdge.targetId = elkEdge.targets[0]; + if (elkEdge.sections) { + elkEdge.sections.forEach(function (section) { + return _this.transferSectionBendpoints(section, sEdge); + }); + } + } + if (elkEdge.junctionPoints) { + elkEdge.junctionPoints.forEach(function (jp, i) { + var sJunction = { + type: "junction", + id: elkEdge.id + "_j" + i, + position: jp, + }; + sEdge.children.push(sJunction); + }); + } + if (elkEdge.labels) { + var sLabels = elkEdge.labels + .filter(function (l) { + return l.text !== undefined; + }) + .map(function (l) { + return _this.transformElkLabel(l); + }); + (_b = sEdge.children).push.apply(_b, sLabels); + } + return sEdge; + }; + ElkGraphJsonToSprotty.prototype.pos = function (elkShape) { + return { x: elkShape.x || 0, y: elkShape.y || 0 }; + }; + ElkGraphJsonToSprotty.prototype.size = function (elkShape) { + return { width: elkShape.width || 0, height: elkShape.height || 0 }; + }; + ElkGraphJsonToSprotty.prototype.checkAndRememberId = function ( + e, + set, + generateIdIfRequired + ) { + if (generateIdIfRequired === void 0) { + generateIdIfRequired = false; + } + if (e.id === undefined) { + if (generateIdIfRequired) { + do { + e.id = this.generateRandomId(); + } while (set.has(e.id)); + } else { + throw Error("An element is missing an id."); + } + } + if (set.has(e.id)) { + throw Error("Duplicate id: " + e.id + "."); + } else { + set.add(e.id); + } + }; + ElkGraphJsonToSprotty.prototype.generateRandomId = function (length) { + if (length === void 0) { + length = 6; + } + var random = Math.random() * (Math.pow(10, length) - 1); + var padded = Math.floor(random) + ""; + while (padded.length < length) { + padded = "0" + padded; + } + return "g_" + padded; + }; + return ElkGraphJsonToSprotty; +})(); +exports.ElkGraphJsonToSprotty = ElkGraphJsonToSprotty; diff --git a/capellambse_context_diagrams/filters.py b/capellambse_context_diagrams/filters.py new file mode 100644 index 00000000..59075fe3 --- /dev/null +++ b/capellambse_context_diagrams/filters.py @@ -0,0 +1,97 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 +"""Functions and registry for filter functionality.""" +from __future__ import annotations + +import collections.abc as cabc +import importlib +import logging +import re +import typing as t + +from capellambse.model import common + +FEX_EX_ITEMS = "show.functional.exchanges.exchange.items.filter" +""" +Show the name of `FunctionalExchange` and its `ExchangeItems` wrapped in +[E1,...] and seperated by ',' - filter in Capella. +""" +EX_ITEMS = "show.exchange.items.filter" +""" +Show `ExchangeItems` wrapped in [E1,...] and seperated by ',' - filter +in Capella. +""" +FEX_OR_EX_ITEMS = "capellambse_context_diagrams-show.functional.exchanges.or.exchange.items.filter" +""" +Show either `FunctionalExchange` name or its `ExchangeItems` wrapped in +[E1,...] and seperated by ',' - Custom filter, not available in Capella. +""" +NO_UUID = "capellambse_context_diagrams-hide.uuids.filter" +"""Filter out UUIDs from label text.""" + +logger = logging.getLogger(__name__) + +UUID_PTRN = re.compile( + r"\s*\([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\)" +) +"""Regular expression pattern for UUIDs of `ModelObject`s.""" + + +def exchange_items(obj: common.GenericElement) -> str: + """ + Return `obj`'s `ExchangeItem`s wrapped in [E1,...] and separated + by ','. + """ + stringifier = importlib.import_module( + "capellambse.aird.parser._filters.global" + )._stringify_exchange_items + return stringifier(obj, obj._model._loader) + + +def exchange_name_and_items(obj: common.GenericElement) -> str: + """Return `obj`'s name and `ExchangeItem`s if there are any.""" + label = obj.name + ex_items = exchange_items(obj) + if ex_items: + label += " " + ex_items + return label + + +def uuid_filter(obj: common.GenericElement, label: str | None = None) -> str: + """Return `obj`'s name or `obj` if string w/o UUIDs in it.""" + filtered_label = label if label is not None else obj.name + assert isinstance(filtered_label, str) + return UUID_PTRN.sub("", filtered_label) + + +FILTER_LABEL_ADJUSTERS: dict[ + str, cabc.Callable[[common.GenericElement, str | None], str] +] = { + EX_ITEMS: lambda obj, _: exchange_items(obj), + FEX_EX_ITEMS: lambda obj, _: exchange_name_and_items(obj), + FEX_OR_EX_ITEMS: lambda obj, _: exchange_items(obj) + if obj.exchange_items + else obj.name, + NO_UUID: uuid_filter, +} +"""Label adjuster registry. """ + + +def sort_exchange_items_label( + value: bool, + exchange: common.GenericElement, + adjustments: dict[str, t.Any], +) -> None: + """Sort the exchange items in the exchange label if value is true.""" + global_filters = importlib.import_module( + "capellambse.aird.parser._filters.global" + ) + adjustments["labels_text"] = global_filters._stringify_exchange_items( + exchange, exchange._model._loader, value + ) + + +RENDER_ADJUSTERS: dict[ + str, cabc.Callable[[bool, common.GenericElement, dict[str, t.Any]], None] +] = {"sorted_exchangedItems": sort_exchange_items_label} +"""Available custom render parameter-solvers registry.""" diff --git a/capellambse_context_diagrams/py.typed b/capellambse_context_diagrams/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/capellambse_context_diagrams/serializers.py b/capellambse_context_diagrams/serializers.py new file mode 100644 index 00000000..770cef0a --- /dev/null +++ b/capellambse_context_diagrams/serializers.py @@ -0,0 +1,323 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 + +"""This submodule provides a serializer that transform data from an ELK- +layouted diagram [_elkjs.ELKOutputData][capellambse_context_diagrams._elkjs.ELKOutputData] +according to [_elkjs.ELKInputData][capellambse_context_diagrams._elkjs.ELKInputData]. +The pre-layouted data was collected with the functions from +[collectors][capellambse_context_diagrams.collectors]. +""" +from __future__ import annotations + +import logging + +from capellambse import aird, helpers +from capellambse.model import common +from capellambse.svg.decorations import icon_padding, icon_size + +from . import _elkjs, collectors, context + +logger = logging.getLogger(__name__) + +ElkChildType = str +""" +Elk types can be one of the following types: +* `graph` +* `node` +* `port` +* `label` +* `edge` +* `junction`. +""" + + +class DiagramSerializer: + """Serialize an ``elk_diagram`` into an + [`aird.Diagram`][capellambse.aird.diagram.Diagram]. + + Attributes + ---------- + model + The [`MelodyModel`][capellambse.model.MelodyModel] instance. + aird_diagram + The created [`aird.Diagram`][capellambse.aird.diagram.Diagram] + instance. + """ + + aird_diagram: aird.Diagram + + def __init__(self, elk_diagram: context.ContextDiagram) -> None: + self.model = elk_diagram.target._model + self._diagram = elk_diagram + self._cache: dict[str, dict[str, aird.Box]] = {} + + def make_diagram(self, data: _elkjs.ELKOutputData) -> aird.Diagram: + """Transform a layouted diagram into an `aird.Diagram`. + + Parameters + ---------- + data + The diagram, including layouting information. + + Returns + ------- + diagram + A [`aird.Diagram`][capellambse.aird.diagram.Diagram] constructed + from the input data. + """ + self.aird_diagram = aird.Diagram( + self._diagram.name, styleclass=self._diagram.styleclass + ) + for child in data["children"]: + self.deserialize_child(child, aird.Vector2D(), None) + + for box in self._cache.values(): + if box["box"].JSON_TYPE != "symbol": # Label is outside + self.check_boxlabel_space(box["box"]) + self.check_viewBox_space(box["box"]) + + for edge in self.aird_diagram: + if isinstance(edge, aird.Edge): + self.check_edgelabel_space(edge) + + return self.aird_diagram + + def deserialize_child( + self, + child: _elkjs.ELKOutputChild, + ref: aird.Vector2D, + parent: aird.Box | aird.Edge | None, + ) -> None: + """Converts a `child` into aird elements and adds it to the + diagram. + + Parameters + ---------- + child : _elkjs.ELKOutputChild + The child to deserialize. + ref : aird.Vector2D + The reference point of the child. + parent : aird.Box | aird.Edge | None + The parent of the child. This is either a box or an edge. + + See Also + -------- + [`aird.Box`][capellambse.aird.diagram.Box] : Box class type. + [`aird.Edge`][capellambse.aird.diagram.Edge] : Edge class type. + [`aird.Circle`][capellambse.aird.diagram.Circle] : Circle class + type. + [`aird.Diagram`][capellambse.aird.diagram.Diagram] : Diagram + class type that stores all previously named classes. + """ + styleclass: str | None = self.get_styleclass(child) + element: aird.Box | aird.Edge + if child["type"] in {"node", "port"}: + assert parent is None or isinstance(parent, aird.Box) + has_symbol_cls = False + try: + obj = self.model.by_uuid(child["id"]) + has_symbol_cls = collectors.makers.is_symbol(obj) + except KeyError: + pass + + is_port = child["type"] == "port" + box_type = ("box", "symbol")[ + is_port + or has_symbol_cls + and not self._diagram.display_symbols_as_boxes + ] + + ref += (child["position"]["x"], child["position"]["y"]) # type: ignore + size = (child["size"]["width"], child["size"]["height"]) # type: ignore + element = aird.Box( + ref, + size, + uuid=child["id"], + parent=parent, + port=is_port, + styleclass=styleclass, + styleoverrides=self.get_styleoverrides(child), + ) + element.JSON_TYPE = box_type + self.aird_diagram.add_element(element) + self._cache[child["id"]] = {"box": element} + elif child["type"] == "edge": + element = aird.Edge( + [ + ref + (point["x"], point["y"]) + for point in child["routingPoints"] + ], + uuid=child["id"], + source=self.aird_diagram[child["sourceId"]], + target=self.aird_diagram[child["targetId"]], + styleclass=styleclass, + styleoverrides=self.get_styleoverrides(child), + ) + self.aird_diagram.add_element(element) + elif child["type"] == "label": + assert parent is not None + if isinstance(parent, aird.Box) and not parent.port: + if parent.JSON_TYPE != "symbol": + parent.label = child["text"] + else: + parent.label = aird.Box( + ref + (child["position"]["x"], child["position"]["y"]), + (child["size"]["width"], child["size"]["height"]), + label=child["text"], + # parent=parent, + ) + else: + assert isinstance(parent, aird.Edge) + parent.labels = [ + aird.Box( + ref + (child["position"]["x"], child["position"]["y"]), + (child["size"]["width"], child["size"]["height"]), + label=child["text"], + styleoverrides=self.get_styleoverrides(child), + ) + ] + + element = parent + elif child["type"] == "junction": + uuid = child["id"].split("_", maxsplit=1)[0] + element = aird.Circle( + aird.Vector2D(**child["position"]), + 5, + uuid=child["id"], + styleclass=self.get_styleclass({"id": uuid}), + styleoverrides=self.get_styleoverrides(child), + ) + self.aird_diagram.add_element(element) + else: + logger.warning("Received unknown type %s", child["type"]) + return + + for i in child.get("children", []): # type: ignore + self.deserialize_child(i, ref, element) + + def get_styleclass(self, obj: _elkjs.ELKOutputChild) -> str | None: + """Return the style-class string from a given + [`_elkjs.ELKOutputChild`][capellambse_context_diagrams._elkjs.ELKOutputChild]. + """ + styleclass: str | None + try: + melodyobj = self._diagram._model.by_uuid(obj["id"]) + styleclass = get_styleclass(melodyobj) + except KeyError: + styleclass = None + return styleclass + + def get_styleoverrides( + self, child: _elkjs.ELKOutputChild + ) -> aird.diagram._StyleOverrides | None: + """Return + [`styling.CSSStyles`][capellambse_context_diagrams.styling.CSSStyles] + from a given + [`_elkjs.ELKOutputChild`][capellambse_context_diagrams._elkjs.ELKOutputChild]. + """ + style_condition = self._diagram.render_styles.get(child["type"]) + styleoverrides = None + if style_condition is not None: + if child["type"] != "junction": + obj = self._diagram._model.by_uuid(child["id"]) + else: + obj = None + + styleoverrides = style_condition(obj) + return styleoverrides + + def check_boxlabel_space(self, box: aird.Box) -> None: + """Check if size of parent boxes is enough for their label.""" + if not box.children or any(child.port for child in box.children): + return + + child_dist = min( + [ + abs(child.pos.y - box.pos.y) + for child in box.children + if isinstance(child, aird.Box) + ] + ) + + if isinstance(box.label, aird.Box): + label = box.label.label + else: + label = box.label or "" + + assert isinstance(label, str) + lines = helpers.word_wrap( + label, box.size.x - (2 * icon_padding + icon_size) + ) + _, labelheight = helpers.extent_func(label) + labelspace = aird.Vector2D( + 0, + child_dist + - (2 * collectors.makers.LABEL_VPAD + len(lines) * labelheight), + ) + if labelspace.y >= 0: + return + + box.pos += labelspace + box.size += abs(labelspace) + assert self.aird_diagram.viewport is not None + viewport_dist = aird.Vector2D( + 0, box.pos.y + labelspace.y - self.aird_diagram.viewport.pos.y + ) + if viewport_dist.y <= 0: + self.aird_diagram.viewport.pos += viewport_dist + self.aird_diagram.viewport.size += abs(viewport_dist) + + def check_viewBox_space(self, box: aird.Box) -> None: + """Check if given box and its label fits inside the diagram.""" + assert self.aird_diagram.viewport is not None + lower_bound = ( + self.aird_diagram.viewport.pos + self.aird_diagram.viewport.size + ) + self._fix_space(lower_bound, box) + if isinstance(box.label, aird.Box): + self._fix_space(lower_bound, box.label) + + def check_edgelabel_space(self, edge: aird.Edge) -> None: + """Check if any label is outside of the viewport.""" + if not edge.labels: + return + + assert self.aird_diagram.viewport is not None + lower_bound = ( + self.aird_diagram.viewport.pos + self.aird_diagram.viewport.size + ) + for label in edge.labels: + self._fix_space(lower_bound, label) + + def _fix_space(self, lower_bound: aird.Vector2D, box: aird.Box) -> None: + assert self.aird_diagram.viewport is not None + space = lower_bound - (box.pos + box.size) + self.aird_diagram.viewport.size += aird.Vector2D( + abs(space.x) if space.x <= 0 else 0, + abs(space.y) if space.y <= 0 else 0, + ) + + +def get_styleclass(obj: common.GenericElement) -> str: + """Return the styleclass for a given `obj`.""" + styleclass = obj.__class__.__name__ + styleclass = ( + aird.parser._semantic.STYLECLASS_LOOKUP.get( + styleclass, (styleclass, None) + )[0] + or styleclass + ) + if styleclass.endswith("Component"): + styleclass = "".join( + ( + styleclass[: -len("Component")], + "Human" * obj.is_human, + ("Component", "Actor")[obj.is_actor], + ) + ) + elif styleclass == "CP": + try: + styleclass += f'_{obj._element.attrib["orientation"]}' + except KeyError: + styleclass = "CP_UNSET" + return styleclass diff --git a/capellambse_context_diagrams/styling.py b/capellambse_context_diagrams/styling.py new file mode 100644 index 00000000..02fcf99a --- /dev/null +++ b/capellambse_context_diagrams/styling.py @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 +"""Functions for style overrides of diagram elements.""" +from __future__ import annotations + +import typing as t + +from capellambse import aird +from capellambse.model import common + +CSSStyles = t.Union[aird.diagram._StyleOverrides, None] +""" +A dictionary with CSS styles. The keys are the attribute names and the +values can be of the types `str`, `aird.RGB` and even +`t.Sequence[aird.RGB]` for coloring a +[`common.GenericElement`][capellambse.model.common.element.GenericElement] +with a gradient. + +See also +-------- +[parent_is_actor_fills_blue][capellambse_context_diagrams.styling.parent_is_actor_fills_blue] +""" +Styler = t.Callable[ + [common.GenericElement], t.Union[aird.diagram._StyleOverrides, None] +] +"""Function that produces `CSSStyles` for given obj.""" + + +def parent_is_actor_fills_blue(obj: common.GenericElement) -> CSSStyles: + """ + Returns `CSSStyles` for given obj (i.e. `common.GenericElement`). + """ + try: + if obj.owner.is_actor: + return { + "fill": [ + aird.capstyle.COLORS["_CAP_Actor_Blue_min"], + aird.capstyle.COLORS["_CAP_Actor_Blue"], + ], + "stroke": aird.capstyle.COLORS["_CAP_Actor_Border_Blue"], + } + except AttributeError: + pass + + return None + + +BLUE_ACTOR_FNCS: dict[str, Styler] = {"node": parent_is_actor_fills_blue} +""" +CSSStyle for coloring Actor Functions (Functions of Components with +the attribute `is_actor` set to `True`) with a blue gradient like in +Capella. +""" diff --git a/docs/assets/images/Context of Left.svg b/docs/assets/images/Context of Left.svg new file mode 100644 index 00000000..cca1db78 --- /dev/null +++ b/docs/assets/images/Context of Left.svg @@ -0,0 +1,78 @@ + +LFLeftRightUpperLeft to rightUpper to Left diff --git a/docs/assets/images/Interface Context of Left to right.svg b/docs/assets/images/Interface Context of Left to right.svg new file mode 100644 index 00000000..ee079349 --- /dev/null +++ b/docs/assets/images/Interface Context of Left to right.svg @@ -0,0 +1,78 @@ + +LFRightCircleLeftFirstSecondThirdthelabelsizeThedeterminesedgelength diff --git a/docs/assets/images/favicon.ico b/docs/assets/images/favicon.ico new file mode 100644 index 00000000..c72daf67 Binary files /dev/null and b/docs/assets/images/favicon.ico differ diff --git a/docs/assets/images/favicon.ico.license b/docs/assets/images/favicon.ico.license new file mode 100644 index 00000000..de4b43b4 --- /dev/null +++ b/docs/assets/images/favicon.ico.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +SPDX-License-Identifier: Apache-2.0 diff --git a/docs/assets/images/favicon.png b/docs/assets/images/favicon.png new file mode 100644 index 00000000..09ec600b Binary files /dev/null and b/docs/assets/images/favicon.png differ diff --git a/docs/assets/images/favicon.png.license b/docs/assets/images/favicon.png.license new file mode 100644 index 00000000..de4b43b4 --- /dev/null +++ b/docs/assets/images/favicon.png.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +SPDX-License-Identifier: Apache-2.0 diff --git a/docs/credits.md b/docs/credits.md new file mode 100644 index 00000000..a6de69e5 --- /dev/null +++ b/docs/credits.md @@ -0,0 +1,16 @@ +--- +hide: + - navigation + - toc +--- + + + +Special thanks +============== + +Our special thanks goes to the developers and maintainers of [ **Eclipse Layout Kernel™**](https://www.eclipse.org/elk/). +Here we use [elkjs](https://github.com/kieler/elkjs#readme). This extension wouldn't exist without the ELK magic. diff --git a/docs/css/base.css b/docs/css/base.css new file mode 100644 index 00000000..9892ade1 --- /dev/null +++ b/docs/css/base.css @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +.md-main__inner { + margin-bottom: 1.5rem; +} + +/* Indentation. */ +div.doc-contents:not(.first) { + padding-left: 25px; + border-left: .05rem solid var(--md-typeset-table-color); +} + +/* Navigation */ +.md-sidebar--primary { + width: 7rem; +} + +/* Table of contents */ +.md-sidebar--secondary { + width: 10rem; +} diff --git a/docs/extras/filters.md b/docs/extras/filters.md new file mode 100644 index 00000000..4237549e --- /dev/null +++ b/docs/extras/filters.md @@ -0,0 +1,72 @@ + + +# Applying Capella filters + +With release [`v0.4.11`](https://github.com/DSD-DBS/py-capellambse/releases/tag/v0.4.11) of [py-capellambse](https://github.com/DSD-DBS/py-capellambse) +you can apply filters headlessly. Since an instance of a [`ContextDiagram`][capellambse_context_diagrams.context.ContextDiagram] is not stored in +the `.aird` file of your Capella model there is no way to apply +filters via Capella/GUI. The [filters][capellambse_context_diagrams.filters] implementation bridge +the filter functionality of py-capellambse such that the labels are +adjusted without needing diagram elements from within the .aird file. + +## Capella filters + +Currently the supported filters are: + +??? success "Show [`ExchangeItem`][capellambse.model.crosslayer.information.ExchangeItem]s" + + ```py + from capellambse import MelodyModel + from capellambse_context_diagrams import filters + + lost = model.by_uuid("a5642060-c9cc-4d49-af09-defaa3024bae") + diag = obj.context_diagram + assert filters.EX_ITEMS == "show.exchange.items.filter" + diag.filters.add(filters.EX_ITEMS) + diag.render("svgdiagram").save_drawing(True) + ``` +
+ +
Context diagram of Lost SystemFunction with applied filter [`EX_ITEMS_FILTER`][capellambse_context_diagrams.filters.EX_ITEMS]
+
+ +??? success "Show [`FunctionalExchange`][capellambse.model.crosslayer.fa.FunctionalExchange]s and [`ExchangeItem`][capellambse.model.crosslayer.information.ExchangeItem]s" + + ```py + from capellambse import MelodyModel + from capellambse_context_diagrams import filters + + lost = model.by_uuid("a5642060-c9cc-4d49-af09-defaa3024bae") + diag = obj.context_diagram + assert filters.FEX_EX_ITEMS == "show.functional.exchanges.exchange.items.filter" + filters.filters = {filters.FEX_EX_ITEMS} + diag.render("svgdiagram").save_drawing(True) + ``` +
+ +
Context diagram of Lost SystemFunction with applied filter [`FEX_EX_ITEMS_FILTER`][capellambse_context_diagrams.filters.FEX_EX_ITEMS]
+
+ +## Custom filters + +??? tip "Custom Filter - Show [`FunctionalExchange`][capellambse.model.crosslayer.fa.FunctionalExchange]s **or** [`ExchangeItem`][capellambse.model.crosslayer.information.ExchangeItem]s" + + ```py + from capellambse import MelodyModel + from capellambse_context_diagrams import filters + + lost = model.by_uuid("a5642060-c9cc-4d49-af09-defaa3024bae") + diag = obj.context_diagram + assert filters.FEX_OR_EX_ITEMS == "capellambse_context_diagrams-show.functional.exchanges.or.exchange.items.filter" + filters.filters.add(filters.FEX_OR_EX_ITEMS) + diag.render("svgdiagram").save_drawing(True) + ``` +
+ +
Context diagram of Lost SystemFunction with applied filter [`FEX_OR_EX_ITEMS_FILTER`][capellambse_context_diagrams.filters.FEX_OR_EX_ITEMS]
+
+ +Make sure to check out our [**Stylings**][capellambse_context_diagrams.styling] feature as well. diff --git a/docs/extras/styling.md b/docs/extras/styling.md new file mode 100644 index 00000000..74e56865 --- /dev/null +++ b/docs/extras/styling.md @@ -0,0 +1,131 @@ + + +# Applying conditional styling + +You can style your rendered diagram-SVGs individually with functions +such that explicit highlighting of objects can be achieved. With this +you can control styling of [`ElkChildType`s][capellambse_context_diagrams.serializers.ElkChildType] +during the serialization while rendering. + +Since SVGs can be styled via CSS your options are gigantic. An example +is given by the styling of Actor Functions (i.e. Functions which their +parent Subsystem has the attribute `is_actor=True`) like it is done in +Capella. These appear to be blue. + +!!! example "[`styling.BLUE_ACTOR_FNCS`][capellambse_context_diagrams.styling.BLUE_ACTOR_FNCS]" + + ``` py + import capellambse + + model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") + diag = model.by_uuid("957c5799-1d4a-4ac0-b5de-33a65bf1519c").context_diagram + diag.render("svgdiagram").save_drawing(True) + ``` + produces +
+ +
Context diagram of Lost SystemFunction with blue actor styling
+
+ +This is currently the default style which overrides the default from +py-capellambse. + +# No symbol rendering + +There are some ModelObjects that are displayed as symbols in a diagram +(e.g. Capabilities or Missions). The `.display_symbols_as_boxes` attribute +gives you the control to render these as boxes such that the symbol is +displayed as an icon beside the box-label. + +??? example "Box-only style for Context diagram of Middle OperationalCapability [OCB]" + + ``` py + from capellambse import aird + + diag = model.by_uuid("da08ddb6-92ba-4c3b-956a-017424dbfe85").context_diagram + diag.display_symbols_as_boxes = True + diag.render("svgdiagram").save_drawing(True) + ``` + produces +
+ +
Context of Middle OperationalCapability [OCB] no-symbols
+
+ +??? example "Box-only style for Context diagram of Capability Capability [MCB]" + + ``` py + from capellambse import aird + + diag = model.by_uuid("9390b7d5-598a-42db-bef8-23677e45ba06").context_diagram + diag.display_symbols_as_boxes = True + diag.render("svgdiagram").save_drawing(True) + ``` + produces +
+ +
Context of Capability Capability [MCB] no-symbols
+
+ +# No edge labels + +The `no_edgelabels` render parameter prevents edge labels from being displayed. + +??? example "No-edgelabels style for Context diagram of Capability Capability [MCB]" + + ``` py + import capellambse + + model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") + diag = model.by_uuid("957c5799-1d4a-4ac0-b5de-33a65bf1519c").context_diagram + diag.render("svgdiagram", no_edgelabels=True).save_drawing(True) + ``` +
+ +
Context diagram of educate Wizards LogicalFunction no-edgelabels
+
+ +# Examples for custom styling + +You can switch to py-capellambse default styling by overriding the +`render_styles` Attribute with an empty dictionary: + +??? example "No styling" + + ``` py + from capellambse import aird + from capellambse_context_diagrams import styling + + diag = model.by_uuid("957c5799-1d4a-4ac0-b5de-33a65bf1519c").context_diagram + diag.render_styles = {} + diag.render("svgdiagram").save_drawing(True) + ``` + produces +
+ +
Context diagram of educate Wizards LogicalFunction w/o any styles
+
+ +Style your diagram elements ([ElkChildType][capellambse_context_diagrams.serializers.ElkChildType]) arbitrarily: + +??? example "Red junction point" + + ``` py + from capellambse import aird + from capellambse_context_diagrams import styling + + diag = model.by_uuid("957c5799-1d4a-4ac0-b5de-33a65bf1519c").context_diagram + diag.render_styles = dict( + styling.BLUE_ACTOR_FNCS, + **{"junction": lambda _: {"fill": aird.RGB(220, 20, 60)}}, + ) + diag.render("svgdiagram").save_drawing(True) + ``` + produces +
+ +
Context diagram of Lost SystemFunction with junction point styling
+
diff --git a/docs/gen_images.py b/docs/gen_images.py new file mode 100644 index 00000000..ab7467f0 --- /dev/null +++ b/docs/gen_images.py @@ -0,0 +1,105 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import logging +import pathlib + +import mkdocs_gen_files +from capellambse import MelodyModel, aird + +from capellambse_context_diagrams import context, filters, styling + +logging.basicConfig() + +dest = pathlib.Path("assets") / "images" +model_path = pathlib.Path(__file__).parent.parent / "tests" / "data" +model = MelodyModel(path=model_path, entrypoint="ContextDiagram.aird") +general_context_diagram_uuids = { + "Environment": "e37510b9-3166-4f80-a919-dfaac9b696c7", + "Eat": "8bcb11e6-443b-4b92-bec2-ff1d87a224e7", + "Middle": "da08ddb6-92ba-4c3b-956a-017424dbfe85", + "Capability": "9390b7d5-598a-42db-bef8-23677e45ba06", + "Lost": "a5642060-c9cc-4d49-af09-defaa3024bae", + "Left": "f632888e-51bc-4c9f-8e81-73e9404de784", + "educate Wizards": "957c5799-1d4a-4ac0-b5de-33a65bf1519c", + "Weird guy": "098810d9-0325-4ae8-a111-82202c0d2016", + "Top secret": "5bf3f1e3-0f5e-4fec-81d5-c113d3a1b3a6", +} +interface_context_diagram_uuids = { + "Left to right": "3ef23099-ce9a-4f7d-812f-935f47e7938d", +} +diagram_uuids = general_context_diagram_uuids | interface_context_diagram_uuids + + +def generate_index_images() -> None: + for uuid in diagram_uuids.values(): + diag: context.ContextDiagram = model.by_uuid(uuid).context_diagram + with mkdocs_gen_files.open(f"{str(dest / diag.name)}.svg", "w") as fd: + print(diag.as_svg, file=fd) + + +def generate_no_symbol_images() -> None: + for name in ("Capability", "Middle"): + uuid = general_context_diagram_uuids[name] + diag: context.ContextDiagram = model.by_uuid(uuid).context_diagram + diag.display_symbols_as_boxes = True + diag.invalidate_cache() + filepath = f"{str(dest / diag.name)} no_symbols.svg" + with mkdocs_gen_files.open(filepath, "w") as fd: + print(diag.as_svg, file=fd) + + +def generate_no_edgelabel_image(uuid: str) -> None: + diagram: context.ContextDiagram = model.by_uuid(uuid).context_diagram + diagram.invalidate_cache() + filename = " ".join((str(dest / diagram.name), "no_edgelabels")) + with mkdocs_gen_files.open(f"{filename}.svg", "w") as fd: + print(diagram.render("svg", no_edgelabels=True), file=fd) + + +def generate_filter_image( + uuid: str, filter_name: str, suffix: str = "" +) -> None: + obj = model.by_uuid(uuid) + diag: context.ContextDiagram = obj.context_diagram + diag.filters.clear() + diag.filters = {filter_name} + filename = " ".join((str(dest / diag.name), suffix)) + with mkdocs_gen_files.open(f"{filename}.svg", "w") as fd: + print(diag.as_svg, file=fd) + + +def generate_styling_image( + uuid: str, styles: dict[str, styling.Styler], suffix: str = "" +) -> None: + obj = model.by_uuid(uuid) + diag: context.ContextDiagram = obj.context_diagram + diag.filters.clear() + diag.render_styles = styles + filename = " ".join((str(dest / diag.name), suffix)) + with mkdocs_gen_files.open(f"{filename}.svg", "w") as fd: + print(diag.as_svg, file=fd) + + +generate_index_images() +generate_no_symbol_images() + +wizard = general_context_diagram_uuids["educate Wizards"] +generate_no_edgelabel_image(wizard) + +lost = general_context_diagram_uuids["Lost"] +generate_filter_image(lost, filters.EX_ITEMS, "ex") +generate_filter_image(lost, filters.FEX_EX_ITEMS, "fex and ex") +generate_filter_image(lost, filters.FEX_OR_EX_ITEMS, "fex or ex") + +generate_styling_image( + lost, + dict( + styling.BLUE_ACTOR_FNCS, + **{"junction": lambda _: {"fill": aird.RGB(220, 20, 60)}}, # type: ignore + ), + "red junction", +) +generate_styling_image(wizard, {}, "no_styles") diff --git a/docs/gen_ref_pages.py b/docs/gen_ref_pages.py new file mode 100644 index 00000000..91db103d --- /dev/null +++ b/docs/gen_ref_pages.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Generate the code reference pages.""" + +from pathlib import Path + +import mkdocs_gen_files + +src = "capellambse_context_diagrams" +nav = mkdocs_gen_files.Nav() + +for path in sorted(Path(src).rglob("*.py")): + module_path = path.relative_to(src).with_suffix("") + doc_path = path.relative_to(src).with_suffix(".md") + full_doc_path = Path("reference", doc_path) + parts = [src] + if (filename := module_path.parts[-1]) != "__init__": + parts += list(module_path.parts) + else: + parts += list(module_path.parts)[:-1] + doc_path = doc_path.with_name("index.md") + full_doc_path = full_doc_path.with_name("index.md") + if filename == "__main__": + continue + + nav[parts] = doc_path.as_posix() + + with mkdocs_gen_files.open(full_doc_path, "w") as fd: + identifier = ".".join(parts) + print("::: " + identifier, file=fd) + + mkdocs_gen_files.set_edit_path(full_doc_path, path) + + +with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: + nav_file.writelines(nav.build_literate_nav()) diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..6c17bbfc --- /dev/null +++ b/docs/index.md @@ -0,0 +1,193 @@ + + +# Context Diagram extension for capellambse + +This is a pluggable extension for [py-capellambse](https://github.com/DSD-DBS/py-capellambse) +that extends the [`AbstractDiagram`][capellambse.model.diagram.AbstractDiagram] +base class with [`ContextDiagram`s][capellambse_context_diagrams.context.ContextDiagram] that are layouted by [elkjs'](https://github.com/kieler/elkjs) Layered algorithm. + +
+ +
Context diagram of Left
+
+ +Generate **Context Diagrams** from your model data! + +
+ +
Interface context diagram of Left to right
+
+ +## Features + +### Functions & Components + +The data is collected by either + +- [portless_collector][capellambse_context_diagrams.collectors.portless.collector] for [`ModelObject`s][capellambse.model.common.element.ModelObject] from the Operational Architecture Layer +- [with_port_collector][capellambse_context_diagrams.collectors.default.collector] for all other Architecture Layers that use ports as connectors of exchanges. + +It is served conveniently by [get_elkdata][capellambse_context_diagrams.collectors.get_elkdata]. + +Available via `.context_diagram` on a [`ModelObject`][capellambse.model.common.element.ModelObject] with (diagram-class): + +- ??? example "[`oa.Entity`][capellambse.model.layers.oa.Entity] (OAB)" + + ``` py + import capellambse + + model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") + diag = model.by_uuid("e37510b9-3166-4f80-a919-dfaac9b696c7").context_diagram + diag.render("svgdiagram").save_drawing(True) + ``` +
+ +
Context diagram of Environment Entity with type [OAB]
+
+ +- ??? example "[`oa.OperationalActivity`][capellambse.model.layers.oa.OperationalActivity] (OAIB)" + + ``` py + import capellambse + + model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") + diag = model.by_uuid("8bcb11e6-443b-4b92-bec2-ff1d87a224e7").context_diagram + diag.render("svgdiagram").save_drawing(True) + ``` +
+ +
Context diagram of Activity Eat with type [OAIB]
+
+ +- ??? example "🔥Brand-new🔥 [`oa.OperationalCapability`][capellambse.model.layers.oa.OperationalCapability] (OCB) 🔥Brand-new🔥" + + ``` py + import capellambse + + model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") + diag = model.by_uuid("da08ddb6-92ba-4c3b-956a-017424dbfe85").context_diagram + diag.render("svgdiagram").save_drawing(True) + ``` +
+ +
Context diagram of Middle OperationalCapability with type [OCB]
+
+ +- ??? example "🔥Brand-new🔥 [`ctx.Mission`][capellambse.model.layers.ctx.Mission] (MCB) 🔥Brand-new🔥" + + ``` py + import capellambse + + model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") + diag = model.by_uuid("5bf3f1e3-0f5e-4fec-81d5-c113d3a1b3a6").context_diagram + diag.render("svgdiagram").save_drawing(True) + ``` +
+ +
Context diagram of Mission Top secret with type [MCB]
+
+ +- ??? example "🔥Brand-new🔥 [`ctx.Capability`][capellambse.model.layers.ctx.Capability] (MCB) 🔥Brand-new🔥" + + ``` py + import capellambse + + model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") + diag = model.by_uuid("9390b7d5-598a-42db-bef8-23677e45ba06").context_diagram + diag.render("svgdiagram").save_drawing(True) + ``` +
+ +
Context diagram of Capability Capability with type [MCB]
+
+ +- [`ctx.SystemComponent`][capellambse.model.layers.ctx.SystemComponent] (SAB) + +- ??? example "[`ctx.SystemFunction`][capellambse.model.layers.ctx.SystemFunction] (SDFB)" + + ``` py + import capellambse + + model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") + diag = model.by_uuid("a5642060-c9cc-4d49-af09-defaa3024bae").context_diagram + diag.render("svgdiagram").save_drawing(True) + ``` +
+ +
Context diagram of Lost SystemFunction with type [SDFB]
+
+ +- ??? example "[`la.LogicalComponent`][capellambse.model.layers.la.LogicalComponent] (LAB)" + + ``` py + import capellambse + + model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") + diag = model.by_uuid("f632888e-51bc-4c9f-8e81-73e9404de784").context_diagram + diag.render("svgdiagram").save_drawing(True) + ``` +
+ +
Context diagram of Left LogicalComponent with type [LAB]
+
+ +- ??? example "[`la.LogicalFunction`][capellambse.model.layers.la.LogicalFunction] (LDFB)" + + ``` py + import capellambse + + model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") + diag = model.by_uuid("957c5799-1d4a-4ac0-b5de-33a65bf1519c").context_diagram + diag.render("svgdiagram").save_drawing(True) + ``` +
+ +
Context diagram of educate Wizards LogicalFunction with type [LDFB]
+
+ +* [`pa.PhysicalComponent`][capellambse.model.layers.pa.PhysicalComponent] (PAB) +* [`pa.PhysicalFunction`][capellambse.model.layers.pa.PhysicalFunction] (PDFB) +* [`pa.PhysicalComponent`][capellambse.model.layers.pa.PhysicalComponent] (PAB) +* [`pa.PhysicalFunction`][capellambse.model.layers.pa.PhysicalFunction] (PDFB) + +### Interfaces (aka ComponentExchanges) + +The data is collected by [get_elkdata_for_exchanges][capellambse_context_diagrams.collectors.exchanges.get_elkdata_for_exchanges] which is using the [`InterfaceContextCollector`][capellambse_context_diagrams.collectors.exchanges.InterfaceContextCollector] underneath. + +??? example "[`fa.ComponentExchange`][capellambse.model.crosslayer.fa.ComponentExchange]" + + ``` py + import capellambse + + model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") + diag = model.by_uuid("3ef23099-ce9a-4f7d-812f-935f47e7938d").context_diagram + diag.render("svgdiagram").save_drawing(True) + ``` +
+ +
Interface context diagram of Left to right LogicalComponentExchange with type [LAB]
+
+ +!!! warning "Interface context only supported for the LogicalComponentExchanges" + +### Customized edge routing + +!!! note "Custom routing" + The routing differs from [ELK's Layered Algorithm](https://www.eclipse.org/elk/reference/algorithms/org-eclipse-elk-layered.html): The flow display is disrupted! + We configure exchanges such that they appear in between the context + participants. This decision breaks the display of data flow which is one + of the main aims of ELK's Layered algorithm. However this lets counter + flow exchanges routes lengths and bendpoints increase. + +
+ +
Context diagram of Weird guy SystemFunction
+
+ +--- + +See the code [reference][capellambse_context_diagrams] section for understanding the underlying +implementation. diff --git a/docs/license.md b/docs/license.md new file mode 100644 index 00000000..d376b528 --- /dev/null +++ b/docs/license.md @@ -0,0 +1,8 @@ + + +``` +--8<-- "LICENSES/Apache-2.0.txt" +``` diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 00000000..c7691f96 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,27 @@ + + +## Requirements + +You need `Python>=3.8` and [py-capellambse](https://github.com/DSD-DBS/py-capellambse) to be +installed. + +## Installation + +With `pip`: +```bash +pip install capellambse_context_diagrams +``` + +## Enjoy the features + +You can now use the `.context_diagram` attribute on the advertised model +elements. Check out the [examples on the home page][functions-components]. + +??? fail "Troubleshooting" + + If your [`MelodyModel`][capellambse.model.MelodyModel] instance raises an + error while initializing the entrypoints or your model element + raises an `AttributeError`, try to reinstall py-capellambse! diff --git a/license_header.txt b/license_header.txt new file mode 100644 index 00000000..de4b43b4 --- /dev/null +++ b/license_header.txt @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +SPDX-License-Identifier: Apache-2.0 diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..e9a98dc2 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,106 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 + +site_name: Context diagrams for py-capellambse +repo_name: capellambse-context-diagrams +repo_url: https://github.com/DSD-DBS/capellambse-context-diagrams +watch: [capellambse_context_diagrams, overrides] + +theme: + name: material + custom_dir: overrides + icon: + repo: fontawesome/brands/github-square + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/weather-night + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/brightness-7 + name: Switch to light mode + font: + text: Roboto + code: Roboto Mono + features: + - navigation.instant + - navigation.tracking + - navigation.tabs + - navigation.tabs.sticky + - navigation.sections + - navigation.expand + - navigation.top + - content.code.annotate + - content.tooltips + +markdown_extensions: + - admonition + - pymdownx.details + - pymdownx.highlight + - pymdownx.superfences + - pymdownx.inlinehilite + - pymdownx.snippets + - attr_list + - md_in_html + - toc: + permalink: "🔗" + +plugins: + - search + - gen-files: + scripts: + - docs/gen_ref_pages.py + - docs/gen_images.py + - section-index + - literate-nav: + nav_file: SUMMARY.md + - autorefs + - mkdocstrings: + handlers: + python: + paths: [capellambse_context_diagrams] + import: + - https://dsd-dbs.github.io/py-capellambse/objects.inv + selection: + docstring_style: numpy + rendering: + show_root_full_path: no + merge_init_into_class: yes + show_submodules: no + show_signature_annotations: yes + separate_signature: yes + enable_inventory: yes + watch: + - capellambse_context_diagrams + +nav: + - Home: + - Overview: index.md + - Quickstart: quickstart.md + - Credits: credits.md + - License: license.md + - Extras: + - Filters: extras/filters.md + - Styling: extras/styling.md + - Code Reference: reference/ + +extra_css: + - css/base.css + +extra: + generator: false + social: + - icon: fontawesome/brands/github + link: https://dsd-dbs.github.io/capellambse-context-diagrams/ + - icon: custom/db + name: DB Netz AG - SET + link: https://github.com/DSD-DBS + +copyright: Copyright © 2022 DB Netz AG diff --git a/overrides/.icons/custom/db.svg b/overrides/.icons/custom/db.svg new file mode 100644 index 00000000..88a03001 --- /dev/null +++ b/overrides/.icons/custom/db.svg @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/overrides/.icons/custom/db.svg.license b/overrides/.icons/custom/db.svg.license new file mode 100644 index 00000000..de4b43b4 --- /dev/null +++ b/overrides/.icons/custom/db.svg.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +SPDX-License-Identifier: Apache-2.0 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..4f14e0e3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,112 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 + +[build-system] +requires = [ + "setuptools>=42", + "setuptools_scm[toml]>=3.4", + "wheel", +] + +[tool.black] +line-length = 79 +target-version = ['py38'] + +[tool.isort] +ensure_newline_before_comments = true +force_grid_wrap = 0 +include_trailing_comma = true +line_length = 79 +multi_line_output = 3 +skip_glob = [ + ".*", +] +use_parentheses = 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 = [ + "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] +expected-line-ending-format = "LF" + +[tool.pylint.master] +extension-pkg-allow-list = [ + "lxml.etree", +] + +[tool.pylint.messages_control] +disable = [ + "arguments-differ", # handled by mypy + "assignment-from-no-return", # handled by mypy + "bad-indentation", # auto-formatting + "broad-except", + "global-statement", + "import-error", # handled by mypy + "import-outside-toplevel", + "inconsistent-quotes", # auto-formatting + "invalid-name", + "line-too-long", # auto-formatting + "missing-class-docstring", + "missing-final-newline", # auto-formatting + "missing-function-docstring", + "missing-kwoa", # handled by mypy + "missing-method-docstring", + "missing-module-docstring", + "mixed-line-endings", # auto-formatting + "multiple-imports", # auto-formatting + "multiple-statements", # auto-formatting + "no-else-break", + "no-else-continue", + "no-else-raise", + "no-else-return", + "no-member", # handled by mypy + "no-value-for-parameter", # handled by mypy + "protected-access", + "redefined-builtin", + "redundant-keyword-arg", # handled by mypy + "signature-differs", # handled by mypy + "syntax-error", # handled by mypy + "too-few-public-methods", + "too-many-ancestors", + "too-many-arguments", + "too-many-boolean-expressions", + "too-many-branches", + "too-many-function-args", # handled by mypy + "too-many-instance-attributes", + "too-many-lines", + "too-many-locals", + "too-many-public-methods", + "too-many-return-statements", + "too-many-statements", + "trailing-newlines", # auto-formatting + "trailing-whitespace", # auto-formatting + "unbalanced-tuple-unpacking", # handled by mypy + "undefined-variable", # handled by mypy + "unexpected-keyword-arg", # handled by mypy + "unexpected-line-ending-format", # auto-formatting + "ungrouped-imports", # auto-formatting + "wrong-import-order", # auto-formatting + "wrong-import-position", # auto-formatting +] +enable = [ + "c-extension-no-member", + "deprecated-pragma", + "use-symbolic-message-instead", + "useless-suppression", +] + +[tool.setuptools_scm] +# This section must exist for setuptools_scm to work diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..1e5153a6 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 + +[metadata] +name = capellambse-context-diagrams +url = https://github.com/DSD-DBS/capellambse-context-diagrams/ +author = DB Netz AG +classifiers = + Development Status :: 5 - Production/Stable + Intended Audience :: Developers + License :: OSI Approved :: Apache Software License + Natural Language :: English + Operating System :: OS Independent + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Topic :: Other/Nonlisted Topic + Topic :: Scientific/Engineering + Topic :: Software Development :: Libraries :: Python Modules +license = Apache +description = Extension for python-capella-mbse that adds automatically generated context diagrams for arbitrary model elements. +long_description = file: README.md +long_description_content_type = text/markdown +keywords = capella, mbse, context, diagram, automatic diagrams +platforms = any + +[options] +zip_safe = false +install_requires = + capellambse + typing_extensions +python_requires = >=3.9 +use_2to3 = false +packages = find: + +[options.entry_points] +capellambse.model_extensions = + context_diagrams = capellambse_context_diagrams:init + +[options.extras_require] +test = + coverage>=5.1 + mypy + pylint + pytest + pytest-cov +docs = + mkdocs-material + mkdocstrings[python]>=0.18 + pytkdocs[numpy-style]>=0.5.0 + mkdocs-gen-files + mkdocs-literate-nav + mkdocs-section-index + mkdocs-autorefs + black + +[options.package_data] +capellambse_context_diagrams = + py.typed + *.js + +[options.packages.find] +include = capellambse_context_diagrams, capellambse_context_diagrams.* diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..1260c337 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Global fixtures for pytest""" +import io +import pathlib +import sys + +import capellambse +import pytest + +TEST_ROOT = pathlib.Path(__file__).parent / "data" +TEST_MODEL = "ContextDiagram.aird" + + +@pytest.fixture +def model(monkeypatch) -> capellambse.MelodyModel: + """Return test model""" + monkeypatch.setattr(sys, "stderr", io.StringIO) + return capellambse.MelodyModel(TEST_ROOT / TEST_MODEL) diff --git a/tests/data/.project b/tests/data/.project new file mode 100644 index 00000000..c11604a8 --- /dev/null +++ b/tests/data/.project @@ -0,0 +1,11 @@ + + + Context Diagrams + + + + + + + + diff --git a/tests/data/.project.license b/tests/data/.project.license new file mode 100644 index 00000000..de4b43b4 --- /dev/null +++ b/tests/data/.project.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +SPDX-License-Identifier: Apache-2.0 diff --git a/tests/data/ContextDiagram.afm b/tests/data/ContextDiagram.afm new file mode 100644 index 00000000..dc32e25b --- /dev/null +++ b/tests/data/ContextDiagram.afm @@ -0,0 +1,6 @@ + + + + + + diff --git a/tests/data/ContextDiagram.afm.license b/tests/data/ContextDiagram.afm.license new file mode 100644 index 00000000..de4b43b4 --- /dev/null +++ b/tests/data/ContextDiagram.afm.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +SPDX-License-Identifier: Apache-2.0 diff --git a/tests/data/ContextDiagram.aird b/tests/data/ContextDiagram.aird new file mode 100644 index 00000000..36f72aec --- /dev/null +++ b/tests/data/ContextDiagram.aird @@ -0,0 +1,4235 @@ + + + + ContextDiagram.afm + ContextDiagram.capella + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + routingStyle + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + routingStyle + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + italic + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + KEEP_LOCATION + KEEP_RATIO + KEEP_SIZE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/ContextDiagram.aird.license b/tests/data/ContextDiagram.aird.license new file mode 100644 index 00000000..de4b43b4 --- /dev/null +++ b/tests/data/ContextDiagram.aird.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +SPDX-License-Identifier: Apache-2.0 diff --git a/tests/data/ContextDiagram.capella b/tests/data/ContextDiagram.capella new file mode 100644 index 00000000..c81d8f6d --- /dev/null +++ b/tests/data/ContextDiagram.capella @@ -0,0 +1,3744 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + The actor lives in a world where predators exist +AND +A <a href="e6e4d30c-4d80-4899-8d8d-1350239c15a7"/> is near the actor + capella:linkedText + + + + + The predator no longer exists +OR +The predator is far away + capella:linkedText + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <a href="dd2d0dab-a35f-4104-91e5-b412f35cba15"/> + capella:linkedText + + + + + The actor feels sated + capella:linkedText + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Spot a huntable animal + capella:linkedText + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Actor feels well rested + capella:linkedText + + + + + + + Actor feels sated + capella:linkedText + + + + + + + Food is cooked + capella:linkedText + + + + + + + Revenge + capella:linkedText + + + + + + + No revenge + capella:linkedText + + + + + + + Success + capella:linkedText + + + + + + + Hunt failed + capella:linkedText + + + + + + + Reached safety + capella:linkedText + + + + + + + Hunt ended + + capella:linkedText + 2 + + + + + + + Actor feels hungry + self.hunger >= 0.8 + capella:linkedText + Python + + + + + + + Actor gets too old + capella:linkedText + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Actor feels well rested + capella:linkedText + + + + + + + + Actor feels sated + capella:linkedText + + + + + + + + Food is cooked + capella:linkedText + + + + + + + + Revenge + capella:linkedText + + + + + + + + No revenge + capella:linkedText + + + + + + + + Success + capella:linkedText + + + + + + + + Hunt failed + capella:linkedText + + + + + + + + Reached safety + capella:linkedText + + + + + + + + Hunt ended + capella:linkedText + + + + + + + + Actor feels hungry + capella:linkedText + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <a href="a0159943-264f-4a97-a245-565fb6bf9db4"/> + capella:linkedText + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + capella:linkedText + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/ContextDiagram.capella.license b/tests/data/ContextDiagram.capella.license new file mode 100644 index 00000000..de4b43b4 --- /dev/null +++ b/tests/data/ContextDiagram.capella.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +SPDX-License-Identifier: Apache-2.0 diff --git a/tests/test_capability_diagrams.py b/tests/test_capability_diagrams.py new file mode 100644 index 00000000..d10bf828 --- /dev/null +++ b/tests/test_capability_diagrams.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 + +import capellambse +import pytest +from capellambse.model.layers import ctx, oa + +TEST_TYPES = (oa.OperationalCapability, ctx.Capability, ctx.Mission) + + +@pytest.mark.parametrize( + "uuid", + [ + pytest.param( + "da08ddb6-92ba-4c3b-956a-017424dbfe85", id="OperationalCapability" + ), + pytest.param("9390b7d5-598a-42db-bef8-23677e45ba06", id="Capability"), + pytest.param("5bf3f1e3-0f5e-4fec-81d5-c113d3a1b3a6", id="Mission"), + ], +) +def test_context_diagrams(model: capellambse.MelodyModel, uuid: str) -> None: + obj = model.by_uuid(uuid) + + diag = obj.context_diagram + + assert isinstance(obj, TEST_TYPES) + assert diag.nodes diff --git a/tests/test_context_diagrams.py b/tests/test_context_diagrams.py new file mode 100644 index 00000000..255c465e --- /dev/null +++ b/tests/test_context_diagrams.py @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 + +import capellambse +import pytest + + +@pytest.mark.parametrize( + "uuid", + [ + pytest.param("e37510b9-3166-4f80-a919-dfaac9b696c7", id="Entity"), + pytest.param("8bcb11e6-443b-4b92-bec2-ff1d87a224e7", id="Activity"), + pytest.param( + "344a405e-c7e5-4367-8a9a-41d3d9a27f81", id="SystemComponent" + ), + pytest.param( + "a5642060-c9cc-4d49-af09-defaa3024bae", id="SystemFunction" + ), + pytest.param( + "f632888e-51bc-4c9f-8e81-73e9404de784", id="LogicalComponent" + ), + pytest.param( + "7f138bae-4949-40a1-9a88-15941f827f8c", id="LogicalFunction" + ), + ], +) +def test_context_diagrams(model: capellambse.MelodyModel, uuid: str) -> None: + obj = model.by_uuid(uuid) + + diag = obj.context_diagram + + assert diag.nodes diff --git a/tests/test_filters.py b/tests/test_filters.py new file mode 100644 index 00000000..5e6bd373 --- /dev/null +++ b/tests/test_filters.py @@ -0,0 +1,157 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import re +import typing as t + +import pytest +from capellambse import MelodyModel, aird +from capellambse.model import crosslayer + +from capellambse_context_diagrams import context, filters + +EX_PTRN = re.compile(r"\[(.*?)\]") +CAP_EXPLOIT = "4513c8cd-b94b-4bde-bd00-4c18aaf600ff" + + +@pytest.mark.parametrize( + "label,expected", + [ + ( + "[CapabilityExploitation] to Capability (9390b7d5-598a-42db-bef8-23677e45ba06) from Affleck (da12377b-fb70-4441-8faa-3a5c153c5de2)", + "[CapabilityExploitation] to Capability from Affleck", + ), + (None, "[CapabilityExploitation] to Capability"), + ("", ""), + ], +) +def test_uuid_filter(model: MelodyModel, label: str, expected: str) -> None: + exploitation = model.by_uuid(CAP_EXPLOIT) + + assert filters.uuid_filter(exploitation, label) == expected + + +def start_filter_apply_test( + model: MelodyModel, filter_name: str, **render_params: t.Any +) -> tuple[list[crosslayer.fa.FunctionalExchange], aird.Diagram]: + """StartUp for every filter test case.""" + obj = model.by_uuid("a5642060-c9cc-4d49-af09-defaa3024bae") + diag: context.ContextDiagram = obj.context_diagram + diag.filters.add(filter_name) + edges = [ + elt + for elt in diag.nodes + if isinstance(elt, crosslayer.fa.FunctionalExchange) + ] + return edges, diag.render(None, **render_params) + + +def get_ExchangeItems(edge: aird.Edge) -> list[str]: + assert isinstance(edge.labels[0].label, str) + match = EX_PTRN.match(edge.labels[0].label) + assert match is not None + return match.group(1).split(", ") + + +def has_sorted_ExchangeItems(edge: aird.Edge) -> bool: + exitems = get_ExchangeItems(edge) + return exitems == sorted(exitems) + + +def test_EX_ITEMS_is_applied(model: MelodyModel) -> None: + edges, aird_diag = start_filter_apply_test(model, filters.EX_ITEMS) + + for edge in edges: + aedge = aird_diag[edge.uuid] + expected = (ex.name for ex in edge.exchange_items) + + assert isinstance(aedge, aird.Edge) + if aedge.labels: + assert get_ExchangeItems(aedge) == list(expected) + + +@pytest.mark.parametrize("sort", [False, True]) +def test_context_diagrams_ExchangeItems_sorting( + model: MelodyModel, sort: bool +) -> None: + edges, aird_diag = start_filter_apply_test( + model, filters.EX_ITEMS, sorted_exchangedItems=sort + ) + + all_sorted = True + for edge in edges: + aedge = aird_diag[edge.uuid] + assert isinstance(aedge, aird.Edge) + if aedge.labels and not has_sorted_ExchangeItems(aedge): + all_sorted = False + break + + assert all_sorted == sort + + +def test_context_diagrams_FEX_EX_ITEMS_is_applied( + model: MelodyModel, +) -> None: + edges, aird_diag = start_filter_apply_test(model, filters.FEX_EX_ITEMS) + + for edge in edges: + aedge = aird_diag[edge.uuid] + expected_label = edge.name + eitems = ", ".join((exi.name for exi in edge.exchange_items)) + if eitems: + expected_label += f" [{eitems}]" + + assert isinstance(aedge, aird.Edge) + assert len(aedge.labels) == 1 + assert isinstance(aedge.labels[0].label, str) + assert aedge.labels[0].label == expected_label + + +def test_context_diagrams_FEX_OR_EX_ITEMS_is_applied( + model: MelodyModel, +) -> None: + edges, aird_diag = start_filter_apply_test(model, filters.FEX_OR_EX_ITEMS) + + for edge in edges: + aedge = aird_diag[edge.uuid] + + assert isinstance(aedge, aird.Edge) + + label = aedge.labels[0].label + if edge.exchange_items: + eitem_label_frag = ", ".join( + (exi.name for exi in edge.exchange_items) + ) + + assert label == f"[{eitem_label_frag}]" + else: + assert label == edge.name + + assert len(aedge.labels) == 1 + + +def test_context_diagrams_NO_UUID_is_applied(model: MelodyModel) -> None: + obj = model.by_uuid("9390b7d5-598a-42db-bef8-23677e45ba06") + diag: context.ContextDiagram = obj.context_diagram + + diag.filters.add(filters.NO_UUID) + aird_diag = diag.render(None) + aedge = aird_diag[CAP_EXPLOIT] + + assert isinstance(aedge, aird.Edge) + assert aedge.labels[0].label == "[CapabilityExploitation] to Capability" + + +def test_context_diagrams_no_edgelabels_render_param_is_applied( + model: MelodyModel, +) -> None: + obj = model.by_uuid("a5642060-c9cc-4d49-af09-defaa3024bae") + diag: context.ContextDiagram = obj.context_diagram + + adiag = diag.render(None, no_edgelabels=True) + + for aedge in adiag: + if isinstance(aedge, aird.Edge): + assert not aedge.labels diff --git a/tests/test_interface_diagrams.py b/tests/test_interface_diagrams.py new file mode 100644 index 00000000..dd363962 --- /dev/null +++ b/tests/test_interface_diagrams.py @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 + +import capellambse +import pytest + + +@pytest.mark.parametrize( + "uuid", + [ + # pytest.param("3c9764aa-4981-44ef-8463-87a053016635", id="OA"), + # pytest.param("86a1afc2-b7fd-4023-bbd5-ab44f5dc2c28", id="SA"), + pytest.param("3ef23099-ce9a-4f7d-812f-935f47e7938d", id="LA"), + ], +) +def test_interface_diagrams_get_rendered( + model: capellambse.MelodyModel, uuid: str +) -> None: + obj = model.by_uuid(uuid) + diag = obj.context_diagram + + assert diag.nodes