Un mécanisme pour appeler des méthodes Ruby à partir de Python qui peut être fait en 200 lignes

J'ai créé une bibliothèque qui vous permet d'appeler des méthodes Ruby depuis Python. Je vais introduire des chaînes de méthodes et des itérateurs car ils peuvent être utilisés naturellement dans une certaine mesure.

https://github.com/yohm/rb_call

Comment ça a été fait

Nous développons une application Rails qui gère les travaux pour les calculs scientifiques et technologiques, et le comportement peut être contrôlé par l'API Ruby. Cependant, comme de nombreuses personnes dans le domaine du calcul scientifique et technologique sont des utilisateurs de Python, il y a eu de nombreuses demandes pour une API Python au lieu de Ruby.

Que pouvez-vous faire?

Par exemple, supposons que vous ayez le code Ruby suivant:

minimal_sample.rb


class MyClass
  def m1
    "m1"
  end

  def m2(a,b)
    "m2 #{a} #{b}"
  end

  def m3(a, b:)
    "m3 #{a} #{b}"
  end

  def m4(a)
    Proc.new { "m4 #{a}" }
  end

  def m5
    enum = Enumerator.new{|y|
      (1..10).each{|i|
        y << "#{i}" if i % 5 == 0
      }
    }
  end
end

if $0 == __FILE__
  obj = MyClass.new
  puts obj.m1, obj.m2(1,2), obj.m3(3,b:4)  #=> "m1", "m2 1 2", "m3 3 4"
  proc = obj.m4('arg of proc')
  puts proc.call                           #=> "m4 arg of proc"
  e = MyClass.m5
  e.each do |i|
    puts i                                 #=> "5", "10"
  end
end

La même chose peut être écrite en Python comme suit.

minimal_sample.py


from rb_call import RubySession

rb = RubySession()                          # Execute a Ruby process
rb.require('./minimal_sample')              # load a Ruby library 'sample_class.rb'

MyClass = rb.const('MyClass')               # get a Class defined in 'sample_class.rb'
obj = MyClass()                             # create an instance of MyClass
print( obj.m1(), obj.m2(1,2), obj.m3(3,b=4) )
                                            #=> "m1", "m2 1 2", "m3 3 4"
proc = obj.m4('arg of proc')
print( proc() )                             #=> "m4 arg of proc"

e = obj.m5()                                # Not only a simple Array but an Enumerator is supported
for i in e:                                 # You can iterate using `for` syntax over an Enumerable
    print(i)                                #=> "5", "10"

Vous pouvez appeler les bibliothèques Ruby à partir de Python presque telles quelles. Vous pouvez faire des chaînes de méthodes, et vous pouvez itérer en utilisant for. Bien que pas dans l'exemple, la notation d'inclusion de liste peut être utilisée comme prévu. Vous pouvez également accéder aux exceptions Ruby.

Par exemple, si vous le combinez avec du code Rails, vous pouvez l'écrire comme ceci.

rails_sample.py


author = Author.find('...id...')
Book.where( {'author':author} ).gt( {'price':100} ).asc( 'year' )

Si vous utilisez bien la méta-programmation et les bibliothèques externes, vous pouvez le réaliser avec un code compact qui tient dans un seul fichier, environ 130 lignes pour Python et environ 80 lignes pour Ruby. Bien sûr, il existe des restrictions comme décrit plus loin, mais cela fonctionne bien pour la plupart des applications.

Comment l'avez-vous mis en œuvre?

Problèmes avec le RPC régulier

J'utilise RPC pour appeler des méthodes Ruby à partir de Python. Cette fois, j'ai utilisé une bibliothèque (spécification?) Appelée MessagePack-RPC. Veuillez vous référer ici pour l'utilisation de base de MessagePack RPC. http://qiita.com/yohm13/items/70b626ca3ac6fbcdf939 Ruby est démarré comme un sous-processus du processus Python, et Ruby et Python communiquent entre eux à l'aide de socket. En gros, donnez le nom de la méthode et les arguments que vous souhaitez appeler de Python vers Ruby, et renvoyez la valeur de retour de Ruby vers Python. A ce moment, la spécification de sérialisation des données transmises / reçues est définie dans MessagePack-RPC.

