├── tests ├── __init__.py ├── assets │ ├── example │ │ ├── __init__.py │ │ ├── base.py │ │ ├── cardinalities.py │ │ └── models.py │ ├── pyproject.toml │ ├── README.md │ ├── smaller_graph.config.toml │ ├── complete_graph.config.toml │ └── cardinalities.config.toml ├── transformers │ ├── __init__.py │ ├── test_dot.py │ ├── conftest.py │ ├── test_utils.py │ ├── test_dot_versions.py │ └── test_mermaid.py ├── test_pyproject.py ├── utils.py ├── test_graph.py ├── conftest.py └── test_cli.py ├── .python-version ├── docs ├── dev │ ├── pypi.md │ ├── github.md │ ├── README.md │ └── cli.md └── example.png ├── paracelsus ├── transformers │ ├── __init__.py │ ├── utils.py │ ├── dot.py │ └── mermaid.py ├── __init__.py ├── compat │ ├── __init__.py │ └── pydot_compat.py ├── pyproject.py ├── config.py ├── graph.py └── cli.py ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── mypy.yaml │ ├── ruff.yaml │ ├── black.yaml │ ├── tomlsort.yaml │ ├── dapperdata.yaml │ ├── pytest.yaml │ └── pypi.yaml ├── .pre-commit-config.yaml ├── LICENSE ├── pyproject.toml ├── makefile ├── .gitignore ├── scripts └── test_pydot_versions.sh └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /docs/dev/pypi.md: -------------------------------------------------------------------------------- 1 | # PyPI 2 | -------------------------------------------------------------------------------- /docs/dev/github.md: -------------------------------------------------------------------------------- 1 | # Github 2 | -------------------------------------------------------------------------------- /tests/assets/example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/transformers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /paracelsus/transformers/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | paracelsus/_version.py export-subst 2 | -------------------------------------------------------------------------------- /docs/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tedivm/paracelsus/HEAD/docs/example.png -------------------------------------------------------------------------------- /paracelsus/__init__.py: -------------------------------------------------------------------------------- 1 | from . import _version 2 | 3 | __version__ = _version.__version__ 4 | -------------------------------------------------------------------------------- /tests/assets/example/base.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import declarative_base 2 | 3 | Base = declarative_base() 4 | -------------------------------------------------------------------------------- /tests/assets/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.paracelsus] 2 | base = "example.base:Base" 3 | imports = [ 4 | "example.models" 5 | ] 6 | -------------------------------------------------------------------------------- /tests/assets/README.md: -------------------------------------------------------------------------------- 1 | # Test Directory 2 | 3 | Please ignore. 4 | 5 | ## Schema 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/assets/smaller_graph.config.toml: -------------------------------------------------------------------------------- 1 | [tool.paracelsus] 2 | base = "example.base:Base" 3 | imports = [ 4 | "example.models" 5 | ] 6 | include_tables = [ 7 | "users", 8 | "posts" 9 | ] 10 | -------------------------------------------------------------------------------- /tests/assets/complete_graph.config.toml: -------------------------------------------------------------------------------- 1 | [tool.paracelsus] 2 | base = "example.base:Base" 3 | imports = [ 4 | "example.models" 5 | ] 6 | include_tables = [ 7 | "users", 8 | "posts", 9 | "comments" 10 | ] 11 | -------------------------------------------------------------------------------- /tests/assets/cardinalities.config.toml: -------------------------------------------------------------------------------- 1 | [tool.paracelsus] 2 | base = "example.cardinalities:Base" 3 | imports = [ 4 | "example.cardinalities" 5 | ] 6 | include_tables = [ 7 | "some_schema.foo", 8 | "some_schema.bar", 9 | "some_schema.baz", 10 | "some_schema.beep" 11 | ] 12 | -------------------------------------------------------------------------------- /paracelsus/compat/__init__.py: -------------------------------------------------------------------------------- 1 | """Compatibility layer for pydot v3 and v4.""" 2 | 3 | from paracelsus.compat.pydot_compat import ( 4 | PYDOT_V4, 5 | PYDOT_VERSION, 6 | Dot, 7 | Edge, 8 | Node, 9 | ) 10 | 11 | __all__ = ["Dot", "Node", "Edge", "PYDOT_V4", "PYDOT_VERSION"] 12 | -------------------------------------------------------------------------------- /tests/test_pyproject.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from paracelsus.pyproject import get_pyproject_settings 4 | 5 | 6 | def test_pyproject(package_path: Path): 7 | settings = get_pyproject_settings(package_path / "pyproject.toml") 8 | assert settings.base == "example.base:Base" 9 | assert settings.imports == ["example.models"] 10 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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@v6 12 | 13 | - uses: actions/setup-python@v6 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/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@v6 12 | 13 | - uses: actions/setup-python@v6 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/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@v6 12 | 13 | - uses: actions/setup-python@v6 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/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@v6 12 | 13 | - uses: actions/setup-python@v6 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 | -------------------------------------------------------------------------------- /.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@v6 12 | 13 | - uses: actions/setup-python@v6 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 | -------------------------------------------------------------------------------- /paracelsus/pyproject.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from .config import ParacelsusTomlConfig 3 | 4 | try: 5 | import tomllib 6 | except ImportError: 7 | import toml as tomllib # type: ignore 8 | 9 | 10 | def get_pyproject_settings(config_file: Path) -> ParacelsusTomlConfig: 11 | if not config_file.exists(): 12 | return ParacelsusTomlConfig() 13 | 14 | data = tomllib.loads(config_file.read_bytes().decode()) 15 | 16 | return ParacelsusTomlConfig(**data.get("tool", {}).get("paracelsus", {})) 17 | -------------------------------------------------------------------------------- /paracelsus/compat/pydot_compat.py: -------------------------------------------------------------------------------- 1 | """Compatibility layer for pydot v3 and v4.""" 2 | 3 | from typing import Optional 4 | 5 | try: 6 | import pydot 7 | from packaging import version 8 | 9 | PYDOT_VERSION: Optional[version.Version] = version.parse(pydot.__version__) 10 | PYDOT_V4 = PYDOT_VERSION is not None and PYDOT_VERSION.major >= 4 11 | except Exception: 12 | PYDOT_V4 = False 13 | PYDOT_VERSION = None 14 | 15 | # Export standard pydot classes 16 | from pydot import Dot, Edge, Node # noqa: F401 17 | 18 | __all__ = ["Dot", "Node", "Edge", "PYDOT_V4", "PYDOT_VERSION"] 19 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] 17 | pydot-version: ["3", "4"] 18 | 19 | steps: 20 | - uses: actions/checkout@v6 21 | 22 | - uses: actions/setup-python@v6 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | 26 | - name: Install Dependencies 27 | run: make install 28 | 29 | - name: Install specific pydot major version 30 | run: | 31 | pip install "pydot>=${{ matrix.pydot-version }},<${{ matrix.pydot-version == '3' && '4' || '5' }}" 32 | python -c "import pydot; print(f'Testing with pydot {pydot.__version__}')" 33 | 34 | - name: Run Tests 35 | run: make pytest 36 | -------------------------------------------------------------------------------- /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): 13 | dot = Dot(metaclass=metaclass, column_sort="preserve-order") 14 | graph_string = str(dot) 15 | 16 | # Verify structure and relationships are correct 17 | dot_assert(graph_string) 18 | 19 | # Verify preserve-order specific column ordering 20 | # In preserve-order mode, columns should appear in the order they're defined 21 | # users: id, display_name, created 22 | assert graph_string.index("users") < graph_string.index("id") 23 | assert graph_string.index("id") < graph_string.index("display_name") 24 | assert graph_string.index("display_name") < graph_string.index("created") 25 | -------------------------------------------------------------------------------- /.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@v6 22 | with: 23 | fetch-depth: 0 24 | fetch-tags: true 25 | 26 | - uses: actions/setup-python@v6 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 | -------------------------------------------------------------------------------- /tests/transformers/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import textwrap 3 | 4 | 5 | @pytest.fixture() 6 | def mermaid_full_string_with_no_layout(mermaid_full_string_preseve_column_sort: str) -> str: 7 | return mermaid_full_string_preseve_column_sort 8 | 9 | 10 | @pytest.fixture() 11 | def mermaid_full_string_with_dagre_layout(mermaid_full_string_preseve_column_sort: str) -> str: 12 | front_matter = textwrap.dedent( 13 | """ 14 | --- 15 | config: 16 | layout: dagre 17 | --- 18 | """ 19 | ) 20 | return f"{front_matter}{mermaid_full_string_preseve_column_sort}" 21 | 22 | 23 | @pytest.fixture() 24 | def mermaid_full_string_with_elk_layout(mermaid_full_string_preseve_column_sort: str) -> str: 25 | front_matter = textwrap.dedent( 26 | """ 27 | --- 28 | config: 29 | layout: elk 30 | --- 31 | """ 32 | ) 33 | return f"{front_matter}{mermaid_full_string_preseve_column_sort}" 34 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/transformers/test_utils.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | import pytest 4 | from sqlalchemy import UniqueConstraint 5 | from sqlalchemy.orm import DeclarativeBase, mapped_column, Mapped 6 | from paracelsus.transformers.utils import is_unique 7 | 8 | 9 | class Base(DeclarativeBase): 10 | __table_args__ = {"schema": "some_schema"} 11 | 12 | 13 | class Foo(Base): 14 | __tablename__ = "foo" 15 | id: Mapped[UUID] = mapped_column(primary_key=True) 16 | 17 | bar: Mapped[str] = mapped_column(unique=True) 18 | baz: Mapped[str] = mapped_column() 19 | 20 | beep: Mapped[str] 21 | boop: Mapped[str] 22 | 23 | __table_args__ = ( 24 | UniqueConstraint("baz"), 25 | UniqueConstraint("beep", "boop"), 26 | Base.__table_args__, 27 | ) 28 | 29 | 30 | @pytest.mark.parametrize( 31 | "column_name, expected_unique", 32 | [ 33 | ("id", True), # primary key 34 | ("bar", True), # unique=True 35 | ("baz", True), # UniqueConstraint on single column 36 | ("beep", False), # part of multi-column UniqueConstraint 37 | ("boop", False), # part of multi-column UniqueConstraint 38 | ], 39 | ) 40 | def test_is_unique(column_name: str, expected_unique: bool): 41 | column = Foo.__table__.columns[column_name] 42 | 43 | assert is_unique(column) == expected_unique 44 | -------------------------------------------------------------------------------- /tests/assets/example/cardinalities.py: -------------------------------------------------------------------------------- 1 | """Contains example SQLAlchemy models demonstrating various cardinality relationships.""" 2 | 3 | from uuid import UUID 4 | 5 | from sqlalchemy import ForeignKey, UniqueConstraint, Uuid 6 | from sqlalchemy.orm import Mapped, DeclarativeBase, mapped_column 7 | 8 | 9 | class Base(DeclarativeBase): 10 | __table_args__ = {"schema": "some_schema"} 11 | 12 | 13 | class Bar(Base): 14 | __tablename__ = "bar" 15 | 16 | id: Mapped[UUID] = mapped_column(primary_key=True) 17 | 18 | 19 | class Baz(Base): 20 | __tablename__ = "baz" 21 | 22 | id: Mapped[UUID] = mapped_column(primary_key=True) 23 | 24 | 25 | class Beep(Base): 26 | __tablename__ = "beep" 27 | 28 | id: Mapped[UUID] = mapped_column(primary_key=True) 29 | 30 | 31 | class Foo(Base): 32 | __tablename__ = "foo" 33 | id: Mapped[UUID] = mapped_column(primary_key=True) 34 | 35 | bar_id: Mapped[UUID] = mapped_column(Uuid, ForeignKey(Bar.id), unique=True) 36 | 37 | baz_id: Mapped[UUID] = mapped_column(Uuid, ForeignKey(Baz.id)) 38 | 39 | beep_id: Mapped[UUID] = mapped_column(Uuid, ForeignKey(Beep.id)) 40 | boop: Mapped[str] 41 | 42 | __table_args__ = ( 43 | UniqueConstraint( 44 | "baz_id", 45 | ), 46 | UniqueConstraint("beep_id", "boop"), 47 | Base.__table_args__, 48 | ) 49 | -------------------------------------------------------------------------------- /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 >= 3.0, < 5.0", 9 | "packaging", 10 | "sqlalchemy", 11 | "typer", 12 | "toml; python_version < '3.11'" 13 | ] 14 | description = "Visualize SQLAlchemy Databases using Mermaid or Dot Diagrams." 15 | dynamic = ["version"] 16 | license = {"file" = "LICENSE"} 17 | name = "paracelsus" 18 | readme = {file = "README.md", content-type = "text/markdown"} 19 | requires-python = ">= 3.10" 20 | 21 | [project.optional-dependencies] 22 | dev = [ 23 | "build", 24 | "dapperdata", 25 | "glom", 26 | "mypy", 27 | "pip-tools", 28 | "pytest", 29 | "pytest-cov", 30 | "pytest-pretty", 31 | "ruamel.yaml", 32 | "ruff", 33 | "toml-sort" 34 | ] 35 | 36 | [project.scripts] 37 | paracelsus = "paracelsus.cli:app" 38 | 39 | [tool.ruff] 40 | exclude = [".venv", "./paracelsus/_version.py"] 41 | line-length = 120 42 | 43 | [tool.setuptools.dynamic] 44 | readme = {file = ["README.md"]} 45 | 46 | [tool.setuptools.package-data] 47 | paracelsus = ["py.typed"] 48 | 49 | [tool.setuptools.packages.find] 50 | exclude = ["tests*", "docs*"] 51 | 52 | [tool.setuptools_scm] 53 | fallback_version = "0.0.0-dev" 54 | write_to = "paracelsus/_version.py" 55 | -------------------------------------------------------------------------------- /paracelsus/transformers/utils.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.sql.schema import Column, UniqueConstraint 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 | 24 | 25 | def is_unique(column: Column) -> bool: 26 | """Determine if a column is unique. 27 | 28 | A single column is considered unique in any of the following cases: 29 | 30 | - It has the ``unique`` attribute set to ``True``. 31 | - It is a primary key. 32 | - There is a ``UniqueConstraint`` defined on the table that includes **only** this column. 33 | """ 34 | if column.unique or column.primary_key: 35 | return True 36 | 37 | # It's unique if there's a UniqueConstraint on just this column. 38 | for constraint in column.table.constraints: 39 | if isinstance(constraint, UniqueConstraint) and constraint.columns.keys() == [column.key]: 40 | return True 41 | 42 | return False 43 | -------------------------------------------------------------------------------- /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/transformers/test_dot_versions.py: -------------------------------------------------------------------------------- 1 | """Test pydot version compatibility.""" 2 | 3 | import pytest 4 | 5 | try: 6 | from paracelsus.compat.pydot_compat import PYDOT_V4, PYDOT_VERSION 7 | except ImportError: 8 | PYDOT_V4 = False 9 | PYDOT_VERSION = None 10 | 11 | 12 | class TestPydotVersionCompatibility: 13 | """Test that dot transformer works across pydot versions.""" 14 | 15 | def test_version_detection(self): 16 | """Test that we can detect pydot version.""" 17 | assert PYDOT_VERSION is not None, "Should detect pydot version" 18 | 19 | def test_basic_graph_creation(self, metaclass): 20 | """Test basic graph creation works on any version.""" 21 | from paracelsus.transformers.dot import Dot 22 | 23 | dot = Dot(metaclass=metaclass, column_sort="key-based") 24 | graph_string = str(dot) 25 | 26 | # Basic assertions that should work on any version 27 | assert "users" in graph_string 28 | assert "posts" in graph_string 29 | assert graph_string.startswith("graph database") 30 | 31 | @pytest.mark.skipif(not PYDOT_V4, reason="pydot v4 specific test") 32 | def test_v4_features(self, metaclass): 33 | """Test features specific to pydot v4.""" 34 | from paracelsus.transformers.dot import Dot 35 | 36 | dot = Dot(metaclass=metaclass, column_sort="key-based") 37 | # v4 should work without issues 38 | assert dot.graph is not None 39 | assert hasattr(dot.graph, "to_string") 40 | 41 | @pytest.mark.skipif(PYDOT_V4, reason="pydot v3 specific test") 42 | def test_v3_compatibility(self, metaclass): 43 | """Test backwards compatibility with v3.""" 44 | from paracelsus.transformers.dot import Dot 45 | 46 | dot = Dot(metaclass=metaclass, column_sort="key-based") 47 | graph_string = str(dot) 48 | # Ensure v3 behavior is maintained 49 | assert isinstance(graph_string, str) 50 | assert len(graph_string) > 0 51 | 52 | def test_version_logging(self, metaclass, caplog): 53 | """Test that we can log version information.""" 54 | import logging 55 | 56 | from paracelsus.transformers.dot import Dot 57 | 58 | with caplog.at_level(logging.INFO): 59 | dot = Dot(metaclass=metaclass, column_sort="key-based") 60 | _ = str(dot) 61 | 62 | # Verify basic functionality works 63 | assert dot.graph is not None 64 | -------------------------------------------------------------------------------- /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 | trailing_newline_assert(output) 16 | 17 | 18 | def dot_assert(output: str) -> None: 19 | assert 'users' in output 20 | assert 'posts' in output 21 | assert 'comments' in output 22 | 23 | # Check for edge relationships with flexible attribute ordering 24 | _assert_dot_edge(output, "users", "posts", "author", "crow", "none") 25 | _assert_dot_edge(output, "posts", "comments", "post", "crow", "none") 26 | _assert_dot_edge(output, "users", "comments", "author", "crow", "none") 27 | 28 | assert 'CHAR(32)authorForeign Key' in output 29 | assert 'CHAR(32)postForeign Key' in output 30 | assert 'DATETIMEcreated' in output 31 | 32 | 33 | def _assert_dot_edge( 34 | output: str, 35 | left_table: str, 36 | right_table: str, 37 | label: str, 38 | arrowhead: str, 39 | arrowtail: str, 40 | ) -> None: 41 | """Assert that a dot edge exists with the expected attributes, regardless of order.""" 42 | import re 43 | 44 | # Match edge line with flexible whitespace and attribute ordering 45 | pattern = rf"{left_table}\s+--\s+{right_table}\s+\[(.*?)\];" 46 | match = re.search(pattern, output) 47 | 48 | assert match, f"Edge '{left_table} -- {right_table}' not found in output" 49 | 50 | attributes = match.group(1) 51 | 52 | # Check each required attribute is present 53 | assert f"label={label}" in attributes, f"label={label} not found in edge attributes: {attributes}" 54 | assert "dir=both" in attributes, f"dir=both not found in edge attributes: {attributes}" 55 | assert f"arrowhead={arrowhead}" in attributes, f"arrowhead={arrowhead} not found in edge attributes: {attributes}" 56 | assert f"arrowtail={arrowtail}" in attributes, f"arrowtail={arrowtail} not found in edge attributes: {attributes}" 57 | 58 | trailing_newline_assert(output) 59 | 60 | 61 | def trailing_newline_assert(output: str) -> None: 62 | """ 63 | Check that the output ends with a single newline. 64 | This reduces end user linter rewrites, 65 | e.g. from pre-commit's end-of-file-fixer hook. 66 | """ 67 | assert output.endswith("\n") 68 | assert not output.endswith("\n\n") 69 | -------------------------------------------------------------------------------- /paracelsus/config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from enum import Enum 3 | from pathlib import Path 4 | from typing import Final 5 | 6 | 7 | class Formats(str, Enum): 8 | mermaid = "mermaid" 9 | mmd = "mmd" 10 | dot = "dot" 11 | gv = "gv" 12 | 13 | 14 | class ColumnSorts(str, Enum): 15 | key_based = "key-based" 16 | preserve = "preserve-order" 17 | 18 | 19 | class Layouts(str, Enum): 20 | dagre = "dagre" 21 | elk = "elk" 22 | 23 | 24 | SORT_DEFAULT: Final[ColumnSorts] = ColumnSorts.key_based 25 | OMIT_COMMENTS_DEFAULT: Final[bool] = False 26 | MAX_ENUM_MEMBERS_DEFAULT: Final[int] = 3 27 | TYPE_PARAMETER_DELIMITER_DEFAULT: Final[str] = "-" 28 | 29 | 30 | def validate_layout(*, format: Formats, layout: Layouts | None) -> None: 31 | """Validate that the layout parameter is only used with the mermaid format.""" 32 | if layout and format != Formats.mermaid: 33 | raise ValueError("The `layout` parameter can only be used with the `mermaid` format.") 34 | 35 | 36 | @dataclass(frozen=True) 37 | class ParacelsusTomlConfig: 38 | """Structure containing configuration options loaded from ``pyproject.toml``. 39 | 40 | They all have default values, so that missing options can be handled gracefully. 41 | """ 42 | 43 | base: str = "" 44 | imports: list[str] = field(default_factory=list) 45 | include_tables: list[str] = field(default_factory=list) 46 | exclude_tables: list[str] = field(default_factory=list) 47 | column_sort: ColumnSorts = SORT_DEFAULT 48 | omit_comments: bool = OMIT_COMMENTS_DEFAULT 49 | max_enum_members: int = MAX_ENUM_MEMBERS_DEFAULT 50 | type_parameter_delimiter: str = TYPE_PARAMETER_DELIMITER_DEFAULT 51 | 52 | 53 | @dataclass(frozen=True) 54 | class ParacelsusSettingsForGraph: 55 | """Structure containing all computed settings for invoking the graph generation. 56 | 57 | They are all mandatory. If need be, default values should be provided either at the 58 | CLI argument parsing level, or when loading from the ``pyproject.toml`` configuration. 59 | """ 60 | 61 | base_class_path: str 62 | import_module: list[str] 63 | include_tables: set[str] 64 | exclude_tables: set[str] 65 | python_dir: list[Path] 66 | format: Formats 67 | column_sort: ColumnSorts 68 | omit_comments: bool 69 | max_enum_members: int 70 | layout: Layouts | None 71 | type_parameter_delimiter: str 72 | 73 | def __post_init__(self) -> None: 74 | validate_layout(format=self.format, layout=self.layout) 75 | 76 | 77 | @dataclass(frozen=True) 78 | class ParacelsusSettingsForInject: 79 | """Structure containing all computed settings for invoking the graph injection. 80 | 81 | They are all mandatory. If need be, default values should be provided either at the 82 | CLI argument parsing level, or when loading from the ``pyproject.toml`` configuration. 83 | """ 84 | 85 | graph_settings: ParacelsusSettingsForGraph 86 | file: Path 87 | replace_begin_tag: str 88 | replace_end_tag: str 89 | check: bool 90 | -------------------------------------------------------------------------------- /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 | # 112 | # Pydot Version Testing 113 | # 114 | 115 | .PHONY: test_pydot_v3 116 | test_pydot_v3: 117 | $(PYTHON) -m pip install "pydot>=3.0,<4.0" 118 | $(PYTHON) -m pytest tests/ -v 119 | 120 | .PHONY: test_pydot_v4 121 | test_pydot_v4: 122 | $(PYTHON) -m pip install "pydot>=4.0,<5.0" 123 | $(PYTHON) -m pytest tests/ -v 124 | 125 | .PHONY: test_pydot_all_versions 126 | test_pydot_all_versions: test_pydot_v3 test_pydot_v4 127 | 128 | # 129 | # Packaging 130 | # 131 | 132 | .PHONY: build 133 | build: $(PACKAGE_CHECK) 134 | $(PYTHON) -m build 135 | -------------------------------------------------------------------------------- /tests/test_graph.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from paracelsus.config import Layouts 4 | from paracelsus.graph import get_graph_string 5 | 6 | from .utils import mermaid_assert 7 | 8 | 9 | @pytest.mark.parametrize("column_sort_arg", ["key-based", "preserve-order"]) 10 | def test_get_graph_string(column_sort_arg, package_path): 11 | graph_string = get_graph_string( 12 | base_class_path="example.base:Base", 13 | import_module=["example.models"], 14 | include_tables=set(), 15 | exclude_tables=set(), 16 | python_dir=[package_path], 17 | format="mermaid", 18 | column_sort=column_sort_arg, 19 | ) 20 | mermaid_assert(graph_string) 21 | 22 | 23 | def test_get_graph_string_with_exclude(package_path): 24 | """Excluding tables removes them from the graph string.""" 25 | graph_string = get_graph_string( 26 | base_class_path="example.base:Base", 27 | import_module=["example.models"], 28 | include_tables=set(), 29 | exclude_tables={"comments"}, 30 | python_dir=[package_path], 31 | column_sort="key-based", 32 | format="mermaid", 33 | ) 34 | assert "comments {" not in graph_string 35 | assert "posts {" in graph_string 36 | assert "users {" in graph_string 37 | assert "users ||--o{ posts" in graph_string 38 | 39 | # Excluding a table to which another table holds a foreign key will raise an error. 40 | graph_string = get_graph_string( 41 | base_class_path="example.base:Base", 42 | import_module=["example.models"], 43 | include_tables=set(), 44 | exclude_tables={"users", "comments"}, 45 | python_dir=[package_path], 46 | format="mermaid", 47 | column_sort="key-based", 48 | ) 49 | assert "posts {" in graph_string 50 | assert "users ||--o{ posts" not in graph_string 51 | 52 | 53 | def test_get_graph_string_with_include(package_path): 54 | """Excluding tables keeps them in the graph string.""" 55 | graph_string = get_graph_string( 56 | base_class_path="example.base:Base", 57 | import_module=["example.models"], 58 | include_tables={"users", "posts"}, 59 | exclude_tables=set(), 60 | python_dir=[package_path], 61 | column_sort="key-based", 62 | format="mermaid", 63 | ) 64 | assert "comments {" not in graph_string 65 | assert "posts {" in graph_string 66 | assert "users {" in graph_string 67 | assert "users ||--o{ posts" in graph_string 68 | 69 | # Including a table that holds a foreign key to a non-existing table will keep 70 | # the table but skip the connection. 71 | graph_string = get_graph_string( 72 | base_class_path="example.base:Base", 73 | import_module=["example.models"], 74 | include_tables={"posts"}, 75 | exclude_tables=set(), 76 | python_dir=[package_path], 77 | column_sort="key-based", 78 | format="mermaid", 79 | ) 80 | assert "posts {" in graph_string 81 | assert "users ||--o{ posts" not in graph_string 82 | 83 | 84 | @pytest.mark.parametrize("layout_arg", ["dagre", "elk"]) 85 | def test_get_graph_string_with_layout(layout_arg, package_path): 86 | graph_string = get_graph_string( 87 | base_class_path="example.base:Base", 88 | import_module=["example.models"], 89 | include_tables=set(), 90 | exclude_tables=set(), 91 | python_dir=[package_path], 92 | format="mermaid", 93 | column_sort="key-based", 94 | layout=Layouts(layout_arg), 95 | ) 96 | mermaid_assert(graph_string) 97 | -------------------------------------------------------------------------------- /paracelsus/transformers/dot.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import ClassVar, Optional 3 | 4 | from sqlalchemy.sql.schema import MetaData, Table 5 | 6 | from paracelsus.compat.pydot_compat import Dot as PydotDot 7 | from paracelsus.compat.pydot_compat import Edge, Node 8 | from paracelsus.config import Layouts 9 | 10 | from .utils import is_unique, sort_columns 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class Dot: 16 | comment_format: ClassVar[str] = "dot" 17 | 18 | def __init__( 19 | self, 20 | metaclass: MetaData, 21 | column_sort: str, 22 | omit_comments: bool = False, 23 | layout: Optional[Layouts] = None, 24 | ) -> None: 25 | self.metadata: MetaData = metaclass 26 | self.graph: PydotDot = PydotDot("database", graph_type="graph") 27 | self.column_sort: str = column_sort 28 | self.omit_comments: bool = omit_comments 29 | self.layout: Optional[Layouts] = layout 30 | 31 | for table in self.metadata.tables.values(): 32 | node = Node(name=table.name) 33 | node.set("label", self._table_label(table)) 34 | node.set("shape", "none") 35 | node.set("margin", "0") 36 | self.graph.add_node(node) 37 | for column in table.columns: 38 | for foreign_key in column.foreign_keys: 39 | key_parts = foreign_key.target_fullname.split(".") 40 | left_table = ".".join(key_parts[:-1]) 41 | left_column = key_parts[-1] 42 | 43 | # We don't add the connection to the fk table if the latter 44 | # is not included in our graph. 45 | if left_table not in self.metadata.tables: 46 | logger.warning( 47 | f"Table '{table}.{column.name}' is a foreign key to '{left_table}' " 48 | "which is not included in the graph, skipping the connection." 49 | ) 50 | continue 51 | 52 | edge = Edge(left_table.split(".")[-1], table.name) 53 | edge.set("label", column.name) 54 | edge.set("dir", "both") 55 | 56 | edge.set("arrowhead", "none") 57 | if not is_unique(column): 58 | edge.set("arrowhead", "crow") 59 | 60 | l_column = self.metadata.tables[left_table].columns[left_column] 61 | edge.set("arrowtail", "none") 62 | if not is_unique(l_column) and not l_column.primary_key: 63 | edge.set("arrowtail", "crow") 64 | 65 | self.graph.add_edge(edge) 66 | 67 | def _table_label(self, table: Table) -> str: 68 | column_output = "" 69 | columns = sort_columns(table_columns=table.columns, column_sort=self.column_sort) 70 | for column in columns: 71 | attributes = set([]) 72 | if column.primary_key: 73 | attributes.add("Primary Key") 74 | 75 | if len(column.foreign_keys) > 0: 76 | attributes.add("Foreign Key") 77 | 78 | if is_unique(column) and not column.primary_key: 79 | attributes.add("Unique") 80 | 81 | column_output += f' {column.type}{column.name}{", ".join(sorted(attributes))}\n' 82 | 83 | return f"""< 84 | 85 | 86 | {column_output.rstrip()} 87 |
{table.name}
88 | >""" 89 | 90 | def __str__(self) -> str: 91 | return self.graph.to_string() 92 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /scripts/test_pydot_versions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Test paracelsus against multiple pydot versions locally 3 | # Usage: ./scripts/test_pydot_versions.sh [version1] [version2] [...] 4 | # 5 | # Examples: 6 | # ./scripts/test_pydot_versions.sh # Test latest v3 and v4 7 | # ./scripts/test_pydot_versions.sh 3.0.2 4.0.1 # Test specific versions 8 | # ./scripts/test_pydot_versions.sh 3.0.4 # Test only specified version 9 | 10 | set -e 11 | 12 | # Get the script's directory and project root 13 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 14 | PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" 15 | 16 | # Check if virtual environment exists, create if not 17 | if [ ! -d "$PROJECT_ROOT/.venv" ]; then 18 | echo "Virtual environment not found. Running 'make install'..." 19 | cd "$PROJECT_ROOT" 20 | make install 21 | echo "" 22 | fi 23 | 24 | # Activate virtual environment 25 | source "$PROJECT_ROOT/.venv/bin/activate" 26 | 27 | echo "=== Pydot Version Compatibility Test ===" 28 | echo "" 29 | 30 | # Function to get latest version for a major version 31 | get_latest_version() { 32 | local major_version=$1 33 | pip index versions pydot 2>/dev/null | grep "Available versions:" | sed 's/Available versions: //' | tr ',' '\n' | sed 's/^[ \t]*//' | grep "^${major_version}\." | head -n1 34 | } 35 | 36 | # Default to latest versions if no arguments provided 37 | if [ $# -eq 0 ]; then 38 | echo "Looking up latest pydot versions..." 39 | V3_LATEST=$(get_latest_version "3") 40 | V4_LATEST=$(get_latest_version "4") 41 | 42 | if [ -z "$V3_LATEST" ] || [ -z "$V4_LATEST" ]; then 43 | echo "⚠️ Failed to fetch latest versions from PyPI, using fallback defaults" 44 | VERSIONS=("3.0.4" "4.0.1") 45 | else 46 | VERSIONS=("$V3_LATEST" "$V4_LATEST") 47 | fi 48 | echo "Testing latest versions: ${VERSIONS[*]}" 49 | else 50 | VERSIONS=("$@") 51 | echo "Testing specified versions: ${VERSIONS[*]}" 52 | fi 53 | echo "" 54 | 55 | # Store original pydot version 56 | ORIGINAL_VERSION=$(pip show pydot 2>/dev/null | grep Version | cut -d' ' -f2 || echo "none") 57 | 58 | echo "Original pydot version: $ORIGINAL_VERSION" 59 | echo "" 60 | 61 | # Track results 62 | PASSED=0 63 | FAILED=0 64 | FAILED_VERSIONS="" 65 | 66 | for version in "${VERSIONS[@]}"; do 67 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" 68 | echo "Testing with pydot==$version" 69 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" 70 | 71 | # Install specific version 72 | if pip install "pydot==$version" --quiet 2>/dev/null; then 73 | echo "✓ Installed pydot $version" 74 | 75 | # Verify installation 76 | INSTALLED=$(python -c "import pydot; print(pydot.__version__)") 77 | echo " Verified: $INSTALLED" 78 | 79 | # Run tests 80 | if pytest tests/ -v --tb=short; then 81 | echo "✅ Tests PASSED with pydot $version" 82 | ((PASSED++)) 83 | else 84 | echo "❌ Tests FAILED with pydot $version" 85 | ((FAILED++)) 86 | FAILED_VERSIONS="$FAILED_VERSIONS $version" 87 | fi 88 | else 89 | echo "⚠️ Could not install pydot $version" 90 | echo "❌ Tests FAILED with pydot $version (installation failed)" 91 | ((FAILED++)) 92 | FAILED_VERSIONS="$FAILED_VERSIONS $version" 93 | fi 94 | 95 | echo "" 96 | done 97 | 98 | # Restore original version if it was installed 99 | if [ "$ORIGINAL_VERSION" != "none" ]; then 100 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" 101 | echo "Restoring original pydot version: $ORIGINAL_VERSION" 102 | pip install "pydot==$ORIGINAL_VERSION" --quiet 103 | echo "✓ Restored" 104 | fi 105 | 106 | # Deactivate virtual environment 107 | deactivate 108 | 109 | echo "" 110 | echo "=== Test Summary ===" 111 | echo "Passed: $PASSED" 112 | echo "Failed: $FAILED" 113 | 114 | if [ $FAILED -gt 0 ]; then 115 | echo "Failed versions:$FAILED_VERSIONS" 116 | exit 1 117 | else 118 | echo "🎉 All versions tested successfully!" 119 | exit 0 120 | fi 121 | -------------------------------------------------------------------------------- /paracelsus/graph.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import os 3 | import re 4 | import sys 5 | from pathlib import Path 6 | from typing import Dict, List, Optional, Set, Union 7 | 8 | from sqlalchemy.schema import MetaData 9 | 10 | from .config import Layouts 11 | from .transformers.dot import Dot 12 | from .transformers.mermaid import Mermaid 13 | 14 | transformers: Dict[str, type[Union[Mermaid, Dot]]] = { 15 | "mmd": Mermaid, 16 | "mermaid": Mermaid, 17 | "dot": Dot, 18 | "gv": Dot, 19 | } 20 | 21 | 22 | def get_graph_string( 23 | *, 24 | base_class_path: str, 25 | import_module: List[str], 26 | include_tables: Set[str], 27 | exclude_tables: Set[str], 28 | python_dir: List[Path], 29 | format: str, 30 | column_sort: str, 31 | omit_comments: bool = False, 32 | max_enum_members: int = 0, 33 | layout: Optional[Layouts] = None, 34 | type_parameter_delimiter: str = "-", 35 | ) -> str: 36 | # Update the PYTHON_PATH to allow more module imports. 37 | sys.path.append(str(os.getcwd())) 38 | for dir in python_dir: 39 | sys.path.append(str(dir)) 40 | 41 | # Import the base class so the metadata class can be extracted from it. 42 | # The metadata class is passed to the transformer. 43 | module_path, class_name = base_class_path.split(":", 2) 44 | base_module = importlib.import_module(module_path) 45 | base_class = getattr(base_module, class_name) 46 | metadata = base_class.metadata 47 | 48 | # The modules holding the model classes have to be imported to get put in the metaclass model registry. 49 | # These modules aren't actually used in any way, so they are discarded. 50 | # They are also imported in scope of this function to prevent namespace pollution. 51 | for module in import_module: 52 | if ":*" in module: 53 | # Sure, execs are gross, but this is the only way to dynamically import wildcards. 54 | exec(f"from {module[:-2]} import *") 55 | else: 56 | importlib.import_module(module) 57 | 58 | # Grab a transformer. 59 | if format not in transformers: 60 | raise ValueError(f"Unknown Format: {format}") 61 | 62 | # Keep only the tables which were included / not-excluded 63 | include_tables = resolve_included_tables( 64 | include_tables=include_tables, exclude_tables=exclude_tables, all_tables=set(metadata.tables.keys()) 65 | ) 66 | filtered_metadata = filter_metadata(metadata=metadata, include_tables=include_tables) 67 | 68 | # Save the graph structure to string. 69 | # Note: type_parameter_delimiter only applies to Mermaid transformer 70 | if format in ["mermaid", "mmd"]: 71 | return str( 72 | Mermaid( 73 | filtered_metadata, 74 | column_sort, 75 | omit_comments=omit_comments, 76 | layout=layout, 77 | type_parameter_delimiter=type_parameter_delimiter, 78 | ) 79 | ) 80 | else: 81 | return str(Dot(filtered_metadata, column_sort, omit_comments=omit_comments)) 82 | 83 | 84 | def resolve_included_tables( 85 | include_tables: Set[str], 86 | exclude_tables: Set[str], 87 | all_tables: Set[str], 88 | ) -> Set[str]: 89 | """Resolves the final set of tables to include in the graph. 90 | 91 | Given sets of inclusions and exclusions and the set of all tables we define 92 | the following cases are: 93 | - Empty inclusion and empty exclusion -> include all tables. 94 | - Empty inclusion and some exclusions -> include all tables except the ones in the exclusion set. 95 | - Some inclusions and empty exclusion -> make sure tables in the inclusion set are present in 96 | all tables then include the tables in the inclusion set. 97 | - Some inclusions and some exclusions -> not resolvable, an error is raised. 98 | """ 99 | match len(include_tables), len(exclude_tables): 100 | case 0, 0: 101 | return all_tables 102 | case 0, int(): 103 | excluded = {table for table in all_tables if any(re.match(pattern, table) for pattern in exclude_tables)} 104 | return all_tables - excluded 105 | case int(), 0: 106 | included = {table for table in all_tables if any(re.match(pattern, table) for pattern in include_tables)} 107 | 108 | if not included: 109 | non_existent_tables = include_tables - all_tables 110 | raise ValueError( 111 | f"Some tables to include ({non_existent_tables}) don't exist " 112 | f"within the found tables ({all_tables})." 113 | ) 114 | return included 115 | case _: 116 | raise ValueError( 117 | f"Only one or none of include_tables ({include_tables}) or exclude_tables" 118 | f"({exclude_tables}) can contain values." 119 | ) 120 | 121 | 122 | def filter_metadata( 123 | metadata: MetaData, 124 | include_tables: Set[str], 125 | ) -> MetaData: 126 | """Create a subset of the metadata based on the tables to include.""" 127 | filtered_metadata = MetaData() 128 | for tablename, table in metadata.tables.items(): 129 | if tablename in include_tables: 130 | if hasattr(table, "to_metadata"): 131 | # to_metadata is the new way to do this, but it's only available in newer versions of SQLAlchemy. 132 | table = table.to_metadata(filtered_metadata) 133 | else: 134 | # tometadata is deprecated, but we still need to support it for older versions of SQLAlchemy. 135 | table = table.tometadata(filtered_metadata) 136 | 137 | return filtered_metadata 138 | -------------------------------------------------------------------------------- /paracelsus/transformers/mermaid.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import textwrap 4 | from typing import Optional 5 | 6 | import sqlalchemy 7 | from sqlalchemy.sql.schema import Column, MetaData, Table 8 | 9 | from paracelsus.config import Layouts 10 | 11 | from .utils import is_unique, sort_columns 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def sanitize_type_for_mermaid(type_str: str, delimiter: str = "-") -> str: 17 | """Replace commas in type parameters with a delimiter for Mermaid compatibility. 18 | 19 | Args: 20 | type_str: The type string to sanitize (e.g., "NUMERIC(10, 2)") 21 | delimiter: The delimiter to use instead of commas (default: "-") 22 | 23 | Returns: 24 | Sanitized type string (e.g., "NUMERIC(10-2)") 25 | 26 | Raises: 27 | ValueError: If delimiter contains comma or space characters 28 | 29 | Note: 30 | Mermaid ER diagrams use commas as structural separators for attribute keys 31 | (PK, FK, UK), so commas in type parameters break the parser. This function 32 | replaces commas with a safe delimiter. See GitHub issue #51. 33 | """ 34 | if "," in delimiter or " " in delimiter: 35 | raise ValueError(f"Type parameter delimiter cannot contain commas or spaces, got: {delimiter!r}") 36 | 37 | # Replace commas (with optional surrounding spaces) in parentheses with delimiter 38 | return re.sub(r"\(([^)]*),\s*([^)]*)\)", rf"(\1{delimiter}\2)", str(type_str)) 39 | 40 | 41 | class Mermaid: 42 | comment_format: str = "mermaid" 43 | metadata: MetaData 44 | column_sort: str 45 | omit_comments: bool 46 | max_enum_members: int 47 | layout: Optional[Layouts] 48 | type_parameter_delimiter: str 49 | 50 | def __init__( 51 | self, 52 | metaclass: MetaData, 53 | column_sort: str, 54 | omit_comments: bool = False, 55 | max_enum_members: int = 0, 56 | layout: Optional[Layouts] = None, 57 | type_parameter_delimiter: str = "-", 58 | ) -> None: 59 | self.metadata = metaclass 60 | self.column_sort = column_sort 61 | self.omit_comments = omit_comments 62 | self.max_enum_members = max_enum_members 63 | self.layout: Optional[Layouts] = layout 64 | 65 | # Validate delimiter doesn't contain commas or spaces 66 | if "," in type_parameter_delimiter or " " in type_parameter_delimiter: 67 | raise ValueError( 68 | f"Type parameter delimiter cannot contain commas or spaces, got: {type_parameter_delimiter!r}" 69 | ) 70 | self.type_parameter_delimiter = type_parameter_delimiter 71 | 72 | def _table(self, table: Table) -> str: 73 | output = f" {table.name}" 74 | output += " {\n" 75 | columns = sort_columns(table_columns=table.columns, column_sort=self.column_sort) 76 | for column in columns: 77 | output += self._column(column) 78 | output += " }\n\n" 79 | return output 80 | 81 | def _column(self, column: Column) -> str: 82 | options = [] 83 | col_type = column.type 84 | is_enum = isinstance(col_type, sqlalchemy.Enum) 85 | 86 | # Sanitize type string to replace commas with delimiter (GitHub issue #51) 87 | sanitized_type = sanitize_type_for_mermaid(str(col_type), self.type_parameter_delimiter) 88 | column_str = f"ENUM {column.name}" if is_enum else f"{sanitized_type} {column.name}" 89 | 90 | if column.primary_key: 91 | if len(column.foreign_keys) > 0: 92 | column_str += " PK,FK" 93 | else: 94 | column_str += " PK" 95 | elif len(column.foreign_keys) > 0: 96 | column_str += " FK" 97 | elif is_unique(column): 98 | column_str += " UK" 99 | 100 | if column.comment and not self.omit_comments: 101 | options.append(column.comment) 102 | 103 | if column.nullable: 104 | options.append("nullable") 105 | 106 | if column.index: 107 | options.append("indexed") 108 | 109 | # For ENUM, add values as a separate part 110 | option_str = ",".join(options) 111 | 112 | if is_enum and self.max_enum_members > 0: 113 | enum_list = list(col_type.enums) # type: ignore # MyPy will fail here, but this code works. 114 | if len(enum_list) <= self.max_enum_members: 115 | enum_values = ", ".join(enum_list) 116 | else: 117 | displayed_values = enum_list[: self.max_enum_members - 1] 118 | enum_values = ", ".join(displayed_values) + ", ..., " + enum_list[-1] 119 | 120 | if option_str: 121 | option_str += f"; values: {enum_values}" 122 | else: 123 | option_str = f"values: {enum_values}" 124 | 125 | if option_str: 126 | column_str += f' "{option_str}"' 127 | 128 | return f" {column_str}\n" 129 | 130 | def _relationships(self, column: Column) -> str: 131 | output = "" 132 | 133 | column_name = column.name 134 | right_table = column.table.name 135 | 136 | if is_unique(column): 137 | right_operand = "o|" 138 | else: 139 | right_operand = "o{" 140 | 141 | for foreign_key in column.foreign_keys: 142 | key_parts = foreign_key.target_fullname.split(".") 143 | left_table = ".".join(key_parts[:-1]) 144 | left_column = key_parts[-1] 145 | left_operand = "" 146 | 147 | # We don't add the connection to the fk table if the latter 148 | # is not included in our graph. 149 | if left_table not in self.metadata.tables: 150 | logger.warning( 151 | f"Table '{right_table}.{column_name}' is a foreign key to '{left_table}' " 152 | "which is not included in the graph, skipping the connection." 153 | ) 154 | continue 155 | 156 | lcolumn = self.metadata.tables[left_table].columns[left_column] 157 | if is_unique(lcolumn): 158 | left_operand = "||" 159 | else: 160 | left_operand = "}o" 161 | 162 | output += f" {left_table.split('.')[-1]} {left_operand}--{right_operand} {right_table} : {column_name}\n" 163 | return output 164 | 165 | def __str__(self) -> str: 166 | output = "" 167 | if self.layout: 168 | yaml_front_matter = textwrap.dedent(f""" 169 | --- 170 | config: 171 | layout: {self.layout.value} 172 | --- 173 | """) 174 | output = yaml_front_matter + output 175 | output += "erDiagram\n" 176 | for table in self.metadata.tables.values(): 177 | output += self._table(table) 178 | 179 | for table in self.metadata.tables.values(): 180 | for column in table.columns.values(): 181 | if len(column.foreign_keys) > 0: 182 | output += self._relationships(column) 183 | 184 | return output 185 | return output 186 | -------------------------------------------------------------------------------- /tests/transformers/test_mermaid.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy 2 | from sqlalchemy import Column, Enum, MetaData, Table 3 | 4 | from paracelsus.config import Layouts 5 | from paracelsus.transformers.mermaid import Mermaid 6 | 7 | from ..utils import mermaid_assert 8 | 9 | 10 | def test_mermaid(metaclass): 11 | mermaid = Mermaid(metaclass=metaclass, column_sort="key-based") 12 | graph_string = str(mermaid) 13 | mermaid_assert(graph_string) 14 | 15 | 16 | def test_mermaid_column_sort_preserve_order(metaclass, mermaid_full_string_preseve_column_sort): 17 | mermaid = Mermaid(metaclass=metaclass, column_sort="preserve-order") 18 | assert str(mermaid) == mermaid_full_string_preseve_column_sort 19 | 20 | 21 | def test_mermaid_keep_comments(metaclass): 22 | mermaid = Mermaid(metaclass=metaclass, column_sort="key-based", omit_comments=False) 23 | assert "True if post is published" in str(mermaid) 24 | 25 | 26 | def test_mermaid_omit_comments(metaclass): 27 | mermaid = Mermaid(metaclass=metaclass, column_sort="key-based", omit_comments=True) 28 | assert "True if post is published" not in str(mermaid) 29 | 30 | 31 | def test_mermaid_enum_values_present(): 32 | metadata = MetaData() 33 | status_enum = Enum("draft", "published", "archived", name="status_enum") 34 | table = Table( 35 | "post", 36 | metadata, 37 | Column("id", sqlalchemy.Integer, primary_key=True), 38 | Column("status", status_enum, nullable=True), 39 | ) 40 | mermaid = Mermaid(metaclass=metadata, column_sort="key-based", max_enum_members=10) 41 | status_column = table.columns["status"] 42 | column_str = mermaid._column(status_column) 43 | 44 | assert "values: draft, published, archived" in column_str 45 | 46 | 47 | def test_mermaid_enum_values_hidden_when_max_zero(): 48 | metadata = MetaData() 49 | status_enum = Enum("draft", "published", "archived", name="status_enum") 50 | table = Table( 51 | "post", 52 | metadata, 53 | Column("id", sqlalchemy.Integer, primary_key=True), 54 | Column("status", status_enum, nullable=True), 55 | ) 56 | mermaid = Mermaid(metaclass=metadata, column_sort="key-based", max_enum_members=0, layout=None) 57 | status_column = table.columns["status"] 58 | column_str = mermaid._column(status_column) 59 | 60 | assert "values:" not in column_str 61 | assert "ENUM status" in column_str 62 | 63 | 64 | def test_mermaid_enum_values_limited(): 65 | metadata = MetaData() 66 | status_enum = Enum("draft", "published", "archived", "deleted", "review", name="status_enum") 67 | table = Table( 68 | "post", 69 | metadata, 70 | Column("id", sqlalchemy.Integer, primary_key=True), 71 | Column("status", status_enum, nullable=True), 72 | ) 73 | mermaid = Mermaid(metaclass=metadata, column_sort="key-based", max_enum_members=3, layout=None) 74 | status_column = table.columns["status"] 75 | column_str = mermaid._column(status_column) 76 | 77 | assert "values: draft, published, ..., review" in column_str 78 | assert "archived" not in column_str 79 | assert "deleted" not in column_str 80 | 81 | 82 | def test_mermaid_with_no_layout(metaclass, mermaid_full_string_with_no_layout): 83 | mermaid = Mermaid(metaclass=metaclass, column_sort="preserve-order", layout=None) 84 | assert str(mermaid) == mermaid_full_string_with_no_layout 85 | 86 | 87 | def test_mermaid_with_dagre_layout(metaclass, mermaid_full_string_with_dagre_layout): 88 | mermaid = Mermaid(metaclass=metaclass, column_sort="preserve-order", layout=Layouts.dagre) 89 | assert str(mermaid) == mermaid_full_string_with_dagre_layout 90 | 91 | 92 | def test_mermaid_with_elk_layout(metaclass, mermaid_full_string_with_elk_layout): 93 | mermaid = Mermaid(metaclass=metaclass, column_sort="preserve-order", layout=Layouts.elk) 94 | assert str(mermaid) == mermaid_full_string_with_elk_layout 95 | 96 | 97 | def test_mermaid_numeric_type_with_parameters(): 98 | """Test that NUMERIC types with parameters are sanitized correctly (issue #51).""" 99 | metadata = MetaData() 100 | table = Table( 101 | "product", 102 | metadata, 103 | Column("id", sqlalchemy.Integer, primary_key=True), 104 | Column("price", sqlalchemy.Numeric(10, 2), nullable=False), 105 | ) 106 | mermaid = Mermaid(metaclass=metadata, column_sort="key-based") 107 | price_column = table.columns["price"] 108 | column_str = mermaid._column(price_column) 109 | 110 | # Should use hyphen instead of comma 111 | assert "NUMERIC(10-2)" in column_str 112 | assert "NUMERIC(10, 2)" not in column_str 113 | 114 | 115 | def test_mermaid_decimal_type_with_parameters(): 116 | """Test that DECIMAL types with parameters are sanitized correctly (issue #51).""" 117 | metadata = MetaData() 118 | table = Table( 119 | "account", 120 | metadata, 121 | Column("id", sqlalchemy.Integer, primary_key=True), 122 | Column("balance", sqlalchemy.DECIMAL(8, 3), nullable=False), 123 | ) 124 | mermaid = Mermaid(metaclass=metadata, column_sort="key-based") 125 | balance_column = table.columns["balance"] 126 | column_str = mermaid._column(balance_column) 127 | 128 | # Should use hyphen instead of comma 129 | assert "DECIMAL(8-3)" in column_str 130 | assert "DECIMAL(8, 3)" not in column_str 131 | 132 | 133 | def test_mermaid_types_without_parameters_unchanged(): 134 | """Test that types without parameters are not affected by sanitization.""" 135 | metadata = MetaData() 136 | table = Table( 137 | "user", 138 | metadata, 139 | Column("id", sqlalchemy.Integer, primary_key=True), 140 | Column("name", sqlalchemy.String(100), nullable=False), 141 | Column("active", sqlalchemy.Boolean, nullable=False), 142 | ) 143 | mermaid = Mermaid(metaclass=metadata, column_sort="key-based") 144 | 145 | # String(100) has parentheses but no comma - should be unchanged 146 | name_column = table.columns["name"] 147 | name_str = mermaid._column(name_column) 148 | assert "VARCHAR(100)" in name_str or "STRING(100)" in name_str 149 | 150 | # Boolean has no parameters - should be unchanged 151 | active_column = table.columns["active"] 152 | active_str = mermaid._column(active_column) 153 | assert "BOOLEAN" in active_str or "BOOL" in active_str 154 | 155 | 156 | def test_mermaid_custom_delimiter(): 157 | """Test using a custom delimiter instead of hyphen.""" 158 | metadata = MetaData() 159 | table = Table( 160 | "product", 161 | metadata, 162 | Column("id", sqlalchemy.Integer, primary_key=True), 163 | Column("price", sqlalchemy.Numeric(10, 2), nullable=False), 164 | ) 165 | mermaid = Mermaid(metaclass=metadata, column_sort="key-based", type_parameter_delimiter="_") 166 | price_column = table.columns["price"] 167 | column_str = mermaid._column(price_column) 168 | 169 | # Should use underscore instead of comma 170 | assert "NUMERIC(10_2)" in column_str 171 | assert "NUMERIC(10-2)" not in column_str 172 | assert "NUMERIC(10, 2)" not in column_str 173 | 174 | 175 | def test_mermaid_invalid_delimiter_with_comma(): 176 | """Test that delimiter containing comma raises ValueError.""" 177 | metadata = MetaData() 178 | try: 179 | Mermaid(metaclass=metadata, column_sort="key-based", type_parameter_delimiter=",") 180 | assert False, "Should have raised ValueError" 181 | except ValueError as e: 182 | assert "cannot contain commas or spaces" in str(e) 183 | 184 | 185 | def test_mermaid_invalid_delimiter_with_space(): 186 | """Test that delimiter containing space raises ValueError.""" 187 | metadata = MetaData() 188 | try: 189 | Mermaid(metaclass=metadata, column_sort="key-based", type_parameter_delimiter=" ") 190 | assert False, "Should have raised ValueError" 191 | except ValueError as e: 192 | assert "cannot contain commas or spaces" in str(e) 193 | 194 | 195 | def test_mermaid_invalid_delimiter_with_comma_and_space(): 196 | """Test that delimiter containing comma and space raises ValueError.""" 197 | metadata = MetaData() 198 | try: 199 | Mermaid(metaclass=metadata, column_sort="key-based", type_parameter_delimiter=", ") 200 | assert False, "Should have raised ValueError" 201 | except ValueError as e: 202 | assert "cannot contain commas or spaces" in str(e) 203 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import sys 4 | import tempfile 5 | from collections.abc import Generator 6 | from datetime import datetime, timezone 7 | from pathlib import Path 8 | from textwrap import dedent 9 | from uuid import uuid4 10 | 11 | import pytest 12 | from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, Uuid 13 | from sqlalchemy.orm import declarative_base, mapped_column 14 | 15 | UTC = timezone.utc 16 | 17 | 18 | @pytest.fixture 19 | def metaclass(): 20 | Base = declarative_base() 21 | 22 | class User(Base): 23 | __tablename__ = "users" 24 | 25 | id = mapped_column(Uuid, primary_key=True, default=uuid4()) 26 | display_name = mapped_column(String(100)) 27 | created = mapped_column(DateTime, nullable=False, default=datetime.now(UTC)) 28 | 29 | class Post(Base): 30 | __tablename__ = "posts" 31 | 32 | id = mapped_column(Uuid, primary_key=True, default=uuid4()) 33 | created = mapped_column(DateTime, nullable=False, default=datetime.now(UTC)) 34 | author = mapped_column(ForeignKey(User.id), nullable=False) 35 | live = mapped_column(Boolean, default=False, comment="True if post is published") 36 | content = mapped_column(Text, default="") 37 | 38 | class Comment(Base): 39 | __tablename__ = "comments" 40 | 41 | id = mapped_column(Uuid, primary_key=True, default=uuid4()) 42 | created = mapped_column(DateTime, nullable=False, default=datetime.now(UTC)) 43 | post = mapped_column(Uuid, ForeignKey(Post.id), default=uuid4()) 44 | author = mapped_column(ForeignKey(User.id), nullable=False) 45 | live = mapped_column(Boolean, default=False) 46 | content = mapped_column(Text, default="") 47 | 48 | return Base.metadata 49 | 50 | 51 | @pytest.fixture 52 | def package_path() -> Generator[Path, None, None]: 53 | template_path = Path(os.path.dirname(os.path.realpath(__file__))) / "assets" 54 | with tempfile.TemporaryDirectory() as package_path: 55 | shutil.copytree(template_path, package_path, dirs_exist_ok=True) 56 | os.chdir(package_path) 57 | # RATIONALE: Purge cached 'example' modules so the new temp directory path is used for imports. 58 | # Without this, earlier tests leave sys.modules['example'] with a __path__ pointing at a deleted 59 | # temp directory. Later tests then fail to import submodules (e.g. example.cardinalities) because 60 | # Python reuses the stale package object and doesn't refresh its search path. Removing only these 61 | # entries enforces a clean import and prevents cross-test leakage / flakiness. 62 | for name in list(sys.modules.keys()): 63 | if name == "example" or name.startswith("example."): 64 | del sys.modules[name] 65 | yield Path(package_path) 66 | 67 | 68 | @pytest.fixture() 69 | def mermaid_full_string_preseve_column_sort() -> str: 70 | return """erDiagram 71 | users { 72 | CHAR(32) id PK 73 | VARCHAR(100) display_name "nullable" 74 | DATETIME created 75 | } 76 | 77 | posts { 78 | CHAR(32) id PK 79 | DATETIME created 80 | CHAR(32) author FK 81 | BOOLEAN live "True if post is published,nullable" 82 | TEXT content "nullable" 83 | } 84 | 85 | comments { 86 | CHAR(32) id PK 87 | DATETIME created 88 | CHAR(32) post FK "nullable" 89 | CHAR(32) author FK 90 | BOOLEAN live "nullable" 91 | TEXT content "nullable" 92 | } 93 | 94 | users ||--o{ posts : author 95 | posts ||--o{ comments : post 96 | users ||--o{ comments : author 97 | """ 98 | 99 | 100 | @pytest.fixture() 101 | def dot_full_string_preseve_column_sort() -> str: 102 | return """graph database { 103 | users [label=< 104 | 105 | 106 | 107 | 108 | 109 |
users
CHAR(32)idPrimary Key
VARCHAR(100)display_name
DATETIMEcreated
110 | >, shape=none, margin=0]; 111 | posts [label=< 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 |
posts
CHAR(32)idPrimary Key
DATETIMEcreated
CHAR(32)authorForeign Key
BOOLEANlive
TEXTcontent
120 | >, shape=none, margin=0]; 121 | users -- posts [label=author, dir=both, arrowhead=crow, arrowtail=none]; 122 | comments [label=< 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 |
comments
CHAR(32)idPrimary Key
DATETIMEcreated
CHAR(32)postForeign Key
CHAR(32)authorForeign Key
BOOLEANlive
TEXTcontent
132 | >, shape=none, margin=0]; 133 | posts -- comments [label=post, dir=both, arrowhead=crow, arrowtail=none]; 134 | users -- comments [label=author, dir=both, arrowhead=crow, arrowtail=none]; 135 | } 136 | """ 137 | 138 | 139 | @pytest.fixture(name="expected_mermaid_smaller_graph") 140 | def fixture_expected_mermaid_smaller_graph() -> str: 141 | return dedent("""\ 142 | # Test Directory 143 | 144 | Please ignore. 145 | 146 | ## Schema 147 | 148 | 149 | ```mermaid 150 | 151 | --- 152 | config: 153 | layout: dagre 154 | --- 155 | erDiagram 156 | users { 157 | CHAR(32) id PK 158 | DATETIME created 159 | VARCHAR(100) display_name "nullable" 160 | } 161 | 162 | posts { 163 | CHAR(32) id PK 164 | CHAR(32) author FK 165 | TEXT content "nullable" 166 | DATETIME created 167 | BOOLEAN live "True if post is published,nullable" 168 | } 169 | 170 | users ||--o{ posts : author 171 | 172 | ``` 173 | 174 | """) 175 | 176 | 177 | @pytest.fixture(name="expected_mermaid_complete_graph") 178 | def fixture_expected_mermaid_complete_graph() -> str: 179 | return dedent("""\ 180 | # Test Directory 181 | 182 | Please ignore. 183 | 184 | ## Schema 185 | 186 | 187 | ```mermaid 188 | 189 | --- 190 | config: 191 | layout: dagre 192 | --- 193 | erDiagram 194 | users { 195 | CHAR(32) id PK 196 | DATETIME created 197 | VARCHAR(100) display_name "nullable" 198 | } 199 | 200 | posts { 201 | CHAR(32) id PK 202 | CHAR(32) author FK 203 | TEXT content "nullable" 204 | DATETIME created 205 | BOOLEAN live "True if post is published,nullable" 206 | } 207 | 208 | comments { 209 | CHAR(32) id PK 210 | CHAR(32) author FK 211 | CHAR(32) post FK "nullable" 212 | TEXT content "nullable" 213 | DATETIME created 214 | BOOLEAN live "nullable" 215 | } 216 | 217 | users ||--o{ posts : author 218 | posts ||--o{ comments : post 219 | users ||--o{ comments : author 220 | 221 | ``` 222 | 223 | """) 224 | 225 | 226 | @pytest.fixture(name="expected_mermaid_cardinalities_graph") 227 | def fixture_expected_mermaid_cardinalities_graph() -> str: 228 | return dedent("""\ 229 | # Test Directory 230 | 231 | Please ignore. 232 | 233 | ## Schema 234 | 235 | 236 | ```mermaid 237 | erDiagram 238 | bar { 239 | CHAR(32) id PK 240 | } 241 | 242 | baz { 243 | CHAR(32) id PK 244 | } 245 | 246 | beep { 247 | CHAR(32) id PK 248 | } 249 | 250 | foo { 251 | CHAR(32) id PK 252 | CHAR(32) bar_id FK 253 | CHAR(32) baz_id FK 254 | CHAR(32) beep_id FK 255 | VARCHAR boop 256 | } 257 | 258 | bar ||--o| foo : bar_id 259 | baz ||--o| foo : baz_id 260 | beep ||--o{ foo : beep_id 261 | 262 | ``` 263 | 264 | """) 265 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Literal 3 | 4 | import pytest 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_inject_pyproject_configuration(package_path: Path): 143 | """Test that the pyproject.toml configuration is used when base class path is not passed as argument.""" 144 | result = runner.invoke( 145 | app, 146 | ["inject", str(package_path / "README.md")], 147 | ) 148 | assert result.exit_code == 0 149 | 150 | with open(package_path / "README.md") as fp: 151 | readme = fp.read() 152 | mermaid_assert(readme) 153 | 154 | 155 | def test_version(): 156 | result = runner.invoke(app, ["version"]) 157 | assert result.exit_code == 0 158 | 159 | 160 | def test_graph_with_inclusion_regex(package_path: Path): 161 | result = runner.invoke( 162 | app, 163 | [ 164 | "graph", 165 | "example.base:Base", 166 | "--import-module", 167 | "example.models", 168 | "--python-dir", 169 | str(package_path), 170 | "--include-tables", 171 | "^com.*", 172 | ], 173 | ) 174 | assert result.exit_code == 0 175 | assert "comments {" in result.stdout 176 | assert "users {" not in result.stdout 177 | assert "post{" not in result.stdout 178 | 179 | 180 | def test_graph_with_exclusion_regex(package_path: Path): 181 | result = runner.invoke( 182 | app, 183 | [ 184 | "graph", 185 | "example.base:Base", 186 | "--import-module", 187 | "example.models", 188 | "--python-dir", 189 | str(package_path), 190 | "--exclude-tables", 191 | "^pos*.", 192 | ], 193 | ) 194 | assert result.exit_code == 0 195 | assert "comments {" in result.stdout 196 | assert "users {" in result.stdout 197 | assert "post {" not in result.stdout 198 | 199 | 200 | @pytest.mark.parametrize("layout_arg", ["dagre", "elk"]) 201 | def test_graph_layout(package_path: Path, layout_arg: Literal["dagre", "elk"]): 202 | result = runner.invoke( 203 | app, 204 | [ 205 | "graph", 206 | "example.base:Base", 207 | "--import-module", 208 | "example.models", 209 | "--python-dir", 210 | str(package_path), 211 | "--layout", 212 | layout_arg, 213 | ], 214 | ) 215 | 216 | assert result.exit_code == 0 217 | mermaid_assert(result.stdout) 218 | 219 | 220 | @pytest.mark.parametrize("layout_arg", ["dagre", "elk"]) 221 | def test_inject_layout(package_path: Path, layout_arg: Literal["dagre", "elk"]): 222 | result = runner.invoke( 223 | app, 224 | [ 225 | "inject", 226 | str(package_path / "README.md"), 227 | "example.base:Base", 228 | "--import-module", 229 | "example.models", 230 | "--python-dir", 231 | str(package_path), 232 | "--layout", 233 | layout_arg, 234 | ], 235 | ) 236 | assert result.exit_code == 0 237 | 238 | with open(package_path / "README.md") as fp: 239 | readme = fp.read() 240 | mermaid_assert(readme) 241 | 242 | 243 | def test_inject_layout_with_custom_smaller_config(package_path: Path, expected_mermaid_smaller_graph: str): 244 | result = runner.invoke( 245 | app, 246 | [ 247 | "inject", 248 | str(package_path / "README.md"), 249 | "example.base:Base", 250 | "--import-module", 251 | "example.models", 252 | "--python-dir", 253 | str(package_path), 254 | "--layout", 255 | "dagre", 256 | "--config", 257 | str(package_path / "smaller_graph.config.toml"), 258 | ], 259 | ) 260 | assert result.exit_code == 0, result.output 261 | 262 | generated_readme = (package_path / "README.md").read_text() 263 | 264 | assert generated_readme == expected_mermaid_smaller_graph 265 | 266 | 267 | def test_inject_layout_with_custom_complete_config(package_path: Path, expected_mermaid_complete_graph: str): 268 | result = runner.invoke( 269 | app, 270 | [ 271 | "inject", 272 | str(package_path / "README.md"), 273 | "example.base:Base", 274 | "--import-module", 275 | "example.models", 276 | "--python-dir", 277 | str(package_path), 278 | "--layout", 279 | "dagre", 280 | "--config", 281 | str(package_path / "complete_graph.config.toml"), 282 | ], 283 | ) 284 | assert result.exit_code == 0, result.output 285 | 286 | generated_readme = (package_path / "README.md").read_text() 287 | 288 | assert generated_readme == expected_mermaid_complete_graph 289 | 290 | 291 | def test_inject_cardinalities_mermaid(package_path: Path, expected_mermaid_cardinalities_graph: str): 292 | result = runner.invoke( 293 | app, 294 | [ 295 | "inject", 296 | str(package_path / "README.md"), 297 | "--python-dir", 298 | str(package_path), 299 | "--config", 300 | str(package_path / "cardinalities.config.toml"), 301 | ], 302 | ) 303 | 304 | assert result.exit_code == 0, result.output 305 | 306 | generated_readme = (package_path / "README.md").read_text() 307 | 308 | assert generated_readme == expected_mermaid_cardinalities_graph 309 | 310 | 311 | def test_graph_with_custom_type_delimiter(package_path: Path): 312 | """Test that custom type parameter delimiter works via CLI.""" 313 | result = runner.invoke( 314 | app, 315 | [ 316 | "graph", 317 | "example.base:Base", 318 | "--import-module", 319 | "example.models", 320 | "--python-dir", 321 | str(package_path), 322 | "--type-parameter-delimiter", 323 | "_", 324 | ], 325 | ) 326 | 327 | assert result.exit_code == 0, result.output 328 | # The output should be valid mermaid 329 | mermaid_assert(result.stdout) 330 | -------------------------------------------------------------------------------- /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 | - [Include or Exclude tables](#include-or-exclude-tables) 12 | - [Specify Column Sort Order](#specify-column-sort-order) 13 | - [Omit Comments](#omit-comments) 14 | - [Type Parameter Delimiter](#type-parameter-delimiter) 15 | - [Generate Mermaid Diagrams](#generate-mermaid-diagrams) 16 | - [Inject Mermaid Diagrams](#inject-mermaid-diagrams) 17 | - [Creating Images](#creating-images) 18 | - [pyproject.toml](#pyprojecttoml) 19 | - [Alternative config files](#alternative-config-files) 20 | - [Sponsorship](#sponsorship) 21 | 22 | ## Features 23 | 24 | - ERDs can be injected into documentation as [Mermaid Diagrams](https://mermaid.js.org/). 25 | - Paracelsus can be run in CICD to check that databases are up to date. 26 | - ERDs can be created as files in either [Dot](https://graphviz.org/doc/info/lang.html) or Mermaid format. 27 | - DOT files can be used to generate SVG or PNG files, or edited in [GraphViz](https://graphviz.org/) or other editors. 28 | 29 | ## Usage 30 | 31 | ### Installation 32 | 33 | 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. 34 | 35 | ```bash 36 | pip install paracelsus 37 | ``` 38 | 39 | ### Basic CLI Usage 40 | 41 | Paracelsus is primarily a CLI application. 42 | 43 | 44 | ```bash 45 | paracelsus --help 46 | ``` 47 | 48 | It has three commands: 49 | 50 | - `version` outputs the version of the currently installed `paracelsus` cli. 51 | - `graph` generates a graph and outputs it to `stdout`. 52 | - `inject` inserts the graph into a markdown file. 53 | 54 | ### Importing Models 55 | 56 | 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. 57 | 58 | The `--import-module` flag can be used to import any python module, which presumably will include one or more SQLAlchemy models inside of it. 59 | 60 | ```bash 61 | paracelsus graph example_app.models.base:Base \ 62 | --import-module "example_app.models.users" \ 63 | --import-module "example_app.models.posts" \ 64 | --import-module "example_app.models.comments" 65 | ``` 66 | 67 | 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. 68 | 69 | ```bash 70 | paracelsus graph example_app.models.base:Base --import-module "example_app.models:*" 71 | ``` 72 | 73 | This is equivalent to running this style of python import: 74 | 75 | ```python 76 | from example_app.models import * 77 | ``` 78 | 79 | ### Include or Exclude tables 80 | 81 | After importing the models, it is possible to select a subset of those models by using the `--exclude-tables` and `--include-tables` options. 82 | These are mutually exclusive options, the user can only provide inclusions or exclusions: 83 | 84 | ```bash 85 | paracelsus graph example_app.models.base:Base \ 86 | --import-module "example_app.models.*" \ 87 | --exclude-tables "comments" 88 | ``` 89 | 90 | This is equivalent to: 91 | 92 | ```bash 93 | paracelsus graph example_app.models.base:Base \ 94 | --import-module "example_app.models.*" \ 95 | --include-tables "users" 96 | --include-tables "posts" 97 | ``` 98 | 99 | You can also use regular expressions in the `include-tables` and `exclude-tables` options. 100 | 101 | ```bash 102 | paracelsus graph example_app.models.base:Base \ 103 | --import-module "example_app.models.*" \ 104 | --exclude-tables "^com.*" 105 | ``` 106 | 107 | ### Specify Column Sort Order 108 | 109 | By default Paracelsus will sort the columns in all models such as primary keys are first, foreign keys are next and all other 110 | columns are sorted alphabetically by name. 111 | 112 | ```bash 113 | paracelsus graph example_app.models.base:Base \ 114 | --import-module "example_app.models.users" \ 115 | ``` 116 | 117 | produces the same results as: 118 | 119 | ```bash 120 | paracelsus graph example_app.models.base:Base \ 121 | --import-module "example_app.models.users" \ 122 | --column-sort key-based 123 | ``` 124 | 125 | Pass the --column-sort option to change this behavior. To preserve the order of fields present in the models use "preserve-order": 126 | 127 | ```bash 128 | paracelsus graph example_app.models.base:Base \ 129 | --import-module "example_app.models.users" \ 130 | --column-sort preserve-order 131 | ``` 132 | 133 | ### Omit Comments 134 | 135 | By default, SQLAlchemy column comments are included in the generated mermaid diagrams. You can omit these comments using the `--omit-comments` flag, which [might improve](https://github.com/tedivm/paracelsus/issues/32) legibility. 136 | 137 | ### Type Parameter Delimiter 138 | 139 | Some SQLAlchemy column types include parameters with commas, such as `NUMERIC(10, 2)` or `DECIMAL(8, 3)`. Since Mermaid's ER diagram parser uses commas as structural separators for attribute keys (PK, FK, UK), these commas can break diagram rendering. 140 | 141 | Paracelsus automatically handles this by replacing commas in type parameters with a delimiter. By default, it uses a hyphen (`-`), converting `NUMERIC(10, 2)` to `NUMERIC(10-2)`. 142 | 143 | You can customize this delimiter using the `--type-parameter-delimiter` option: 144 | 145 | ```bash 146 | paracelsus graph example_app.models.base:Base \ 147 | --import-module "example_app.models.users" \ 148 | --type-parameter-delimiter "_" 149 | ``` 150 | 151 | This would convert `NUMERIC(10, 2)` to `NUMERIC(10_2)`. 152 | 153 | **Note:** The delimiter cannot contain commas or spaces, as these characters would cause the same parsing issues in Mermaid diagrams. 154 | 155 | ### Generate Mermaid Diagrams 156 | 157 | 158 | > paracelsus graph example_app.models.base:Base --import-module "example_app.models:*" 159 | 160 | ```text 161 | erDiagram 162 | users { 163 | CHAR(32) id PK 164 | DATETIME created 165 | VARCHAR(100) display_name "nullable" 166 | } 167 | 168 | posts { 169 | CHAR(32) id PK 170 | CHAR(32) author FK 171 | TEXT content "nullable" 172 | DATETIME created 173 | BOOLEAN live "nullable" 174 | } 175 | 176 | comments { 177 | CHAR(32) id PK 178 | CHAR(32) author FK 179 | CHAR(32) post FK "nullable" 180 | TEXT content "nullable" 181 | DATETIME created 182 | BOOLEAN live "nullable" 183 | } 184 | 185 | users ||--o{ posts : author 186 | posts ||--o{ comments : post 187 | users ||--o{ comments : author 188 | ``` 189 | 190 | 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. 191 | 192 | ```mermaid 193 | erDiagram 194 | users { 195 | CHAR(32) id PK 196 | DATETIME created 197 | VARCHAR(100) display_name "nullable" 198 | } 199 | 200 | posts { 201 | CHAR(32) id PK 202 | CHAR(32) author FK 203 | TEXT content "nullable" 204 | DATETIME created 205 | BOOLEAN live "nullable" 206 | } 207 | 208 | comments { 209 | CHAR(32) id PK 210 | CHAR(32) author FK 211 | CHAR(32) post FK "nullable" 212 | TEXT content "nullable" 213 | DATETIME created 214 | BOOLEAN live "nullable" 215 | } 216 | 217 | users ||--o{ posts : author 218 | posts ||--o{ comments : post 219 | users ||--o{ comments : author 220 | ``` 221 | 222 | ### Inject Mermaid Diagrams 223 | 224 | 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. 225 | 226 | 227 | 228 | ```markdown 229 | ## Schema 230 | 231 | 232 | 233 | ``` 234 | 235 | > paracelsus inject db/README.md example_app.models.base:Base --import-module "example_app.models:*" 236 | 237 | 238 | 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. 239 | 240 | > paracelsus inject db/README.md example_app.models.base:Base --import-module "example_app.models:*" --check 241 | 242 | ### Creating Images 243 | 244 | 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. 245 | 246 | To create an SVG file: 247 | 248 | > paracelsus graph example_app.models.base:Base --import-module "example_app.models:*" --format dot | dot -Tsvg > output.svg 249 | 250 | To create a PNG file: 251 | 252 | > paracelsus graph example_app.models.base:Base --import-module "example_app.models:*" --format dot | dot -Tpng > output.png 253 | 254 | ![Alt text](./docs/example.png "a title") 255 | 256 | 257 | ### pyproject.toml 258 | 259 | Some of the settings for your project can be saved directly in the `pyprojects.toml` file of your project. 260 | 261 | ```toml 262 | [tool.paracelsus] 263 | base = "example.base:Base" 264 | imports = [ 265 | "example.models" 266 | ] 267 | ``` 268 | 269 | This also allows users to set excludes, includes, column sorting, and type parameter delimiter. 270 | 271 | ```toml 272 | [tool.paracelsus] 273 | base = "example.base:Base" 274 | imports = [ 275 | "example.models" 276 | ] 277 | exclude_tables = [ 278 | "comments" 279 | ] 280 | column_sort = "preserve-order" 281 | omit_comments = false 282 | max_enum_members = 10 283 | type_parameter_delimiter = "-" # Default is hyphen, cannot contain commas or spaces 284 | ``` 285 | 286 | ### Alternative config files 287 | 288 | It is possible to use an alternative configuration file for both `graph` and `inject` by passing the `--config` flag to the CLI. 289 | 290 | ```bash 291 | paracelsus graph --config path/to/alternative_pyproject.toml 292 | ``` 293 | 294 | This file does not need to be named `pyproject.toml`, as long as it is a valid TOML file and contains a `[tool.paracelsus]` section. 295 | 296 | ## Sponsorship 297 | 298 | This project is developed by [Robert Hafner](https://blog.tedivm.com) If you find this project useful please consider sponsoring me using Github! 299 | 300 |
301 | 302 | [![Github Sponsorship](https://raw.githubusercontent.com/mechPenSketch/mechPenSketch/master/img/github_sponsor_btn.svg)](https://github.com/sponsors/tedivm) 303 | 304 |
305 | -------------------------------------------------------------------------------- /paracelsus/cli.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | from dataclasses import asdict 4 | from pathlib import Path 5 | from textwrap import dedent 6 | from typing import List, Optional 7 | 8 | import typer 9 | from typing_extensions import Annotated 10 | 11 | from paracelsus.config import ( 12 | MAX_ENUM_MEMBERS_DEFAULT, 13 | SORT_DEFAULT, 14 | ColumnSorts, 15 | Formats, 16 | Layouts, 17 | ParacelsusSettingsForGraph, 18 | ParacelsusSettingsForInject, 19 | ) 20 | 21 | from .graph import get_graph_string, transformers 22 | from .pyproject import get_pyproject_settings 23 | 24 | app = typer.Typer() 25 | 26 | 27 | def get_base_class(base_class_path: str | None, base_from_config: str) -> str: 28 | if base_class_path: 29 | return base_class_path 30 | if base_from_config: 31 | return base_from_config 32 | 33 | raise ValueError( 34 | dedent( 35 | """\ 36 | Either provide `--base-class-path` argument or define `base` in the pyproject.toml file: 37 | [tool.paracelsus] 38 | base = "example.base:Base" 39 | """ 40 | ) 41 | ) 42 | 43 | 44 | @app.command(help="Create the graph structure and print it to stdout.") 45 | def graph( 46 | config: Annotated[ 47 | Path, 48 | typer.Option( 49 | help="Path to a pyproject.toml file to load configuration from.", 50 | file_okay=True, 51 | dir_okay=False, 52 | resolve_path=True, 53 | exists=True, 54 | default_factory=lambda: Path.cwd() / "pyproject.toml", 55 | show_default=str(Path.cwd() / "pyproject.toml"), 56 | ), 57 | ], 58 | base_class_path: Annotated[ 59 | Optional[str], 60 | typer.Argument(help="The SQLAlchemy base class used by the database to graph."), 61 | ] = None, 62 | import_module: Annotated[ 63 | List[str], 64 | typer.Option( 65 | help="Module, typically an SQL Model, to import. Modules that end in :* will act as `from module import *`" 66 | ), 67 | ] = [], 68 | exclude_tables: Annotated[ 69 | List[str], 70 | typer.Option(help="List of tables or regular expression patterns for tables that are excluded from the graph"), 71 | ] = [], 72 | include_tables: Annotated[ 73 | List[str], 74 | typer.Option(help="List of tables or regular expression patterns for tables that are included in the graph"), 75 | ] = [], 76 | python_dir: Annotated[ 77 | List[Path], 78 | typer.Option( 79 | help="Paths to add to the `PYTHON_PATH` for module lookup.", 80 | file_okay=False, 81 | dir_okay=True, 82 | resolve_path=True, 83 | exists=True, 84 | ), 85 | ] = [], 86 | format: Annotated[ 87 | Formats, typer.Option(help="The file format to output the generated graph to.") 88 | ] = Formats.mermaid.value, # type: ignore # Typer will fail to render the help message, but this code works. 89 | column_sort: Annotated[ 90 | Optional[ColumnSorts], 91 | typer.Option( 92 | help="Specifies the method of sorting columns in diagrams.", 93 | show_default=str(SORT_DEFAULT.value), 94 | ), 95 | ] = None, 96 | omit_comments: Annotated[ 97 | Optional[bool], 98 | typer.Option( 99 | "--omit-comments", 100 | help="Omit SQLAlchemy column comments from the diagram.", 101 | ), 102 | ] = None, 103 | max_enum_members: Annotated[ 104 | Optional[int], 105 | typer.Option( 106 | "--max-enum-members", 107 | help="Maximum number of enum members to display in diagrams. 0 means no enum values are shown, any positive number limits the display.", 108 | show_default=str(MAX_ENUM_MEMBERS_DEFAULT), 109 | ), 110 | ] = None, 111 | layout: Annotated[ 112 | Optional[Layouts], 113 | typer.Option( 114 | help="Specifies the layout of the diagram. Only applicable for mermaid format.", 115 | ), 116 | ] = None, 117 | type_parameter_delimiter: Annotated[ 118 | Optional[str], 119 | typer.Option( 120 | "--type-parameter-delimiter", 121 | help="Delimiter to use for type parameters in mermaid diagrams (e.g., NUMERIC(10-2)). Cannot contain commas or spaces.", 122 | show_default="-", 123 | ), 124 | ] = None, 125 | ): 126 | settings = get_pyproject_settings(config_file=config) 127 | 128 | graph_settings = ParacelsusSettingsForGraph( 129 | base_class_path=get_base_class(base_class_path, settings.base), 130 | import_module=import_module + settings.imports, 131 | include_tables=set(include_tables + settings.include_tables), 132 | exclude_tables=set(exclude_tables + settings.exclude_tables), 133 | python_dir=python_dir, 134 | format=format, 135 | column_sort=column_sort if column_sort is not None else settings.column_sort, 136 | omit_comments=omit_comments if omit_comments is not None else settings.omit_comments, 137 | max_enum_members=max_enum_members if max_enum_members is not None else settings.max_enum_members, 138 | layout=layout, 139 | type_parameter_delimiter=type_parameter_delimiter 140 | if type_parameter_delimiter is not None 141 | else settings.type_parameter_delimiter, 142 | ) 143 | 144 | graph_string = get_graph_string( 145 | **asdict(graph_settings), 146 | ) 147 | typer.echo(graph_string, nl=not graph_string.endswith("\n")) 148 | 149 | 150 | @app.command(help="Create a graph and inject it as a code field into a markdown file.") 151 | def inject( 152 | config: Annotated[ 153 | Path, 154 | typer.Option( 155 | help="Path to a pyproject.toml file to load configuration from.", 156 | file_okay=True, 157 | dir_okay=False, 158 | resolve_path=True, 159 | exists=True, 160 | default_factory=lambda: Path.cwd() / "pyproject.toml", 161 | show_default=str(Path.cwd() / "pyproject.toml"), 162 | ), 163 | ], 164 | file: Annotated[ 165 | Path, 166 | typer.Argument( 167 | help="The file to inject the generated graph into.", 168 | file_okay=True, 169 | dir_okay=False, 170 | resolve_path=True, 171 | exists=True, 172 | ), 173 | ], 174 | base_class_path: Annotated[ 175 | Optional[str], 176 | typer.Argument(help="The SQLAlchemy base class used by the database to graph."), 177 | ] = None, 178 | replace_begin_tag: Annotated[ 179 | str, 180 | typer.Option(help=""), 181 | ] = "", 182 | replace_end_tag: Annotated[ 183 | str, 184 | typer.Option(help=""), 185 | ] = "", 186 | import_module: Annotated[ 187 | List[str], 188 | typer.Option( 189 | help="Module, typically an SQL Model, to import. Modules that end in :* will act as `from module import *`" 190 | ), 191 | ] = [], 192 | exclude_tables: Annotated[ 193 | List[str], 194 | typer.Option(help="List of tables that are excluded from the graph"), 195 | ] = [], 196 | include_tables: Annotated[ 197 | List[str], 198 | typer.Option(help="List of tables that are included in the graph"), 199 | ] = [], 200 | python_dir: Annotated[ 201 | List[Path], 202 | typer.Option( 203 | help="Paths to add to the `PYTHON_PATH` for module lookup.", 204 | file_okay=False, 205 | dir_okay=True, 206 | resolve_path=True, 207 | exists=True, 208 | ), 209 | ] = [], 210 | format: Annotated[ 211 | Formats, typer.Option(help="The file format to output the generated graph to.") 212 | ] = Formats.mermaid.value, # type: ignore # Typer will fail to render the help message, but this code works. 213 | check: Annotated[ 214 | bool, 215 | typer.Option( 216 | "--check", 217 | help="Perform a dry run and return a success code of 0 if there are no changes or 1 otherwise.", 218 | ), 219 | ] = False, 220 | column_sort: Annotated[ 221 | Optional[ColumnSorts], 222 | typer.Option( 223 | help="Specifies the method of sorting columns in diagrams.", 224 | show_default=str(SORT_DEFAULT.value), 225 | ), 226 | ] = None, 227 | omit_comments: Annotated[ 228 | Optional[bool], 229 | typer.Option( 230 | "--omit-comments", 231 | help="Omit SQLAlchemy column comments from the diagram.", 232 | ), 233 | ] = None, 234 | max_enum_members: Annotated[ 235 | Optional[int], 236 | typer.Option( 237 | "--max-enum-members", 238 | help="Maximum number of enum members to display in diagrams. 0 means no enum values are shown, any positive number limits the display.", 239 | show_default=str(MAX_ENUM_MEMBERS_DEFAULT), 240 | ), 241 | ] = None, 242 | layout: Annotated[ 243 | Optional[Layouts], 244 | typer.Option( 245 | help="Specifies the layout of the diagram. Only applicable for mermaid format.", 246 | ), 247 | ] = None, 248 | type_parameter_delimiter: Annotated[ 249 | Optional[str], 250 | typer.Option( 251 | "--type-parameter-delimiter", 252 | help="Delimiter to use for type parameters in mermaid diagrams (e.g., NUMERIC(10-2)). Cannot contain commas or spaces.", 253 | show_default="-", 254 | ), 255 | ] = None, 256 | ): 257 | settings = get_pyproject_settings(config_file=config) 258 | 259 | inject_settings = ParacelsusSettingsForInject( 260 | graph_settings=ParacelsusSettingsForGraph( 261 | base_class_path=get_base_class(base_class_path, settings.base), 262 | import_module=import_module + settings.imports, 263 | include_tables=set(include_tables + settings.include_tables), 264 | exclude_tables=set(exclude_tables + settings.exclude_tables), 265 | python_dir=python_dir, 266 | format=format, 267 | column_sort=column_sort if column_sort is not None else settings.column_sort, 268 | omit_comments=omit_comments if omit_comments is not None else settings.omit_comments, 269 | max_enum_members=max_enum_members if max_enum_members is not None else settings.max_enum_members, 270 | layout=layout, 271 | type_parameter_delimiter=type_parameter_delimiter 272 | if type_parameter_delimiter is not None 273 | else settings.type_parameter_delimiter, 274 | ), 275 | file=file, 276 | replace_begin_tag=replace_begin_tag, 277 | replace_end_tag=replace_end_tag, 278 | check=check, 279 | ) 280 | 281 | # Generate Graph 282 | graph = get_graph_string( 283 | **asdict(inject_settings.graph_settings), 284 | ) 285 | 286 | comment_format = transformers[inject_settings.graph_settings.format].comment_format # type: ignore 287 | 288 | # Convert Graph to Injection String 289 | graph_piece = f"""{inject_settings.replace_begin_tag} 290 | ```{comment_format} 291 | {graph} 292 | ``` 293 | {inject_settings.replace_end_tag}""" 294 | 295 | # Get content from current file. 296 | old_content = inject_settings.file.read_text() 297 | 298 | # Replace old content with newly generated content. 299 | pattern = re.escape(inject_settings.replace_begin_tag) + "(.*)" + re.escape(inject_settings.replace_end_tag) 300 | new_content = re.sub(pattern, graph_piece, old_content, flags=re.MULTILINE | re.DOTALL) 301 | 302 | # Return result depends on whether we're in check mode. 303 | if inject_settings.check: 304 | if new_content == old_content: 305 | # If content is the same then we passed the test. 306 | typer.echo("No changes detected.") 307 | sys.exit(0) 308 | else: 309 | # If content is different then we failed the test. 310 | typer.echo("Changes detected.") 311 | sys.exit(1) 312 | else: 313 | # Dump newly generated contents back to file. 314 | inject_settings.file.write_text(new_content) 315 | 316 | 317 | @app.command(help="Display the current installed version of paracelsus.") 318 | def version(): 319 | from . import _version 320 | 321 | typer.echo(_version.version) 322 | 323 | 324 | if __name__ == "__main__": 325 | app() 326 | --------------------------------------------------------------------------------