PythonNET - Creando una calculadora básica para Windows


INTRODUCCIÓN

Si has leido PythonNET - Crear aplicaciones para Windows con Python y .NET te habrás dado cuenta de que lo que mas me gusta de PythonNET es que permite crear interfaces gráficas (GUI) para aplicaciones python sin tener que escribir código.

No significa que no se puedan crear aplicaciones con PythonNET sin crear una librería dll en visual studio, y tampoco significa que ya no me guste programar. Me encanta programar aplicaciones, pero desarrollar la GUI de una aplicación no me gusta especialmente. Prefiero tratar a la interfaz gráfica como si se tratase de hardware sobre el que se programa cierta funcionalidad. Se que no es así, pero como programador de python he tenido que escribir formularios, etiquetas, cajas de texto y botones demasiadas veces. Posicionar los widgets y definir sus dimensiones desde código realmente me resulta aburrido, y sin embargo disfruto mucho pensando y plasmando la lógica de la aplicación.

Dicho esto, he abierto visual studio y he creado un assembly llamado calculadora.dll que si se carga en una aplicación se ve así:

Aplicación Calculadora (screenshot)

Lo que ves es un Form identificado como FormCalculadora que contiene un Label identificado como lblResultado y una serie de botones. Los botones que representan los números del 0 al 9 están identificados como btnN, siendo N el número del botón, es decir, el botón con texto 9 está identificado como btn9, el de texto 8 es btn8 y así sucesivamente. Los botones que representan las operaciones matemáticas sumar, restar, multiplicar y dividir están identificados como btnSumar, btnRestar, btnMultiplicar y btnDividir respectivamente. El botón cuyo texto es un "signo de puntuación" está identificado como self.btnPunto. El botón "=" es btnResultado, "DEL" es btnDelAll y "<<" es btnDelOne.

Al final de este artículo habrás creado un ejecutable llamado calculadora.exe que permite realizar las operaciones matemáticas básicas.


SCRIPTEANDO LA CALCULADORA

En una carpeta llamada calculadora-pythonnet he copiado el archivo calculadora.dll y he creado un archivo calculadora.py. Además he copiado el mismo icono que añadí al formulario desde el explorador de propiedades de visual studio: calculadora.ico.

El código inicial de calculadora.py es el siguiente:

import clr

clr.AddReference('calculadora')

from System.Windows.Forms import *
from calculadora import FormCalculadora


class AppCalculadora(FormCalculadora):

    def __init__(self):
    	super().__init__(self)

    def event_bindings(self):
    	pass

    def add_digit(self, sender, e):
    	pass

    def delete(self, sender, e):
    	pass

    def calcular_resultado(self, sender, e):
    	pass


if __name__ == '__main__':

    calculadora = AppCalculadora()
    Application.Run(calculadora)

Si no entiendes este código o si no sabes como crear el archivo calculadora.dll lee el artículo PythonNET - Crear aplicaciones para Windows con Python y .NET, que es una introducción a PythonNET. Continuemos...

Como puedes observar en el código, he importado FormCalculadora después de añadir una referencia a calculadora.dll. Además he importado todo lo que hay en System.Windows.Forms.

He definido una clase AppCalculadora que hereda de FormCalculadora. Bajo la sentencia if __name__ == '__main__: he instanciado la clase AppCalculadora y la he pasado como parámetro de Application.Run, que pone en marcha el bucle de eventos del programa y permite que la GUI permanezca abierta hasta que sea cerrada por el usuario.


MÉTODO __init__


Para dar funcionalidad a la aplicación vamos a scriptear la clase AppCalculadora. Lo primero que haremos será definir algunos atributos de objeto dentro del método __init__:

def __init__(self):
    super().__init__(self)

    self.base_val = self.lblResultado.Text

    self.botones_operaciones = (
    		self.btn0, self.btn1, self.btn2, self.btn3,
    		self.btn4, self.btn5, self.btn6, self.btn7,
    		self.btn8, self.btn9, self.btnPunto, self.btnSumar,
    		self.btnRestar, self.btnMultiplicar, self.btnDividir
    	)

    self.botones_del = self.btnDelAll, self.btnDelOne

    self.event_bindings()
    self.Select()

