15 juil. 2020

[Dynamo += Python] Moduler vos noeuds Python






La composition d'un design Script est-elle forcément constituée de nœuds consécutifs et de façon séquentielle ? Voici une alternative...



Parfois la mise consécutive de nœuds Python requière une petite vigilance, pour exemple lorsqu'il faut exécuter des nœuds python avec plusieurs chemin d'exécution possible, il faut gérer les conditions d'exécution dans chaque nœud.





Bien que pour les petits/moyens scripts, la gestion des opérations conditionnel peut être réalisé dans un seul nœud Python, pour les plus gros script, une alternative peut être de scinder le script en plusieurs "morceaux", comme lorsqu'un travaille sur un projet Python avec plusieurs fichiers/classes. L'objectif est donc d'utiliser des noeuds Python comme librairies/modules interne.


Un exemple avec un mini projet (chaque couleur représente un module), ici le but est d'extraire des informations des éléments de la maquette (sous projets ou utilisateur/créateur ) et d'exporter le résultat en Excel ou au format csv.


Ce qui donne sous Dynamo le graphe suivant où chaque module est en fait une classe Python:




2 méthodes pour utiliser ces classes dans le nœud principal (nœud Main) :


  • La méthode simple : 
Une instance de classe est créée dans les nœuds respectifs ou dans le nœud Python principal, puis on utilise les propriétés et méthodes des objets créés.


Il faut juste être vigilant à l'ordre d'affectation des variables d'entrée.


  • La méthode plus complexe :
On importe les classes comme des modules Python interne.

La solution consiste à utiliser un hook pour personnaliser le processus d'importation, en ajoutant nos classes Python comme objets chercheurs avec la méthode sys.meta_path.

Enfin pour que l'importation soit valide et que l'on puisse filtrer les imports personnalisés il est nécessaire d'implémenter 2 méthodes : "find_module()" et "load_module()"



