erdify 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
erdify/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """erdify - Generate PlantUML ERD diagrams from your models."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from .config import EntityInfo, EnumInfo, FieldInfo
6
+ from .generator import PlantUMLGenerator, generate_plantuml
7
+ from .parser import ASTDatabaseParser, parse_models_directory
8
+
9
+ try:
10
+ __version__ = version("erdify")
11
+ except PackageNotFoundError: # package is not installed (e.g. running from a raw checkout)
12
+ __version__ = "0.0.0+unknown"
13
+
14
+ __all__ = [
15
+ # Data classes
16
+ "FieldInfo",
17
+ "EnumInfo",
18
+ "EntityInfo",
19
+ # Parser
20
+ "ASTDatabaseParser",
21
+ "parse_models_directory",
22
+ # Generator
23
+ "PlantUMLGenerator",
24
+ "generate_plantuml",
25
+ # Version
26
+ "__version__",
27
+ ]
erdify/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for python -m erdify."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
erdify/cli.py ADDED
@@ -0,0 +1,114 @@
1
+ """Command-line interface for erdify."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from . import __version__
8
+ from .generator import generate_plantuml
9
+ from .parser import parse_models_directory
10
+
11
+
12
+ def main() -> int:
13
+ """Main entry point for the CLI."""
14
+ parser = argparse.ArgumentParser(
15
+ prog="erdify",
16
+ description="Generate PlantUML ERD diagrams from SQLModel and SQLAlchemy models",
17
+ epilog="Example: erdify ./src/database -o database_erd.puml",
18
+ )
19
+ parser.add_argument(
20
+ "input",
21
+ type=Path,
22
+ help="Directory containing model files (searches for models.py recursively)",
23
+ )
24
+ parser.add_argument(
25
+ "-o",
26
+ "--output",
27
+ type=Path,
28
+ help="Output .puml file (default: stdout)",
29
+ )
30
+ parser.add_argument(
31
+ "--title",
32
+ default="Database ERD",
33
+ help="Diagram title (default: 'Database ERD')",
34
+ )
35
+ parser.add_argument(
36
+ "--exclude",
37
+ nargs="*",
38
+ default=[],
39
+ metavar="PATTERN",
40
+ help=(
41
+ "Glob patterns (case-sensitive) to exclude entities by class name "
42
+ "or table name, e.g. --exclude '*Link' audit_log"
43
+ ),
44
+ )
45
+ parser.add_argument(
46
+ "--infer-keys",
47
+ action="store_true",
48
+ help=(
49
+ "For keyless models (Pydantic/dataclass), infer a primary key from a "
50
+ "field named 'id' and a foreign key from '<x>_id' (target table '<x>')"
51
+ ),
52
+ )
53
+ parser.add_argument(
54
+ "--no-enums",
55
+ action="store_true",
56
+ help="Skip enum definitions in output",
57
+ )
58
+ parser.add_argument(
59
+ "--no-relationships",
60
+ action="store_true",
61
+ help="Skip relationship lines in output",
62
+ )
63
+ parser.add_argument(
64
+ "-v",
65
+ "--version",
66
+ action="version",
67
+ version=f"%(prog)s {__version__}",
68
+ )
69
+
70
+ args = parser.parse_args()
71
+
72
+ # Validate input path
73
+ if not args.input.exists():
74
+ print(f"Error: Input path does not exist: {args.input}", file=sys.stderr)
75
+ return 1
76
+
77
+ if not args.input.is_dir():
78
+ print(f"Error: Input path is not a directory: {args.input}", file=sys.stderr)
79
+ return 1
80
+
81
+ # Parse models
82
+ try:
83
+ entities, enums = parse_models_directory(
84
+ args.input, args.exclude, infer_keys=args.infer_keys
85
+ )
86
+ except Exception as e:
87
+ print(f"Error parsing models: {e}", file=sys.stderr)
88
+ return 1
89
+
90
+ if not entities:
91
+ print(
92
+ f"Warning: No tables found in {args.input}",
93
+ file=sys.stderr,
94
+ )
95
+
96
+ # Generate PlantUML
97
+ output = generate_plantuml(
98
+ entities=entities,
99
+ enums=enums,
100
+ title=args.title,
101
+ include_enums=not args.no_enums,
102
+ include_relationships=not args.no_relationships,
103
+ )
104
+
105
+ # Write output
106
+ if args.output:
107
+ args.output.parent.mkdir(parents=True, exist_ok=True)
108
+ args.output.write_text(output)
109
+ print(f"Generated ERD diagram: {args.output}", file=sys.stderr)
110
+ print(f" Found {len(entities)} entities and {len(enums)} enums", file=sys.stderr)
111
+ else:
112
+ print(output)
113
+
114
+ return 0
erdify/config.py ADDED
@@ -0,0 +1,41 @@
1
+ """Data classes for erdify."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import List, Tuple
5
+
6
+
7
+ @dataclass
8
+ class FieldInfo:
9
+ """Represents a database field/column."""
10
+
11
+ name: str
12
+ type_str: str
13
+ is_primary_key: bool = False
14
+ is_foreign_key: bool = False
15
+ is_nullable: bool = False
16
+ foreign_table: str | None = None
17
+ index: bool = False
18
+ default_value: str | None = None
19
+
20
+
21
+ @dataclass
22
+ class EnumInfo:
23
+ """Represents an enum type."""
24
+
25
+ name: str
26
+ values: List[str] = field(default_factory=list)
27
+
28
+
29
+ @dataclass
30
+ class EntityInfo:
31
+ """Represents a database table/entity."""
32
+
33
+ name: str
34
+ table_name: str
35
+ fields: List[FieldInfo] = field(default_factory=list)
36
+ relationships: List[Tuple[str, str, str]] = field(
37
+ default_factory=list
38
+ ) # (target, type, attribute_name)
39
+ is_link_table: bool = False
40
+ base_classes: List[str] = field(default_factory=list)
41
+ source: str = "sqlmodel" # one of: sqlmodel, sqlalchemy, pydantic, dataclass
erdify/generator.py ADDED
@@ -0,0 +1,322 @@
1
+ """PlantUML ERD diagram generator."""
2
+
3
+ from typing import Dict, List, Tuple
4
+
5
+ from .config import EntityInfo, EnumInfo, FieldInfo
6
+
7
+
8
+ class PlantUMLGenerator:
9
+ """Generates PlantUML ERD diagram from entities."""
10
+
11
+ def __init__(
12
+ self,
13
+ entities: Dict[str, EntityInfo],
14
+ enums: Dict[str, EnumInfo] | None = None,
15
+ title: str = "Database ERD",
16
+ ):
17
+ self.entities = entities
18
+ self.enums = enums or {}
19
+ self.title = title
20
+
21
+ def generate(self) -> str:
22
+ """Generate PlantUML diagram."""
23
+ lines = [
24
+ f"@startuml {self.title}",
25
+ '!define Table(name,desc) class name as "desc" << (T,#FFAAAA) >>',
26
+ "!define primary_key(x) <b><color:#b8861b><&key></color> x</b>",
27
+ "!define foreign_key(x) <color:#aaaaaa><&key></color> x",
28
+ "!define column(x) <color:#efefef><&media-record></color> x",
29
+ "",
30
+ "skinparam linetype ortho",
31
+ "skinparam roundcorner 5",
32
+ "skinparam class {",
33
+ " BackgroundColor White",
34
+ " ArrowColor Gray",
35
+ " BorderColor Gray",
36
+ "}",
37
+ "",
38
+ ]
39
+
40
+ # Generate enums (only those used by entities)
41
+ used_enums = self._get_used_enums()
42
+ if used_enums:
43
+ lines.append("' Enums")
44
+ lines.append("")
45
+ for enum_name in sorted(used_enums):
46
+ if enum_name in self.enums:
47
+ lines.extend(self._generate_enum(self.enums[enum_name]))
48
+ lines.append("")
49
+
50
+ lines.append("' Entities")
51
+ lines.append("")
52
+
53
+ # Generate entities
54
+ for entity in self.entities.values():
55
+ lines.extend(self._generate_entity(entity))
56
+ lines.append("")
57
+
58
+ lines.append("' Relationships")
59
+ lines.append("")
60
+
61
+ # Build map of link tables for many-to-many relationships
62
+ link_table_map = self._build_link_table_map()
63
+
64
+ # Generate relationships
65
+ seen_relationships: set[str] = set()
66
+
67
+ # First, generate direct foreign key relationships (not through link tables)
68
+ for entity in self.entities.values():
69
+ if not entity.is_link_table:
70
+ for relationship in self._generate_direct_relationships(entity, link_table_map):
71
+ if relationship not in seen_relationships:
72
+ lines.append(relationship)
73
+ seen_relationships.add(relationship)
74
+
75
+ # Then, generate many-to-many relationships through link tables
76
+ for link_entity in self.entities.values():
77
+ if link_entity.is_link_table:
78
+ for relationship in self._generate_link_table_relationships(link_entity):
79
+ if relationship not in seen_relationships:
80
+ lines.append(relationship)
81
+ seen_relationships.add(relationship)
82
+
83
+ # Finally, draw declared relationships (Relationship()/relationship() and
84
+ # Pydantic/dataclass nested refs) for entity pairs not already connected by a
85
+ # foreign-key line. This gives keyless models their lines while avoiding
86
+ # duplicate lines for SQLModel/SQLAlchemy, whose relationships are already
87
+ # rendered from foreign keys above.
88
+ connected_pairs = self._connected_pairs()
89
+ for entity in self.entities.values():
90
+ for relationship, pair in self._generate_relationship_list_lines(entity):
91
+ if pair in connected_pairs or relationship in seen_relationships:
92
+ continue
93
+ lines.append(relationship)
94
+ seen_relationships.add(relationship)
95
+ connected_pairs.add(pair)
96
+
97
+ lines.append("")
98
+ lines.append("@enduml")
99
+
100
+ return "\n".join(lines)
101
+
102
+ def _generate_entity(self, entity: EntityInfo) -> List[str]:
103
+ """Generate PlantUML entity definition."""
104
+ lines = []
105
+
106
+ # Entity header
107
+ if entity.is_link_table:
108
+ lines.append(
109
+ f'entity "{entity.table_name}" as {entity.name} << (L, #AAFFAA) link >> {{'
110
+ )
111
+ else:
112
+ lines.append(f'entity "{entity.table_name}" as {entity.name} {{')
113
+
114
+ # Fields
115
+ if entity.fields:
116
+ for field in entity.fields:
117
+ field_line = self._format_field(field)
118
+ lines.append(f" {field_line}")
119
+ else:
120
+ lines.append(" ' (no fields)")
121
+
122
+ lines.append("}")
123
+
124
+ return lines
125
+
126
+ def _generate_enum(self, enum_info: EnumInfo) -> List[str]:
127
+ """Generate PlantUML enum definition."""
128
+ lines = [f"enum {enum_info.name} << (E,#FFCC00) >> {{"]
129
+ for value in enum_info.values:
130
+ lines.append(f" {value}")
131
+ lines.append("}")
132
+ return lines
133
+
134
+ def _get_used_enums(self) -> "set[str]":
135
+ """Get set of enum names used by entity fields."""
136
+ used_enums: set[str] = set()
137
+ for entity in self.entities.values():
138
+ for field in entity.fields:
139
+ # Check if field type matches a known enum
140
+ type_name = field.type_str.split(".")[-1]
141
+ if type_name in self.enums:
142
+ used_enums.add(type_name)
143
+ return used_enums
144
+
145
+ def _format_field(self, field: FieldInfo) -> str:
146
+ """Format a field for PlantUML."""
147
+ # Determine prefix
148
+ if field.is_primary_key:
149
+ prefix = "primary_key"
150
+ elif field.is_foreign_key:
151
+ prefix = "foreign_key"
152
+ else:
153
+ prefix = "column"
154
+
155
+ # Clean up type
156
+ type_str = field.type_str.split(".")[-1] # Get just the class name
157
+
158
+ # Add nullable marker
159
+ nullable = "?" if field.is_nullable else ""
160
+
161
+ # Add default value
162
+ default = ""
163
+ if field.default_value is not None:
164
+ # Shorten enum defaults (OrderStatus.PENDING -> PENDING)
165
+ default_val = field.default_value
166
+ if "." in default_val:
167
+ default_val = default_val.split(".")[-1]
168
+ default = f" = {default_val}"
169
+
170
+ return f"{prefix}({field.name}) : {type_str}{nullable}{default}"
171
+
172
+ def _build_link_table_map(self) -> Dict[Tuple[str, str], str]:
173
+ """Build a map of (entity1, entity2) -> link_table_name for many-to-many."""
174
+ link_map: Dict[Tuple[str, str], str] = {}
175
+
176
+ for entity in self.entities.values():
177
+ if entity.is_link_table and len(entity.fields) == 2:
178
+ # Link tables typically have exactly 2 foreign key fields
179
+ fk_fields = [f for f in entity.fields if f.is_foreign_key]
180
+ if len(fk_fields) == 2:
181
+ # Extract table names from foreign keys
182
+ table1 = (
183
+ fk_fields[0].foreign_table.split(".")[0]
184
+ if fk_fields[0].foreign_table
185
+ else None
186
+ )
187
+ table2 = (
188
+ fk_fields[1].foreign_table.split(".")[0]
189
+ if fk_fields[1].foreign_table
190
+ else None
191
+ )
192
+
193
+ if table1 and table2:
194
+ # Map both directions
195
+ link_map[(table1, table2)] = entity.name
196
+ link_map[(table2, table1)] = entity.name
197
+
198
+ return link_map
199
+
200
+ def _generate_direct_relationships(
201
+ self, entity: EntityInfo, link_table_map: Dict[Tuple[str, str], str]
202
+ ) -> List[str]:
203
+ """Generate direct relationships (foreign keys without link tables)."""
204
+ lines: List[str] = []
205
+
206
+ # Generate relationships from foreign keys in this entity
207
+ for field in entity.fields:
208
+ if field.is_foreign_key and field.foreign_table:
209
+ target_table = field.foreign_table.split(".")[0]
210
+
211
+ # Find target entity by table name
212
+ target_entity = None
213
+ for e in self.entities.values():
214
+ if e.table_name == target_table:
215
+ target_entity = e
216
+ break
217
+
218
+ if target_entity:
219
+ # This is a direct foreign key relationship
220
+ # PlantUML syntax: }o--|| means "zero or more to exactly one"
221
+ rel_line = f'{entity.name} }}o--|| {target_entity.name} : "{field.name}"'
222
+ lines.append(rel_line)
223
+
224
+ return lines
225
+
226
+ def _entity_by_table(self, table_name: str) -> EntityInfo | None:
227
+ """Find an entity by its table name."""
228
+ for entity in self.entities.values():
229
+ if entity.table_name == table_name:
230
+ return entity
231
+ return None
232
+
233
+ def _connected_pairs(self) -> "set[frozenset[str]]":
234
+ """Compute entity-name pairs already connected by a foreign-key line."""
235
+ pairs: set[frozenset[str]] = set()
236
+ for entity in self.entities.values():
237
+ for field in entity.fields:
238
+ if field.is_foreign_key and field.foreign_table:
239
+ target = self._entity_by_table(field.foreign_table.split(".")[0])
240
+ if target:
241
+ pairs.add(frozenset((entity.name, target.name)))
242
+ return pairs
243
+
244
+ def _generate_relationship_list_lines(
245
+ self, entity: EntityInfo
246
+ ) -> "List[Tuple[str, frozenset[str]]]":
247
+ """Generate lines from an entity's declared relationships.
248
+
249
+ Returns (line, pair) tuples where pair is the frozenset of the two
250
+ connected entity names, so callers can skip already-connected pairs.
251
+ """
252
+ results: List[Tuple[str, frozenset[str]]] = []
253
+ for target_name, rel_type, attr in entity.relationships:
254
+ target = self.entities.get(target_name)
255
+ if not target:
256
+ continue
257
+ if rel_type == "many":
258
+ line = f'{entity.name} ||--o{{ {target.name} : "{attr}"'
259
+ else:
260
+ line = f'{entity.name} }}o--|| {target.name} : "{attr}"'
261
+ results.append((line, frozenset((entity.name, target.name))))
262
+ return results
263
+
264
+ def _generate_link_table_relationships(self, link_entity: EntityInfo) -> List[str]:
265
+ """Generate relationships through a link table (many-to-many)."""
266
+ lines: List[str] = []
267
+
268
+ # Get the two foreign keys from the link table
269
+ fk_fields = [f for f in link_entity.fields if f.is_foreign_key]
270
+
271
+ if len(fk_fields) != 2:
272
+ return lines
273
+
274
+ # Find the two entities being linked
275
+ entities_to_link: List[Tuple[EntityInfo, str]] = []
276
+ for fk_field in fk_fields:
277
+ if fk_field.foreign_table:
278
+ target_table = fk_field.foreign_table.split(".")[0]
279
+ for e in self.entities.values():
280
+ if e.table_name == target_table:
281
+ entities_to_link.append((e, fk_field.name))
282
+ break
283
+
284
+ if len(entities_to_link) == 2:
285
+ entity1, fk1_name = entities_to_link[0]
286
+ entity2, fk2_name = entities_to_link[1]
287
+
288
+ # Draw: Entity1 --o{ LinkTable }o-- Entity2
289
+ line1 = f'{entity1.name} ||--o{{ {link_entity.name} : "{fk1_name}"'
290
+ line2 = f'{link_entity.name} }}o--|| {entity2.name} : "{fk2_name}"'
291
+ lines.append(line1)
292
+ lines.append(line2)
293
+
294
+ return lines
295
+
296
+
297
+ def generate_plantuml(
298
+ entities: Dict[str, EntityInfo],
299
+ enums: Dict[str, EnumInfo] | None = None,
300
+ title: str = "Database ERD",
301
+ include_enums: bool = True,
302
+ include_relationships: bool = True,
303
+ ) -> str:
304
+ """
305
+ Generate PlantUML ERD diagram from parsed entities.
306
+
307
+ Args:
308
+ entities: Dictionary of entity name to EntityInfo
309
+ enums: Optional dictionary of enum name to EnumInfo
310
+ title: Title for the diagram
311
+ include_enums: Whether to include enum definitions
312
+ include_relationships: Whether to include relationship lines
313
+
314
+ Returns:
315
+ PlantUML diagram as string
316
+ """
317
+ generator = PlantUMLGenerator(
318
+ entities=entities,
319
+ enums=enums if include_enums else None,
320
+ title=title,
321
+ )
322
+ return generator.generate()