├── 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) | author | Foreign Key |
' in output
29 | assert '| CHAR(32) | post | Foreign Key |
' in output
30 | assert '| DATETIME | created | |
' 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 | | {table.name} |
86 | {column_output.rstrip()}
87 |
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 | | users |
106 | | CHAR(32) | id | Primary Key |
107 | | VARCHAR(100) | display_name | |
108 | | DATETIME | created | |
109 |
110 | >, shape=none, margin=0];
111 | posts [label=<
112 |
113 | | posts |
114 | | CHAR(32) | id | Primary Key |
115 | | DATETIME | created | |
116 | | CHAR(32) | author | Foreign Key |
117 | | BOOLEAN | live | |
118 | | TEXT | content | |
119 |
120 | >, shape=none, margin=0];
121 | users -- posts [label=author, dir=both, arrowhead=crow, arrowtail=none];
122 | comments [label=<
123 |
124 | | comments |
125 | | CHAR(32) | id | Primary Key |
126 | | DATETIME | created | |
127 | | CHAR(32) | post | Foreign Key |
128 | | CHAR(32) | author | Foreign Key |
129 | | BOOLEAN | live | |
130 | | TEXT | content | |
131 |
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 | 
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 | [](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 |
--------------------------------------------------------------------------------