Note 1 :
Les méthodes "find_module()" et "load_module()" sont dépréciées depuis la version 3.4 de Python. Les méthodes respectives à utiliser sont "find_spec()" et (create_module() + exec_module()
À noté que le mécanisme d'importation essaie find_module() uniquement si le chercheur n'implémente pas find_spec().
De plus amples informations ici :
https://docs.python.org/fr/3/reference/import.html (voir paragraphe sur les Métas-chemins)
https://docs.python.org/3/library/importlib.html

Un des avantages avec le moteur CPython3 de Dynamo c'est que l'ensemble des nœuds Python sont exécuté dans un même environnement Python. Nœud Python de Classe Python convertie en module
  
# https://stackoverflow.com/questions/65009309/dynamically-import-module-from-memory-in-python-3-using-hooks
import sys
import clr
import importlib
from importlib.abc import Loader, MetaPathFinder
import types
clr.AddReference('ProtoGeometry')
from Autodesk.DesignScript.Geometry import *

class StringLoader(Loader):

    def __init__(self, modules):
        self._modules = modules

    def has_module(self, fullname):
        return (fullname in self._modules)

    def create_module(self, spec):
        if self.has_module(spec.name):
            module = types.ModuleType(spec.name)
            return self._modules[spec.name]()

    def exec_module(self, module):
        pass


class StringFinder(MetaPathFinder):

    def __init__(self, loader):
        self._loader = loader

    def find_spec(self, fullname, path, target=None):
        if self._loader.has_module(fullname):
            return importlib.machinery.ModuleSpec(fullname, self._loader)

class Foo():
    guid = "a8c3aa76-f731-4086-ae08-8cb41464e425"
    def __init__(self):
        self.args = [chr(i) for i in range(97, 103)]
        
    def __name__(self):
        return "Foo"
        
    def bar(self):
        return ', '.join(self.args)
        
    def Test(self):
        return Point.ByCoordinates(1,2,-3)

moduleName = Foo.__name__
# MAKE A DICT OF MODULES
modules = {moduleName: Foo}
# PURGE PREVIOUS MODULES
for i in range(10):
    for idx, m in enumerate(sys.meta_path):
        if hasattr(m, "_loader") and hasattr(m._loader, "_modules") and moduleName in m._loader._modules:
            sys.meta_path.pop(idx)
            break
# DEL PREVIOUS MODULE IN sys.modules
if moduleName in sys.modules:
    del sys.modules[moduleName]
# IMPORT MODULE IN sys.meta_path
finder = StringFinder(StringLoader(modules))
sys.meta_path.append(finder)
import Foo
####################################################################################
##### NOW MODULE Foo IS ACCESSIBLE IN ALL OTHER PYTHON NODES JUST NEED IMPORT ######
####################################################################################

OUT = 0
Nœud Python de l'utilisation de Classe Python
import sys
import sys
import clr
clr.AddReference('ProtoGeometry')
from Autodesk.DesignScript.Geometry import *

import Foo
import Foo2
pta = Foo.Test()
ptb = Foo2.Test()
OUT = Line.ByStartPointEndPoint(pta, ptb), Foo.bar()

-



-
Note 2:
  - afin de facilité l'import les classes passées à la variable OUT n'ont pas de paramètres dans leur constructeur.
  - la variable/propriété "guid" (généré aléatoirement) permet de filtrer les imports d'autres librairies afin qu'elles ne passent pas par ce hook.

Le contenu des différents nœuds Python

  • Les modules 
    • La classe pour l'exportation
   
import clr
import sys
clr.AddReference('ProtoGeometry')
from Autodesk.DesignScript.Geometry import *

import System
from System import Array
from System.Collections.Generic import *

clr.AddReference('System.Drawing')
import System.Drawing
from System.Drawing import *

clr.AddReferenceByName('Microsoft.Office.Interop.Excel, Version=11.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c' )
from Microsoft.Office.Interop import Excel
from System.Runtime.InteropServices import Marshal

pf_path = System.Environment.GetFolderPath(System.Environment.SpecialFolder.ProgramFilesX86)
sys.path.append('%sIronPython 2.7Lib' % pf_path)
import csv


class ExportUtils():
    guid = "a8c3aa76-f731-4086-ae08-8cb41464e425"
    def __init__(self):
        pass
    def __name__(self):
        return "ExportUtils"    
        
    def exportXls(self, datas, folderpath):
        filePath = folderpath + '
ecap.xlsx'
        ex = Excel.ApplicationClass()
        ex.Visible = True
        ex.DisplayAlerts = False
        workbook = ex.Workbooks.Add()
        workbook.SaveAs(filePath)
        ws = workbook.Worksheets[1] 
        nbr_row = len(datas)
        nbr_colum = len(datas[0])
        xlrange  = ws.Range[ws.Cells(1, 1), ws.Cells(nbr_row, nbr_colum)]
        a = Array.CreateInstance(object, nbr_row, nbr_colum)
        for indexR, row in enumerate(datas):
            for indexC , value in  enumerate(row):
                a[indexR,indexC] = value
                
        #copy Array in range            
        xlrange.Value2 = a      
        used_range = ws.UsedRange   
        for column in used_range.Columns:
            column.AutoFit()
                       
    def exportCsv(self, datas, folderpath):
        filePath = folderpath + '\\recap.csv'
        with open(filePath, 'w') as f:
            writer = csv.writer(f, lineterminator='\n')
            writer.writerows(datas)     
                        
OUT  =  ExportUtils     	  


    • La classe pour l'interface "Utilisateur"
 
import clr
clr.AddReference('ProtoGeometry')
from Autodesk.DesignScript.Geometry import *

clr.AddReference('System.Drawing')
clr.AddReference('System.Windows.Forms')
import System.Drawing
import System.Windows.Forms

from System.Drawing import *
from System.Windows.Forms import *

class UserUI():
    guid = "a8c3aa76-f731-4086-ae08-8cb41464e425"
    def __init__(self):
        pass
        
    def __name__(self):
        return "UserUI"
        
    def selectFolder(self):
        folderBrowserDialog1 = System.Windows.Forms.FolderBrowserDialog()
        result = folderBrowserDialog1.ShowDialog()
        folderName = folderBrowserDialog1.SelectedPath
        return folderName
    
    class UserForm(Form):
        def __init__(self, title, text1, text2):
            self._title = title
            self._text1 = text1
            self._text2 = text2
            self.choice = None
            self.InitializeComponent()
        
        def InitializeComponent(self):
            self._Process1 = System.Windows.Forms.Button()
            self._Process2 = System.Windows.Forms.Button()
            self.SuspendLayout()
            # 
            # Process1
            # 
            self._Process1.Location = System.Drawing.Point(33, 62)
            self._Process1.Name = "input1" 
            self._Process1.Size = System.Drawing.Size(177, 47)
            self._Process1.TabIndex = 0
            self._Process1.Text = self._text1 
            self._Process1.UseVisualStyleBackColor = True
            self._Process1.Click += self.ProcessClick
            # 
            # Process2
            # 
            self._Process2.Location = System.Drawing.Point(250, 62)
            self._Process2.Name = "input2" 
            self._Process2.Size = System.Drawing.Size(177, 47)
            self._Process2.TabIndex = 0
            self._Process2.Text = self._text2
            self._Process2.UseVisualStyleBackColor = True
            self._Process2.Click += self.ProcessClick
            # 
            # Form6
            # 
            self.ClientSize = System.Drawing.Size(476, 165)
            self.Controls.Add(self._Process2)
            self.Controls.Add(self._Process1)
            self.Name = "Form6"
            self.Text = self._title 
            self.ResumeLayout(False)
    
    
        def ProcessClick(self, sender, e):
            self.choice = sender.Text
            self.Close()

OUT = UserUI  


    • La classe pour l'Analyse des Éléments de la maquette
 

import clr

clr.AddReference("RevitServices")
import RevitServices
from RevitServices.Persistence import DocumentManager
doc = DocumentManager.Instance.CurrentDBDocument

clr.AddReference("RevitAPI")
from Autodesk.Revit.DB import *


class CheckerWorkset():
    guid = "a8c3aa76-f731-4086-ae08-8cb41464e425"
    def __init__(self):
        self._fecElems = FilteredElementCollector(doc).WhereElementIsNotElementType().ToElements()
        fecWkset = FilteredWorksetCollector(doc).OfKind(WorksetKind.UserWorkset)
        self._dictWkset = {x.Id : x.Name for x in fecWkset}
        
    def __name__(self):
        return "CheckerWorkset"
        
    def checkElemUsers(self):
        outCheck = []   
        outCheck.append(["Id", "Name", "Creator", "LastChangedBy"])
        for x in self._fecElems:
            if hasattr(x, "Name"):
                nameElem = x.Name
            else:
                nameElem = Element.Name.GetValue(x)     
            tooltip = WorksharingUtils.GetWorksharingTooltipInfo(doc, x.Id)
            if x.Location is not None:
                outCheck.append([x.Id, nameElem, tooltip.Creator, tooltip.LastChangedBy])
        return outCheck
        
    def checkElemWorkset(self):
        outCheck = []   
        outCheck.append(["Id", "Name", "WorksetName"])
        for x in self._fecElems:    
            wksetName = self._dictWkset.get(x.WorksetId)
            outCheck.append([x.Id, x.Name, wksetName])
        return outCheck 
    
    
OUT = CheckerWorkset


    • Enfin le nœud principal où l'on gère l'import des modules et les différentes conditions suivant le choix d'utilisateur 
  
import clr
import sys

class FinderImporter(object):
    def __init__(self, module):
        self._module = module
        self._guid = "a8c3aa76-f731-4086-ae08-8cb41464e425"

    def find_module(self, fullname, path=None):
        if fullname in self._module.__name__:
            if self._guid == self._module.guid :
                return self
            else:   
                return None 
        else:   
            return None
    def load_module(self, fullname):
        if  fullname not in self._module.__name__:
            if self._guid != self._module.guid :
                raise ImportError(fullname)
        return self._module()
        
modulesEntry = IN
##Method to Load internal Module##
for mod in modulesEntry:
    if hasattr(mod, 'guid'):
        sys.meta_path.append(FinderImporter(mod))

import UserUI
from UserUI import *
import ExportUtils
import CheckerWorkset


varinput1 = "Checker les sous projects"
varinput2 = "Checker les Utilisateur"
objForm1 = UserForm("Check Elements", varinput1, varinput2)
objForm1.ShowDialog()
if objForm1.choice == varinput1:
    lstCheck = CheckerWorkset.checkElemWorkset()
else:
    lstCheck = CheckerWorkset.checkElemUsers()

if 'lstCheck' in locals():
    varinput1 = "Export Excel"
    varinput2 = "Export csv"
    objForm1 = UserForm("Check Elements", varinput1, varinput2)
    objForm1.ShowDialog()
    folderpath = selectFolder()
    if objForm1.choice == varinput1:
        ExportUtils.exportXls(lstCheck, folderpath)
    else:   
        ExportUtils.exportCsv(lstCheck, folderpath)
OUT = lstCheck  
  


Outre d'avoir un script avec des méthodes mieux organisées, un des avantages c'est l'ajout facilité ultérieur de nouvelles fonctionnalités / méthodes. 

L'exemple des scripts ci-dessus au format Dynamo est disponible ici














0 commentaires:

Enregistrer un commentaire