Publicaciones sobre pyqt

DeVicenzo 2

A long time ago I "wrote a web browser". Those there are some very heavy quotes. You may imagine me doing air quotes while I write it, maybe?

That's because I didn't really, what I actually did was write UI around Qt's webkit-based widget. It was a fun project, specially because I did it with the absurd constraint of staying below 128 lines of code.

And then I did not touch if for six years. But yesterday I did.

commit 0b29b060ab9962a32e671551b0f035764cbeffaa
Author: Roberto Alsina <ralsina@medallia.com>
Date:   Tue Oct 30 12:32:43 2018 -0300

    Initial PySide2 port

commit 831c30d2c7e6b6b2a0a4d5d362ee7bc36493b975
Author: roberto.alsina@gmail.com <roberto.alsina@gmail.com@1bbba601-83ea-880f-26a2-52609c2bd284>
Date:   Fri Jun 1 15:24:46 2012 +0000

    nicer, smaller margins

Six years is a long time. So, nowadays:

  • I prefer my code to be formatted better
  • Python 3 is the thing
  • PySide is official, so I would recommend using it instead of PyQt
  • Qt is now on version 5 instead of 4

So, with those new constraints in mind, I ported DeVicenzo to the latest everything, formatted the code properly using black, and expanded by line limit to a generous 256.

And Here it is ... it's not realy useful but it is an example of how expressive the Python/Qt combination can be, even while being an absurdly bad example nobody should follow (Oh, the lambdas!)

screenshot

Qt Mac Tips

Mi equipo viene trabajando en portar algunas cosas de PyQt a Mac OSX, y nos cruzamos con algunos bugs de Qt, lamentablemente. Acá hay dos, y como los resolvimos.

Los diálogos nativos no andan. Usando QFileDialog.getExistingDirectory notamos éstos síntomas:

  • Si no hacés nada, el diálogo desaparase por su cuenta en más o menos 20 segundos.
  • Después de usarlo una vez, tal vez aparezca y desaparezca inmediatamente. O no.

Solución: usar la opción DontUseNativeDialog option.

Los widgets en un QTreeWidgetItems no se mueven.

Cuando uno pone widgets adentro de los ítems de un QTreeWidget (que no es muy común, pero a veces es útil), los widgets no se mueven junto con el ítem.

Solución, usar la opción -graphicssystem raster. Hasta se la puede inyectar en argv si la plataforma es darwin.

The Future of PyQt by Example

Perdón, sólo inglés porque no voy a publicar esto en castellano, mantener el código con comentarios bilingües es demasiado complicado


Three years ago, I started a series of long posts called "PyQt by Example". It reached five posts before I abandoned for a series of reasons that don't matter anymore. That series is coming back starting next week, rewritten, improved and extended.

It will do so in a new site, and the "old" posts will be retired to an archive page. Why? Well, the technologies used in some of them are obsolete or don't quite work nowadays. So, the new versions will be the preferred ones.

And while I am not promising anything, I have enough written to make this something quite longer, more nicely layouted, more interesting and make it cover more ground. BUT, while doing some checks on the traffic statistics for the old posts, some things popped out.

This was very popular
About 60% of my site's traffic goes to those five posts. Out of about 1200 posts over 12 years, 60% of the viewers go to the 0.4% of the pages. That is a lot.
It's a long tail
The traffic has not decreased in three years. If anything, it has increased
https://p.twimg.com/Aw0MHhoCAAAXmro.png:large

A long and tall tail.

So, all this means there is a desire for PyQt documentation that is not satisfied. I am not surprised: PyQt is great, and the recommended book is not free, so there is bound to be a lot of demand.

And, here's the not-so-rosy bit: I had unobtrusive, relevant, out-of-the-way-but-visible ads in those pages for more than two years. Of the 70000 unique visitors, not even one clicked on an ad. Don't worry, I was not expecting to get money out of them (although I would love to some day collect a $100 check instead of having google hold my money for me ad eternum).