Lo primero que he hecho es almacenar el valor de self.lblResultado.Text en el atributo self.base_val. Esto lo hago porque posteriormente estableceremos ciertas condiciones donde queremos resetear el valor de la etiqueta. Quiero que si en el futuro modifico el texto de la etiqueta en calculadora.dll el programa siga funcionando correctamente.

A continuación he creado una tupla llamada self.botones_operaciones que contiene los botones numéricos y los de los operadores matemáticos. Los botones para eliminar el texto de la etiqueta están almacenados en otra tupla llamada self.botones_del.

La definición del método __init__ finaliza invocando al método self.event_bindings y seleccionando al propio formulario: self.Select(). Esto ultimo es para que al abrir el programa no aparezca seleccionado o "destacado" ningún botón. Podría implementar esta funcionalidad desde visual studio, probablemente habría bastado con modificar la propiedad TabIndex de los widgets, pero ya es demasiado tarde...


MÉTODO event_bindings


Hemos creado dos tuplas de botones: botones_operaciones y botones_del. Esto no lo hemos hecho por capricho, sino que los botones agrupados en cada tupla van a ser manejados por el mismo manejador de eventos, es decir, al hacer click en ellos va a ejecutarse el mismo método, ya que comparten funcionalidad.

Al hacer click en cualquiera de los botones en self.botones_operaciones se va a ejecutar el método self.add_digit:

for boton in self.botones_operaciones:
    boton.Click += self.add_digit

Y al hacer click en cualquiera de los botones en self.botones_del se va a ejecutar el método self.delete:

for boton in self.botones_del:
    boton.Click += self.delete

Además hay que asociar el evento click sobre self.btnResultado con un manejador, que será self.calcular_resultado:

self.btnResultado.Click += self.calcular_resultado

Finalmente, el método self.event_bindings queda definido así:

def event_bindings(self):

    for boton in self.botones_operaciones:
    	boton.Click += self.add_digit

    for boton in self.botones_del:
    	boton.Click += self.delete

    self.btnResultado.Click += self.calcular_resultado

MÉTODO calcular_resultado (click event handler)


Lo primero que vamos a definir es lo que sucede al hacer click en self.btnResultado. Definimos el método self.calcular_resultado de la siguiente manera:

def calcular_resultado(self, sender, e):

    try:
    	resultado = eval(self.lblResultado.Text.replace('x', '*'))
    	resultado = round(resultado, 2)
    	resultado = str(resultado)
    	self.lblResultado.Text = resultado

    except Exception as e:
    	MessageBox.Show(str(e))

¿que estamos haciendo? La funcionalidad dentro del método la metemos en un try/except para evitar posibles errores al evaluar el texto de la etiqueta:self.lblResultado.Text

El valor del texto de la etiqueta es evaluado por python mediante la función predefinida eval, el resultado es redondeado para que solamente tenga dos decimales, se convierte en str y se asigna como nuevo valor de self.lblResultado.Text.

Antes de evaluar el texto de la etiqueta observa que he reemplazado x por *: self.lblResultado.Text.replace('x', '*'). Esto es así porque los únicos caracteres que puede tener el texto de la etiqueta son el propio texto de los botones en self.botones_operaciones, y el argumento pasado a eval debe ser una expresión python válida. Si no te ha quedado claro lo comprenderás cuando definamos el método self.add_digit.

En caso de que la expresión no sea una expresión python válida se mostrará el error en un MessageBox: MessageBox.Show(str(e))


MÉTODO add_digit (click event handler)


El método self.add_digit es el manejador del evento click para todos los botones en self.botones_operaciones.

Piensa por un momento qué debe hacer este médoto: debe aplicar cambios en self.lblResultado.Text.

