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í:

Cronómetro PythonNET

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:

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:

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:

Cronómetro - Botones desactivados

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:

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()