But really? Not even one ad click? In more than two years, thousands of people? I have to wonder if I just attract cheap people ;-)

APIs de Ubuntu One en Ejemplos (parte 1)

Una de las cosas lindas de trabajar en Canonical es que producimos software open source. Yo, específicamente, trabajo en el equipo que hace los clientes de escritorio de Ubuntu One que es un trabajo muy copado, y un software muy copado también. Sin embargo, una cosa que no se sabe lo suficiente es que tenemos unas excelentes APIs para terceros. Las necesitamos, ya que es lo que usamos nosotros!

Así que acá va un pequeño tutorial acerca de como usar algunas de esas APIs. Lo hice usando Python y PyQt por varios motivos:

  • Son excelentes herramientas para prototipos
  • Tienen excelente soporte para las cosas que necesito (DBus, HTTP, OAuth)
  • Es lo que sé y me gusta. Lo hice un domingo, no lo pienso hacer en PHP y Gtk.

Dicho eso, no hay nada específico de python o de Qt en este código. Donde hago un request HTTP usando QtNetwork, podés usar libsoup o lo que fuere.

Vayamos a los bifes entonces. Las piezas más importantes de Ubuntu One, desde el punto de vista de infraestructura, son Ubuntu SSO Client, que se encarga de login, registración, etc, y SyncDaemos que maneja la sincronización de archivos.

Para interactuar con ellas, en Linux, ofrecen interfaces DBus. Así que, por ejemplo, este es un fragmento mostrando como obtener las credenciales de Ubuntu One (esto normalmente sería parte del __init__ de un objeto):

# Get the session bus
bus = dbus.SessionBus()

:
:
:

# Get the credentials proxy and interface
self.creds_proxy = bus.get_object("com.ubuntuone.Credentials",
                        "/credentials",
                        follow_name_owner_changes=True)

# Connect to signals so you get a call when something
# credential-related happens
self.creds_iface = dbus.Interface(self.creds_proxy,
    "com.ubuntuone.CredentialsManagement")
self.creds_proxy.connect_to_signal('CredentialsFound',
    self.creds_found)
self.creds_proxy.connect_to_signal('CredentialsNotFound',
    self.creds_not_found)
self.creds_proxy.connect_to_signal('CredentialsError',
    self.creds_error)

# Call for credentials
self._credentials = None
self.get_credentials()

Tal vez notaste que get_credentials no devuelve las credenciales. Lo que hace es, le dice a SyncDaemon que las obtenga, y entonces, si/cuando aparecen, se emite una de esas señales, y uno de los métodos conectados se llama. Esto está bueno porque no tenemos que preocuparnos de que se nos bloquee la aplicación mientras SyncDaemon está buscando las credenciales.

¿Y qué hay en esos métodos? ¡No mucho!

def get_credentials(self):
    # Do we have them already? If not, get'em
    if not self._credentials:
        self.creds_proxy.find_credentials()
    # Return what we've got, could be None
    return self._credentials

def creds_found(self, data):
    # Received credentials, save them.
    print "creds_found", data
    self._credentials = data
    # Don't worry about get_quota yet ;-)
    if not self._quota_info:
        self.get_quota()

def creds_not_found(self, data):
    # No credentials, remove old ones.
    print "creds_not_found", data
    self._credentials = None

def creds_error(self, data):
    # No credentials, remove old ones.
    print "creds_error", data
    self._credentials = None

Así que básicamente, self._credentials contiene unas credenciales, o None. Felicitaciones, ya entramos a Ubuntu One.

¡Hagamos algo útil! ¿Que tal preguntar cuánto espacio libre hay en la cuenta? Para eso, no podemos usar las APIs locales, si no conectarnos a los servers, que son los que saben si estás excedido de quota o no.

El acceso se controla via OAuth, por lo que para acceder a esa API necesitamos firmar nuestros pedidos. Aquí se ve como se hace. No es particularmente iluminador, yo no lo escribí, solamente lo uso:

def sign_uri(self, uri, parameters=None):
    # Without credentials, return unsigned URL
    if not self._credentials:
        return uri
    if isinstance(uri, unicode):
        uri = bytes(iri2uri(uri))
    print "uri:", uri
    method = "GET"
    credentials = self._credentials
    consumer = oauth.OAuthConsumer(credentials["consumer_key"],
                                   credentials["consumer_secret"])
    token = oauth.OAuthToken(credentials["token"],
                             credentials["token_secret"])
    if not parameters:
        _, _, _, _, query, _ = urlparse(uri)
        parameters = dict(cgi.parse_qsl(query))
    request = oauth.OAuthRequest.from_consumer_and_token(
                                        http_url=uri,
                                        http_method=method,
                                        parameters=parameters,
                                        oauth_consumer=consumer,
                                        token=token)
    sig_method = oauth.OAuthSignatureMethod_HMAC_SHA1()
    request.sign_request(sig_method, consumer, token)
    print "SIGNED:", repr(request.to_url())
    return request.to_url()

¿Y cómo pedimos el estado de quota? Accediendo al punto de entrada https://one.ubuntu.com/api/quota/ con la autorización adecuada, se obtiene un diccionario JSON con el espacio total y el usado. Acá hay una muestra de como hacerlo:

    # This is on __init__
    self.nam = QtNetwork.QNetworkAccessManager(self,
        finished=self.reply_finished)

:
:
:

def get_quota(self):
    """Launch quota info request."""
    uri = self.sign_uri(QUOTA_API)
    url = QtCore.QUrl()
    url.setEncodedUrl(uri)
    self.nam.get(QtNetwork.QNetworkRequest(url))

De nuevo: get_quota no devuelve la quota. Sólo lanza un pedido HTTP a los servers de Ubuntu One, que (eventualmente) responden con los datos. No querés que tu app se quede ahí trabada mientras tanto, por eso QNetworkAccessManager va a llamar a self.reply_finished cuando tenga la respuesta:

def reply_finished(self, reply):
    if unicode(reply.url().path()) == u'/api/quota/':
        # Handle quota responses
        self._quota_info = json.loads(unicode(reply.readAll()))
        print "Got quota: ", self._quota_info
        # Again, don't worry about update_menu yet ;-)
        self.update_menu()

¿Qué más queremos? ¿Qué tal notificación cuando cambia el status de SyncDaemon? Por ejemplo, cuando la sincronización está al día, o cuando te desconecta. De nuevo, esas son señales DBus a las que uno se conecta en __init__:

self.status_proxy = bus.get_object(
    'com.ubuntuone.SyncDaemon', '/status')
self.status_iface = dbus.Interface(self.status_proxy,
    dbus_interface='com.ubuntuone.SyncDaemon.Status')
self.status_iface.connect_to_signal(
    'StatusChanged', self.status_changed)

# Get the status as of right now
self._last_status = self.process_status(
    self.status_proxy.current_status())

Y esta es status_changed:

def status_changed(self, status):
    print "New status:", status
    self._last_status = self.process_status(status)
    self.update_menu()

la función process_status is código aburrido para convertir la info de status de syncdaemon en una cosa legible como "Sync is up-to-date", así que guardamos eso en self._last_status y actualizamos el menú.

¿Qué menú? ¡Un menú contextual de un QSystemTrayIcon! Lo que leyeron son las piezas principales que se necesitan para crear algo útil: una aplicación de SystemTray para Ubuntu One que se puede usar en KDE, XFCE u Openbox. O, si estás en unity y tenés sni-qt instalado, un app indicator.

http://ubuntuone.com/7iXTbysoMM9PIUS9Ai4TNn

El indicador en acción.

El código fuente del ejemplo completo está en mi proyecto u1-toys en launchpad y éste es el código fuente completo (excepto los iconos, bajense el repo, mejor)

