Skip to content

Commit 759c9f9

Browse files
authored
Support PyDot v3 and v4 (#53)
1 parent 61ffde3 commit 759c9f9

8 files changed

Lines changed: 257 additions & 18 deletions

File tree

.github/workflows/pytest.yaml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,23 @@ jobs:
1313
strategy:
1414
fail-fast: false
1515
matrix:
16-
version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
16+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
17+
pydot-version: ["3", "4"]
1718

1819
steps:
1920
- uses: actions/checkout@v6
2021

2122
- uses: actions/setup-python@v6
2223
with:
23-
python-version: ${{ matrix.version }}
24+
python-version: ${{ matrix.python-version }}
2425

2526
- name: Install Dependencies
2627
run: make install
2728

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+
2834
- name: Run Tests
2935
run: make pytest

makefile

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,24 @@ dapperdata_check:
107107
.PHONY: tomlsort_check
108108
tomlsort_check:
109109
$(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+
110128
#
111129
# Packaging
112130
#

paracelsus/compat/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
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"]

paracelsus/compat/pydot_compat.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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"]

paracelsus/transformers/dot.py

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1+
import logging
12
from typing import ClassVar, Optional
23

3-
import pydot # type: ignore
4-
import logging
54
from sqlalchemy.sql.schema import MetaData, Table
65

7-
from .utils import sort_columns, is_unique
6+
from paracelsus.compat.pydot_compat import Dot as PydotDot
7+
from paracelsus.compat.pydot_compat import Edge, Node
88
from paracelsus.config import Layouts
99

10+
from .utils import is_unique, sort_columns
11+
1012
logger = logging.getLogger(__name__)
1113

1214

@@ -21,16 +23,16 @@ def __init__(
2123
layout: Optional[Layouts] = None,
2224
) -> None:
2325
self.metadata: MetaData = metaclass
24-
self.graph: pydot.Dot = pydot.Dot("database", graph_type="graph")
26+
self.graph: PydotDot = PydotDot("database", graph_type="graph")
2527
self.column_sort: str = column_sort
2628
self.omit_comments: bool = omit_comments
2729
self.layout: Optional[Layouts] = layout
2830

2931
for table in self.metadata.tables.values():
30-
node = pydot.Node(name=table.name)
31-
node.set_label(self._table_label(table))
32-
node.set_shape("none")
33-
node.set_margin("0")
32+
node = Node(name=table.name)
33+
node.set("label", self._table_label(table))
34+
node.set("shape", "none")
35+
node.set("margin", "0")
3436
self.graph.add_node(node)
3537
for column in table.columns:
3638
for foreign_key in column.foreign_keys:
@@ -47,18 +49,18 @@ def __init__(
4749
)
4850
continue
4951

50-
edge = pydot.Edge(left_table.split(".")[-1], table.name)
51-
edge.set_label(column.name)
52-
edge.set_dir("both")
52+
edge = Edge(left_table.split(".")[-1], table.name)
53+
edge.set("label", column.name)
54+
edge.set("dir", "both")
5355

54-
edge.set_arrowhead("none")
56+
edge.set("arrowhead", "none")
5557
if not is_unique(column):
56-
edge.set_arrowhead("crow")
58+
edge.set("arrowhead", "crow")
5759

5860
l_column = self.metadata.tables[left_table].columns[left_column]
59-
edge.set_arrowtail("none")
61+
edge.set("arrowtail", "none")
6062
if not is_unique(l_column) and not l_column.primary_key:
61-
edge.set_arrowtail("crow")
63+
edge.set("arrowtail", "crow")
6264

6365
self.graph.add_edge(edge)
6466

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ requires = ["setuptools>=67.0", "setuptools_scm[toml]>=7.1"]
55
[project]
66
authors = [{"name" = "Robert Hafner"}]
77
dependencies = [
8-
"pydot < 4.0",
8+
"pydot >= 3.0, < 5.0",
9+
"packaging",
910
"sqlalchemy",
1011
"typer",
1112
"toml; python_version < '3.11'"

scripts/test_pydot_versions.sh

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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

0 commit comments

Comments
 (0)