1# -*- coding: utf-8 -*-
2
3# Copyright (c) 2007 - 2021 Detlev Offenbach <detlev@die-offenbachs.de>
4#
5
6"""
7Module implementing a dialog showing an UML like class diagram of a package.
8"""
9
10import glob
11import os.path
12from itertools import zip_longest
13
14from PyQt5.QtWidgets import QApplication, QGraphicsTextItem
15
16from E5Gui.E5ProgressDialog import E5ProgressDialog
17
18from .UMLDiagramBuilder import UMLDiagramBuilder
19
20import Utilities
21import Preferences
22
23
24class 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, ""
569