Viniendo pronto (espero), más apps de ejemplo, y cosas copadas que se pueden hacer con nuestras APIs.

PyQt Quickie: parsear línea de comandos

Si estás escribiendo una aplicación con PyQt y querés soportar opciones en la línea de comandos, seguro hacés algo así:

opt_parser = OptionParser()
opt_parser.add_option("-q", dest="quickly", action="store_true",
    help="Do it quickly (default=False)")
(options, args) = opt_parser.parse_args(sys.argv)
app = QApplication(sys.argv)
:
:
:

O tal vez incluso QApplication([]). Bueno, eso está mal. Y está mal en casi todos los tutoriales, también. ¿Porqué? Porque Qt (y por lo tanto PyQt) soporta un montón de opciones útiles. Al hacerlo como en ese primer listado, si le pasás "-style=oxygen" o lo que sea, va a pasar alguna de estas cosas:

  1. OptParser te va a decir que es una opción inválida y abortar
  2. Vas a ignorar la opción y no vas a hacer nada útil con ella
  3. Vas a tener tu propia opción -style y vas a hacer dos cosas

Ninguna de esas opciones es la idea. La manera correcta de hacerlo es ésta:

opt_parser = OptionParser()
opt_parser.add_option("-q", dest="quickly", action="store_true",
    help="Do it quickly (default=False)")
app = QApplication(sys.argv)
(options, args) = opt_parser.parse_args(app.arguments())
:
:
:

De esta manera, le das a PyQt la oportunidad de procesar las opciones que reconoce y después, vos manejás el resto, porque a app.arguments() ya le sacaron todas las opciones de Qt.

El lado malo es que --help va a ser mas lento, porque tiene que instanciar QApplication al divino botón, y vas a tener opciones no documentadas. Soluciones para ambos problemas se dejan como ejercicio.

Escribir, y qué escribir.

Algunos sabrán que escribí algo así como el 30% de un libro, llamado "Python No Muerde", disponible en http://nomuerde.netmanagers.com.ar Ese libro está estancado hace mucho.

Por otro lado, escribí una serie muy popular de posts, llamada "PyQt en Ejemplos", que (adivinen) lleva mucho tiempo estancada.

El problema con el libro es que traté de cubrir demasiado terreno. Terminado sería un libro de 500 páginas, y eso incluye escribir media docena de apps de ejemplo, algunas de ellas en áreas en las que no soy experto.

El problema principal con los posts es que el ejemplo es pedorro (¡app de TODOs!) y expandirla es aburrido.

¡Qué mejor manera de resolver el problema que mezclar las dos cosas!

Voy a dejar Python No Muerde como está, y voy a hacer un libro nuevo, que se llame PyQt No Muerde. Va a mantener el tono y el lenguaje del anterior, y va a compartir varios capítulos, pero se va a enfocar en desarrollar apps PyQt, en vez de apuntar a metas demasiado ambiciosas. Espero que sea de unas 200 páginas.

Tengo permiso de la superioridad (mi señora) para trabajar en esto un par de horas al día temprano a la mañana. Tal vez avance, tal vez no. Como siempre, yo no prometo, experimento.

PyQt Quickie: Que no te lleve el basurero

Hay un área en qur Qt y Python (y en consecuencia PyQt) no están de acuerdo: manejo de la memoria.

Qt tiene sus mecanismos para crear y eliminar objetos (el árbol de QObjects, smart pointers, etc.) y PyQt usa Python, así que tiene garbage collection.

Consideremos un ejemplo simple:

from PyQt4 import QtCore

def finished():
    print "El proceso termino!"
    # Salir de la aplicación
    QtCore.QCoreApplication.instance().quit()

def launch_process():
    # Hacer algo asincrono
    proc = QtCore.QProcess()
    proc.start("/bin/sleep 3")
    # Cuando termine, llamar a finished
    proc.finished.connect(finished)