S'il s'agit d'un processus consistant à "simplement donner un argument et renvoyer une valeur", il n'y a aucun problème avec cette méthode. Cependant, Ruby vous donne souvent envie de faire une chaîne de méthodes, et il existe de nombreuses bibliothèques qui le supposent. Par exemple, dans Rails, vous écririez fréquemment le code suivant.

Book.where( author: author ).gt( price: 100 ).asc( :year )

Une telle chaîne de méthodes ne peut pas être réalisée par un RPC ordinaire.

Le problème est essentiellement dû à l'impossibilité de sauvegarder l'état au milieu de la chaîne de méthodes. RPC entre Ruby et Python ne peut échanger que des objets qui peuvent être sérialisés avec MessagePack, donc l'objet après Book.where sera sérialisé lorsqu'il est renvoyé du processus Ruby au processus Python, donc c'est un autre Même si vous souhaitez appeler la méthode de, vous ne pouvez pas l'appeler.

En d'autres termes, il est nécessaire de conserver un objet Ruby dans le processus Ruby, et un mécanisme pour y faire référence plus tard si nécessaire est nécessaire.

Solution

Par conséquent, cette fois, l'objet Ruby qui ne peut pas être sérialisé par la valeur de retour de Ruby est conservé dans le processus Ruby, et seuls l'ID et la classe de l'objet sont renvoyés du côté Python. Définissez une classe appelée RubyObject du côté Python, conservez la paire de (ID, classe) provenant du côté Ruby en tant que membre et déléguez l'appel de méthode à ce RubyObject à l'objet dans le processus Ruby. Faire.

Le traitement lors du renvoi d'une valeur côté Ruby est à peu près le suivant.

@@variables[ obj.object_id ] = obj          #Conservez l'objet pour qu'il puisse être référencé ultérieurement par ID
MessagePack.pack( [self.class.to_s, self.object_id] )  #Renvoie l'ID de classe et d'objet du côté Python

Cependant, tout ce qui peut être sérialisé avec MessagePack, tel que String et Fixnum, est envoyé à Python tel quel.

Le traitement lorsqu'il est reçu côté Python

class RubyObject():
    def __init__(self, rb_class, obj_id):  #RubyObject contient le nom et l'ID de la classe
        self.rb_class = rb_class
        self.obj_id = obj_id

#Gestion des valeurs renvoyées par RPC
rb_class, obj_id = msgpack.unpackb(obj.data, encoding='utf-8')
RubyObject( rb_class, obj_id )

Quand je l'écris dans une image, cela ressemble à ceci, et l'objet du côté Python n'a qu'un pointeur vers l'objet Ruby.

RbCall.png

Après cela, vous pouvez transférer l'appel de méthode effectué vers RubyObject en Python vers l'objet réel côté Ruby. Définissez une méthode __getattr__ ( method_missing dans Ruby) qui est appelée lorsqu'un attribut qui n'existe pas est appelé pour le RubyObject.

class RubyObject():
    ...
    def __getattr__( self, attr ):
        def _method_missing(*args, **kwargs):
            return self.send( attr, *args, **kwargs )
        return _method_missing

    def send(self, method, *args, **kwargs):
        #ID d'objet dans RPC,Envoyer le nom et l'argument de la méthode à Ruby
        obj = self.client.call('send_method', self.obj_id, method, args, kwargs )
        return self.cast(obj)       #Convertir la valeur de retour en RubyObject

Code appelé côté Ruby

  def send_method( objid, method_name, args = [], kwargs = {})
    obj = find_object(objid)                       #Récupère l'objet enregistré depuis objid
    ret = obj.send(method_name, *args, **kwargs)   #Exécuter la méthode
  end

Ensuite, la méthode appelée pour RubyObject en Python sera appelée comme méthode de Ruby. Avec cela, les objets Ruby se comportent également comme s'ils étaient affectés à des variables Python, et les méthodes Ruby peuvent être appelées naturellement à partir de Python.

Points à prendre en compte lors du montage

Utiliser le type d'extension du pack de messages

MessagePack a une spécification qui vous permet de définir un type défini par l'utilisateur appelé type d'extension. https://github.com/msgpack/msgpack/blob/master/spec.md#types-extension-type

