14 oct. 2024

[Dynamo += Python] PythonNet et les Interfaces de Classe .NET




Si vous développez en Python avec PythonNet, vous avez peut-être été confronté aux problèmes de l'implémentation des Interfaces .Net, voici une solution

#DynamoBIM #Revit #PythonNet #NET #Interface  #AutodeskExpertElite #AutodeskCommunity 

if this article is not in your language, use the Google Translate widget ⬈ (bottom of page for Mobile version )


Introduction

Dans cet article, nous allons explorer comment utiliser une interface de Classe .Net avec PythonNet

  • une interface ?

En .NET, une interface est un contrat que les classes doivent respecter en implémentant ses membres (méthodes, propriétés, événements). Cela permet de créer des architectures flexibles et modifiables sans dépendre directement d'une classe spécifique

  • Différence entre IronPython et PythonNet


Critère IronPython (DLR) PythonNet (CPython + .NET)
Basé sur DLR (Dynamic Language Runtime) CPython (interpréteur Python standard)
Interopérabilité avec .NET Excel (intégration native via CLR) Très bonne (accès direct via clr en CPython)
Interopérabilité avec les objets COM Excellente (grâce à la prise en charge dynamique du DLR) Possible, mais nécessite parfois des conversions de types explicites
Gestion des objets dynamiques Très naturelle et intuitive (support direct du DLR) Plus limitée, demande souvent des déclarations explicites et des conversions de types
Utilisation des bibliothèques CPython Non (pas de support pour les modules C comme NumPy, SciPy, etc.) Oui (support complet des modules CPython comme NumPy)
Limitation par le GIL Non (pas de Global Interpreter Lock) Oui (limité par le GIL de CPython)
Multithreading Véritable exécution parallèle (pas de GIL) Concurrence limitée à cause du GIL
Interopérabilité avec les types statiquement typés Excellente (gestion par le CLR) Excellente (intégration directe des types .NET)
Interopérabilité avec les bibliothèques .NET dynamiques Très fluide grâce au DLR Moins fluide pour les objets dynamiques, meilleure pour les types statiques
Performance en multithreading Très bonne (pas de GIL) Limité par le GIL, sauf si multiprocessing est utilisé
Gestion de la mémoire Gérée par le garbage collector du CLR Gérée par le garbage collector de CPython

Explications CLR / DLR:

    • CLR est le runtime de base utilisé pour tous les langages .NET, gérant principalement des langages statiquement typés et offrant des services comme la gestion de la mémoire, la gestion des exceptions, et la sécurité.

    • DLR est une couche supplémentaire au-dessus du CLR, conçue spécifiquement pour prendre en charge les langages dynamiques et permettre des opérations dynamiques, comme la modification des objets et l'exécution de méthodes à l'exécution.




Création d'un Objet .Net  

