├── .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 | 
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 | [](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 | {table.name} |
77 | {column_output.rstrip()}
78 |
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 | users |
94 | CHAR(32) | id | Primary Key |
95 | VARCHAR(100) | display_name | |
96 | DATETIME | created | |
97 |
98 | >, shape=none, margin=0];
99 | posts [label=<
100 |
101 | posts |
102 | CHAR(32) | id | Primary Key |
103 | DATETIME | created | |
104 | CHAR(32) | author | Foreign Key |
105 | BOOLEAN | live | |
106 | TEXT | content | |
107 |
108 | >, shape=none, margin=0];
109 | users -- posts [label=author, dir=both, arrowhead=crow, arrowtail=none];
110 | comments [label=<
111 |
112 | comments |
113 | CHAR(32) | id | Primary Key |
114 | DATETIME | created | |
115 | CHAR(32) | post | Foreign Key |
116 | CHAR(32) | author | Foreign Key |
117 | BOOLEAN | live | |
118 | TEXT | content | |
119 |
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) | author | Foreign Key |
' in output
26 | assert 'CHAR(32) | post | Foreign Key |
' in output
27 | assert 'DATETIME | created | |
' in output
28 |
--------------------------------------------------------------------------------