src/eric7/Graphics/PackageDiagramBuilder.py

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

eric ide

mercurial