eric7/Graphics/PackageDiagramBuilder.py

branch
eric7
changeset 8312
800c432b34c8
parent 8295
3f5e8b0a338e
child 8318
962bce857696
equal deleted inserted replaced
8311:4e8b98454baa 8312:800c432b34c8
1 # -*- coding: utf-8 -*-
2
3 # Copyright (c) 2007 - 2021 Detlev Offenbach <detlev@die-offenbachs.de>
4 #
5
6 """
7 Module implementing a dialog showing an UML like class diagram of a package.
8 """
9
10 import glob
11 import os.path
12 from itertools import zip_longest
13
14 from PyQt5.QtWidgets import QApplication, QGraphicsTextItem
15
16 from E5Gui.E5ProgressDialog import E5ProgressDialog
17
18 from .UMLDiagramBuilder import UMLDiagramBuilder
19
20 import Utilities
21 import Preferences
22
23
24 class PackageDiagramBuilder(UMLDiagramBuilder):
25 """
26 Class implementing a builder for UML like class diagrams of a package.
27 """
28 def __init__(self, dialog, view, project, package, noAttrs=False):
29 """
30 Constructor
31
32 @param dialog reference to the UML dialog
33 @type UMLDialog
34 @param view reference to the view object
35 @type UMLGraphicsView
36 @param project reference to the project object
37 @type Project
38 @param package name of a python package to be shown
39 @type str
40 @param noAttrs flag indicating, that no attributes should be shown
41 @type bool
42 """
43 super().__init__(dialog, view, project)
44 self.setObjectName("PackageDiagram")
45
46 self.package = os.path.abspath(package)
47 self.noAttrs = noAttrs
48
49 self.__relPackage = (
50 self.project.getRelativePath(self.package)
51 if self.project.isProjectSource(self.package) else
52 ""
53 )
54
55 def initialize(self):
56 """
57 Public method to initialize the object.
58 """
59 pname = self.project.getProjectName()
60 name = (
61 self.tr("Package Diagram {0}: {1}").format(
62 pname, self.project.getRelativePath(self.package))
63 if pname else
64 self.tr("Package Diagram: {0}").format(self.package)
65 )
66 self.umlView.setDiagramName(name)
67
68 def __getCurrentShape(self, name):
69 """
70 Private method to get the named shape.
71
72 @param name name of the shape
73 @type str
74 @return shape
75 @rtype QCanvasItem
76 """
77 return self.allClasses.get(name)
78
79 def __buildModulesDict(self):
80 """
81 Private method to build a dictionary of modules contained in the
82 package.
83
84 @return dictionary of modules contained in the package
85 @rtype dict
86 """
87 import Utilities.ModuleParser
88
89 supportedExt = (
90 ['*{0}'.format(ext) for ext in
91 Preferences.getPython("Python3Extensions")] +
92 ['*.rb']
93 )
94 extensions = (
95 Preferences.getPython("Python3Extensions") +
96 ['.rb']
97 )
98
99 moduleDict = {}
100 modules = []
101 for ext in supportedExt:
102 modules.extend(glob.glob(
103 Utilities.normjoinpath(self.package, ext)))
104 tot = len(modules)
105 progress = E5ProgressDialog(
106 self.tr("Parsing modules..."),
107 None, 0, tot, self.tr("%v/%m Modules"), self.parent())
108 progress.setWindowTitle(self.tr("Package Diagram"))
109 try:
110 progress.show()
111 QApplication.processEvents()
112
113 for prog, module in enumerate(modules):
114 progress.setValue(prog)
115 QApplication.processEvents()
116 try:
117 mod = Utilities.ModuleParser.readModule(
118 module, extensions=extensions, caching=False)
119 except ImportError:
120 continue
121 else:
122 name = mod.name
123 if name.startswith(self.package):
124 name = name[len(self.package) + 1:]
125 moduleDict[name] = mod
126 finally:
127 progress.setValue(tot)
128 progress.deleteLater()
129 return moduleDict
130
131 def __buildSubpackagesDict(self):
132 """
133 Private method to build a dictionary of sub-packages contained in this
134 package.
135
136 @return dictionary of sub-packages contained in this package
137 @rtype dict
138 """
139 import Utilities.ModuleParser
140
141 supportedExt = (
142 ['*{0}'.format(ext) for ext in
143 Preferences.getPython("Python3Extensions")] +
144 ['*.rb']
145 )
146 extensions = (
147 Preferences.getPython("Python3Extensions") +
148 ['.rb']
149 )
150
151 subpackagesDict = {}
152 subpackagesList = []
153
154 for subpackage in os.listdir(self.package):
155 subpackagePath = os.path.join(self.package, subpackage)
156 if (
157 os.path.isdir(subpackagePath) and
158 subpackage != "__pycache__" and
159 len(glob.glob(
160 os.path.join(subpackagePath, "__init__.*")
161 )) != 0
162 ):
163 subpackagesList.append(subpackagePath)
164
165 tot = 0
166 for ext in supportedExt:
167 for subpackage in subpackagesList:
168 tot += len(glob.glob(Utilities.normjoinpath(subpackage, ext)))
169 progress = E5ProgressDialog(
170 self.tr("Parsing modules..."),
171 None, 0, tot, self.tr("%v/%m Modules"), self.parent())
172 progress.setWindowTitle(self.tr("Package Diagram"))
173 try:
174 progress.show()
175 QApplication.processEvents()
176
177 for subpackage in subpackagesList:
178 packageName = os.path.basename(subpackage)
179 subpackagesDict[packageName] = []
180 modules = []
181 for ext in supportedExt:
182 modules.extend(glob.glob(
183 Utilities.normjoinpath(subpackage, ext)))
184 for prog, module in enumerate(modules):
185 progress.setValue(prog)
186 QApplication.processEvents()
187 try:
188 mod = Utilities.ModuleParser.readModule(
189 module, extensions=extensions, caching=False)
190 except ImportError:
191 continue
192 else:
193 name = mod.name
194 if "." in name:
195 name = name.rsplit(".", 1)[1]
196 subpackagesDict[packageName].append(name)
197 subpackagesDict[packageName].sort()
198 # move __init__ to the front
199 if "__init__" in subpackagesDict[packageName]:
200 subpackagesDict[packageName].remove("__init__")
201 subpackagesDict[packageName].insert(0, "__init__")
202 finally:
203 progress.setValue(tot)
204 progress.deleteLater()
205 return subpackagesDict
206
207 def buildDiagram(self):
208 """
209 Public method to build the class shapes of the package diagram.
210
211 The algorithm is borrowed from Boa Constructor.
212 """
213 self.allClasses = {}
214
215 initlist = glob.glob(os.path.join(self.package, '__init__.*'))
216 if len(initlist) == 0:
217 ct = QGraphicsTextItem(None)
218 self.scene.addItem(ct)
219 ct.setHtml(
220 self.tr("The directory <b>'{0}'</b> is not a package.")
221 .format(self.package))
222 return
223
224 modules = self.__buildModulesDict()
225 subpackages = self.__buildSubpackagesDict()
226
227 if not modules and not subpackages:
228 ct = QGraphicsTextItem(None)
229 self.scene.addItem(ct)
230 ct.setHtml(self.buildErrorMessage(
231 self.tr("The package <b>'{0}'</b> does not contain any modules"
232 " or subpackages.").format(self.package)
233 ))
234 return
235
236 # step 1: build all classes found in the modules
237 classesFound = False
238
239 for modName in list(modules.keys()):
240 module = modules[modName]
241 for cls in list(module.classes.keys()):
242 classesFound = True
243 self.__addLocalClass(cls, module.classes[cls], 0, 0)
244 if not classesFound and not subpackages:
245 ct = QGraphicsTextItem(None)
246 self.scene.addItem(ct)
247 ct.setHtml(self.buildErrorMessage(
248 self.tr("The package <b>'{0}'</b> does not contain any"
249 " classes or subpackages.").format(self.package)
250 ))
251 return
252
253 # step 2: build the class hierarchies
254 routes = []
255 nodes = []
256
257 for modName in list(modules.keys()):
258 module = modules[modName]
259 todo = [module.createHierarchy()]
260 while todo:
261 hierarchy = todo[0]
262 for className in hierarchy:
263 cw = self.__getCurrentShape(className)
264 if not cw and className.find('.') >= 0:
265 cw = self.__getCurrentShape(className.split('.')[-1])
266 if cw:
267 self.allClasses[className] = cw
268 if cw and cw.noAttrs != self.noAttrs:
269 cw = None
270 if cw and not (cw.external and
271 (className in module.classes or
272 className in module.modules)
273 ):
274 if className not in nodes:
275 nodes.append(className)
276 else:
277 if className in module.classes:
278 # this is a local class (defined in this module)
279 self.__addLocalClass(
280 className, module.classes[className],
281 0, 0)
282 elif className in module.modules:
283 # this is a local module (defined in this module)
284 self.__addLocalClass(
285 className, module.modules[className],
286 0, 0, True)
287 else:
288 self.__addExternalClass(className, 0, 0)
289 nodes.append(className)
290
291 if hierarchy.get(className):
292 todo.append(hierarchy.get(className))
293 children = list(hierarchy.get(className).keys())
294 for child in children:
295 if (className, child) not in routes:
296 routes.append((className, child))
297
298 del todo[0]
299
300 # step 3: build the subpackages
301 for subpackage in sorted(subpackages.keys()):
302 self.__addPackage(subpackage, subpackages[subpackage], 0, 0)
303 nodes.append(subpackage)
304
305 self.__arrangeClasses(nodes, routes[:])
306 self.__createAssociations(routes)
307 self.umlView.autoAdjustSceneSize(limit=True)
308
309 def __arrangeClasses(self, nodes, routes, whiteSpaceFactor=1.2):
310 """
311 Private method to arrange the shapes on the canvas.
312
313 The algorithm is borrowed from Boa Constructor.
314
315 @param nodes list of nodes to arrange
316 @type list of str
317 @param routes list of routes
318 @type list of tuple of (str, str)
319 @param whiteSpaceFactor factor to increase whitespace between
320 items
321 @type float
322 """
323 from . import GraphicsUtilities
324 generations = GraphicsUtilities.sort(nodes, routes)
325
326 # calculate width and height of all elements
327 sizes = []
328 for generation in generations:
329 sizes.append([])
330 for child in generation:
331 sizes[-1].append(
332 self.__getCurrentShape(child).sceneBoundingRect())
333
334 # calculate total width and total height
335 width = 0
336 height = 0
337 widths = []
338 heights = []
339 for generation in sizes:
340 currentWidth = 0
341 currentHeight = 0
342
343 for rect in generation:
344 if rect.bottom() > currentHeight:
345 currentHeight = rect.bottom()
346 currentWidth += rect.right()
347
348 # update totals
349 if currentWidth > width:
350 width = currentWidth
351 height += currentHeight
352
353 # store generation info
354 widths.append(currentWidth)
355 heights.append(currentHeight)
356
357 # add in some whitespace
358 width *= whiteSpaceFactor
359 height = height * whiteSpaceFactor - 20
360 verticalWhiteSpace = 40.0
361
362 sceneRect = self.umlView.sceneRect()
363 width += 50.0
364 height += 50.0
365 swidth = sceneRect.width() if width < sceneRect.width() else width
366 sheight = sceneRect.height() if height < sceneRect.height() else height
367 self.umlView.setSceneSize(swidth, sheight)
368
369 # distribute each generation across the width and the
370 # generations across height
371 y = 10.0
372 for currentWidth, currentHeight, generation in (
373 zip_longest(widths, heights, generations)
374 ):
375 x = 10.0
376 # whiteSpace is the space between any two elements
377 whiteSpace = (
378 (width - currentWidth - 20) /
379 (len(generation) - 1.0 or 2.0)
380 )
381 for className in generation:
382 cw = self.__getCurrentShape(className)
383 cw.setPos(x, y)
384 rect = cw.sceneBoundingRect()
385 x = x + rect.width() + whiteSpace
386 y = y + currentHeight + verticalWhiteSpace
387
388 def __addLocalClass(self, className, _class, x, y, isRbModule=False):
389 """
390 Private method to add a class defined in the module.
391
392 @param className name of the class to be as a dictionary key
393 @type str
394 @param _class class to be shown
395 @type ModuleParser.Class
396 @param x x-coordinate
397 @type float
398 @param y y-coordinate
399 @type float
400 @param isRbModule flag indicating a Ruby module
401 @type bool
402 """
403 from .ClassItem import ClassItem, ClassModel
404 name = _class.name
405 if isRbModule:
406 name = "{0} (Module)".format(name)
407 cl = ClassModel(
408 name,
409 sorted(_class.methods.keys())[:],
410 sorted(_class.attributes.keys())[:],
411 sorted(_class.globals.keys())[:]
412 )
413 cw = ClassItem(cl, False, x, y, noAttrs=self.noAttrs, scene=self.scene,
414 colors=self.umlView.getDrawingColors())
415 cw.setId(self.umlView.getItemId())
416 self.allClasses[className] = cw
417
418 def __addExternalClass(self, _class, x, y):
419 """
420 Private method to add a class defined outside the module.
421
422 If the canvas is too small to take the shape, it
423 is enlarged.
424
425 @param _class class to be shown
426 @type ModuleParser.Class
427 @param x x-coordinate
428 @type float
429 @param y y-coordinate
430 @type float
431 """
432 from .ClassItem import ClassItem, ClassModel
433 cl = ClassModel(_class)
434 cw = ClassItem(cl, True, x, y, noAttrs=self.noAttrs, scene=self.scene,
435 colors=self.umlView.getDrawingColors())
436 cw.setId(self.umlView.getItemId())
437 self.allClasses[_class] = cw
438
439 def __addPackage(self, name, modules, x, y):
440 """
441 Private method to add a package to the diagram.
442
443 @param name package name to be shown
444 @type str
445 @param modules list of module names contained in the package
446 @type list of str
447 @param x x-coordinate
448 @type float
449 @param y y-coordinate
450 @type float
451 """
452 from .PackageItem import PackageItem, PackageModel
453 pm = PackageModel(name, modules)
454 pw = PackageItem(pm, x, y, scene=self.scene,
455 colors=self.umlView.getDrawingColors())
456 pw.setId(self.umlView.getItemId())
457 self.allClasses[name] = pw
458
459 def __createAssociations(self, routes):
460 """
461 Private method to generate the associations between the class shapes.
462
463 @param routes list of relationsships
464 @type list of tuple of (str, str)
465 """
466 from .AssociationItem import AssociationItem, AssociationType
467 for route in routes:
468 if len(route) > 1:
469 assoc = AssociationItem(
470 self.__getCurrentShape(route[1]),
471 self.__getCurrentShape(route[0]),
472 AssociationType.GENERALISATION,
473 topToBottom=True,
474 colors=self.umlView.getDrawingColors())
475 self.scene.addItem(assoc)
476
477 def getPersistenceData(self):
478 """
479 Public method to get a string for data to be persisted.
480
481 @return persisted data string
482 @rtype str
483 """
484 return "package={0}, no_attributes={1}".format(
485 self.package, self.noAttrs)
486
487 def parsePersistenceData(self, version, data):
488 """
489 Public method to parse persisted data.
490
491 @param version version of the data
492 @type str
493 @param data persisted data to be parsed
494 @type str
495 @return flag indicating success
496 @rtype bool
497 """
498 parts = data.split(", ")
499 if (
500 len(parts) != 2 or
501 not parts[0].startswith("package=") or
502 not parts[1].startswith("no_attributes=")
503 ):
504 return False
505
506 self.package = parts[0].split("=", 1)[1].strip()
507 self.noAttrs = Utilities.toBool(parts[1].split("=", 1)[1].strip())
508
509 self.initialize()
510
511 return True
512
513 def toDict(self):
514 """
515 Public method to collect data to be persisted.
516
517 @return dictionary containing data to be persisted
518 @rtype dict
519 """
520 data = {
521 "project_name": self.project.getProjectName(),
522 "no_attributes": self.noAttrs,
523 }
524
525 data["package"] = (
526 Utilities.fromNativeSeparators(self.__relPackage)
527 if self.__relPackage else
528 Utilities.fromNativeSeparators(self.package)
529 )
530
531 return data
532
533 def fromDict(self, version, data):
534 """
535 Public method to populate the class with data persisted by 'toDict()'.
536
537 @param version version of the data
538 @type str
539 @param data dictionary containing the persisted data
540 @type dict
541 @return tuple containing a flag indicating success and an info
542 message in case the diagram belongs to a different project
543 @rtype tuple of (bool, str)
544 """
545 try:
546 self.noAttrs = data["no_attributes"]
547
548 package = Utilities.toNativeSeparators(data["package"])
549 if os.path.isabs(package):
550 self.package = package
551 self.__relPackage = ""
552 else:
553 # relative package paths indicate a project package
554 if data["project_name"] != self.project.getProjectName():
555 msg = self.tr(
556 "<p>The diagram belongs to project <b>{0}</b>."
557 " Please open it and try again.</p>"
558 ).format(data["project_name"])
559 return False, msg
560
561 self.__relPackage = package
562 self.package = self.project.getAbsolutePath(package)
563 except KeyError:
564 return False, ""
565
566 self.initialize()
567
568 return True, ""

eric ide

mercurial