Cette fois, j'ai défini RubyObject (c'est-à-dire, la chaîne du nom de la classe et le Fixnum de l'ID d'objet) comme type d'extension et je l'ai utilisé. Du côté Ruby, la méthode to_msgpack_ext a été définie par monkey patching sur Object. Au fait, le dernier gem msgpack prend en charge le type Extension, mais msgpack-rpc-ruby semble avoir arrêté le développement et n'a pas utilisé le dernier msgpack. Forked pour s'appuyer sur les dernières gemmes. https://github.com/yohm/msgpack-rpc-ruby Le code ressemble à ceci:

Object.class_eval
  def self.from_msgpack_ext( data )
    rb_cls, obj_id = MessagePack.unpack( data )
    RbCall.find_object( obj_id )
  end

  def to_msgpack_ext
    RbCall.store_object( self )   #Enregistrez l'objet dans la variable
    MessagePack.pack( [self.class.to_s, self.object_id] )
  end
end
MessagePack::DefaultFactory.register_type(40, Object)

Du côté Python également, j'ai écrit un processus pour convertir le 40e type d'extension en type RubyObject.

Compter le nombre de références d'objets

À ce rythme, chaque fois qu'un objet est renvoyé du côté Ruby au côté Python, les variables enregistrées dans le processus Ruby augmentent de manière monotone et une fuite de mémoire se produit. Les variables qui ne sont plus référencées côté Python doivent également être déréférencées côté Ruby.

C'est pourquoi j'ai remplacé le RubyObject Python __del__. __del__ est une méthode appelée lorsque la variable qui fait référence à un objet en Python est 0 et peut être collectée par GC. À ce moment, les variables du côté Ruby sont également supprimées. http://docs.python.jp/3/reference/datamodel.html#object.del

    def __del__(self):
        self.session.call('del_object', self.obj_id)

Le code suivant est appelé côté Ruby.

  def del_object
    @@variables.delete(objid)
  end

Cependant, si vous utilisez simplement cette méthode, elle ne fonctionnera pas correctement si deux variables Python font référence à un objet Ruby. Par conséquent, le nombre de références renvoyées du côté Ruby au côté Python est également compté, et les variables du côté Ruby sont également libérées lorsqu'il devient nul.

class RbCall
  def self.store_object( obj )
    key = obj.object_id
    if @@variables.has_key?( key )
      @@variables[key][1] += 1
    else
      @@variables[key] = [obj, 1]
    end
  end

  def self.find_object( obj_id )
    @@variables[obj_id][0]
  end

  def del_object( args, kwargs = {} )
    objid = args[0]
    @@variables[objid][1] -= 1
    if @@variables[objid][1] == 0
      @@variables.delete(objid)
    end
    nil
  end
end

Les objets supprimés de @@ variables sont correctement libérés par Ruby GC. Il ne devrait y avoir aucun problème à gérer la vie de l'objet.

exception

Activé pour obtenir des informations sur les exceptions Ruby. Dans le msgpack-rpc-ruby publié, lorsqu'une exception se produit du côté Ruby, l'exception est envoyée en tant que to_s, mais cette méthode perd la plupart des informations sur l'exception. Par conséquent, l'objet d'exception de Ruby est également envoyé en tant qu'instance de RubyObject de Python. Encore une fois, j'ai modifié msgpack-rpc-ruby pour apporter des modifications à sérialiser si Msgpack peut sérialiser, plutôt que de toujours faire to_s.

Le traitement lorsqu'une exception se produit est le suivant. Lorsqu'une exception se produit du côté Ruby, msgpackrpc.error.RPCError se produit du côté Python. Ceci est une spécification de msgpack-rpc-python. Mettez une instance de RubyObject dans l'attribut ʻargsde l'exception. Si RubyObject est inclus, lancezRubyExceptiondéfini du côté Python. À ce moment, l'attributrb_exception` stocke la référence à l'objet d'exception qui s'est produit du côté Ruby. Vous pouvez maintenant accéder aux exceptions côté Ruby.

Le traitement côté Python est simplifié et écrit comme suit.