def main():
    app = QtCore.QCoreApplication([])
    # Lanzar el proceso
    launch_process()
    app.exec_()

main()

Si ejecutás eso, te va a pasar esto:

QProcess: Destroyed while process is still running.
El proceso termino!

Encima el script no termina nunca. ¡Diversión! El problema es que proc está siendo borrado al final de launch_process porque no hay más referencias a él.

Ésta es una mejor manera de hacerlo:

from PyQt4 import QtCore

processes = set([])

def finished():
    print "El proceso termino!"
    # Salir de la aplicación
    QtCore.QCoreApplication.instance().quit()

def launch_process():
    # Hacer algo asincrono
    proc = QtCore.QProcess()
    processes.add(proc)
    proc.start("/bin/sleep 3")
    # Cuando termine, llamar a finished
    proc.finished.connect(finished)

def main():
    app = QtCore.QCoreApplication([])
    # Lanzar el proceso
    launch_process()
    app.exec_()

main()

Al agregar un processes global y meter ahí proc, mantenemos siempre una referencia, y el programa funciona. Sin embargo, sigue teniendo un problema: nunca eliminamos los objetos QProcess.

Si bien en este caso la pérdida de memoria es muy breve porque el programa termina enseguida, en un programa de verdad esto no es buena idea.

Así que necesitamos agregar una manera de sacar proc de processes cuando no lo necesitemo. Esto no es tan fácil como parece. Por ejemplo, esto no funciona bien:

def launch_process():
    # Hacer algo asincrono
    proc = QtCore.QProcess()
    processes.add(proc)
    proc.start("/bin/sleep 3")
    # Sacamos el proceso del global cuando no lo necesitamos
    proc.finished.connect(lambda: processes.remove(proc))
    # Cuando termine, llamar a finished
    proc.finished.connect(finished)

¡En esta versión, todavía tenemos un memory leak de proc, aunque processes esté vacío! Lo que pasa es que el lambda contiene una referencia a proc.

No tengo una my buena respuesta para este problema que no involucre convertir todo en miembros de un Qbject y usar sender para saber cuál proceso es el que termina, o usar QSignalMapper. Esa versión la dejo como ejercicio para el lector ;-)

PyQt Quickie: QTimer

QTimer es una clase sencillita: la usás cuando querés que algo pase "dentro de un rato" o "cada tanto".

El primer caso es así:

# llamar f() en 3 segundos
QTimer.singleShot(3000, f)

El segundo es así:

# Creamos un QTimer
timer = QTimer()
# Lo conectamos a f
timer.timeout.connect(f)
# Llamamos a f() cada 5 segundos
timer.start(5000)

¿Fácil, no? Bueno, sí, pero tiene un par de trampas.

  1. Hay que guardar la referencia a timer

    Si no, lo recoge el basurero, y nunca se llama a f()

  2. Capaz que son más de 5 segundos

    Va a llamar a f() más o menos cada 5 segundos después de que entre al event loop. Tal vez eso no sea enseguida después de que arrancás el timer!

  3. Capaz que se pisan las llamadas

    Si f() tarda mucho en terminar, y vuelve a entrar al event loop (por ejemplo, llamando a processEvents) tal vez timer se dispare antes que f() termine, y la llame de nuevo. Eso casi nunca es buena idea.

Una alternativa:

def f():
    try:
        # Hacé cosas
    finally:
        QTimer.singleShot(5000, f)

f()

Ese fragmento llama a f() una sola vez, pero ella misma se pone en cola para correr en 5 segundos. Ya que lo hace en un finally lo va a hacer aún si las cosas se rompen.

O sea, no se va a pisar. También quiere decir que no son 5 segundos, sino 5 más lo que tarde f. Y no hace falta guardar referencias al QTimer.

Último tipo: podés usar QTimer para que algo se haga "apenas estés en el event loop"

QTimer.singleShot(0, f)

¡Ojalá sirva!