El valor inicial de self.lblResultado.Text es 0.00 y lo hemos almacenado en la variable self.base_val. Si por ejemplo el usuario hace click en el botón self.bnt1, ¿que tiene que suceder? debería añadir el dígito 1 al texto de la etiqueta. Pero si simplemente lo añade, ahora el texto de la etiqueta sería 0.001 y lo que espera el usuario es que el texto sea el primer valor que él/ella ha introducido: 1.

Por lo anterior, el método self.add_digit tiene que empezar con esta condición:

def add_digit(self, sender, e):

    if self.lblResultado.Text == self.base_val:
    	self.lblResultado.Text = ''

Es decir, si el valor del texto de la etiqueta es el valor inicial cuando se hace click lo primero que sucederá es que se eliminará el texto.

Ahora pensemos por un momento en posibles expresiones que podemos introducir y generarían error en self.calcular_resultado. Si el primer botón clickeado por el usuario es self.btnMultiplicar, por ejemplo, y luego hace click en otros botones, el valor de self.lblResultado.Text podría ser algo como x81x2, que no es una expresión python válida. Para solucionar este posible problema añadimos una segunda condición:

if not self.lblResultado.Text and not sender.Text.isdigit():
    self.lblResultado.Text = self.base_val
    return None

Si el texto de la etiqueta es nulo ("") y si el texto del botón pulsado no es un número se reasigna self.base_val al texto de la etiqueta y se sale de la función. Es decir, con estas dos condiciones definidas, si el usuario lo primero que clickea es un operador matemático no percibirá ningún cambio, y lo que sucederá si pulsa un número (un botón cuyo texto es un número) lo vamos a definir a continuación:

digit = sender.Text.lower()

sender es el botón pulsado. El dígito es la versión lowercase del texto del botón. He decidido que sea lowercase simplemente porque el texto X del botón de multiplicar está en mayúscula y prefiero que el dígito añadido a la etiqueta sea en minúscula.

En principio bastaría con añadir la siguiente asignación en el método:

self.lblResultado.Text += digit

De esta manera al texto de la etiqueta se añade el dígito correspondiente al texto del botón sobre el que el usuario ha hecho click. Pero nos enfrentamos a otro posible error: el usuario podría pulsar varias veces el botón self.btnPunto. Por ejemplo, 5x11.1.1 no es una expresión python válida y generaría una excepción dentro del método self.calcular_resultado.

No nos queda mas remedio que añadir una nueva condición al método self.add_digit:

if digit == '.' and '.' in self.lblResultado.Text:
    self.lblResultado.Text = self.lblResultado.Text.replace('.', '') + digit
else:
    self.lblResultado.Text += digit

O sea, que si el dígito introducido es "un punto" y ya hay "un punto" en el texto de la etiqueta eliminamos todos los puntos del texto de la etiqueta mediante self.lblResultado.Text.replace('.', '') y asignamos este valor mas el dígito a self.lblResultado.Text. Si el dígito es "un punto" pero el texto no contiene signos de puntuación, o si el dígito no es "un punto" simplemente se ejecuta self.lblResultado.Text += digit.

El método self.add_digit queda finalmente así:

def add_digit(self, sender, e):

    if self.lblResultado.Text == self.base_val:
    	self.lblResultado.Text = ''

    if not self.lblResultado.Text and not sender.Text.isdigit():
    	self.lblResultado.Text = self.base_val
    	return None

    digit = sender.Text.lower()

    if digit == '.' and '.' in self.lblResultado.Text:
    	self.lblResultado.Text = self.lblResultado.Text.replace('.', '') + digit
    else:
    	self.lblResultado.Text += digit