(avec héritage d'une Interface)

       
    • Avec  IronPython, une instance d'une classe qui hérite d'un type .NET est un objet .NET. Cela signifie que bien que l'objet soit créé à partir d'une classe définie dans IronPython, il est traité comme un objet .NET dans le runtime .NET (CLR)


  class Temperature(System.IComparable):
    def __init__(self, init_value):
        self.__t = System.Int32(init_value)


    def CompareTo(self, obj):
        if obj is None:
            return System.Int32(1)
        else:
            return self.Celsius.CompareTo(obj.Celsius)

    @property
    def Celsius(self):
        return self.__t

    @Celsius.setter
    def Celsius(self, value: System.Int32):
        self.__t = System.Int32(value)

    • Avec PythonNet, il faut rajouter une syntaxe spécifique 

- Un espace de nom spécifique avec l'attribut __namespace__

- Lorsque vous héritez de types .NET en Python, si vous utilisez/surchargez la méthode d'initialisation  __init__, vous devez appeler explicitement le constructeur de base en utilisant super().__init__(.....) (surtout valable avec PythonNet3).



class Temperature(System.IComparable):
    __namespace__ = "My.Temperature"
    def __init__(self, init_value):
        self.__t = System.Int32(init_value)
        super().__init__()

    def CompareTo(self, obj):
        if obj is None:
            return System.Int32(1)
        else:
            return self.Celsius.CompareTo(obj.Celsius)

    @property
    def Celsius(self):
        return self.__t

    @Celsius.setter
    def Celsius(self, value: System.Int32):
        self.__t = System.Int32(value)

Autre Exemple de construction d'objet .Net

import sys
import clr
import System
from System.Collections.Generic import List

class ExampleClrClass(System.Object):
    __namespace__ = "MyNameSpace_"
    def __init__(self, lst):
        super().__init__()
        self.__lst = lst
        
    @clr.clrmethod(List[System.Int32], [int])
    def Multiply(self, x):
        return List[System.Int32]([n * x for n in self.__lst])

    @clr.clrproperty(List[System.Int32])
    def GetList(self):
        return List[System.Int32](self.__lst)
              
a = ExampleClrClass(list(range(10000)))

OUT = a



Le problème, c'est qu'à chaque appel de cette classe est créé :
    • 1 Espace de Nom  + 1 Type 
        • avec PythonNet 2.5 → localisable dans l'Assembly Python.Runtime 
        • avec PythonNet 3 → localisable dans sys.modules 

Il en résulte que l'exécution d'un script python avec des types contenant __namespace__, plus d'une fois, lèvera une exception Duplicate Type Name
.




Solution "uuid" : un espace de nom différent a chaque appel



import clr
import sys
import System
#import net library
from System import Array, GC
from System.Collections.Generic import List, IList, Dictionary
#import Revit API
clr.AddReference('RevitAPI')
import Autodesk
from Autodesk.Revit.DB import *
import Autodesk.Revit.DB as DB

#import Revit APIUI namespace
clr.AddReference('RevitAPIUI')
from Autodesk.Revit.UI import *
from Autodesk.Revit.UI.Selection import *

my_path = System.Environment.GetFolderPath(System.Environment.SpecialFolder.MyDocuments)

import uuid
import os
import logging
from logging.handlers import RotatingFileHandler
import traceback

## Start create logger Object ##
logger = logging.getLogger("TestInterface_ByRename_Logger")
# set to  DEBUG
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter('%(asctime)s :: %(levelname)s :: %(funcName)s :: %(process)d :: %(message)s')
# create handler 
file_handler = RotatingFileHandler(my_path + '\\TestInterface_ByRename_Logger.log', mode='a', maxBytes=100000, backupCount=1)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
logger.disabled = False


class Custom_SelectionElem1(ISelectionFilter):
    __namespace__ = "CustomSelectionElem_"+ str(uuid.uuid4())
    def __init__(self, bic):
        super().__init__()
        self.bic = bic
    def AllowElement(self, e):
        if e.Category.Id == ElementId(self.bic):
            return True
        else:
            return False
    def AllowReference(self, ref, point):
        return True 
        
        
def get_Memory(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        GC.Collect()
        memory = GC.GetTotalMemory(True) / 1024
        type_name = ""
        total_number_type = 0
        for asm in System.AppDomain.CurrentDomain.GetAssemblies():
            for atyp in asm.GetTypes():
                if "Custom_SelectionElem" in atyp.Name :
                    total_number_type += 1
                    type_name = atyp.Name
        for module_name in dict(sys.modules).keys():
            if "Custom_SelectionElem" in module_name :
                total_number_type += 1
                type_name = module_name
        logger.info((func.__name__, type_name, memory, total_number_type))
        return result
    return wrapper
        
@get_Memory
def Test_Rename_NameSpace():
    a = Custom_SelectionElem1(BuiltInCategory.OST_AbutmentPiles)
        
        
Test_Rename_NameSpace()

for handler in logger.handlers:
    if isinstance(handler, logging.FileHandler):
        handler.close()

Ici la solution consiste à générer un nouvel __namespace__ à chaque appel de la classe, avec la librairie uuid (UUID aléatoire a chaque appel).

Cependant, lorsque nous lançons plusieurs fois ce script, nous observons qu'à chaque lancement :
    • le runtime PythonNet crée un nouveau Type en mémoire
    • la mémoire utilisée augmente en conséquence (même en fermant Dynamo entre chaque lancement)
      (prenant soin de supprimer les objets non référencés avec GC.Collect() )



lancement successif du script Dynamo - Solution "uuid"


Solution "__import__()" : re-utiliser le Type généré

Exemple :

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

clr.AddReference('RevitAPIUI')
import Autodesk.Revit.UI as RUI
from Autodesk.Revit.UI import *
from Autodesk.Revit.UI.Selection import *

clr.AddReference("RevitServices")
import RevitServices
from RevitServices.Persistence import DocumentManager
from RevitServices.Transactions import TransactionManager
doc = DocumentManager.Instance.CurrentDBDocument
uiapp = DocumentManager.Instance.CurrentUIApplication
uidoc = uiapp.ActiveUIDocument


class Custom_SelectionElem:
    def __new__(cls, *args, **kwargs):
        cls.args = args
        # name of namespace : "CustomNameSpace" + "_" + shortUUID (8 characters)
        # IMPORTANT NOTE Each time you modify this class, you must change the namesapce name.
        cls.__namespace__ = "SelectionNameSpace_tEfYX0DHE"
        try:
            # 1. Try to import the module and class. If it already exists, you've probably already created 
            module_type = __import__(cls.__namespace__)
            return module_type._InnerClassInterface(*cls.args)
        except ImportError as ex:
            print(ex)
            # 2. If we get an ImportError, then the derivative class hasn't been built. Build it. Make sure to set the
            #    __namespace__ so that it is created with the appropriate .Net namespace.
            class _InnerClassInterface(ISelectionFilter):
                __namespace__ = cls.__namespace__
                #
                def __init__(self, bic):
                    super().__init__()
                    self.bic = bic
                    
                def AllowElement(self, e):
                    if e.Category.Id == ElementId(self.bic):
                        return True
                    else:
                        return False
                def AllowReference(self, ref, point):
                    return True 
                    
            return _InnerClassInterface(*cls.args)
       
for i in range(3):
    RUI.TaskDialog.Show("Select", f"Select a door {i+1}/3")
    ref = uidoc.Selection.PickObject(ObjectType.Element, Custom_SelectionElem(BuiltInCategory.OST_Doors))
    print(ref.ElementId)
 
Cette fois, la solution est de créer un Type de Classe (avec Namespace) une seule fois et de réutilisé celui lors des appels suivants.

Pour cela, j'utilise ici :
    • la méthode __new__() afin de pouvoir construire et retourner un objet
    • la méthode __import__() pour essayer d'importer le namespace (module) pendant l'exécution
ainsi, nous réduisons le coût en mémoire par rapport la méthode précédente

lancement successif du script Dynamo - Solution "__import__()"


Note pour le nommage du cls.__namespace__ :
    • je recommande un nom unique avec éventuellement un short UUID
    • à chaque fois que vous modifierez cette classe, il faudra changer cette variable (d'où le short UUID ) sinon vous allez charge les anciens types/modules (jusqu'au redémarrage de l'application hôte)
Compatibilité de cette méthode :
 
  • Net Framework 4.x
    • Revit / Civil 2022 - CPython3/PythonNet2.5 : Oui
    • Revit / Civil 2023 - CPython3/PythonNet2.5 : Oui
    • Revit / Civil 2024 (Dynamo 2.19.3) - CPython3/PythonNet2.5 : NON (comptabilité incomplète, probablement dû au ciblage du Framework NetStandard 2.0 avec PythonNet2.5 dans un environnement Framework 4.8)


  • Net Core 8+
    • Revit / Civil 2025 - CPython3/PythonNet2.5 : Oui
    • Revit / Civil 2025+ - PythonNet3 : Oui
Pour ceux qui travaillent avec IronPython3 et qui souhaitent basculer vers PythonNet3 ultérieurement, cette méthode est compatible avec IronPython3


Cas des Interfaces de Classe .Net avec des paramètres out

 
Les méthodes .NET qui ont des paramètres out  au sein des Interfaces de classe .Net, leurs valeurs doivent être retournées dans un tuple comprenant :
    • la valeur de retour de la méthode
    • la valeur de retour du / des valeur(s) modifiée(s) des paramètres out
Attention cette syntaxe ne fonctionne qu'avec PythonNet3, n'essayez pas avec CPython3/PythonNet2 sous peine de faire crasher l'application hôte.

Exemple :


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

clr.AddReference('RevitAPIUI')
import Autodesk.Revit.UI as RUI
from Autodesk.Revit.UI import *
from Autodesk.Revit.UI.Selection import *

clr.AddReference("RevitServices")
import RevitServices
from RevitServices.Persistence import DocumentManager
from RevitServices.Transactions import TransactionManager
doc = DocumentManager.Instance.CurrentDBDocument
uiapp = DocumentManager.Instance.CurrentUIApplication
uidoc = uiapp.ActiveUIDocument

#         
class Custom_FamilyOption:
    def __new__(cls, *args, **kwargs):
        cls.args = args
        # name of namespace : "CustomNameSpace" + "_" + shortUUID (8 characters)
        # IMPORTANT NOTE Each time you modify this class, you must change the namesapce name.
        cls.__namespace__ = "FamilyOptionNameSpace_tEfYX0DHE"
        try:
            # 1. Try to import the module and class. If it already exists, you've probably already created it once, so you can use it.
            module_type = __import__(cls.__namespace__)
            return module_type._InnerClassInterface(*cls.args)
        except ImportError as ex:
            print(ex)
            # 2. If we get an ImportError, then the derivative class hasn't been built. Build it. Make sure to set the
            #    __namespace__ so that it is created with the appropriate .Net namespace.
            class _InnerClassInterface(IFamilyLoadOptions) :
                __namespace__ = cls.__namespace__
            
                def __init__(self):
                    super().__init__() 
                    
                def OnFamilyFound(self, familyInUse, _overwriteParameterValues):
                    overwriteParameterValues = True
                    return (True, overwriteParameterValues)
            
                def OnSharedFamilyFound(self, sharedFamily, familyInUse, source, _overwriteParameterValues):
                    overwriteParameterValues = True      
                    return (True, overwriteParameterValues)
                    
            return _InnerClassInterface(*cls.args)
#        
def loadFamily(path):
    TransactionManager.Instance.EnsureInTransaction(doc)
    opts = Custom_FamilyOption()
    dummyFamily = None
    loadFamily = doc.LoadFamily(path, opts, dummyFamily)
    TransactionManager.Instance.TransactionTaskDone()

for path in IN[0] : loadFamily(path)


Note avec cette syntaxe, nous pouvons redéfinir notre classe Température avec des setter et getter, ce qui donne :

      
class Temperature:
    def __new__(cls, *args, **kwargs):
        cls.args = args
        cls.__namespace__ = "Temperature_tEfYX0DHE"
        try:
            module_type = __import__(cls.__namespace__)
            return module_type._Temperature(*cls.args)
        except ImportError as ex:		
            class _Temperature(System.IComparable):
                __namespace__ = cls.__namespace__
                def __init__(self, init_value):
                    self.__t = System.Int32(init_value)
                    super().__init__()

                def CompareTo(self, obj):
                    if obj is None:
                        return System.Int32(1)
                    else:
                        return self.Celsius.CompareTo(obj.Celsius)
                        
                # define getter and setter
                def get_Celsius(self):
                    return self.__t
                def set_Celsius(self, value: System.Int32):
                    self.__t = System.Int32(value)
                    
                Celsius = clr.clrproperty(System.Int32, get_Celsius, set_Celsius)
                
                # add custom method
                @clr.clrmethod(System.Int32)
                def ToFahrenheit(self):
                    return (self.Celsius * 1.8) + 32
                    
                @clr.clrmethod(System.Int32, System.Int32)
                def Multiply(self, x):
                    return self.Celsius * x
                    
            return _Temperature(*cls.args)

À mesure que PythonNet continue de s'améliorer, notamment avec la version 3, nous pouvons nous espérer à une simplification de ces processus à l'avenir.




"La simplicité est la sophistication ultime."
— Steve Jobs

0 commentaires:

Enregistrer un commentaire