src/eric7/Graphics/UMLClassDiagramBuilder.py

branch
eric7
changeset 9209
b99e7fd55fd3
parent 8881
54e42bc2437a
child 9221
bf71ee032bb4
equal deleted inserted replaced
9208:3fc8dfeb6ebe 9209:b99e7fd55fd3
1 # -*- coding: utf-8 -*-
2
3 # Copyright (c) 2007 - 2022 Detlev Offenbach <detlev@die-offenbachs.de>
4 #
5
6 """
7 Module implementing a dialog showing a UML like class diagram.
8 """
9
10 from itertools import zip_longest
11 import os
12
13 from PyQt6.QtWidgets import QGraphicsTextItem
14
15 import Utilities
16 import Preferences
17
18 from .UMLDiagramBuilder import UMLDiagramBuilder
19
20
21 class UMLClassDiagramBuilder(UMLDiagramBuilder):
22 """
23 Class implementing a builder for UML like class diagrams.
24 """
25 def __init__(self, dialog, view, project, file, noAttrs=False):
26 """
27 Constructor
28
29 @param dialog reference to the UML dialog
30 @type UMLDialog
31 @param view reference to the view object
32 @type UMLGraphicsView
33 @param project reference to the project object
34 @type Project
35 @param file file name of a python module to be shown
36 @type str
37 @param noAttrs flag indicating, that no attributes should be shown
38 @type bool
39 """
40 super().__init__(dialog, view, project)
41 self.setObjectName("UMLClassDiagramBuilder")
42
43 self.file = file
44 self.noAttrs = noAttrs
45
46 self.__relFile = (
47 self.project.getRelativePath(self.file)
48 if self.project.isProjectSource(self.file) else
49 ""
50 )
51
52 def initialize(self):
53 """
54 Public method to initialize the object.
55 """
56 pname = self.project.getProjectName()
57 name = (
58 self.tr("Class Diagram {0}: {1}").format(
59 pname, self.project.getRelativePath(self.file))
60 if pname and self.project.isProjectSource(self.file) else
61 self.tr("Class Diagram: {0}").format(self.file)
62 )
63 self.umlView.setDiagramName(name)
64
65 def __getCurrentShape(self, name):
66 """
67 Private method to get the named shape.
68
69 @param name name of the shape
70 @type str
71 @return shape
72 @rtype QGraphicsItem
73 """
74 return self.allClasses.get(name)
75
76 def buildDiagram(self):
77 """
78 Public method to build the class shapes of the class diagram.
79
80 The algorithm is borrowed from Boa Constructor.
81 """
82 import Utilities.ModuleParser
83
84 self.allClasses = {}
85 self.allModules = {}
86
87 try:
88 extensions = (
89 Preferences.getPython("Python3Extensions") +
90 ['.rb']
91 )
92 module = Utilities.ModuleParser.readModule(
93 self.file, extensions=extensions, caching=False)
94 except ImportError:
95 ct = QGraphicsTextItem(None)
96 ct.setHtml(self.buildErrorMessage(
97 self.tr("The module <b>'{0}'</b> could not be found.")
98 .format(self.file)
99 ))
100 self.scene.addItem(ct)
101 return
102
103 if self.file not in self.allModules:
104 self.allModules[self.file] = []
105
106 routes = []
107 nodes = []
108 todo = [module.createHierarchy()]
109 classesFound = False
110 while todo:
111 hierarchy = todo[0]
112 for className in hierarchy:
113 classesFound = True
114 cw = self.__getCurrentShape(className)
115 if not cw and className.find('.') >= 0:
116 cw = self.__getCurrentShape(className.split('.')[-1])
117 if cw:
118 self.allClasses[className] = cw
119 if className not in self.allModules[self.file]:
120 self.allModules[self.file].append(className)
121 if cw and cw.noAttrs != self.noAttrs:
122 cw = None
123 if cw and not (cw.external and
124 (className in module.classes or
125 className in module.modules)):
126 if cw.scene() != self.scene:
127 self.scene.addItem(cw)
128 cw.setPos(10, 10)
129 if className not in nodes:
130 nodes.append(className)
131 else:
132 if className in module.classes:
133 # this is a local class (defined in this module)
134 self.__addLocalClass(
135 className, module.classes[className], 0, 0)
136 elif className in module.modules:
137 # this is a local module (defined in this module)
138 self.__addLocalClass(
139 className, module.modules[className], 0, 0, True)
140 else:
141 self.__addExternalClass(className, 0, 0)
142 nodes.append(className)
143
144 if hierarchy.get(className):
145 todo.append(hierarchy.get(className))
146 children = list(hierarchy.get(className).keys())
147 for child in children:
148 if (className, child) not in routes:
149 routes.append((className, child))
150
151 del todo[0]
152
153 if classesFound:
154 self.__arrangeClasses(nodes, routes[:])
155 self.__createAssociations(routes)
156 self.umlView.autoAdjustSceneSize(limit=True)
157 else:
158 ct = QGraphicsTextItem(None)
159 ct.setHtml(self.buildErrorMessage(
160 self.tr("The module <b>'{0}'</b> does not contain any"
161 " classes.").format(self.file)
162 ))
163 self.scene.addItem(ct)
164
165 def __arrangeClasses(self, nodes, routes, whiteSpaceFactor=1.2):
166 """
167 Private method to arrange the shapes on the canvas.
168
169 The algorithm is borrowed from Boa Constructor.
170
171 @param nodes list of nodes to arrange
172 @type list of str
173 @param routes list of routes
174 @type list of tuple of (str, str)
175 @param whiteSpaceFactor factor to increase whitespace between
176 items
177 @type float
178 """
179 from . import GraphicsUtilities
180 generations = GraphicsUtilities.sort(nodes, routes)
181
182 # calculate width and height of all elements
183 sizes = []
184 for generation in generations:
185 sizes.append([])
186 for child in generation:
187 sizes[-1].append(
188 self.__getCurrentShape(child).sceneBoundingRect())
189
190 # calculate total width and total height
191 width = 0
192 height = 0
193 widths = []
194 heights = []
195 for generation in sizes:
196 currentWidth = 0
197 currentHeight = 0
198
199 for rect in generation:
200 if rect.bottom() > currentHeight:
201 currentHeight = rect.bottom()
202 currentWidth += rect.right()
203
204 # update totals
205 if currentWidth > width:
206 width = currentWidth
207 height += currentHeight
208
209 # store generation info
210 widths.append(currentWidth)
211 heights.append(currentHeight)
212
213 # add in some whitespace
214 width *= whiteSpaceFactor
215 height = height * whiteSpaceFactor - 20
216 verticalWhiteSpace = 40.0
217
218 sceneRect = self.umlView.sceneRect()
219 width += 50.0
220 height += 50.0
221 swidth = sceneRect.width() if width < sceneRect.width() else width
222 sheight = sceneRect.height() if height < sceneRect.height() else height
223 self.umlView.setSceneSize(swidth, sheight)
224
225 # distribute each generation across the width and the
226 # generations across height
227 y = 10.0
228 for currentWidth, currentHeight, generation in (
229 zip_longest(widths, heights, generations)
230 ):
231 x = 10.0
232 # whiteSpace is the space between any two elements
233 whiteSpace = (
234 (width - currentWidth - 20) /
235 (len(generation) - 1.0 or 2.0)
236 )
237 for className in generation:
238 cw = self.__getCurrentShape(className)
239 cw.setPos(x, y)
240 rect = cw.sceneBoundingRect()
241 x = x + rect.width() + whiteSpace
242 y = y + currentHeight + verticalWhiteSpace
243
244 def __addLocalClass(self, className, _class, x, y, isRbModule=False):
245 """
246 Private method to add a class defined in the module.
247
248 @param className name of the class to be as a dictionary key
249 @type str
250 @param _class class to be shown
251 @type ModuleParser.Class
252 @param x x-coordinate
253 @type float
254 @param y y-coordinate
255 @type float
256 @param isRbModule flag indicating a Ruby module
257 @type bool
258 """
259 from .ClassItem import ClassItem, ClassModel
260 name = _class.name
261 if isRbModule:
262 name = "{0} (Module)".format(name)
263 cl = ClassModel(
264 name,
265 sorted(_class.methods.keys())[:],
266 sorted(_class.attributes.keys())[:],
267 sorted(_class.globals.keys())[:]
268 )
269 cw = ClassItem(cl, False, x, y, noAttrs=self.noAttrs, scene=self.scene,
270 colors=self.umlView.getDrawingColors())
271 cw.setId(self.umlView.getItemId())
272 self.allClasses[className] = cw
273 if _class.name not in self.allModules[self.file]:
274 self.allModules[self.file].append(_class.name)
275
276 def __addExternalClass(self, _class, x, y):
277 """
278 Private method to add a class defined outside the module.
279
280 If the canvas is too small to take the shape, it
281 is enlarged.
282
283 @param _class class to be shown
284 @type ModuleParser.Class
285 @param x x-coordinate
286 @type float
287 @param y y-coordinate
288 @type float
289 """
290 from .ClassItem import ClassItem, ClassModel
291 cl = ClassModel(_class)
292 cw = ClassItem(cl, True, x, y, noAttrs=self.noAttrs, scene=self.scene,
293 colors=self.umlView.getDrawingColors())
294 cw.setId(self.umlView.getItemId())
295 self.allClasses[_class] = cw
296 if _class not in self.allModules[self.file]:
297 self.allModules[self.file].append(_class)
298
299 def __createAssociations(self, routes):
300 """
301 Private method to generate the associations between the class shapes.
302
303 @param routes list of relationsships
304 @type list of tuple of (str, str)
305 """
306 from .AssociationItem import AssociationItem, AssociationType
307 for route in routes:
308 if len(route) > 1:
309 assoc = AssociationItem(
310 self.__getCurrentShape(route[1]),
311 self.__getCurrentShape(route[0]),
312 AssociationType.GENERALISATION,
313 topToBottom=True,
314 colors=self.umlView.getDrawingColors())
315 self.scene.addItem(assoc)
316
317 def parsePersistenceData(self, version, data):
318 """
319 Public method to parse persisted data.
320
321 @param version version of the data
322 @type str
323 @param data persisted data to be parsed
324 @type str
325 @return flag indicating success
326 @rtype bool
327 """
328 parts = data.split(", ")
329 if (
330 len(parts) != 2 or
331 not parts[0].startswith("file=") or
332 not parts[1].startswith("no_attributes=")
333 ):
334 return False
335
336 self.file = parts[0].split("=", 1)[1].strip()
337 self.noAttrs = Utilities.toBool(parts[1].split("=", 1)[1].strip())
338
339 self.initialize()
340
341 return True
342
343 def toDict(self):
344 """
345 Public method to collect data to be persisted.
346
347 @return dictionary containing data to be persisted
348 @rtype dict
349 """
350 data = {
351 "project_name": self.project.getProjectName(),
352 "no_attributes": self.noAttrs,
353 }
354
355 data["file"] = (
356 Utilities.fromNativeSeparators(self.__relFile)
357 if self.__relFile else
358 Utilities.fromNativeSeparators(self.file)
359 )
360
361 return data
362
363 def fromDict(self, version, data):
364 """
365 Public method to populate the class with data persisted by 'toDict()'.
366
367 @param version version of the data
368 @type str
369 @param data dictionary containing the persisted data
370 @type dict
371 @return tuple containing a flag indicating success and an info
372 message in case the diagram belongs to a different project
373 @rtype tuple of (bool, str)
374 """
375 try:
376 self.noAttrs = data["no_attributes"]
377
378 file = Utilities.toNativeSeparators(data["file"])
379 if os.path.isabs(file):
380 self.file = file
381 self.__relFile = ""
382 else:
383 # relative file paths indicate a project file
384 if data["project_name"] != self.project.getProjectName():
385 msg = self.tr(
386 "<p>The diagram belongs to project <b>{0}</b>."
387 " Please open it and try again.</p>"
388 ).format(data["project_name"])
389 return False, msg
390
391 self.__relFile = file
392 self.file = self.project.getAbsolutePath(file)
393 except KeyError:
394 return False, ""
395
396 self.initialize()
397
398 return True, ""

eric ide

mercurial