El usuario todavía podría introducir expresiones inválidas (por ejemplo, 12xx2 o 5/x3, pero para no complicar mas el método self.add_digit lo dejamos como está y el código en self.calcular_resultado permanece en un try/except. Evidentemente habría que complicar mas el código en una aplicación profesional.

Ya solo nos queda definir el método para eliminar dígitos del texto de la etiqueta.


MÉTODO delete (click event handler)


El método delete se disparará cada vez que se haga click en el botón self.btnDelOne o en el botón self.btnDelAll.

Primero vamos a definir lo que sucede si el botón clickeado es self.btnDelOne:

if sender == self.btnDelOne and self.lblResultado.Text != self.base_val:
    self.lblResultado.Text = self.lblResultado.Text[:-1]

Si el botón es self.btnDelOne y el texto de la etiqueta no es el texto inicial se eliminará el último caracter del texto de la etiqueta. O sea, que si el texto de la etiqueta es 0.00 no sucede nada, y en caso contrario se asigna al texto de la etiqueta el texto que ya tenía menos el último caracter.

Para el botón self.btnDelAll añadimos lo siguiente:

if sender == self.btnDelAll or not self.lblResultado.Text:
    self.lblResultado.Text = self.base_val

Lo que significa al hacer click en el botón self.btnDelAll se reasignará el texto inicial a la etiqueta (0.00). Si el botón es self.btnDelOne y el texto de la etiqueta es "" también se reasignará self.base_val (así el usuario núnca verá la etiqueta vacía).

Con esto hemos finalizado de definir la clase AppCalculadora.


CÓDIGO FINAL: calculadora.py

import clr

clr.AddReference('calculadora')

from System.Windows.Forms import *
from calculadora import FormCalculadora


class AppCalculadora(FormCalculadora):

    def __init__(self):
    	super().__init__(self)

    	self.base_val = self.lblResultado.Text

    	self.botones_operaciones = (
    			self.btn0, self.btn1, self.btn2, self.btn3,
    			self.btn4, self.btn5, self.btn6, self.btn7,
    			self.btn8, self.btn9, self.btnPunto, self.btnSumar,
    			self.btnRestar, self.btnMultiplicar, self.btnDividir
    		)

    	self.botones_del = self.btnDelAll, self.btnDelOne

    	self.event_bindings()
    	self.Select()

    def event_bindings(self):

    	for boton in self.botones_operaciones:
    		boton.Click += self.add_digit

    	for boton in self.botones_del:
    		boton.Click += self.delete

    	self.btnResultado.Click += self.calcular_resultado

    def add_digit(self, sender, e):

    	if self.lblResultado.Text == self.base_val:
    		self.lblResultado.Text = ''

    	if not self.lblResultado.Text and not sender.Text.isdigit():
    		self.lblResultado.Text = self.base_val
    		return None

    	digit = sender.Text.lower()

    	if digit == '.' and '.' in self.lblResultado.Text:
    		self.lblResultado.Text = self.lblResultado.Text.replace('.', '') + digit
    	else:
    		self.lblResultado.Text += digit

    def delete(self, sender, e):

    	if sender == self.btnDelOne and self.lblResultado.Text != self.base_val:
    		self.lblResultado.Text = self.lblResultado.Text[:-1]

    	if sender == self.btnDelAll or not self.lblResultado.Text:
    		self.lblResultado.Text = self.base_val

    def calcular_resultado(self, sender, e):

    	try:
    		resultado = eval(self.lblResultado.Text.replace('x', '*'))
    		resultado = round(resultado, 2)
    		resultado = str(resultado)
    		self.lblResultado.Text = resultado

    	except Exception as e:
    		MessageBox.Show(str(e))



if __name__ == '__main__':

    calculadora = AppCalculadora()
    Application.Run(calculadora)

COMPILAR APLICACIÓN

Puedes crear un standalone de la aplicación con PyInstaller así:

pyinstaller --onefile --windowed --icon="calculadora.ico" --add-data "calculadora.dll;." --exclude-module "tkinter" calculadora.py

Este comando generará un archivo calculadora.exe en el directorio dist. Comprobé que al compilar sin el flag --exclude-module "tkinter" se añade el módulo tkinter que no estamos utilizando, por lo que lo excluimos explicitamente.


DESPEDIDA

Espero que hayas disfrutando creando una calculadora básica para Windows con PythonNET. Es estupendo poder integrar el runtime de python con el CLR de .NET.

Tan pronto como pueda seguiré escribiendo sobre pythonnet. Después de tratar en detalle cómo desarrollar aplicaciones WinForms me gustaría tratar las aplicaciones UWP.

¡¡ Hasta la próxima !!