Outils pour utilisateurs

Outils du site


pyqt4_console_python

Ceci est une ancienne révision du document !


Intégrer une console Python dans un programme

[Python v2.7.3, PyQt4 v4.9.5, Qt4 v4.8.3]

[modification le 28/1/2013 16h]

Objectif

On est en train d'utiliser un programme, et on a un petit calcul à faire. Oui, je sais, tous les PC ont une calculatrice, mais le calcul dont on a besoin ne s'y trouve pas forcément: calcul de crédit, calcul en nombres complexes, calcul arithmétique (PGCD, loi de Poisson, …), etc… Selon le domaine dans lequel on est, il suffira d'avoir le module supplémentaire à importer.

Le but de ce projet est de permettre à un programme quelconque Python-PyQt4 d'ouvrir une fenêtre à l'intérieur de laquelle on disposera de l'interpréteur Python, comme la partie interactive de IDLE.

Et ça marchera, même dans un programme autonome traité par cx_freeze, et exécuté sur un PC qui n'a pas Python!

Principe de la solution proposée

On utilise le module “code” qui est fait pour ça.

La fenêtre est toute simple: QWidget avec à l'intérieur un QTextEdit. Mais le QTextEdit est particulier, parce qu'il a fallu traiter certains caractères comme la touche “entrée” pour envoyer la dernière ligne tapée à l'interpréteur. J'ai donc sous-classé le QTextEdit pour ça: classe “Visu” dans le code ci-dessous. Cette classe devra aussi lancer le thread qui portera l'interpréteur, et recevoir ses réponses pour les afficher.

l'interpréteur sera porté par un thread. Pourquoi un thread? Parce que l'attente de la prochaine instruction à exécuter (sorte de “raw_input”) ferait figer le graphique. Et puis ce serait dommage qu'une exécution un peu longue fige aussi le graphique.

Mais cela posera un problème particulier: un code s'exécutant dans un thread ne doit pas toucher directement au graphique PyQt4. Les échanges d'information se feront exclusivement par échange de messages. J'ai donc choisi une thread “QThread”.

La partie interpréteur utilisée est la classe “InteractiveConsole” du module code. Elle est sous-classée et certaines méthodes surchargées pour assurer les échanges d'entrée/sortie avec la partie graphique, y compris avec un détournement des sys.stdout et sys.stderr.

Pour traiter les 2 points précédents, j'ai utilisé une classe, appelée ici “Interpy”, qui hérite en même temps de “QtCore.QThread” et “code.InteractiveConsole”. Les programmes en PyQt4 supportent très bien l'héritage multiple, à condition qu'il n'y ait pas plusieurs ancêtres issus de QWidget.

Dernier point à résoudre, le détournement des sys.stdout et sys.stderr. Ce sont des objets de type “fichier”. On peut toujours les détourner sur un “StringIO.StringIO” et récupérer le contenu après exécution pour l'afficher, mais l'impression à l'intérieur d'une boucle ne marchera pas. Il faut donc que chaque demande d'affichage soit satisfaite, même à l'intérieur d'une exécution complexe. La solution choisie est de sous-classer la classe StringIO, ce qui donne la classe “MonStringIO”, et de surcharger sa méthode write.

Voilà pour les principes. le reste est largement commenté dans le code lui-même.

Ce code fonctionne bien, mais il n'est pas encore parfait: il faut le considérer en version “beta”. Les remarques sont bienvenues!

La seule chose que je n'ai pas réussi à faire, c'est d'arrêter un code trop long avec Ctrl-C. En effet, l'exécution se faisant dans le thread, l'exception KeyboardInterrupt n'a pas d'action sur lui. Ici, un Ctrl-C ferme la fenêtre: c'est tout ce que j'ai trouvé pour se débarrasser d'un code trop long ou planté. Je reste à l'écoute d'une solution plus intelligente!

