PythonNET - Creando un cronómetro con interfaz WinForms
INTRODUCCIÓN
En este artículo-tutorial vamos a crear un cronómetro con python cuya interfaz gráfica ha sido contruida en un assembly de .NET en visual studio.
Crear un cronómetro ya es bastante interesante de por sí, pero lo que me llevó a escribir esto fue los problemas que encontré mientras desarrollaba la aplicación: Pueden presentarse problemas al ejecutar Threads de Python que manipulan la GUI cuando se trabaja con PythonNET. Estos bugs se resuelven utilizando delegados e invocándolos con Form.Invoke en el thread principal.
Si no sabes lo que son los delegados no te preocupes porque esto es algo propio del lenguaje C# y es muy fácil crearlos en pythonnet.
Otro motivo que me llevó a escribir esto es que pythonnet permite dotar a mis aplicaciones python de interfaz gráfica sin tener que preocuparme de crear/programar la interfaz. Me gusta la idea de crear una librería .dll de caja negra que puede manipularse desde python. Me gusta centrarme en la lógica del programa y no tanto en los detalles de la interfaz.
Dicho esto empecemos por comprender cómo podemos medir el tiempo en python...
MEDICIÓN DEL TIEMPO
Para medir el tiempo utilizaremos la función monotonic
del módulo time. Observa el siguiente código en la consola de python:
>>> import time >>> time.monotonic() 315742.796 >>> time.monotonic() 315747.734 >>>
time.monotonic()
devuelve el valor (en fracciones de segundo) de un reloj monótono, es decir, un reloj que no puede retroceder. El reloj no se ve afectado por las actualizaciones del reloj del sistema. El punto de referencia del valor devuelto no está definido, por lo que solo es válida la diferencia entre los resultados de llamadas consecutivas.
Es decir, no importa cual es el valor devuelto por time.monotonic()
, lo importante es la diferencia entre dos valores devueltos por esta función:
>>> t1 = time.monotonic() >>> t2 = time.monotonic() >>> dif = t2 - t1 >>> dif 5.764999999955762
Este código significa que han pasado 5.76 segundos entre que se ejecutó time.monotonic()
por primera y segunda vez (tardé 5.76 segundos entre que asigné el valor de t1
y el de t2
).
Podemos crear un script que muestre al usuario el tiempo que lleva ejecutándose el propio script:
import time
init_time = time.monotonic()
while True:
now = time.monotonic()
dif = now - init_time
print(dif)
Si ejecutas este código verás que se imprime el tiempo trascurrido indefinidamente. Lo primero que he hecho ha sido inicializar una variable init_time = time.monotonic()
, que es una constante que sirve de referencia para conocer el tiempo inicial. A continuación creamos un bucle infinito donde se obtiene el tiempo actual en cada ciclo: now = time.monotonic()
. Calculamos la diferencia entre el tiempo actual y el tiempo inicial para conocer el tiempo trascurrido: dif = now - init_time
.
Podemos formarear el tiempo trascurrido para que en lugar de imprimirse el tiempo en segundos se muestre el tiempo en horas, minutos, segundos y centésimas de segundo. Para ello vamos a crear una función que tome dif
como parámetro y haga lo siguiente:
def format_time(dif_time):
segundos = int(dif_time)
centesimas = int((dif_time - segundos) * 100)
minutos = 0
horas = 0
while segundos >= 60:
segundos -= 60
minutos += 1
while minutos >= 60:
minutos -= 60
horas += 1
data = []
for i in horas, minutos, segundos, centesimas:
t = str(i) if i >= 10 else f'0{i}'
data.append(t)
horas, minutos, segundos, centesimas = data
return f'{horas}:{minutos}:{segundos},{centesimas}'
La función fomat_time
toma la diferencia de tiempo como parámetro y a partir de esta divide el tiempo en horas, minutos, segundos y centésimas de segundo. Los segundos se obtienen convirtiendo el float dif_time
en un int: segundos = int(dif_time)
. Las centésimas de segundo pueden obtenerse facilmente restando los segundos a dif_time
y multiplicando su valor entero por 100.
Para obtener los minutos y horas inicializamos estas variables: minutos = 0; horas = 0
y creamos dos bucles:
while segundos >= 60:
segundos -= 60
minutos += 1
while minutos >= 60:
minutos -= 60
horas += 1
Mientras el valor de los segundos sea mayor o igual a 60 se resta 60 a los segundos y se suma un minuto. Así habremos obtenido los minutos, y sobre estos aplicamos un nuevo bucle en el que mientras haya 60 o mas minutos se resta 60 y se suma una hora.
Lo siguiente dentro de la función format_time
es simplemente para que si el valor de horas, minutos, segundos o centésimas es menor de 10 se añada un cero a la izquierda antes de formatear la cadena final que queremos mostrar al usuario: f'{horas}:{minutos}:{segundos},{centesimas}'
.
Ahora al ejecutar el programa se muestra al usuario el tiempo trascurrido en un formato al que los humanos estamos mas acostumbrados. El script por ahora luce así:
import time
def format_time(dif_time):
segundos = int(dif_time)
centesimas = int((dif_time - segundos) * 100)
minutos = 0
horas = 0
while segundos >= 60:
segundos -= 60
minutos += 1
while minutos >= 60:
minutos -= 60
horas += 1
data = []
for i in horas, minutos, segundos, centesimas:
t = str(i) if i >= 10 else f'0{i}'
data.append(t)
horas, minutos, segundos, centesimas = data
return f'{horas}:{minutos}:{segundos},{centesimas}'
init_time = time.monotonic()
while True:
now = time.monotonic()
dif = now - init_time
print(format_time(dif))
Vamos a aplicar una pequeña modificación en el bucle infinito para percatarnos de una cosa:
init_time = time.monotonic()
dif = time.monotonic()
exit = False
while True:
if 6 > dif > 5:
time.sleep(5)
exit = True
now = time.monotonic()
dif = now - init_time
print(format_time(dif))
if exit:
break
Antes de ejecutar el bucle he inicializado las variables dif y exit. Cuando dif
tenga un valor comprendido entre 5 y 6 se ejecuta time.sleep(5)
y se asigna exit = True
para salir del bucle en ese mismo ciclo:
if exit:
break
¿que ha sucedido? cuando dif > 5
el programa espera 5 segundos sin hacer nada, imprime el último valor de tiempo trascurrido y sale del bucle, con lo que finaliza el programa. En la consola se imprime lo siguiente:
00:00:05,00 00:00:05,01 00:00:10,01
De manera que aunque hemos pausado el bucle el tiempo trascurrido desde que empezó a ejecutarse el programa sigue midiendose correctamente. Pero nosotros queremos crear un cronómetro en el que si pausamos el tiempo debe detenerse, y al reanudar debe continuar desde donde se hizo la pausa.
Tenemos que encontrar una manera de detectar el tiempo trascurrido en pausa y restarlo al valor del tiempo medido.
En nuestro programa final habrá una interfaz gráfica con un botón PAUSE, pero de momento estamos simulando una pausa intencionada por parte del usuario con time.sleep(5)
. Esta pausa se produce cuando el tiempo trascurrido es 00:00:05,01 y queremos que al finalizar la pausa (después de 5 segundos) el tiempo continúe desde 00:00:05,01 y no desde 00:00:10,01. Para conseguir esto creamos una nueva variable llamada base que inicializamos con valor 0: base = 0
. Cuando se produzca una pausa de 5 segundos se incrementa en 5 esta variable: base += 5
y calculamos la diferencia de tiempo restando este tiempo perdido: dif = now - init_time - base
.
El código ahora se ve así:
init_time = time.monotonic()
dif = time.monotonic()
exit = False
base = 0
while True:
if 6 > dif > 5:
time.sleep(5)
base += 5
exit = True
now = time.monotonic()
dif = now - init_time - base
print(format_time(dif))
Ejecuta el código y comprueba lo último que se imprime en consola:
00:00:05,00 00:00:05,01 00:00:05,01
Restando el tiempo que el cronómetro ha estado en pausa para calcular la diferencia de tiempo hemos desvinculado la medición del tiempo de el tiempo real trascurrido, es decir, estamos descartando el tiempo que no nos interesa.
Nuestro cronómetro además tendrá un botón STOP que servirá para reinicar el contador de tiempo y así poder volver a empezar la medición desde 0. Esto es muy fácil de simular en nuestro script de consola:
init_time = time.monotonic()
while True:
now = time.monotonic()
dif = now - init_time
print(format_time(dif))
if dif >= 5:
init_time = time.monotonic()
En este caso, cuando el cronómetro lleva funcionando 5 segundos o mas se vuelve a calcular el tiempo inicial: init_time = time.monotonic()
. De modo que cuando se alcancen 5 segundos de tiempo trascurrido el contador vuelve a cero. Este cronómetro solamente puede medir 5 segundos y se reinicia.
Una vez comprendido todo esto podemos empezar a construir la interzaf gráfica de la aplicación para crear un cronómetro real que puede iniciarse, pausarse y detenerse.
INTERFAZ GRÁFICA
Para crear la interfaz gráfica de la aplicación he creado una librería dll de caja negra en visual studio llamada cronoform.dll (descargar). Si no sabes como crear una interfaz gráfica de esta manera echa un vistazo a PythonNET - Página Principal.
La librería cronoform.dll define una clase que hereda de System.Windows.Forms.Form
llamada CronoForm
, que es el formulario de la aplicación.
Si quieres puedes descargar el proyecto cronoform para abrirlo con visual studio y ver como generé esta librería.
La clase CronoForm contiene una etiqueta identificada como lblTime
y tres botones: btnStart
, btnPauseResume
y btnStop
. Si utilizas la librería cronoform en una aplicación su interfaz se ve así:
La clase CronoForm y cada uno de sus controles tiene una serie de propiedades que toman el valor por defecto asignado en visual studio, a excepción de las siguientes propiedades que he personalizado:
CronoForm
(Name): CronoForm
Title: CRONÓMETRO PythonNET
MaximizeBox: False
FormBorderStyle: FixedSingle
lblTime
- (Name): lblTime
- Text: 00:00:00,00
- TextAlign: MiddleCenter
- Autosize: False
- BackColor: HighlightText
- Font:
- Name: Consolas
- Size: 38
- Modifiers: Public
- BorderStyle: FixedSingle
- BackColor: GradientActiveCaption
btnStart
- (Name): btnStart
- Text: START
- Modifiers: Public
btnPauseResume
- (Name): btnPauseResume
- Text: PAUSE
- Modifiers: Public
btnStop
- (Name): btnStop
- Text: STOP
- Modifiers: Public
Lo mas importante es que para todos los controles dentro del formulario, a su propiedad Modifiers debe asignarse el valor Public para que sean controles accesibles por PythonNET.
Utilizamos esta librería como interfaz de la aplicación. Crea un archivo cronometro.py y copia cronoform.dll en la misma carpeta. Edita cronometro.py así:
import sys, os, time, threading # Módulos que serán necesarios
import clr # Common Language Runtime (.NET)
import System # (.NET) Para asignar Icono al programa y crear Delegados
clr.AddReference('cronoform') # Referencia a cronoform.dll
import cronoform # Utilizamos cronoform.dll como si fuese un módulo python
class App(cronoform.CronoForm):
def __init__(self):
super().__init__(self)
if __name__ == '__main__':
app = App()
app.ShowDialog() # Mostrar GUI (bucle en el thread principal)
Si ejecutas la aplicación se mostrará la interfaz gráfica, pero todavía no sucede nada si pulsamos cualquiera de los botones en el formulario. Analicemos el código en este momento:
import clr
- Así se importa a pythonnet. clr hace referencia al common language runtime de .NET.clr.AddReference('cronoform')
- Añadimos una referencia a cronoform.dll (fíjate en que omitimos la extensión .dll de la librería). Una vez hecho esto podemos importar cronoform como si se tratase de un módulo/paquete de python.import cronoform
- Importamos cronoform, donde está definida la clase CronoForm que creé en visual studio.class App(cronoform.CronoForm):
- Creamos una clase python llamada App que hereda de la clase c# cronoform.CronoForm. PythonNET permite extender clases .NET desde Python, cosa que personalmente me parece increible. Definimos el método__init__
de App e inicializamos CronoForm mediantesuper().__init__(self)
.app = App()
- Instanciamos un objeto de la clase App que llamamosapp
.app.ShowDialog()
- Mostramos la interfaz gráfica de la aplicación. También podríamos haber ejecutadoSystem.Windows.Forms.Application.Run(app)
.
Vamos a añadir los métodos de los que tiene que disponer la clase App. Estos serán: __init__
, init
, start_clock
, reset_clock
, pause_resume_clock
, clock_loop
, clock_tick
y update_view
. El código queda así:
import sys, os, time, threading
import clr
import System
clr.AddReference('cronoform')
import cronoform
class App(cronoform.CronoForm):
def __init__(self):
"""configuración básica"""
super().__init__(self)
def init(self, *args):
"""configuración avanzada"""
pass
def start_clock(self, *args):
"""iniciar el cronómetro"""
pass
def reset_clock(self, *args):
"""reiniciar el cronómetro"""
pass
def pause_resume_clock(self, *args):
"""pausar o continúar cronometrando"""
pass
def clock_loop(self):
"""bucle en el que se ejecuta self.clock_tick (thread sepadado)"""
pass
def clock_tick(self):
"""función principal del cronómetro"""
pass
def update_view(self):
"""actualización de self.lblTime.Text"""
pass
if __name__ == '__main__':
app = App()
app.ShowDialog()
De momento los métodos de la clase App no hacen nada, pero añadiremos las sentencias que debe ejecutar cada uno de ellos en el siguiente apartado.
En los métodos __init__
e init
relaizaremos la configuración de la aplicación.
Los métodos start_clock
, reset_clock
y pause_resume_clock
son los manejadores de eventos de la aplicación. El primero se ejecutará cuando se haga click en el botón self.btnStart
, el segundo al hacerse click en el botón self.btnStop
y el tercero al hacer click sobre el botón self.btnPauseResume
. Fíjate en que estos métodos reciben un parámetro *args
. Esto es necesario para poder utilizarlos como manejadores de eventos (en lugar de *args
podrían recibir sender
y e
como parámetros, pero me he decantado por *args
para simplificar y porque en estos manejadores no vamos a necesitar referirnos ni a sender
ni a e
).
El método clock_loop
ejecutará a clock_tick
en un bucle infinito según unas condiciones. Para no bloquear al bucle de eventos del formulario, clock_loop
deberá ejecutarse en un thread separado. El método update_view
será el encargado de asignar la representación del tiempo trascurrido a self.lblTime.Text
. Este método no podremos ejecutarlo directamente, sino que será necesario ejecutarlo como un delegado en el thread del blucle de eventos del formulario (mas adelante explicaré en detalle por qué esto es necesario, por ahora quédate con que los delegados son algo propio de C#).
A continuación vamos a darle la funcionalidad de un cronómetro a la clase App.
PROGRAMANDO EL CRONÓMETRO
En este apartado vamos a centrarnos en programar los métodos de la clase App. Empecemos por definir la configuración básica en el método __init__
:
Configuración básica: __init__
Al ejecutar la aplicación queremos que solamente se pueda hacer click en el botón START, ya que el cronómetro no estará funcionando hasta que no se haga click en este botón y, por tanto, no tiene sentido pulsar STOP o PAUSE cuando el cronómetro está detenido. Para ello hay que asignar False al atributo Enabled de estos botones:
def __init__(self):
super().__init__(self)
self.btnPauseResume.Enabled = False
self.btnStop.Enabled = False
Vamos a añadir el icono del programa (cronometro.ico):
self.Icon = System.Drawing.Icon('cronometro.ico')
El formulario (self) tiene un atributo Icon cuyo valor tiene que ser un System.Drawing.Icon
en el que se especifíca el path del icono.
A continuación vamos a añadir un manejador del evento click para cada uno de los botones del formulario:
self.btnStart.Click += self.start_clock
self.btnPauseResume.Click += self.pause_or_resume
self.btnStop.Click += self.reset_clock
Si ejecutas la aplicación, al hacer click en el botón START se ejecuta el método self.start_clock
, pero este método todavía no hace nada. También hemos definido los manejadores para los botones desactivados y de momento no se puede hacer click en ellos.
Nos interesa que el método init
se ejecute cuando el formulario haya sido mostrado. Lo logramos añadiéndolo como un manejador del evento Shown del formulario:
self.Shown += self.init
De esta manera se ejecutará init
cuando se muestre el formulario. Hacemos esto porque en __init__
no puede definirse un delegado de c# (solamente puede crearse un delegado una vez que se haya ejecturado app.ShowDialog()
, lo que inicia el bucle de eventos de app). Si no sabes lo que son los delegados de c# no te preocupes, lo veremos mas adelante. En init
tendremos que crear un delegado.
Además vamos a hacer que todos los threads del programa se cierren cuando se haga click en [X]:
self.HandleDestroyed += lambda *args: os._exit(0)
Mediante os._exit(0)
podemos cerrar todos los threads de la aplicación (hacemos esto porque el programa tendrá dos hilos: uno es el bucle de eventos del formulario, y el otro el bucle que ejecuta clock_loop
). Si omitimos este manejador se producirá un error al intentar cerrar la aplicación: en el thread de clock_loop
se intentará actualizar el valor de self.lblTime.Text
cuando esta etiqueta ya esté destruida.
El método __init__
de la clase App queda finalmente así:
def __init__(self):
super().__init__(self)
self.btnPauseResume.Enabled = False
self.btnStop.Enabled = False
self.Icon = System.Drawing.Icon('cronometro.ico')
self.btnStart.Click += self.start_clock
self.btnPauseResume.Click += self.pause_or_resume
self.btnStop.Click += self.reset_clock
self.Shown += self.init
self.HandleDestroyed += lambda *args: os._exit(0)
Si ahora ejecutas la aplicación se observa que los botones PAUSE y STOP están desactivados. La aplicación todavía no hace nada:
Antes de tratar la configuración avanzada de la clase App en el método init
vamos a definir lo que tiene que suceder en los métodos reset_clock
y clock_loop
:
Reiniciar el cronómetro: reset_clock
Anteriormente dije que reset_clock
es el manejador del evento click de self.btnStop
y que daba igual definirlo como def reset_clock(self, *args):
o como def reset_clock(self, sender, e):
, pero realmente no da igual.
Este método no solo va a ser un manejador de eventos, sino que también es un método de configuración donde definimos atributos de la clase que nos servirá para reiniciar el cronómetro.
La primera vez que se ejecute self.reset_clock
será en init
y el resto de veces se ejecutará en respuesta al evento click de self.btnStop
, por lo que hay que definir el método para que reciba un número de argumentos arbitrario (que será diferente si se ejecuta como una función o como un manejador de eventos).
Este método se encarga de devolver al cronómetro a su estado inicial. Empezamos definiéndolo así:
def reset_clock(self, *args):
self.btnStart.Enabled = True
self.btnPauseResume.Enabled = self.btnStop.Enabled = False
O sea, al ejecutarse este método se activa a self.btnStart
y se desactiva a self.btnPauseResume
y a self.btnStop
, tal como hicimos anteriormente en __init__
(de hecho al terminar de escribir el programa puedes borrar estas sentencias de __init__
).
Además hay que asegurar que el texto del botón self.PauseResume
sea "PAUSE"
al resetear el cronómetro, ya que este botón se comportará de manera distinta según el valor de su atributo Text (que podrá ser "PAUSE"
o "RESUME"
):
self.btnPauseResume.Text = 'PAUSE'
Finalmente definimos una serie de atributos para la clase que serán necesarios para el funcionamiento del cronómetro:
self.init_time = self.dif = self.base = self.horas = self.minutos = self.segundos = self.centesimas = 0
Estas variables serán creadas la primera vez que se invoque a reset_clock
y se les asigna el valor 0
. Cuando reset_clock
se ejecute como manejador del evento click estos atributos vuelven a tener el valor 0
.
El método self.reset_clock
queda finalmente así:
def reset_clock(self, *args):
self.btnStart.Enabled = True
self.btnPauseResume.Enabled = self.btnStop.Enabled = False
self.btnPauseResume.Text = 'PAUSE'
self.init_time = self.dif = self.base = self.horas = self.minutos = self.segundos = self.centesimas = 0
Por ahora la aplicación funciona como al principio, ya que el botón self.btnStop
permanece desactivado y todavía no hemos definido self.init
. Ten paciencia, enseguida verás todos estos métodos como un conjunto.
Lo siguiente es definir el bucle de funcionamiento del cronómetro.
Bucle del cronómetro: clock_loop
En este método vamos a implementar el bucle sobre el que se va a ejecutar toda la funcionalidad del cronómetro como tal. Es un bucle infinito que funcionará hasta que se ejecute os._exit(0)
. Si recuerdas el método __init__
habíamos definido lo siguiente:
# En App.__init__
self.HandleDestroyed += lambda *args: os._exit(0)
Lo que significa que cuando se destruya el formulario se detendrá el proceso del programa. Todos los hilos de la aplicación se cerrarán al pulsar [X]. Esto es necesario porque clock_loop
se va a ejecutar en un thread separado del bucle principal de App, y en este thread separado se va a actualizar self.lblTime.Text
periodicamente: si se intenta actualizar el texto de la etiquera cuando esta ha sido destruida se producirá un error en tiempo de ejecución.
Empecemos a implementar el método:
def clock_loop(self):
while True:
self.clock_tick()
O sea que clock_loop
ejecuta indefinidamente al método clock_tick
, que todavía no hemos implementado. Sin embargo, clock_tick
solamente tiene que ejecutarse cuando el cronómetro esté funcionando, y no debe ejecutarse si se pulsa self.btnStop
ni cuando se pulse self.btnPauseResume
si self.btnPauseResume.Text == 'RESUME'
, ya que este botón tendrá el texto "RESUME" precisamente cuando el cronómetro esté en pausa:
def clock_loop(self):
while True:
if not self.btnStart.Enabled and self.btnPauseResume.Text == 'PAUSE':
self.clock_tick()
Ahora para que se ejecute clock_tick
deben cumplirse las condiciones que determinan que el cronómetro no se encuentra detenido o en pausa.
Por otra parte, la unidad mínima de tiempo que representará el cronómetro es la centésima de segundo, así que aprovechamos esto para añadir un tiempo de espera dentro del bucle que corresponda a una centésima de segundo:
def clock_loop(self):
while True:
time.sleep(0.01)
if not self.btnStart.Enabled and self.btnPauseResume.Text == 'PAUSE':
self.clock_tick()
Con time.sleep(0.01)
además garantizamos que durante ese periodo de espera se ceda trabajo del procesador al thread principal de la aplicación, es decir, al bucle de eventos que se inicia al ejecutar app.ShowDialog()
.
Todavía no hemos definido a update_view
, pero te anticipo que debe ejecutarse en clock_loop
para que el usuario reciba la información del cronómetro actualizada. Este método debe ejecutarse incondicionalmente para garantizar que al detener el cronómetro el tiempo mostrado sea 00:00:00,00:
def clock_loop(self):
while True:
time.sleep(0.01)
self.update_view()
if not self.btnStart.Enabled and self.btnPauseResume.Text == 'PAUSE':
self.clock_tick()
¿recuerdas que dije que clock_loop
debe ejecutarse en un thread separado para no bloquear al bucle de eventos? Esto vamos a configurarlo en init
:
Configuración Avanzada: init
Lo mas importante y el motivo por el que me decidí a escribir este artículo es lo que sucede en el método init
. Aquí vamos a implementar unas instrucciones que son muy importantes a la hora de desarrollar aplicaciones con PythonNET.
Si recuerdas bien, en __init__
definimos lo siguiente:
# En App.__init__
self.Shown += self.init
Esto significa que init
será ejecutado cuando se haya mostrado el formulario. Esto no es importante de momento, pero al finalizar este artículo comprenderás el por qué de esta decisión al crear el cronómetro (por ahora piensa que lo que definamos en init
podría haberse definido perfectamente en __init__
).
Tenemos que hacer que clock_loop
se ejecute en un hilo separado del bucle principal de la aplicación:
def init(self, *args):
# Intrucciones que
# definiremos
# posteriormente
self.clock_loop = threading.Thread(target=self.clock_loop, daemon=True)
self.clock_loop.start()
Por el momento ignora los comentarios, simplemente quiero que quede claro que cuando hayamos definido todos los métodos de App tendremos que volver a init
para implementar los retoques finales y mas importantes de la aplicación.
Lo que hemos hecho es que self.clock_loop
sea un Thread y justo después de esto ejecutamos el thread self.clock_loop (que ejecuta lo que habíamos definido en el método self.clock_loop
). O sea, hemos sobreescrito el método para que sea un thread que ejecuta el propio método.
El thread self.clock_loop
se ejecutará sólo una vez (dentro de init
), ya que es un bucle infinito (solamente se detendrá cuando se cierre la aplicación).
Pero antes de iniciar el bucle de self.clock_loop
debemos resetear el cronómetro mediante self.reset_clock
(en este caso mas bien lo estamos inicializando, ya que es la primera vez que se ejecuta, y el resto de veces actuará como un manejador de eventos):
def init(self, *args):
# Intrucciones que
# definiremos
# posteriormente
self.reset_clock()
self.clock_loop = threading.Thread(target=self.clock_loop, daemon=True)
self.clock_loop.start()
Ya estamos listos para implementar lo que sucede al hacer click en el botón self.btnStart
:
Iniciar el cronómetro: start_clock
Aunque ya hemos implementado gran parte de la aplicación todavía no hemos percibido ningún progreso, ya que solamente self.btnStart
está activado y no hemos definido self.start_clock
ni self.clock_tick
ni self.update_view
. Además al final tendremos que revisar self.init
.
self.start_clock
es el manejador del evento click para self.btnStart
. En este manejador debe suceder lo siguiente:
- Que se desactive el propio botón START (no puede inicializarse el cronómetro una vez que este ha sido iniciado) -
self.btnStart.Enabled = False
- Que se activen los botones PAUSE y STOP (una vez se inica el cronómetro deben estar disponibles las opciones de pausarlo o detenerlo). -
self.btnStop.Enabled = True; self.btnPauseResume.Enabled = True
- Que se reinicie
self.init_time
(el botón START se pulsa en el momento en el que el usuario espera que se empiece a contabilizar el tiempo) -self.init_time = time.monotonic()
Por tanto implementamos este handler así:
def start_clock(self, *args):
self.btnStart.Enabled = False
self.btnStop.Enabled = True
self.btnPauseResume.Enabled = True
self.init_time = time.monotonic()
Si ejecutas la aplicación y pulsas START se desactiva este botón y se activan los demás, pero no sucede nada mas. Antes de implementar lo que sucede al hacer click en los botones que ahora han sido activados tenemos que implementar el funcionamiento de clock_tick
y de update_view
.
Función principal del cronómetro: clock_tick
En clock_tick
tenemos que realizar la medición del tiempo. Lo primero es obtener un valor que represente el momento actual:
now = time.monotonic()
La variable now
representa el momento actual. Su valor es un float
que no tiene significado per se. Si comparamos su valor con el de self.init_time
obtenemos la diferencia de tiempo entre dos momentos:
self.dif = now - self.init_time
Esta expresión es válida para un cronómetro que no puede pausarse, pero nuestro cronómetro tiene que restar el tiempo que ha permanecido en pausa al calcular la diferencia de tiempo:
self.dif = now - self.init_time - self.base
Hay que tener en cuenta que al iniciar el cronómetro, la primera vez que se ejectue clock_tick
, se cumple que self.init_time == 0
, que no es un valor útil. Si self.init_time == 0
debemos asignar a esta variable el valor del tiempo actual (now) para que sea una referencia del tiempo inicial (el momento en que se inicia el cronómetro). Antes de la sentencia anterior:
if self.init_time == 0:
self.init_time = now
Las sucesivas veces que se ejecute clock_tick
el valor de self.init_time
permanece constante, mientras que now
vuelve a calcularse (se va incrementando). Se compara now
con self.init_time
para obtener el tiempo trascurrido: self.dif
.
La variable self.base
representa el tiempo perdido. De momento su valor siempre es 0
(que es el valor que le asignamos en self.reset_clock
), pero en self.pause_resume_clock
haremos que guarde el tiempo perdido (veremos esto mas adelante).
El valor de self.dif
representa el tiempo cronometrado. Es un float
cuyo valor es el tiempo en segundos. Este tiempo lo vamos a representar en el formato horas:minutos:segundos,centésimas. Para ello tenemos que descomponer self.dif
en self.horas
, self.minutos
, self.segundos
y self.centesimas
.
Para obtener los segundos:
self.segundos = int(self.dif)
Este es el número total de segundos enteros que han sido cronometrados.
Para obtener las centésimas de segundo:
self.centesimas = int((self.dif - self.segundos) * 100)
Es decir, self.centesimas
se calcula restando self.segundos
(que es la parte entera de self.dif
) a self.dif
y multiplicando por 100. El resultado se convierte a int
porque nos interesa que sea un número entero de dos cifras.
Ya tenemos los segundos y las centésimas de segundo pero solamente nos interesa guardar en self.segundos
cantidades menores de 60, ya que cantidades mayores pueden representarse como minutos.
Para extraer los grupos de 60 segundos de self.segundos
y guardarlos en self.minutos
hacemos lo siguiente:
self.minutos = 0 # reseteamos los minutos
self.horas = 0 # reseteamos las horas
while self.segundos >= 60:
self.segundos -= 60
self.minutos += 1
Mientras haya 60 segundos o mas guardados en self.segundos
se restará 60
y se sumará 1
a self.minutos
. Ahora tenemos el total de minutos en self.minutos
.
self.minutos
no debe tener un valor superior a 59
, ya que es mejor representar 60 minutos como una hora.
Procedemos de manera análoga a lo que hicimos anteriormente para obtener las horas:
while self.minutos >= 60:
self.minutos -= 60
self.horas += 1
Ya hemos terminado de definir el método clock_tick
. Su código final es el siguinte:
def clock_tick(self):
now = time.monotonic()
if self.init_time == 0:
self.init_time = now
self.dif = now - self.init_time - self.base
self.segundos = int(self.dif)
self.centesimas = int((self.dif - self.segundos) * 100)
self.minutos = 0
self.horas = 0
while self.segundos >= 60:
self.segundos -= 60
self.minutos += 1
while self.minutos >= 60:
self.minutos -= 60
self.horas += 1
Una vez calculado el valor de self.horas
, self.minutos
, self.segundos
y self.centesimas
formatearemos el resultado en el método update_view
para asignarlo a self.lblTime.Text
y que sea visible para el usuario.
Actualización del texto de la etiqueta: update_view
Recuerda que el método update_view
se está ejecutando en el bucle clock_loop
.
Este método debe definirse así:
def update_view(self):
data = []
for i in self.horas, self.minutos, self.segundos, self.centesimas:
t = str(i) if i >= 10 else f'0{i}'
data.append(t)
horas, minutos, segundos, centesimas = data
self.lblTime.Text = f'{horas}:{minutos}:{segundos},{centesimas}'
O sea que para cada una de las variables que contienen información sobre el tiempo, si su valor es mayor o igual que 10, convertimos este valor a str
, y en caso contrario se convierte a str
añadiendo un "0"
a la izquierda. La idea es que la representación de horas, minutos, segundos y centésimas de segundo siempre tenga dos caracteres de longitud.
El resultado de esta conversión se almacena en una lista data
que posteriormente se desempaqueta para asignar la cadena formateada f'{horas}:{minutos}:{segundos},{centesimas}'
a self.lblTime.Text
, con lo que el tiempo medido se hace visible al usuario cada 0.01 segundos (lo especificamos así en clock_loop
ejecutando time.sleep(0.01)
, ya que 0.01 es una centésima de segundo, que corresponde a la unidad de tiempo mas pequeña que estamos teniendo en cuenta).
Si ahora ejecutas el programa y haces click en el botón START pueden suceder dos cosas: que el cronómetro se ponga en marcha o que la aplicación se congele. Si la aplicación no se congela se activará el botón STOP, y si haces click en él puede resetearse el cronómetro o la aplicación puede congelarse.
¿a que se debe esto? Me llevó mucho tiempo saber cual es el error que estaba cometiendo. El código en principio está bien, pero el error se produce dentro de update_view
en su última sentencia:
self.lblTime.Text = f'{horas}:{minutos}:{segundos},{centesimas}'
Cuando se ejecuta esta línea no sucede ningún error mientras no hagas click en los botones (a no ser que la aplicación se congele al hacer click en START). En el momento en que haces click en STOP es muy probable que la aplicación se congele y tengas que cerrarla.
Este error tiene una solución muy sencilla que me costó mucho encontrar, y vas a ver que es muy fácil de implementar. Lo que está sucediendo es que en la aplicación están funcionando dos threads de forma concurrente (si, en python los threads no se ejecutan en paralelo, sino alternativamente según la demanda de cpu de cada uno de los threads). Uno de los threads es el de la GUI (en este thread se ejecuta el bucle de eventos del formulario y mantiene la aplicación abierta). Este thread se inicia al ejecutar app.ShowDialog()
. El otro thread es el que mide el tiempo (el que creamos en init
y ejecuta las intrucciones definidas en self.clock_loop
). Gracias a time.sleep(0.01)
estamos asegurando que el thread que se encarga de medir el tiempo no acapare todo el procesamiento de la cpu y ponga a disposición del otro thread la cpu intervalos de 0.01 segundos.
Pero en PythonNET hay un problema cuando se intenta cambiar de thread, y es que se están ejecutando dos runtimes a la vez (el clr de .NET y el runtime de python). Si hacemos click en un botón y se dispara un evento, y se hace justo mientras se ejecuta una sentencia en el otro thread que involucra a tipos de datos de .NET se produce un conflicto y la aplicación se congela. Esto se debe a que pythonnet no es capaz de gestionar correctamente el GIL de python.
Realmente no se explicar esto correctamente, pero en cualquier caso la solución es ejecutar la sentencia que produce el error en el thread principal.
Para simplificar lo que haremos será ejecutar update_view
en el thread principal, pero hubiese bastado con ejecutar la ultima sentencia de esta función fuera del thread del cronómetro.
¿cómo lo hacemos? Hay que crear un delegado, que no es mas que un tipo de dato de c# que permite pasar funciones como parámetros (en python las funciones pueden pasarse directamente como parámetros, pero no es así en todos los lenguajes). A este delegado lo invocaremos en el thread principal:
Ajustes finales de la configuración avanzada: init
Volvemos a init
a "rematar la faena". Hay que crear un delegado que pueda ejecutarse en el thread principal y así evitar el error que congela el programa. Habíamos definido el método init
así:
def init(self, *args):
# Intrucciones que
# definiremos
# posteriormente
self.reset_clock()
self.clock_loop = threading.Thread(target=self.clock_loop, daemon=True)
self.clock_loop.start()
Justo al inicio de la función (donde están los comentarios) vamos a crear un delegado:
delegate = System.Action(self.update_view)
Con System.Action
podemos crear fácilmente un delegado. Las condiciones para crear delegados así es que la función pasada no reciba argumentos y que tampoco devuelva ningún valor. Como nuestra función updata_view
es mas bien un procedimiento estamos cumpliendo estas condiciones.
Y a continuación vamos a redefinir self.update_view
para que sea una función que invoca al delegado en el thread principal:
self.update_view = lambda: self.Invoke(delegate)
Todos los controles/widgets de un Form
, así como el Form
en sí (self
) tienen un método Invoke
con el que puede ejecutarse un delegado en el thread en el que trabaja dicho control.
El método init
queda finalmente así:
def init(self, *args):
delegate = System.Action(self.update_view)
self.update_view = lambda: self.Invoke(delegate)
self.reset_clock()
self.clock_loop = threading.Thread(target=self.clock_loop, daemon=True)
self.clock_loop.start()
Cuando se ejecute update_view
en el bucle del cronómetro no se estará ejecutando el método update_view
que habíamos definido, sino una función anónima (lambda) que invoca (Invoke) al delegado encargado de ejecutar lo que habíamos definido en update_view
. La ejecución ya no sucede en el thread del cronómetro, sino en el thread que se creó al ejecutar app.ShowDialog()
.
Si ahora ejecutas la aplicación verás que no se producen errores, ¡misión cumplida!
Ya sólo nos falta hacer que el usuario pueda pausar el cronómetro...
Pausar o restablecer el funcionamiento del cronómetro: pause_resume_clock
El método/manejador pause_resume_clock
es muy sencillo. Su código es el siguiente:
def pause_or_resume(self, *args):
if self.btnPauseResume.Text == 'PAUSE':
self.btnPauseResume.Text = 'RESUME'
elif self.btnPauseResume.Text == 'RESUME':
self.btnPauseResume.Text = 'PAUSE'
self.base = time.monotonic() - self.dif - self.init_time
Si self.btnPauseResume.Text == 'PAUSE'
cuando el usuario hace click en él, el cronómetro debe pausarse. En este caso simplemente cambiamos el texto del botón: self.btnPauseResume.Text = 'RESUME'
y como en clock_loop
solamente se ejecuta self.clock_tick
cuando se cumple self.btnPauseResume.Text == 'PAUSE'
el cronómetro se detiene.
Si self.btnPauseResume.Text == 'RESUME'
cuando el usuario hace click en él, el cronómetro debe reanudarse. Para ello cambiamos el texto del botón: self.btnPauseResume.Text = 'PAUSE'
para que dentro de clock_loop
se pueda ejecutar clock_tick
. Además almacenamos el tiempo perdido en self.base
:
self.base = time.monotonic() - self.dif - self.init_time
Al valor devuelto por time.monotonic()
le restamos self.dif
y self.init_time
para obtener el tiempo perdido. Al ejecutarse clock_tick
se tiene en cuenta el tiempo perdido y el cronómetro continúa desde donde se detuvo.
COMPILAR LA APLICACIÓN
Para crear un standalone de esta aplicación con PyInstaller hay que añadir lo siguiente al inicio de cronometro.py:
if hasattr(sys, '_MEIPASS'):
os.chdir(sys._MEIPASS)
Con esto conseguimos que si existe la variable sys._MEIPASS
se cambie el directorio de trabajo al que apunta esta variable. Cuando se compila con pyinstaller con el flag --onefile los recursos de la aplicación se extraen en el directorio sys._MEIPASS
, de modo que en este caso tenemos que cambiar el directorio de trabajo para que sea el indicado por esta variable, ya que es ahí donde se han extraido los archivos cronometro.ico y cronoform.dll.
Ahora podemos compilar la aplicación con el siguiente comando:
pyinstaller --onefile --windowed --exclude-module "tkinter" --icon="cronometro.ico" --add-data "cronometro.ico;." --add-data "cronoform.dll;." cronometro.py
¡¡ y listo !! ya tienes tu cronometro.exe
CÓDIGO FINAL
import sys, os, time, threading
if hasattr(sys, '_MEIPASS'):
os.chdir(sys._MEIPASS)
import clr
import System
clr.AddReference('cronoform')
import cronoform
class App(cronoform.CronoForm):
def __init__(self):
super().__init__(self)
self.btnPauseResume.Enabled = False
self.btnStop.Enabled = False
self.Icon = System.Drawing.Icon('cronometro.ico')
self.btnStart.Click += self.start_clock
self.btnPauseResume.Click += self.pause_or_resume
self.btnStop.Click += self.reset_clock
self.Shown += self.init
self.HandleDestroyed += lambda *args: os._exit(0)
def init(self, *args):
delegate = System.Action(self.update_view)
self.update_view = lambda: self.Invoke(delegate)
self.reset_clock()
self.clock_loop = threading.Thread(target=self.clock_loop, daemon=True)
self.clock_loop.start()
def start_clock(self, *args):
self.btnStart.Enabled = False
self.btnStop.Enabled = True
self.btnPauseResume.Enabled = True
self.init_time = time.monotonic()
def reset_clock(self, *args):
self.btnStart.Enabled = True
self.btnPauseResume.Enabled = self.btnStop.Enabled = False
self.btnPauseResume.Text = 'PAUSE'
self.init_time = self.dif = self.base = self.horas = self.minutos = self.segundos = self.centesimas = 0
def pause_or_resume(self, *args):
if self.btnPauseResume.Text == 'PAUSE':
self.btnPauseResume.Text = 'RESUME'
elif self.btnPauseResume.Text == 'RESUME':
self.btnPauseResume.Text = 'PAUSE'
self.base = time.monotonic() - self.dif - self.init_time
def clock_loop(self):
while True:
time.sleep(0.01)
self.update_view()
if not self.btnStart.Enabled and self.btnPauseResume.Text == 'PAUSE':
self.clock_tick()
def clock_tick(self):
now = time.monotonic()
if self.init_time == 0:
self.init_time = now
self.dif = now - self.init_time - self.base
self.segundos = int(self.dif)
self.centesimas = int((self.dif - self.segundos) * 100)
self.minutos = 0
self.horas = 0
while self.segundos >= 60:
self.segundos -= 60
self.minutos += 1
while self.minutos >= 60:
self.minutos -= 60
self.horas += 1
def update_view(self):
data = []
for i in self.horas, self.minutos, self.segundos, self.centesimas:
t = str(i) if i >= 10 else f'0{i}'
data.append(t)
horas, minutos, segundos, centesimas = data
self.lblTime.Text = f'{horas}:{minutos}:{segundos},{centesimas}'
if __name__ == '__main__':
app = App()
app.ShowDialog()