class RubyObject():
    def send(self, method, *args, **kwargs):
        try:
            obj = self.session.client.call('send_method', self.obj_id, method, args, kwargs )
            return self.cast(obj)
        except msgpackrpc.error.RPCError as ex:
            arg = RubyObject.cast( ex.args[0] )
            if isinstance( arg, RubyObject ):
                raise RubyException( arg.message(), arg ) from None
            else:
                raise

class RubyException( Exception ):
    def __init__(self,message,rb_exception):
        self.args = (message,)
        self.rb_exception = rb_exception

Par exemple, lorsqu'une Ruby ArgumentError est générée, le processus Python est le suivant.

try:
    obj.my_method("invalid", "number", "of", "arg")  #RubyObject mon_Nombre incorrect d'arguments de méthode
except RubyException as ex:    #Une exception RubyException se produit
    ex.args.rb_exception       # ex.args.rb_L'exception a un RubyObject qui fait référence à une exception Ruby

Compatible générateur

Par exemple, envisagez d'effectuer le traitement Ruby suivant.

articles = Article.all

ʻArticle.all` est Enumerable, pas Array, et n'est pas réellement développé en tant que tableau en mémoire. Seulement lorsque chacun est tourné, la base de données est accessible et les informations de chaque enregistrement peuvent être acquises.

Du côté Python également, il est nécessaire de définir un générateur pour exécuter la boucle sous la forme de for a in articles.

Pour ce faire, définissez la méthode __iter__ dans la classe RubyObject côté Python. __iter__ est une méthode qui renvoie un itérateur, et cette méthode est implicitement appelée dans l'instruction for. Cela correspond directement au ʻeach de Ruby, alors appelez ʻeach dans __iter__. http://anandology.com/python-practice-book/iterators.html https://docs.ruby-lang.org/ja/latest/class/Enumerator.html

En Python, lors de la rotation d'une boucle, la méthode __next__ est appelée pour la valeur de retour de __iter__. Il y a exactement la même correspondance dans Ruby, et ʻEnumerator # nextest la méthode correspondante. Lorsque l'itération atteint sa fin, le côté Ruby lève une exception appeléeStopIteration. Python a les mêmes spécifications, et lorsqu'une exception se produit, une exception appelée StopIteration` est levée. (Il se trouve que c'est une exception avec le même nom.)

class RubyObject():
    ...
    def __iter__(self):
        return self.send( "each" )
    def __next__(self):
        try:
            n = self.send( "next" )
            return n
        except RubyException as ex:
            if ex.rb_exception.rb_class == 'StopIteration': #Lorsqu'une exception Stop Iteration est levée dans Ruby
                raise StopIteration()  #Déclenche une exception Stop Iteration en Python
            else:
                raise

Vous pouvez maintenant utiliser la boucle de Python vers Ruby's Enumerable.

autres

Définissez des méthodes dans RubyObject afin que les fonctions intégrées de Python fonctionnent correctement. Les méthodes Ruby correspondantes sont:

-- __eq__ est == -- __dir __ est public_methods -- __str__ est to_s -- __len__ est "taille" --__getitem__ est [] --__call__ est un appel

Limites

Quand je l'ai implémenté, j'ai trouvé que Python et Ruby avaient une correspondance très similaire, et cela pouvait être implémenté très proprement en définissant bien la méthode correspondante. Il s'agit en fait d'environ 200 lignes de code, donc si vous êtes intéressé, veuillez lire la source.

Recommended Posts

Un mécanisme pour appeler des méthodes Ruby à partir de Python qui peut être fait en 200 lignes
Comment utiliser la méthode __call__ dans la classe Python
Comment configurer un serveur SMTP simple qui peut être testé localement en Python
Un enregistrement que GAMEBOY n'a pas pu être fait avec Python. (PYBOY)
Une histoire que heroku, qui peut se faire en 5 minutes, a en fait duré 3 jours
J'ai essayé de créer une classe qui peut facilement sérialiser Json en Python
J'ai créé une image Docker qui peut appeler FBX SDK Python à partir de Node.js
Comment installer la bibliothèque Python qui peut être utilisée par les sociétés pharmaceutiques
Comment installer la bibliothèque Python qui peut être utilisée par les sociétés pharmaceutiques
Un mécanisme pour appeler des méthodes Ruby à partir de Python qui peut être fait en 200 lignes
J'ai essayé de résumer le contenu de chaque paquet enregistré par Python pip en une seule ligne
Enquête sur l'alimentation CC contrôlable par Python
[Python3] Code qui peut être utilisé lorsque vous souhaitez découper une image dans une taille spécifique
D'un livre que les programmeurs peuvent apprendre ... (Python): Pointer
[Python] Création d'une méthode pour convertir la base en 1 seconde
[Python] Comment appeler une fonction de c depuis python (édition ctypes)
Convertir des images du SDK FlyCapture en un formulaire pouvant être utilisé avec openCV
Comment découper un bloc de plusieurs tableaux à partir d'un multiple en Python
À partir d'un livre que les programmeurs peuvent apprendre ... (Python): À propos du tri
traitement python3 qui semble utilisable dans paiza
À partir d'un livre que les programmeurs peuvent apprendre (Python): Décoder les messages
Analyse de texte pouvant être effectuée en 5 minutes [Word Cloud]
J'ai essayé "Comment obtenir une méthode décorée en Python"
Scripts pouvant être utilisés lors de l'utilisation de Bottle en Python
Comment appeler Python ou Julia à partir de Ruby (implémentation expérimentale)
[Python3] Code qui peut être utilisé lorsque vous souhaitez redimensionner des images dossier par dossier
Méthode de visualisation des fluides BOS qui peut être effectuée à la maison pour voir ce qui est invisible
Un mémorandum (masOS) qui importe tkinter n'a pas pu être fait avec python installé à partir de pyenv
[Python] Un programme pour trouver le nombre de pommes et d'oranges qui peuvent être récoltées
Convertir les données de maillage exportées de SpriteUV2 dans un format pouvant être importé par Spine
Appelez Matlab depuis Python pour optimiser
[Python] Créez un graphique qui peut être déplacé avec Plotly
8 services que même les débutants peuvent apprendre Python (des débutants aux utilisateurs avancés)
Publier un message d'IBM Cloud Functions sur Slack en Python
Appeler popcount depuis Ruby / Python / C #
À propos de psd-tools, une bibliothèque capable de traiter des fichiers psd en Python
Comment obtenir une chaîne à partir d'un argument de ligne de commande en python
Créez une Spinbox qui peut être affichée en binaire avec Tkinter
À partir d'un livre que le programmeur peut apprendre ... (Python): trouver la valeur la plus fréquente
À partir d'un livre que les programmeurs peuvent apprendre ... (Python): examen des tableaux
Un minuteur (ticker) qui peut être utilisé sur le terrain (peut être utilisé n'importe où)
Une fonction qui mesure le temps de traitement d'une méthode en python
Appelez dlm depuis python pour exécuter un modèle de régression à coefficient variable dans le temps
Résumé de l'entrée standard de Python pouvant être utilisée dans Competition Pro
・ <Slack> Ecrire une fonction pour notifier Slack afin qu'elle puisse être citée à tout moment (Python)
Vérifiez si vous pouvez vous connecter à un port TCP en Python
Créez une Spinbox pouvant être affichée dans HEX avec Tkinter
À partir d'un livre que les programmeurs peuvent apprendre (Python): valeur de l'écart de traitement statistique
J'ai écrit un tri-arbre qui peut être utilisé pour l'implémentation de dictionnaire à grande vitesse en langage D et Python
Il n'y a pas de variables d'instance «privées» en Python qui ne sont accessibles qu'à partir d'un objet.
[Python] Un programme qui trouve une paire qui peut être divisée par une valeur spécifiée
J'ai créé une application Web en Python qui convertit Markdown en HTML
[Python] Un programme qui calcule le nombre de chaussettes jumelées
Comment créer un bot Janken qui peut être facilement déplacé (commentaire)
Bot LINE sans serveur qui peut être réalisé en 2 heures (acquisition de l'identifiant source)
Extraire les lignes qui correspondent aux conditions d'un fichier texte avec python
[Peut être fait en 10 minutes] Créez rapidement un site Web local avec Django
Comment obtenir la valeur du magasin de paramètres dans lambda (en utilisant python)
À partir d'un livre que le programmeur peut apprendre ... (Python): Recherche conditionnelle (valeur maximale)
J'ai essayé de développer un formateur qui génère des journaux Python en JSON