L'idée initiale de ce code m'a été soufflée sur un forum (http://www.developpez.net/forums/d1301688/autres-langages/python-zope/gui/pyside-pyqt/console-python-qtextedit/): merci à wiztricks!

Quelques modifications ont été faites depuis la toute 1ère publication (ce matin!). La plus importante est que l'on peut désormais fournir, dès le lancement, du code Python pour que l'interpréteur l'exécute au départ.

On peut ainsi:

  • fournir une chaine de caractères: elle devra être le nom d'un fichier Python existant avec son chemin. Le fichier sera lu et ses lignes exécutées une par une.
  • fournir une liste (ou un tuple) d'instructions Python: ces instructions seront exécutées une par une.

Dans les 2 cas, les instructions seront exécutées comme si l'utilisateur les avaient tapées lui-même.

si l'argument transmis est une chaine mais que le fichier n'existe pas, ou est n'importe quoi d'autre qu'une liste ou un tuple, l'interpréteur n'en tiendra pas compte.

Voir les exemples fournis à la fin du code ci-dessous.

Cette fonctionnalité va bien avec l'objectif de départ: quelque soit le domaine dans lequel on est (scientifique, financier, construction, etc…), on peut initialiser automatiquement la console avec des modules de fonctions adaptées, fonctions qu'on pourra utiliser dans la console comme avec une calculatrice spécialisée!

Voilà ce que ça va donner:

Voilà le code.

Code

#!/usr/bin/python
# -*- coding: utf-8 -*-
from __future__ import division
# Python 2.7
 
import sys, os
import code
import StringIO
from time import sleep
 
from PyQt4 import QtCore, QtGui
 
#############################################################################
# variables globales pour gérer les données communes avec le thread
 
mutexokwrite = QtCore.QMutex()
okwrite = False
 
#############################################################################
class MonStringIO(StringIO.StringIO):
 
    def __init__(self, orig, buf = ''):
        StringIO.StringIO.__init__(self, buf)
        self.orig = orig
 
    def write(self, s):
        # envoie la réponse sous forme de message à la fenêtre
        self.orig.write(s) 
 
#############################################################################
class Interpy(QtCore.QThread, code.InteractiveConsole):
 
    #========================================================================
    def __init__(self, proprio, locals=None, filename="<console>"): 
        QtCore.QThread.__init__(self)
        code.InteractiveConsole.__init__(self, locals=None, filename="<console>")
        self.proprio = proprio # adresse de l'objet qui a lancé le thread
        self.texte = None # initialisation du texte disponible
        # se préparer à recevoir les instructions du QTextEdit personnalisé
        self.connect(self.proprio, QtCore.SIGNAL("pourlire(PyQt_PyObject)"), self.textedispo) 
 
    #========================================================================
    def run(self):
        """partie exécutée en asynchrone"""
        self.interact()
 
    #========================================================================
    def runcode(self, code):
        """surcharge de runcode pour rediriger sys.stdout et sys.stderr"""
        # redirection de la sortie d'affichage
        std_sav = sys.stdout, sys.stderr
        sys.stdout = sys.stderr = sio = MonStringIO(self)
        # exécution de l'instruction Python        
        try:
            exec code in self.locals
        except SystemExit:
            self.quit()
        except:
            self.showtraceback()
        finally:
            # remettre la sortie d'affichage initiale
            sys.stdout, sys.stderr = std_sav
            sio.close()
 
    #========================================================================
    def raw_input(self, prompt):
        """lire la prochaine ligne d'instruction"""
        try:
            line = self.read(prompt)
        except Exception as err:
            log.exception (err)
            raise SystemExit()
        else:
            return line
 
    #========================================================================
    def write(self, data):
        """affiche data en envoyant un message à la fenêtre"""
        global okwrite, mutexokwrite # drapeau pour le shake-hand d'affichage
 
        # pour être sûr que data est en unicode
        if not isinstance(data, unicode): 
            data = unicode(data)
        # débarrasse data des éventuels fins de ligne des 2 côtés
        while data!=u"" and data[0] in ['\n', '\r']: data = data[1:]
        while data!=u"" and data[-1] in ['\n', '\r']: data = data[:-1]
        if data!=u"":
            # envoie la réponse sous forme de message (avec shake-hand)
            mutexokwrite.lock()
            okwrite = False
            mutexokwrite.unlock()
            self.emit(QtCore.SIGNAL("pourafficher(PyQt_PyObject)"), data) 
            while not okwrite:
                pass
 
    #========================================================================
    def textedispo(self, texte):
        """exécuté à chaque message reçu "pourlire" accompagné du texte """
        self.texte = texte
 
    #========================================================================
    def read(self, prompt):
        """lit la chaine à interpréter"""
 
        # envoi l'invite pour affichage
        self.write(prompt)
 
        # retourne la chaine dès qu'il y en a une de saisie
        while self.texte==None:
            sleep(0.1)
        texte = self.texte
        self.texte = None
        return texte
 
    #========================================================================
    def quit(self):
        """appelé quand on veut fermer l'application"""
        # émet le signal de fin pour la fenêtre
        self.emit(QtCore.SIGNAL("finconsole()"))
 
#############################################################################
class Visu(QtGui.QTextEdit):
 
    def __init__(self, parent=None, initpy=[]):
 
        super(Visu, self).__init__(parent)
        self.setAcceptRichText(False)
        self.setLineWrapMode(QtGui.QTextEdit.NoWrap) 
 
        # prépa du lancement du thread qui porte l'interpréteur Python
        self.interpy = Interpy(self)
        # prépa pour recevoir de interpy un signal de fermeture de la fenêtre 
        self.connect(self.interpy, QtCore.SIGNAL("finconsole()"), self.quitter)
        # prépa pour recevoir du thread 'interpy' du texte à afficher 
        self.affichinit = False
        self.connect(self.interpy, QtCore.SIGNAL("pourafficher(PyQt_PyObject)"), self.affiche)
        # lancement du thread
        self.interpy.start()
 
        # initialisation de la position du curseur dans le texte
        self.pos1 = self.textCursor().position()
 
        # initialisation de l'historique des lignes d'instruction Python
        self.historique = []
        self.ih = 0
 
        # prépa de l'initialisation de l'interpréteur si demandé au lancement
        self.initpy = initpy
        if isinstance(self.initpy, (str, unicode)):
            # si c'est une chaine: elle doit représenter un fichier à charger
            nfc = self.initpy
            self.initpy = []
            if os.path.exists(nfc):
                with open(nfc, 'r') as f:
                    for ligne in f:
                        self.initpy.append(ligne.rstrip())
 
        else:
            if not isinstance(self.initpy, (list, tuple)):
                # mauvais type pour self.initpy: on n'en tient pas compte
                self.initpy = []
        self.lginitpy = len(self.initpy)
        self.nbinvites = -1
 
 
 
    #========================================================================
    def affiche(self, texte):
        """Affiche la chaine 'texte' dans le widget QTextEdit"""
        global okwrite, mutextokwrite # drapeau pour le shake-hand d'affichage
 
        # ajoute la chaine unicode à la fin du QTextEdit
        self.append(texte)
        # déplace le curseur à la fin du texte
        self.moveCursor(QtGui.QTextCursor.End, QtGui.QTextCursor.MoveAnchor)
        # force le rafraichissement pour affichage en temps réel
        QtCore.QCoreApplication.processEvents() 
        # met à jour la position du curseur dans le texte
        self.pos1 = self.textCursor().position()
 
        # renvoie un accusé de réception de fin d'affichage (shake-hand)
        mutexokwrite.lock()
        okwrite = True
        mutexokwrite.unlock()
 
        # envoie les lignes de code d'initialisation après la 1ère invite
        if self.nbinvites<self.lginitpy:
            if self.nbinvites>=0:
                self.emit(QtCore.SIGNAL("pourlire(PyQt_PyObject)"), self.initpy[self.nbinvites])
            self.nbinvites += 1
 
    # =======================================================================
    def keyPressEvent(self, event):
        """traitement des évènements clavier du QTextEdit"""
 
        #--------------------------------------------------------------------
        if event.key() in [QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter]:
            # touche "retour"
            # déplacer le curseur à la fin du texte si ce n'est pas le cas
            self.moveCursor(QtGui.QTextCursor.End, QtGui.QTextCursor.MoveAnchor)
            # texte situé entre la précédente position du curseur et l'actuelle
            pos1 = self.pos1
            pos2 = self.textCursor().position()
            texte = unicode(self.toPlainText())[pos1:pos2]
            # débarrasse l'instruction 'texte' des éventuels fins de ligne
            while texte!=u"" and texte[0] in ['\n', '\r']: texte = texte[1:]
            texte = texte.rstrip()
            #envoyer la ligne d'instruction à l'interpréteur Python
            self.emit(QtCore.SIGNAL("pourlire(PyQt_PyObject)"), texte)
            # conserver la ligne d'instruction dans l'historique
            self.historique.append(texte)
            self.ih = len(self.historique)-1 # pointe sur le dernier élément
            # sauvegarde la position du curseur de début d'instruction
            self.pos1 = pos2
            event.accept()
 
        #--------------------------------------------------------------------
        elif event.key()==QtCore.Qt.Key_Z and \
                         (event.modifiers() & QtCore.Qt.ControlModifier):
            # ne fait rien, mais neutralise le Ctrl-Z
            event.accept()
 
        #--------------------------------------------------------------------
        elif event.key()==QtCore.Qt.Key_C and \
                         (event.modifiers() & QtCore.Qt.ControlModifier):
            # Controle-C arrête l'interpréteur et ferme la fenêtre
            self.interpy.quit()
            event.accept()
 
        #--------------------------------------------------------------------
        elif event.key()==QtCore.Qt.Key_Up:
            # traitement touche flèche en haut: instruction précédente
            if self.historique!=[] and self.ih>=0:
                pos1 = self.pos1
                pos2 = self.textCursor().position()
                instruction = self.historique[self.ih]
                texte = unicode(self.toPlainText())
                texte = texte[:pos1] + instruction + texte[pos2:]
                self.clear()
                self.affiche(texte)
                self.pos1 = pos1
                if self.ih>0:
                    self.ih -= 1
            event.accept()
 
        #--------------------------------------------------------------------
        elif event.key()==QtCore.Qt.Key_Down:
            # traitement touche flèche en bas, instruction suivante
            if self.historique!=[] and self.ih<len(self.historique)-1:
                self.ih += 1
                pos1 = self.pos1
                pos2 = self.textCursor().position()
                instruction = self.historique[self.ih]
                texte = unicode(self.toPlainText())
                texte = texte[:pos1] + instruction + texte[pos2:]
                self.clear()
                self.affiche(texte)
                self.pos1 = pos1
            event.accept()
 
        #--------------------------------------------------------------------
        elif event.key()==QtCore.Qt.Key_Backspace:
            # empêche le backspace de revenir sur l'invite
            pos = self.textCursor().position()
            if pos>self.pos1:
                event.ignore()
                # évènement transmis à l'ancêtre
                QtGui.QTextEdit.keyPressEvent(self, event)
            else:
                event.accept()    
 
        #--------------------------------------------------------------------
        else:
            # n'importe quel autre caractère que ceux ci-dessus
            event.ignore()
            # évènement transmis à l'ancêtre
            QtGui.QTextEdit.keyPressEvent(self, event)
 
    #========================================================================
    def quitter(self):
        """la fenêtre a reçu le signal de fermeture de l'interpréteur """
        # on ré-émet le signal de fermeture pour la fenêtre
        self.emit(QtCore.SIGNAL("finfenetre()"))
 
#############################################################################
class Consolepy(QtGui.QWidget):
 
    #========================================================================
    def __init__(self, initpy=[], parent=None):
        super(Consolepy, self).__init__(parent)
 
        # instructions pour la fenêtre 
        self.setWindowTitle(u"Interpréteur Python")
        self.resize(700, 700) # definit la taille de la fenetre
        QtGui.QApplication.setStyle(QtGui.QStyleFactory.create('Plastique'))
        #self.setWindowIcon(QtGui.QIcon('iconefenetre.ico'))
 
        # créer le QTextEdit personnalisé
        self.visu = Visu(self, initpy)
 
        # Changer la police de caractères et sa taille
        font = QtGui.QFont()
        font.setFamily(u"DejaVu Sans Mono")
        font.setPointSize(10)
        self.visu.setFont(font)
 
        # positionner dans la fenêtre
        posit = QtGui.QGridLayout()
        posit.addWidget(self.visu, 0, 0)
        self.setLayout(posit)
 
        # se préparer à recevoir de self.visu un signal de fermeture
        self.connect(self.visu, QtCore.SIGNAL("finfenetre()"), self.quitter)
 
    #========================================================================
    def quitter(self):
        """la fenêtre a reçu le signal de fermeture"""
        self.close()
 
#############################################################################
if __name__ == '__main__':
 
    app = QtGui.QApplication(sys.argv)
 
    # possibilité de donner comme argument une liste d'instructions Python 
    initpy = [u"# -*- coding: utf-8 -*-",
              u"from __future__ import division",
              u"from math import *"]
    # possibilité de donner comme argument un nom de fichier Python à exécuter
    #initpy = "consolepy_init.py"
    # un argument d'un mauvais type ne sera pas considéré (ici un dictionnaire)
    #initpy = {}
 
    fen = Consolepy(initpy)
    fen.show()
    sys.exit(app.exec_())


Amusez-vous bien!

pyqt4_console_python.1359391323.txt.gz · Dernière modification: 2013/01/28 17:42 de tyrtamos

Outils de la page