├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── black.yaml │ ├── dapperdata.yaml │ ├── mypy.yaml │ ├── pypi.yaml │ ├── pytest.yaml │ ├── ruff.yaml │ └── tomlsort.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version ├── LICENSE ├── README.md ├── docs ├── dev │ ├── README.md │ ├── cli.md │ ├── github.md │ └── pypi.md └── example.png ├── makefile ├── paracelsus ├── __init__.py ├── cli.py ├── graph.py ├── pyproject.py └── transformers │ ├── __init__.py │ ├── dot.py │ ├── mermaid.py │ └── utils.py ├── pyproject.toml └── tests ├── __init__.py ├── assets ├── README.md ├── example │ ├── __init__.py │ ├── base.py │ └── models.py └── pyproject.toml ├── conftest.py ├── test_cli.py ├── test_graph.py ├── test_pyproject.py ├── transformers ├── __init__.py ├── test_dot.py └── test_mermaid.py └── utils.py /.gitattributes: -------------------------------------------------------------------------------- 1 | paracelsus/_version.py export-subst 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | -------------------------------------------------------------------------------- /.github/workflows/black.yaml: -------------------------------------------------------------------------------- 1 | name: Black Formatting 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | black: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - uses: actions/setup-python@v5 14 | with: 15 | python-version-file: .python-version 16 | 17 | - name: Install Dependencies 18 | run: make install 19 | 20 | - name: Test Formatting 21 | run: make black_check 22 | -------------------------------------------------------------------------------- /.github/workflows/dapperdata.yaml: -------------------------------------------------------------------------------- 1 | name: Configuration File Formatting 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | dapperdata: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - uses: actions/setup-python@v5 14 | with: 15 | python-version-file: .python-version 16 | 17 | - name: Install Dependencies 18 | run: make install 19 | 20 | - name: Test Formatting 21 | run: make dapperdata_check 22 | -------------------------------------------------------------------------------- /.github/workflows/mypy.yaml: -------------------------------------------------------------------------------- 1 | name: Mypy testing 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | mypy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - uses: actions/setup-python@v5 14 | with: 15 | python-version-file: .python-version 16 | 17 | - name: Install Dependencies 18 | run: make install 19 | 20 | - name: Test Typing 21 | run: make mypy_check 22 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yaml: -------------------------------------------------------------------------------- 1 | name: PyPI 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | tags: 8 | - "v[0-9]+.[0-9]+.[0-9]+" 9 | pull_request: 10 | 11 | env: 12 | PUBLISH_TO_PYPI: true 13 | SETUOTOOLS_SCM_DEBUG: 1 14 | 15 | jobs: 16 | pypi: 17 | runs-on: ubuntu-latest 18 | permissions: 19 | id-token: write 20 | steps: 21 | - uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | fetch-tags: true 25 | 26 | - uses: actions/setup-python@v5 27 | with: 28 | python-version-file: .python-version 29 | 30 | - name: Install Dependencies 31 | run: make install 32 | 33 | - name: Build Wheel 34 | run: make build 35 | 36 | # This will only run on Tags 37 | - name: Publish package 38 | if: ${{ env.PUBLISH_TO_PYPI == 'true' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags')}} 39 | uses: pypa/gh-action-pypi-publish@release/v1 40 | -------------------------------------------------------------------------------- /.github/workflows/pytest.yaml: -------------------------------------------------------------------------------- 1 | name: PyTest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | env: 8 | COLUMNS: 120 9 | 10 | jobs: 11 | pytest: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | version: ["3.10", "3.11", "3.12"] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - uses: actions/setup-python@v5 22 | with: 23 | python-version: ${{ matrix.version }} 24 | 25 | - name: Install Dependencies 26 | run: make install 27 | 28 | - name: Run Tests 29 | run: make pytest 30 | -------------------------------------------------------------------------------- /.github/workflows/ruff.yaml: -------------------------------------------------------------------------------- 1 | name: Ruff Linting 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | ruff: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - uses: actions/setup-python@v5 14 | with: 15 | python-version-file: .python-version 16 | 17 | - name: Install Dependencies 18 | run: make install 19 | 20 | - name: Test Formatting 21 | run: make ruff_check 22 | -------------------------------------------------------------------------------- /.github/workflows/tomlsort.yaml: -------------------------------------------------------------------------------- 1 | name: TOML Formatting 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | tomlsort: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - uses: actions/setup-python@v5 14 | with: 15 | python-version-file: .python-version 16 | 17 | - name: Install Dependencies 18 | run: make install 19 | 20 | - name: Test Typing 21 | run: make tomlsort_check 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | .ruff_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/#use-with-ide 111 | .pdm.toml 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .env 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | #.idea/ 162 | 163 | output.png 164 | test.md 165 | paracelsus/_version.py 166 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: pytest 5 | name: pytest 6 | entry: make pytest 7 | language: system 8 | pass_filenames: false 9 | - id: ruff 10 | name: ruff 11 | entry: make ruff_check 12 | language: system 13 | pass_filenames: false 14 | - id: black 15 | name: black 16 | entry: make black_check 17 | language: system 18 | pass_filenames: false 19 | - id: mypy 20 | name: mypy 21 | entry: make mypy_check 22 | language: system 23 | pass_filenames: false 24 | - id: tomlsort 25 | name: tomlsort 26 | entry: make tomlsort_check 27 | language: system 28 | pass_filenames: false 29 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023, Robert Hafner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Paracelsus 2 | 3 | Paracelsus generates Entity Relationship Diagrams by reading your SQLAlchemy models. 4 | 5 | - [Paracelsus](#paracelsus) 6 | - [Features](#features) 7 | - [Usage](#usage) 8 | - [Installation](#installation) 9 | - [Basic CLI Usage](#basic-cli-usage) 10 | - [Importing Models](#importing-models) 11 | - [Generate Mermaid Diagrams](#generate-mermaid-diagrams) 12 | - [Inject Mermaid Diagrams](#inject-mermaid-diagrams) 13 | - [Creating Images](#creating-images) 14 | - [pyproject.toml](#pyprojecttoml) 15 | - [Sponsorship](#sponsorship) 16 | 17 | ## Features 18 | 19 | - ERDs can be injected into documentation as [Mermaid Diagrams](https://mermaid.js.org/). 20 | - Paracelsus can be run in CICD to check that databases are up to date. 21 | - ERDs can be created as files in either [Dot](https://graphviz.org/doc/info/lang.html) or Mermaid format. 22 | - DOT files can be used to generate SVG or PNG files, or edited in [GraphViz](https://graphviz.org/) or other editors. 23 | 24 | ## Usage 25 | 26 | ### Installation 27 | 28 | The paracelsus package should be installed in the same environment as your code, as it will be reading your SQLAlchemy base class to generate the diagrams. 29 | 30 | ```bash 31 | pip install paracelsus 32 | ``` 33 | 34 | ### Basic CLI Usage 35 | 36 | Paracelsus is primarily a CLI application. 37 | 38 | 39 | ```bash 40 | paracelsus --help 41 | ``` 42 | 43 | It has three commands: 44 | 45 | - `version` outputs the version of the currently installed `paracelsus` cli. 46 | - `graph` generates a graph and outputs it to `stdout`. 47 | - `inject` inserts the graph into a markdown file. 48 | 49 | ### Importing Models 50 | 51 | SQLAlchemy models have to be imported before they are put into the model registry inside of the base class. This is similar to how [Alembic](https://alembic.sqlalchemy.org/en/latest/) needs models to be imported in order to generate migrations. 52 | 53 | The `--import-module` flag can be used to import any python module, which presumably will include one or more SQLAlchemy models inside of it. 54 | 55 | ```bash 56 | paracelsus graph example_app.models.base:Base \ 57 | --import-module "example_app.models.users" \ 58 | --import-module "example_app.models.posts" \ 59 | --import-module "example_app.models.comments" 60 | ``` 61 | 62 | The `:*` modify can be used to specify that a wild card import should be used. Make sure to wrap the module name in quotes when using this to prevent shell expansion. 63 | 64 | ```bash 65 | paracelsus graph example_app.models.base:Base --import-module "example_app.models:*" 66 | ``` 67 | 68 | This is equivalent to running this style of python import: 69 | 70 | ```python 71 | from example_app.models import * 72 | ``` 73 | 74 | ### Include or Exclude tables 75 | 76 | After importing the models, it is possible to select a subset of those models by using the `--exclude-tables` and `--include-tables` options. 77 | These are mutually exclusive options, the user can only provide inclusions or exclusions: 78 | 79 | ```bash 80 | paracelsus graph example_app.models.base:Base \ 81 | --import-module "example_app.models.*" \ 82 | --exclude-tables "comments" 83 | ``` 84 | 85 | This is equivalent to: 86 | 87 | ```bash 88 | paracelsus graph example_app.models.base:Base \ 89 | --import-module "example_app.models.*" \ 90 | --include-tables "users" 91 | --include-tables "posts" 92 | ``` 93 | 94 | You can also use regular expressions in the `include-tables` and `exclude-tables` options. 95 | 96 | ```bash 97 | paracelsus graph example_app.models.base:Base \ 98 | --import-module "example_app.models.*" \ 99 | --exclude-tables "^com.*" 100 | ``` 101 | 102 | ### Specify Column Sort Order 103 | 104 | By default Paracelsus will sort the columns in all models such as primary keys are first, foreign keys are next and all other 105 | columns are sorted alphabetically by name. 106 | 107 | ```bash 108 | paracelsus graph example_app.models.base:Base \ 109 | --import-module "example_app.models.users" \ 110 | ``` 111 | 112 | produces the same results as: 113 | 114 | ```bash 115 | paracelsus graph example_app.models.base:Base \ 116 | --import-module "example_app.models.users" \ 117 | --column-sort key-based 118 | ``` 119 | 120 | Pass the --column-sort option to change this behavior. To preserve the order of fields present in the models use "preserve-order": 121 | 122 | ```bash 123 | paracelsus graph example_app.models.base:Base \ 124 | --import-module "example_app.models.users" \ 125 | --column-sort preserve-order 126 | ``` 127 | 128 | ### Generate Mermaid Diagrams 129 | 130 | 131 | > paracelsus graph example_app.models.base:Base --import-module "example_app.models:*" 132 | 133 | ```text 134 | erDiagram 135 | users { 136 | CHAR(32) id PK 137 | DATETIME created 138 | VARCHAR(100) display_name "nullable" 139 | } 140 | 141 | posts { 142 | CHAR(32) id PK 143 | CHAR(32) author FK 144 | TEXT content "nullable" 145 | DATETIME created 146 | BOOLEAN live "nullable" 147 | } 148 | 149 | comments { 150 | CHAR(32) id PK 151 | CHAR(32) author FK 152 | CHAR(32) post FK "nullable" 153 | TEXT content "nullable" 154 | DATETIME created 155 | BOOLEAN live "nullable" 156 | } 157 | 158 | users ||--o{ posts : author 159 | posts ||--o{ comments : post 160 | users ||--o{ comments : author 161 | ``` 162 | 163 | When run through a Mermaid viewer, such as the ones installed in the markdown viewers of many version control systems, this will turn into a graphic. 164 | 165 | ```mermaid 166 | erDiagram 167 | users { 168 | CHAR(32) id PK 169 | DATETIME created 170 | VARCHAR(100) display_name "nullable" 171 | } 172 | 173 | posts { 174 | CHAR(32) id PK 175 | CHAR(32) author FK 176 | TEXT content "nullable" 177 | DATETIME created 178 | BOOLEAN live "nullable" 179 | } 180 | 181 | comments { 182 | CHAR(32) id PK 183 | CHAR(32) author FK 184 | CHAR(32) post FK "nullable" 185 | TEXT content "nullable" 186 | DATETIME created 187 | BOOLEAN live "nullable" 188 | } 189 | 190 | users ||--o{ posts : author 191 | posts ||--o{ comments : post 192 | users ||--o{ comments : author 193 | ``` 194 | 195 | ### Inject Mermaid Diagrams 196 | 197 | Mermaid Diagrams and Markdown work extremely well together, and it's common to place diagrams inside of project documentation. Paracelsus can be used to inject diagrams directly into markdown configuration. It does so by looking for specific tags and placing a code block inside of them, replacing any existing content between the tags. 198 | 199 | 200 | 201 | ```markdown 202 | ## Schema 203 | 204 | 205 | 206 | ``` 207 | 208 | > paracelsus inject db/README.md example_app.models.base:Base --import-module "example_app.models:*" 209 | 210 | 211 | The `--check` flag can be used to see if the command would make any changes. If the file is already up to date then it will return a status code of `0`, otherwise it will return `1` if changes are needed. This is useful in CI/CD or precommit hook to enforce that documentation is always current. 212 | 213 | > paracelsus inject db/README.md example_app.models.base:Base --import-module "example_app.models:*" --check 214 | 215 | ### Creating Images 216 | 217 | GraphViz has a command line tool named [dot](https://graphviz.org/doc/info/command.html) that can be used to turn `dot` graphs into images. 218 | 219 | To create an SVG file: 220 | 221 | > paracelsus graph example_app.models.base:Base --import-module "example_app.models:*" --format dot | dot -Tsvg > output.svg 222 | 223 | To create a PNG file: 224 | 225 | > paracelsus graph example_app.models.base:Base --import-module "example_app.models:*" --format dot | dot -Tpng > output.png 226 | 227 | ![Alt text](./docs/example.png "a title") 228 | 229 | 230 | ### pyproject.toml 231 | 232 | The settings for your project can be saved directly in the `pyprojects.toml` file of your project. 233 | 234 | ```toml 235 | [tool.paracelsus] 236 | base = "example.base:Base" 237 | imports = [ 238 | "example.models" 239 | ] 240 | ``` 241 | 242 | This also allows users to set excludes, includes, and column sorting. 243 | 244 | ```toml 245 | [tool.paracelsus] 246 | base = "example.base:Base" 247 | imports = [ 248 | "example.models" 249 | ] 250 | exclude_tables = [ 251 | "comments" 252 | ] 253 | column_sort = "preserve-order" 254 | ``` 255 | 256 | ## Sponsorship 257 | 258 | This project is developed by [Robert Hafner](https://blog.tedivm.com) If you find this project useful please consider sponsoring me using Github! 259 | 260 |
261 | 262 | [![Github Sponsorship](https://raw.githubusercontent.com/mechPenSketch/mechPenSketch/master/img/github_sponsor_btn.svg)](https://github.com/sponsors/tedivm) 263 | 264 |
265 | -------------------------------------------------------------------------------- /docs/dev/README.md: -------------------------------------------------------------------------------- 1 | # Developer Readme 2 | 3 | 1. [CLI](./cli.md) 4 | 5 | 1. [Dependencies](./dependencies.md) 6 | 1. [Github Actions](./github.md) 7 | 1. [PyPI](./pypi.md) 8 | -------------------------------------------------------------------------------- /docs/dev/cli.md: -------------------------------------------------------------------------------- 1 | # CLI 2 | 3 | This project uses [Typer](https://typer.tiangolo.com/) and [Click](https://click.palletsprojects.com/) for CLI functionality. When the project is installed the cli is available at `paracelsus`. 4 | 5 | The full help contents can be visited with the help flag. 6 | 7 | ```bash 8 | paracelsus --help 9 | ``` 10 | 11 | The CLI itself is defined at `paracelsus.cli`. New commands can be added there. 12 | -------------------------------------------------------------------------------- /docs/dev/github.md: -------------------------------------------------------------------------------- 1 | # Github 2 | -------------------------------------------------------------------------------- /docs/dev/pypi.md: -------------------------------------------------------------------------------- 1 | # PyPI 2 | -------------------------------------------------------------------------------- /docs/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tedivm/paracelsus/f473a574aa8bdb0cbf2aa38aa2610f3ac66dca4c/docs/example.png -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | PACKAGE_SLUG=paracelsus 3 | ifdef CI 4 | PYTHON_PYENV := 5 | PYTHON_VERSION := $(shell python --version|cut -d" " -f2) 6 | else 7 | PYTHON_PYENV := pyenv 8 | PYTHON_VERSION := $(shell cat .python-version) 9 | endif 10 | PYTHON_SHORT_VERSION := $(shell echo $(PYTHON_VERSION) | grep -o '[0-9].[0-9]*') 11 | 12 | ifeq ($(USE_SYSTEM_PYTHON), true) 13 | PYTHON_PACKAGE_PATH:=$(shell python -c "import sys; print(sys.path[-1])") 14 | PYTHON_ENV := 15 | PYTHON := python 16 | PYTHON_VENV := 17 | else 18 | PYTHON_PACKAGE_PATH:=.venv/lib/python$(PYTHON_SHORT_VERSION)/site-packages 19 | PYTHON_ENV := . .venv/bin/activate && 20 | PYTHON := . .venv/bin/activate && python 21 | PYTHON_VENV := .venv 22 | endif 23 | 24 | # Used to confirm that pip has run at least once 25 | PACKAGE_CHECK:=$(PYTHON_PACKAGE_PATH)/piptools 26 | PYTHON_DEPS := $(PACKAGE_CHECK) 27 | 28 | 29 | .PHONY: all 30 | all: $(PACKAGE_CHECK) 31 | 32 | .PHONY: install 33 | install: $(PYTHON_PYENV) $(PYTHON_VENV) pip 34 | 35 | .venv: 36 | python -m venv .venv 37 | 38 | .PHONY: pyenv 39 | pyenv: 40 | pyenv install --skip-existing $(PYTHON_VERSION) 41 | 42 | .PHONY: pip 43 | pip: $(PYTHON_VENV) 44 | $(PYTHON) -m pip install -e .[dev] 45 | 46 | $(PACKAGE_CHECK): $(PYTHON_VENV) 47 | $(PYTHON) -m pip install -e .[dev] 48 | 49 | .PHONY: pre-commit 50 | pre-commit: 51 | pre-commit install 52 | 53 | # 54 | # Chores 55 | # 56 | 57 | .PHONY: chores 58 | chores: ruff_fixes black_fixes dapperdata_fixes tomlsort_fixes 59 | 60 | .PHONY: ruff_fixes 61 | ruff_fix: 62 | $(PYTHON) -m ruff . --fix 63 | 64 | .PHONY: black_fixes 65 | black_fixes: 66 | $(PYTHON) -m ruff format . 67 | 68 | .PHONY: dapperdata_fixes 69 | dapperdata_fixes: 70 | $(PYTHON) -m dapperdata.cli pretty . --no-dry-run 71 | 72 | .PHONY: tomlsort_fixes 73 | tomlsort_fixes: 74 | $(PYTHON_ENV) toml-sort $$(find . -not -path "./.venv/*" -name "*.toml") -i 75 | 76 | # 77 | # Testing 78 | # 79 | 80 | .PHONY: tests 81 | tests: install pytest ruff_check black_check mypy_check dapperdata_check tomlsort_check 82 | 83 | .PHONY: pytest 84 | pytest: 85 | $(PYTHON) -m pytest --cov=./${PACKAGE_SLUG} --cov-report=term-missing tests 86 | 87 | .PHONY: pytest_loud 88 | pytest_loud: 89 | $(PYTHON) -m pytest -s --cov=./${PACKAGE_SLUG} --cov-report=term-missing tests 90 | 91 | .PHONY: ruff_check 92 | ruff_check: 93 | $(PYTHON) -m ruff check 94 | 95 | .PHONY: black_check 96 | black_check: 97 | $(PYTHON) -m ruff format . --check 98 | 99 | .PHONY: mypy_check 100 | mypy_check: 101 | $(PYTHON) -m mypy ${PACKAGE_SLUG} 102 | 103 | .PHONY: dapperdata_check 104 | dapperdata_check: 105 | $(PYTHON) -m dapperdata.cli pretty . 106 | 107 | .PHONY: tomlsort_check 108 | tomlsort_check: 109 | $(PYTHON_ENV) toml-sort $$(find . -not -path "./.venv/*" -name "*.toml") --check 110 | # 111 | # Packaging 112 | # 113 | 114 | .PHONY: build 115 | build: $(PACKAGE_CHECK) 116 | $(PYTHON) -m build 117 | -------------------------------------------------------------------------------- /paracelsus/__init__.py: -------------------------------------------------------------------------------- 1 | from . import _version 2 | 3 | __version__ = _version.__version__ 4 | -------------------------------------------------------------------------------- /paracelsus/cli.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | from enum import Enum 4 | from pathlib import Path 5 | from typing import Any, Dict, List, Optional 6 | 7 | import typer 8 | from typing_extensions import Annotated 9 | 10 | from .graph import get_graph_string, transformers 11 | from .pyproject import get_pyproject_settings 12 | 13 | app = typer.Typer() 14 | 15 | PYPROJECT_SETTINGS = get_pyproject_settings() 16 | 17 | 18 | class Formats(str, Enum): 19 | mermaid = "mermaid" 20 | mmd = "mmd" 21 | dot = "dot" 22 | gv = "gv" 23 | 24 | 25 | class ColumnSorts(str, Enum): 26 | key_based = "key-based" 27 | preserve = "preserve-order" 28 | 29 | 30 | if "column_sort" in PYPROJECT_SETTINGS: 31 | SORT_DEFAULT = ColumnSorts(PYPROJECT_SETTINGS["column_sort"]).value 32 | else: 33 | SORT_DEFAULT = ColumnSorts.key_based.value 34 | 35 | 36 | def get_base_class(base_class_path: str | None, settings: Dict[str, Any] | None) -> str: 37 | if base_class_path: 38 | return base_class_path 39 | if not settings: 40 | raise ValueError("`base_class_path` argument must be passed if no pyproject.toml file is present.") 41 | if "base" not in settings: 42 | raise ValueError("`base_class_path` argument must be passed if not defined in pyproject.toml.") 43 | return settings["base"] 44 | 45 | 46 | @app.command(help="Create the graph structure and print it to stdout.") 47 | def graph( 48 | base_class_path: Annotated[ 49 | Optional[str], 50 | typer.Argument(help="The SQLAlchemy base class used by the database to graph."), 51 | ] = None, 52 | import_module: Annotated[ 53 | List[str], 54 | typer.Option( 55 | help="Module, typically an SQL Model, to import. Modules that end in :* will act as `from module import *`" 56 | ), 57 | ] = [], 58 | exclude_tables: Annotated[ 59 | List[str], 60 | typer.Option(help="List of tables or regular expression patterns for tables that are excluded from the graph"), 61 | ] = [], 62 | include_tables: Annotated[ 63 | List[str], 64 | typer.Option(help="List of tables or regular expression patterns for tables that are included in the graph"), 65 | ] = [], 66 | python_dir: Annotated[ 67 | List[Path], 68 | typer.Option( 69 | help="Paths to add to the `PYTHON_PATH` for module lookup.", 70 | file_okay=False, 71 | dir_okay=True, 72 | resolve_path=True, 73 | exists=True, 74 | ), 75 | ] = [], 76 | format: Annotated[ 77 | Formats, typer.Option(help="The file format to output the generated graph to.") 78 | ] = Formats.mermaid.value, # type: ignore # Typer will fail to render the help message, but this code works. 79 | column_sort: Annotated[ 80 | ColumnSorts, 81 | typer.Option( 82 | help="Specifies the method of sorting columns in diagrams.", 83 | ), 84 | ] = SORT_DEFAULT, # type: ignore # Typer will fail to render the help message, but this code works. 85 | ): 86 | settings = get_pyproject_settings() 87 | base_class = get_base_class(base_class_path, settings) 88 | 89 | if "imports" in settings: 90 | import_module.extend(settings["imports"]) 91 | 92 | typer.echo( 93 | get_graph_string( 94 | base_class_path=base_class, 95 | import_module=import_module, 96 | include_tables=set(include_tables + settings.get("include_tables", [])), 97 | exclude_tables=set(exclude_tables + settings.get("exclude_tables", [])), 98 | python_dir=python_dir, 99 | format=format.value, 100 | column_sort=column_sort, 101 | ) 102 | ) 103 | 104 | 105 | @app.command(help="Create a graph and inject it as a code field into a markdown file.") 106 | def inject( 107 | file: Annotated[ 108 | Path, 109 | typer.Argument( 110 | help="The file to inject the generated graph into.", 111 | file_okay=True, 112 | dir_okay=False, 113 | resolve_path=True, 114 | exists=True, 115 | ), 116 | ], 117 | base_class_path: Annotated[ 118 | str, 119 | typer.Argument(help="The SQLAlchemy base class used by the database to graph."), 120 | ], 121 | replace_begin_tag: Annotated[ 122 | str, 123 | typer.Option(help=""), 124 | ] = "", 125 | replace_end_tag: Annotated[ 126 | str, 127 | typer.Option(help=""), 128 | ] = "", 129 | import_module: Annotated[ 130 | List[str], 131 | typer.Option( 132 | help="Module, typically an SQL Model, to import. Modules that end in :* will act as `from module import *`" 133 | ), 134 | ] = [], 135 | exclude_tables: Annotated[ 136 | List[str], 137 | typer.Option(help="List of tables that are excluded from the graph"), 138 | ] = [], 139 | include_tables: Annotated[ 140 | List[str], 141 | typer.Option(help="List of tables that are included in the graph"), 142 | ] = [], 143 | python_dir: Annotated[ 144 | List[Path], 145 | typer.Option( 146 | help="Paths to add to the `PYTHON_PATH` for module lookup.", 147 | file_okay=False, 148 | dir_okay=True, 149 | resolve_path=True, 150 | exists=True, 151 | ), 152 | ] = [], 153 | format: Annotated[ 154 | Formats, typer.Option(help="The file format to output the generated graph to.") 155 | ] = Formats.mermaid.value, # type: ignore # Typer will fail to render the help message, but this code works. 156 | check: Annotated[ 157 | bool, 158 | typer.Option( 159 | "--check", 160 | help="Perform a dry run and return a success code of 0 if there are no changes or 1 otherwise.", 161 | ), 162 | ] = False, 163 | column_sort: Annotated[ 164 | ColumnSorts, 165 | typer.Option( 166 | help="Specifies the method of sorting columns in diagrams.", 167 | ), 168 | ] = SORT_DEFAULT, # type: ignore # Typer will fail to render the help message, but this code works. 169 | ): 170 | settings = get_pyproject_settings() 171 | if "imports" in settings: 172 | import_module.extend(settings["imports"]) 173 | 174 | # Generate Graph 175 | graph = get_graph_string( 176 | base_class_path=base_class_path, 177 | import_module=import_module, 178 | include_tables=set(include_tables + settings.get("include_tables", [])), 179 | exclude_tables=set(exclude_tables + settings.get("exclude_tables", [])), 180 | python_dir=python_dir, 181 | format=format.value, 182 | column_sort=column_sort, 183 | ) 184 | 185 | comment_format = transformers[format].comment_format # type: ignore 186 | 187 | # Convert Graph to Injection String 188 | graph_piece = f"""{replace_begin_tag} 189 | ```{comment_format} 190 | {graph} 191 | ``` 192 | {replace_end_tag}""" 193 | 194 | # Get content from current file. 195 | with open(file, "r") as fp: 196 | old_content = fp.read() 197 | 198 | # Replace old content with newly generated content. 199 | pattern = re.escape(replace_begin_tag) + "(.*)" + re.escape(replace_end_tag) 200 | new_content = re.sub(pattern, graph_piece, old_content, flags=re.MULTILINE | re.DOTALL) 201 | 202 | # Return result depends on whether we're in check mode. 203 | if check: 204 | if new_content == old_content: 205 | # If content is the same then we passed the test. 206 | typer.echo("No changes detected.") 207 | sys.exit(0) 208 | else: 209 | # If content is different then we failed the test. 210 | typer.echo("Changes detected.") 211 | sys.exit(1) 212 | else: 213 | # Dump newly generated contents back to file. 214 | with open(file, "w") as fp: 215 | fp.write(new_content) 216 | 217 | 218 | @app.command(help="Display the current installed version of paracelsus.") 219 | def version(): 220 | from . import _version 221 | 222 | typer.echo(_version.version) 223 | 224 | 225 | if __name__ == "__main__": 226 | app() 227 | -------------------------------------------------------------------------------- /paracelsus/graph.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import os 3 | import sys 4 | from pathlib import Path 5 | import re 6 | from typing import List, Set 7 | 8 | from sqlalchemy.schema import MetaData 9 | from .transformers.dot import Dot 10 | from .transformers.mermaid import Mermaid 11 | 12 | transformers = { 13 | "mmd": Mermaid, 14 | "mermaid": Mermaid, 15 | "dot": Dot, 16 | "gv": Dot, 17 | } 18 | 19 | 20 | def get_graph_string( 21 | *, 22 | base_class_path: str, 23 | import_module: List[str], 24 | include_tables: Set[str], 25 | exclude_tables: Set[str], 26 | python_dir: List[Path], 27 | format: str, 28 | column_sort: str, 29 | ) -> str: 30 | # Update the PYTHON_PATH to allow more module imports. 31 | sys.path.append(str(os.getcwd())) 32 | for dir in python_dir: 33 | sys.path.append(str(dir)) 34 | 35 | # Import the base class so the metadata class can be extracted from it. 36 | # The metadata class is passed to the transformer. 37 | module_path, class_name = base_class_path.split(":", 2) 38 | base_module = importlib.import_module(module_path) 39 | base_class = getattr(base_module, class_name) 40 | metadata = base_class.metadata 41 | 42 | # The modules holding the model classes have to be imported to get put in the metaclass model registry. 43 | # These modules aren't actually used in any way, so they are discarded. 44 | # They are also imported in scope of this function to prevent namespace pollution. 45 | for module in import_module: 46 | if ":*" in module: 47 | # Sure, execs are gross, but this is the only way to dynamically import wildcards. 48 | exec(f"from {module[:-2]} import *") 49 | else: 50 | importlib.import_module(module) 51 | 52 | # Grab a transformer. 53 | if format not in transformers: 54 | raise ValueError(f"Unknown Format: {format}") 55 | transformer = transformers[format] 56 | 57 | # Keep only the tables which were included / not-excluded 58 | include_tables = resolve_included_tables( 59 | include_tables=include_tables, exclude_tables=exclude_tables, all_tables=set(metadata.tables.keys()) 60 | ) 61 | filtered_metadata = filter_metadata(metadata=metadata, include_tables=include_tables) 62 | 63 | # Save the graph structure to string. 64 | return str(transformer(filtered_metadata, column_sort)) 65 | 66 | 67 | def resolve_included_tables( 68 | include_tables: Set[str], 69 | exclude_tables: Set[str], 70 | all_tables: Set[str], 71 | ) -> Set[str]: 72 | """Resolves the final set of tables to include in the graph. 73 | 74 | Given sets of inclusions and exclusions and the set of all tables we define 75 | the following cases are: 76 | - Empty inclusion and empty exclusion -> include all tables. 77 | - Empty inclusion and some exclusions -> include all tables except the ones in the exclusion set. 78 | - Some inclusions and empty exclusion -> make sure tables in the inclusion set are present in 79 | all tables then include the tables in the inclusion set. 80 | - Some inclusions and some exclusions -> not resolvable, an error is raised. 81 | """ 82 | match len(include_tables), len(exclude_tables): 83 | case 0, 0: 84 | return all_tables 85 | case 0, int(): 86 | excluded = {table for table in all_tables if any(re.match(pattern, table) for pattern in exclude_tables)} 87 | return all_tables - excluded 88 | case int(), 0: 89 | included = {table for table in all_tables if any(re.match(pattern, table) for pattern in include_tables)} 90 | 91 | if not included: 92 | non_existent_tables = include_tables - all_tables 93 | raise ValueError( 94 | f"Some tables to include ({non_existent_tables}) don't exist" 95 | "withinthe found tables ({all_tables})." 96 | ) 97 | return included 98 | case _: 99 | raise ValueError( 100 | f"Only one or none of include_tables ({include_tables}) or exclude_tables" 101 | f"({exclude_tables}) can contain values." 102 | ) 103 | 104 | 105 | def filter_metadata( 106 | metadata: MetaData, 107 | include_tables: Set[str], 108 | ) -> MetaData: 109 | """Create a subset of the metadata based on the tables to include.""" 110 | filtered_metadata = MetaData() 111 | for tablename, table in metadata.tables.items(): 112 | if tablename in include_tables: 113 | if hasattr(table, "to_metadata"): 114 | # to_metadata is the new way to do this, but it's only available in newer versions of SQLAlchemy. 115 | table = table.to_metadata(filtered_metadata) 116 | else: 117 | # tometadata is deprecated, but we still need to support it for older versions of SQLAlchemy. 118 | table = table.tometadata(filtered_metadata) 119 | 120 | return filtered_metadata 121 | -------------------------------------------------------------------------------- /paracelsus/pyproject.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from typing import Any, Dict 4 | 5 | try: 6 | import tomllib 7 | except: # noqa: E722 8 | import toml as tomllib # type: ignore 9 | 10 | 11 | def get_pyproject_settings(dir: Path = Path(os.getcwd())) -> Dict[str, Any]: 12 | pyproject = dir / "pyproject.toml" 13 | 14 | if not pyproject.exists(): 15 | return {} 16 | 17 | with open(pyproject, "rb") as f: 18 | data = tomllib.loads(f.read().decode()) 19 | 20 | return data.get("tool", {}).get("paracelsus", {}) 21 | -------------------------------------------------------------------------------- /paracelsus/transformers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tedivm/paracelsus/f473a574aa8bdb0cbf2aa38aa2610f3ac66dca4c/paracelsus/transformers/__init__.py -------------------------------------------------------------------------------- /paracelsus/transformers/dot.py: -------------------------------------------------------------------------------- 1 | import pydot # type: ignore 2 | import logging 3 | from sqlalchemy.sql.schema import MetaData, Table 4 | 5 | from .utils import sort_columns 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class Dot: 12 | comment_format: str = "dot" 13 | metadata: MetaData 14 | graph: pydot.Dot 15 | column_sort: str 16 | 17 | def __init__(self, metaclass: MetaData, column_sort: str) -> None: 18 | self.metadata = metaclass 19 | self.graph = pydot.Dot("database", graph_type="graph") 20 | self.column_sort = column_sort 21 | 22 | for table in self.metadata.tables.values(): 23 | node = pydot.Node(name=table.name) 24 | node.set_label(self._table_label(table)) 25 | node.set_shape("none") 26 | node.set_margin("0") 27 | self.graph.add_node(node) 28 | for column in table.columns: 29 | for foreign_key in column.foreign_keys: 30 | key_parts = foreign_key.target_fullname.split(".") 31 | left_table = ".".join(key_parts[:-1]) 32 | left_column = key_parts[-1] 33 | 34 | # We don't add the connection to the fk table if the latter 35 | # is not included in our graph. 36 | if left_table not in self.metadata.tables: 37 | logger.warning( 38 | f"Table '{table}.{column.name}' is a foreign key to '{left_table}' " 39 | "which is not included in the graph, skipping the connection." 40 | ) 41 | continue 42 | 43 | edge = pydot.Edge(left_table.split(".")[-1], table.name) 44 | edge.set_label(column.name) 45 | edge.set_dir("both") 46 | 47 | edge.set_arrowhead("none") 48 | if not column.unique: 49 | edge.set_arrowhead("crow") 50 | 51 | l_column = self.metadata.tables[left_table].columns[left_column] 52 | edge.set_arrowtail("none") 53 | if not l_column.unique and not l_column.primary_key: 54 | edge.set_arrowtail("crow") 55 | 56 | self.graph.add_edge(edge) 57 | 58 | def _table_label(self, table: Table) -> str: 59 | column_output = "" 60 | columns = sort_columns(table_columns=table.columns, column_sort=self.column_sort) 61 | for column in columns: 62 | attributes = set([]) 63 | if column.primary_key: 64 | attributes.add("Primary Key") 65 | 66 | if len(column.foreign_keys) > 0: 67 | attributes.add("Foreign Key") 68 | 69 | if column.unique: 70 | attributes.add("Unique") 71 | 72 | column_output += f' {column.type}{column.name}{", ".join(sorted(attributes))}\n' 73 | 74 | return f"""< 75 | 76 | 77 | {column_output.rstrip()} 78 |
{table.name}
79 | >""" 80 | 81 | def __str__(self) -> str: 82 | return self.graph.to_string() 83 | -------------------------------------------------------------------------------- /paracelsus/transformers/mermaid.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from sqlalchemy.sql.schema import Column, MetaData, Table 3 | 4 | from .utils import sort_columns 5 | 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class Mermaid: 11 | comment_format: str = "mermaid" 12 | metadata: MetaData 13 | column_sort: str 14 | 15 | def __init__(self, metaclass: MetaData, column_sort: str) -> None: 16 | self.metadata = metaclass 17 | self.column_sort = column_sort 18 | 19 | def _table(self, table: Table) -> str: 20 | output = f" {table.name}" 21 | output += " {\n" 22 | columns = sort_columns(table_columns=table.columns, column_sort=self.column_sort) 23 | for column in columns: 24 | output += self._column(column) 25 | output += " }\n\n" 26 | return output 27 | 28 | def _column(self, column: Column) -> str: 29 | options = [] 30 | column_str = f"{column.type} {column.name}" 31 | 32 | if column.primary_key: 33 | if len(column.foreign_keys) > 0: 34 | column_str += " PK,FK" 35 | else: 36 | column_str += " PK" 37 | elif len(column.foreign_keys) > 0: 38 | column_str += " FK" 39 | elif column.unique: 40 | column_str += " UK" 41 | 42 | if column.comment: 43 | options.append(column.comment) 44 | 45 | if column.nullable: 46 | options.append("nullable") 47 | 48 | if column.index: 49 | options.append("indexed") 50 | 51 | if len(options) > 0: 52 | column_str += f' "{",".join(options)}"' 53 | 54 | return f" {column_str}\n" 55 | 56 | def _relationships(self, column: Column) -> str: 57 | output = "" 58 | 59 | column_name = column.name 60 | right_table = column.table.name 61 | 62 | if column.unique: 63 | right_operand = "o|" 64 | else: 65 | right_operand = "o{" 66 | 67 | for foreign_key in column.foreign_keys: 68 | key_parts = foreign_key.target_fullname.split(".") 69 | left_table = ".".join(key_parts[:-1]) 70 | left_column = key_parts[-1] 71 | left_operand = "" 72 | 73 | # We don't add the connection to the fk table if the latter 74 | # is not included in our graph. 75 | if left_table not in self.metadata.tables: 76 | logger.warning( 77 | f"Table '{right_table}.{column_name}' is a foreign key to '{left_table}' " 78 | "which is not included in the graph, skipping the connection." 79 | ) 80 | continue 81 | 82 | lcolumn = self.metadata.tables[left_table].columns[left_column] 83 | if lcolumn.unique or lcolumn.primary_key: 84 | left_operand = "||" 85 | else: 86 | left_operand = "}o" 87 | 88 | output += f" {left_table.split('.')[-1]} {left_operand}--{right_operand} {right_table} : {column_name}\n" 89 | return output 90 | 91 | def __str__(self) -> str: 92 | output = "erDiagram\n" 93 | for table in self.metadata.tables.values(): 94 | output += self._table(table) 95 | 96 | for table in self.metadata.tables.values(): 97 | for column in table.columns.values(): 98 | if len(column.foreign_keys) > 0: 99 | output += self._relationships(column) 100 | 101 | return output 102 | -------------------------------------------------------------------------------- /paracelsus/transformers/utils.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.sql.schema import Column 2 | from sqlalchemy.sql import ColumnCollection 3 | 4 | 5 | def key_based_column_sort(column: Column) -> str: 6 | if column.primary_key: 7 | prefix = "01" 8 | elif len(column.foreign_keys): 9 | prefix = "02" 10 | else: 11 | prefix = "03" 12 | return f"{prefix}_{column.name}" 13 | 14 | 15 | def sort_columns(table_columns: ColumnCollection, column_sort: str) -> list: 16 | match column_sort: 17 | case "preserve-order": 18 | columns = [column for column in table_columns] 19 | case _: 20 | columns = sorted(table_columns, key=key_based_column_sort) 21 | 22 | return columns 23 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "setuptools.build_meta" 3 | requires = ["setuptools>=67.0", "setuptools_scm[toml]>=7.1"] 4 | 5 | [project] 6 | authors = [{"name" = "Robert Hafner"}] 7 | dependencies = [ 8 | "pydot", 9 | "sqlalchemy", 10 | "typer", 11 | "toml; python_version < '3.11'" 12 | ] 13 | description = "Visualize SQLAlchemy Databases using Mermaid or Dot Diagrams." 14 | dynamic = ["version"] 15 | license = {"file" = "LICENSE"} 16 | name = "paracelsus" 17 | readme = {file = "README.md", content-type = "text/markdown"} 18 | requires-python = ">= 3.10" 19 | 20 | [project.optional-dependencies] 21 | dev = [ 22 | "build", 23 | "dapperdata", 24 | "glom", 25 | "mypy", 26 | "pip-tools", 27 | "pytest", 28 | "pytest-cov", 29 | "pytest-pretty", 30 | "ruamel.yaml", 31 | "ruff", 32 | "toml-sort" 33 | ] 34 | 35 | [project.scripts] 36 | paracelsus = "paracelsus.cli:app" 37 | 38 | [tool.ruff] 39 | exclude = [".venv", "./paracelsus/_version.py"] 40 | line-length = 120 41 | 42 | [tool.setuptools.dynamic] 43 | readme = {file = ["README.md"]} 44 | 45 | [tool.setuptools.package-data] 46 | paracelsus = ["py.typed"] 47 | 48 | [tool.setuptools.packages] 49 | find = {} 50 | 51 | [tool.setuptools_scm] 52 | fallback_version = "0.0.0-dev" 53 | write_to = "paracelsus/_version.py" 54 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tedivm/paracelsus/f473a574aa8bdb0cbf2aa38aa2610f3ac66dca4c/tests/__init__.py -------------------------------------------------------------------------------- /tests/assets/README.md: -------------------------------------------------------------------------------- 1 | # Test Directory 2 | 3 | Please ignore. 4 | 5 | ## Schema 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /tests/assets/example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tedivm/paracelsus/f473a574aa8bdb0cbf2aa38aa2610f3ac66dca4c/tests/assets/example/__init__.py -------------------------------------------------------------------------------- /tests/assets/example/base.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import declarative_base 2 | 3 | Base = declarative_base() 4 | -------------------------------------------------------------------------------- /tests/assets/example/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from uuid import uuid4 3 | 4 | from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, Uuid 5 | from sqlalchemy.orm import mapped_column 6 | 7 | from .base import Base 8 | 9 | UTC = timezone.utc 10 | 11 | 12 | class User(Base): 13 | __tablename__ = "users" 14 | 15 | id = mapped_column(Uuid, primary_key=True, default=uuid4()) 16 | display_name = mapped_column(String(100)) 17 | created = mapped_column(DateTime, nullable=False, default=datetime.now(UTC)) 18 | 19 | 20 | class Post(Base): 21 | __tablename__ = "posts" 22 | 23 | id = mapped_column(Uuid, primary_key=True, default=uuid4()) 24 | author = mapped_column(ForeignKey(User.id), nullable=False) 25 | created = mapped_column(DateTime, nullable=False, default=datetime.now(UTC)) 26 | live = mapped_column(Boolean, default=False, comment="True if post is published") 27 | content = mapped_column(Text, default="") 28 | 29 | 30 | class Comment(Base): 31 | __tablename__ = "comments" 32 | 33 | id = mapped_column(Uuid, primary_key=True, default=uuid4()) 34 | post = mapped_column(Uuid, ForeignKey(Post.id), default=uuid4()) 35 | author = mapped_column(ForeignKey(User.id), nullable=False) 36 | created = mapped_column(DateTime, nullable=False, default=datetime.now(UTC)) 37 | live = mapped_column(Boolean, default=False) 38 | content = mapped_column(Text, default="") 39 | content = mapped_column(Text, default="") 40 | -------------------------------------------------------------------------------- /tests/assets/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.paracelsus] 2 | base = "example.base:Base" 3 | imports = [ 4 | "example.models" 5 | ] 6 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import tempfile 4 | from datetime import datetime, timezone 5 | from pathlib import Path 6 | from uuid import uuid4 7 | 8 | import pytest 9 | from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, Uuid 10 | from sqlalchemy.orm import declarative_base, mapped_column 11 | 12 | UTC = timezone.utc 13 | 14 | 15 | @pytest.fixture 16 | def metaclass(): 17 | Base = declarative_base() 18 | 19 | class User(Base): 20 | __tablename__ = "users" 21 | 22 | id = mapped_column(Uuid, primary_key=True, default=uuid4()) 23 | display_name = mapped_column(String(100)) 24 | created = mapped_column(DateTime, nullable=False, default=datetime.now(UTC)) 25 | 26 | class Post(Base): 27 | __tablename__ = "posts" 28 | 29 | id = mapped_column(Uuid, primary_key=True, default=uuid4()) 30 | created = mapped_column(DateTime, nullable=False, default=datetime.now(UTC)) 31 | author = mapped_column(ForeignKey(User.id), nullable=False) 32 | live = mapped_column(Boolean, default=False, comment="True if post is published") 33 | content = mapped_column(Text, default="") 34 | 35 | class Comment(Base): 36 | __tablename__ = "comments" 37 | 38 | id = mapped_column(Uuid, primary_key=True, default=uuid4()) 39 | created = mapped_column(DateTime, nullable=False, default=datetime.now(UTC)) 40 | post = mapped_column(Uuid, ForeignKey(Post.id), default=uuid4()) 41 | author = mapped_column(ForeignKey(User.id), nullable=False) 42 | live = mapped_column(Boolean, default=False) 43 | content = mapped_column(Text, default="") 44 | 45 | return Base.metadata 46 | 47 | 48 | @pytest.fixture 49 | def package_path(): 50 | template_path = Path(os.path.dirname(os.path.realpath(__file__))) / "assets" 51 | with tempfile.TemporaryDirectory() as package_path: 52 | shutil.copytree(template_path, package_path, dirs_exist_ok=True) 53 | yield Path(package_path) 54 | 55 | 56 | @pytest.fixture() 57 | def mermaid_full_string_preseve_column_sort() -> str: 58 | return """erDiagram 59 | users { 60 | CHAR(32) id PK 61 | VARCHAR(100) display_name "nullable" 62 | DATETIME created 63 | } 64 | 65 | posts { 66 | CHAR(32) id PK 67 | DATETIME created 68 | CHAR(32) author FK 69 | BOOLEAN live "True if post is published,nullable" 70 | TEXT content "nullable" 71 | } 72 | 73 | comments { 74 | CHAR(32) id PK 75 | DATETIME created 76 | CHAR(32) post FK "nullable" 77 | CHAR(32) author FK 78 | BOOLEAN live "nullable" 79 | TEXT content "nullable" 80 | } 81 | 82 | users ||--o{ posts : author 83 | posts ||--o{ comments : post 84 | users ||--o{ comments : author 85 | """ 86 | 87 | 88 | @pytest.fixture() 89 | def dot_full_string_preseve_column_sort() -> str: 90 | return """graph database { 91 | users [label=< 92 | 93 | 94 | 95 | 96 | 97 |
users
CHAR(32)idPrimary Key
VARCHAR(100)display_name
DATETIMEcreated
98 | >, shape=none, margin=0]; 99 | posts [label=< 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 |
posts
CHAR(32)idPrimary Key
DATETIMEcreated
CHAR(32)authorForeign Key
BOOLEANlive
TEXTcontent
108 | >, shape=none, margin=0]; 109 | users -- posts [label=author, dir=both, arrowhead=crow, arrowtail=none]; 110 | comments [label=< 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 |
comments
CHAR(32)idPrimary Key
DATETIMEcreated
CHAR(32)postForeign Key
CHAR(32)authorForeign Key
BOOLEANlive
TEXTcontent
120 | >, shape=none, margin=0]; 121 | posts -- comments [label=post, dir=both, arrowhead=crow, arrowtail=none]; 122 | users -- comments [label=author, dir=both, arrowhead=crow, arrowtail=none]; 123 | } 124 | """ 125 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Literal 3 | import pytest 4 | 5 | from typer.testing import CliRunner 6 | 7 | from paracelsus.cli import app 8 | 9 | from .utils import mermaid_assert 10 | 11 | runner = CliRunner() 12 | 13 | 14 | def test_graph(package_path: Path): 15 | result = runner.invoke( 16 | app, 17 | ["graph", "example.base:Base", "--import-module", "example.models", "--python-dir", str(package_path)], 18 | ) 19 | 20 | assert result.exit_code == 0 21 | mermaid_assert(result.stdout) 22 | 23 | 24 | @pytest.mark.parametrize("column_sort_arg", ["key-based", "preserve-order"]) 25 | def test_graph_column_sort(package_path: Path, column_sort_arg: Literal["key-based"] | Literal["preserve-order"]): 26 | result = runner.invoke( 27 | app, 28 | [ 29 | "graph", 30 | "example.base:Base", 31 | "--import-module", 32 | "example.models", 33 | "--python-dir", 34 | str(package_path), 35 | "--column-sort", 36 | column_sort_arg, 37 | ], 38 | ) 39 | 40 | assert result.exit_code == 0 41 | mermaid_assert(result.stdout) 42 | 43 | 44 | def test_graph_with_exclusion(package_path: Path): 45 | result = runner.invoke( 46 | app, 47 | [ 48 | "graph", 49 | "example.base:Base", 50 | "--import-module", 51 | "example.models", 52 | "--python-dir", 53 | str(package_path), 54 | "--exclude-tables", 55 | "comments", 56 | ], 57 | ) 58 | assert result.exit_code == 0 59 | assert "posts {" in result.stdout 60 | assert "comments {" not in result.stdout 61 | 62 | 63 | def test_graph_with_inclusion(package_path: Path): 64 | result = runner.invoke( 65 | app, 66 | [ 67 | "graph", 68 | "example.base:Base", 69 | "--import-module", 70 | "example.models", 71 | "--python-dir", 72 | str(package_path), 73 | "--include-tables", 74 | "comments", 75 | ], 76 | ) 77 | assert result.exit_code == 0 78 | assert "posts {" not in result.stdout 79 | assert "comments {" in result.stdout 80 | 81 | 82 | def test_inject_check(package_path: Path): 83 | result = runner.invoke( 84 | app, 85 | [ 86 | "inject", 87 | str(package_path / "README.md"), 88 | "example.base:Base", 89 | "--import-module", 90 | "example.models", 91 | "--python-dir", 92 | str(package_path), 93 | "--check", 94 | ], 95 | ) 96 | assert result.exit_code == 1 97 | 98 | 99 | def test_inject(package_path: Path): 100 | result = runner.invoke( 101 | app, 102 | [ 103 | "inject", 104 | str(package_path / "README.md"), 105 | "example.base:Base", 106 | "--import-module", 107 | "example.models", 108 | "--python-dir", 109 | str(package_path), 110 | ], 111 | ) 112 | assert result.exit_code == 0 113 | 114 | with open(package_path / "README.md") as fp: 115 | readme = fp.read() 116 | mermaid_assert(readme) 117 | 118 | 119 | @pytest.mark.parametrize("column_sort_arg", ["key-based", "preserve-order"]) 120 | def test_inject_column_sort(package_path: Path, column_sort_arg: Literal["key-based"] | Literal["preserve-order"]): 121 | result = runner.invoke( 122 | app, 123 | [ 124 | "inject", 125 | str(package_path / "README.md"), 126 | "example.base:Base", 127 | "--import-module", 128 | "example.models", 129 | "--python-dir", 130 | str(package_path), 131 | "--column-sort", 132 | column_sort_arg, 133 | ], 134 | ) 135 | assert result.exit_code == 0 136 | 137 | with open(package_path / "README.md") as fp: 138 | readme = fp.read() 139 | mermaid_assert(readme) 140 | 141 | 142 | def test_version(): 143 | result = runner.invoke(app, ["version"]) 144 | assert result.exit_code == 0 145 | 146 | 147 | def test_graph_with_inclusion_regex(package_path: Path): 148 | result = runner.invoke( 149 | app, 150 | [ 151 | "graph", 152 | "example.base:Base", 153 | "--import-module", 154 | "example.models", 155 | "--python-dir", 156 | str(package_path), 157 | "--include-tables", 158 | "^com.*", 159 | ], 160 | ) 161 | assert result.exit_code == 0 162 | assert "comments {" in result.stdout 163 | assert "users {" not in result.stdout 164 | assert "post{" not in result.stdout 165 | 166 | 167 | def test_graph_with_exclusion_regex(package_path: Path): 168 | result = runner.invoke( 169 | app, 170 | [ 171 | "graph", 172 | "example.base:Base", 173 | "--import-module", 174 | "example.models", 175 | "--python-dir", 176 | str(package_path), 177 | "--exclude-tables", 178 | "^pos*.", 179 | ], 180 | ) 181 | assert result.exit_code == 0 182 | assert "comments {" in result.stdout 183 | assert "users {" in result.stdout 184 | assert "post {" not in result.stdout 185 | -------------------------------------------------------------------------------- /tests/test_graph.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from paracelsus.graph import get_graph_string 4 | 5 | from .utils import mermaid_assert 6 | 7 | 8 | @pytest.mark.parametrize("column_sort_arg", ["key-based", "preserve-order"]) 9 | def test_get_graph_string(column_sort_arg, package_path): 10 | graph_string = get_graph_string( 11 | base_class_path="example.base:Base", 12 | import_module=["example.models"], 13 | include_tables=set(), 14 | exclude_tables=set(), 15 | python_dir=[package_path], 16 | format="mermaid", 17 | column_sort=column_sort_arg, 18 | ) 19 | mermaid_assert(graph_string) 20 | 21 | 22 | def test_get_graph_string_with_exclude(package_path): 23 | """Excluding tables removes them from the graph string.""" 24 | graph_string = get_graph_string( 25 | base_class_path="example.base:Base", 26 | import_module=["example.models"], 27 | include_tables=set(), 28 | exclude_tables={"comments"}, 29 | python_dir=[package_path], 30 | column_sort="key-based", 31 | format="mermaid", 32 | ) 33 | assert "comments {" not in graph_string 34 | assert "posts {" in graph_string 35 | assert "users {" in graph_string 36 | assert "users ||--o{ posts" in graph_string 37 | 38 | # Excluding a table to which another table holds a foreign key will raise an error. 39 | graph_string = get_graph_string( 40 | base_class_path="example.base:Base", 41 | import_module=["example.models"], 42 | include_tables=set(), 43 | exclude_tables={"users", "comments"}, 44 | python_dir=[package_path], 45 | format="mermaid", 46 | column_sort="key-based", 47 | ) 48 | assert "posts {" in graph_string 49 | assert "users ||--o{ posts" not in graph_string 50 | 51 | 52 | def test_get_graph_string_with_include(package_path): 53 | """Excluding tables keeps them in the graph string.""" 54 | graph_string = get_graph_string( 55 | base_class_path="example.base:Base", 56 | import_module=["example.models"], 57 | include_tables={"users", "posts"}, 58 | exclude_tables=set(), 59 | python_dir=[package_path], 60 | column_sort="key-based", 61 | format="mermaid", 62 | ) 63 | assert "comments {" not in graph_string 64 | assert "posts {" in graph_string 65 | assert "users {" in graph_string 66 | assert "users ||--o{ posts" in graph_string 67 | 68 | # Including a table that holds a foreign key to a non-existing table will keep 69 | # the table but skip the connection. 70 | graph_string = get_graph_string( 71 | base_class_path="example.base:Base", 72 | import_module=["example.models"], 73 | include_tables={"posts"}, 74 | exclude_tables=set(), 75 | python_dir=[package_path], 76 | column_sort="key-based", 77 | format="mermaid", 78 | ) 79 | assert "posts {" in graph_string 80 | assert "users ||--o{ posts" not in graph_string 81 | -------------------------------------------------------------------------------- /tests/test_pyproject.py: -------------------------------------------------------------------------------- 1 | from paracelsus.pyproject import get_pyproject_settings 2 | 3 | 4 | def test_pyproject(package_path): 5 | settings = get_pyproject_settings(package_path) 6 | assert "base" in settings 7 | assert "imports" in settings 8 | assert settings["base"] == "example.base:Base" 9 | assert "example.models" in settings["imports"] 10 | 11 | 12 | def test_pyproject_none(): 13 | settings = get_pyproject_settings() 14 | assert settings == {} 15 | -------------------------------------------------------------------------------- /tests/transformers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tedivm/paracelsus/f473a574aa8bdb0cbf2aa38aa2610f3ac66dca4c/tests/transformers/__init__.py -------------------------------------------------------------------------------- /tests/transformers/test_dot.py: -------------------------------------------------------------------------------- 1 | from paracelsus.transformers.dot import Dot 2 | 3 | from ..utils import dot_assert 4 | 5 | 6 | def test_dot(metaclass): 7 | dot = Dot(metaclass=metaclass, column_sort="key-based") 8 | graph_string = str(dot) 9 | dot_assert(graph_string) 10 | 11 | 12 | def test_dot_column_sort_preserve_order(metaclass, dot_full_string_preseve_column_sort): 13 | dot = Dot(metaclass=metaclass, column_sort="preserve-order") 14 | assert str(dot) == dot_full_string_preseve_column_sort 15 | -------------------------------------------------------------------------------- /tests/transformers/test_mermaid.py: -------------------------------------------------------------------------------- 1 | from paracelsus.transformers.mermaid import Mermaid 2 | 3 | from ..utils import mermaid_assert 4 | 5 | 6 | def test_mermaid(metaclass): 7 | mermaid = Mermaid(metaclass=metaclass, column_sort="key-based") 8 | graph_string = str(mermaid) 9 | mermaid_assert(graph_string) 10 | 11 | 12 | def test_mermaid_column_sort_preserve_order(metaclass, mermaid_full_string_preseve_column_sort): 13 | mermaid = Mermaid(metaclass=metaclass, column_sort="preserve-order") 14 | assert str(mermaid) == mermaid_full_string_preseve_column_sort 15 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | def mermaid_assert(output: str) -> None: 2 | assert "users {" in output 3 | assert "posts {" in output 4 | assert "comments {" in output 5 | 6 | assert "users ||--o{ posts : author" in output 7 | assert "posts ||--o{ comments : post" in output 8 | assert "users ||--o{ comments : author" in output 9 | 10 | assert "CHAR(32) author FK" in output 11 | assert 'CHAR(32) post FK "nullable"' in output 12 | assert 'BOOLEAN live "True if post is published,nullable"' in output 13 | assert "DATETIME created" in output 14 | 15 | 16 | def dot_assert(output: str) -> None: 17 | assert 'users' in output 18 | assert 'posts' in output 19 | assert 'comments' in output 20 | 21 | assert "users -- posts [label=author, dir=both, arrowhead=crow, arrowtail=none];" in output 22 | assert "posts -- comments [label=post, dir=both, arrowhead=crow, arrowtail=none];" in output 23 | assert "users -- comments [label=author, dir=both, arrowhead=crow, arrowtail=none];" in output 24 | 25 | assert 'CHAR(32)authorForeign Key' in output 26 | assert 'CHAR(32)postForeign Key' in output 27 | assert 'DATETIMEcreated' in output 28 | --------------------------------------------------------------------------------