Αντρω δι Νεξυνω

[Python] Tkinter e thread paralleli, un esempio, Esposizione di una metodologia spicciola sviluppata nel tempo

« Older   Newer »
  Share  
view post Posted on 22/4/2024, 12:18
Avatar

Nubbio x Sempre

Group:
Moderazione globale
Posts:
7,226

Status:


I miei saluti ad eventuali, improbabili, lettori

Questo post nasce, tanto per cambiare, dal voler fornire una possibile risposta alla domanda:
CITAZIONE
il mio problema ora è di far convivere nello stesso script l'interfaccia tkinter (che so essere task monopolizzate) e la routine che devo eseguire ogni 30/60 secondi per vedere se ci sono messaggi nuovi.

richiesta posta in questo post di python.it

Il mainloop di tkinter, come in qualsiasi altro framework grafico, è un thread bloccante, ciò vale a dire che mentre agisce niente al di fuori di esso può essere eseguito ... quasi.
Ovviamente, metodi per soddisfare la richiesta sopra ce ne sono, già da tkinter stesso (p.e., un self.after(...) richiamante ricorsivamente un metodo di una classe appositamente definita) ma, personalmente, nel tempo ho acquisito l'abitudine, per operazioni di una certa consistenza, che potrebbero, quindi, bloccare il refresh della GUI, di utilizzare i thread, magari definendoli quali "daemon" in caso sia necessario occasionalmente e/o a tempi non determinati il loro intervento.
Ho deciso, quindi, di realizzare una piccola applicazione di test, utilizzante detto approccio, da proporre a @trescon, proponente il quesito.
In sostanza una piccola applicazione grafica che legga, da un database SQLite, in situazione di accesso concorrente, dei messaggi che non ha già letto e li esponga all'user della applicazione ... ovviamente, non sono andato ad impelagarmi nella implementazione di una applicazione finita, l'esempio implementato è per la sola lettuea, il database ed i "messaggi" vengono definiti in una sessione di shell aperta in concorrenza con la applicazione grafica, come nella sottostante immagine
png

limitandosi, i processi implementati nello esempio, ad intercettare i messaggi non già letti dal nick-user, comunicarli all'utente e registrare l'avvenuta lettura, in modo che non si ripeta l'indicazione del messaggio in un secondo momento

png

Le immagini sopra spiegano, meglio di mille parole cosa fa l'esempio posto in essere.

Ovviamente, per poter effettuare l'esempio ho dovuto creare manualmente un minimale database così composto

NzP:~$ sqlite3 for_trescon.db
SQLite version 3.37.2 2022-01-06 13:25:41
Enter ".help" for usage hints.
sqlite> .tables
messages messages_read
sqlite> .schema messages
CREATE TABLE messages(
id INTEGER PRIMARY KEY AUTOINCREMENT,
nick VARCHAR(30),
msg TEXT);
sqlite> .mode box --wrap 30
sqlite> SELECT * FROM messages;
┌──┬───────┬────────────────────────┐
│ id │ nick │ msg │
├──┼───────┼────────────────────────┤
│ 1 │ nuzzopippo │ Un primo messaggio per esempio a @trescon │
└──┴───────┴────────────────────────┘
sqlite> .schema messages_read
CREATE TABLE messages_read(
message_id INTEGER,
readernick VARCHAR(30) NOT NULL,
UNIQUE(message_id, readernick),
FOREIGN KEY(message_id) REFERENCES messages(id));
sqlite> SELECT * FROM messages_read;
sqlite>

Scusate la pessima resa della esposizione tabellare dei dati, comunque, nulla di più che una tavola, "messages", con indice auto-incrementante in cui inserire un nick ed il messaggio da comunicare, ed una tavola, "messages_read", ove registrare l'identificativo di un messaggio letto ed il nick di chi lo ha letto, in maniera da poter escludere, in una query, quelli già letti.

Veniamo, ora, alle modalità di funzionamento dell'esempio realizzato, al fine di essere "chiaro" ho suddiviso gli script in tre moduli distinti, ognuno dedicato ad un particolare aspetto delle problematiche in essere ... vediamole un po'

Comunicazione tra i thread


Come accennato all'inizio tkinter agisce in un thread bloccante, ciò vuol dire che avviato il suo mainloop eventuale codice che segue l'istruzione di avvio non viene eseguita fin quando il mainloop non cessa di esistere.
Per loro natura i thread agiscono parallelamente ed indipendentemente l'uno dall'altro, con "ordine" stabilito dal sistema operativo, non condividono niente e non sanno nulla l'uno dell'altro ... tale situazione è spesso scomoda e ci sono diversi modi per affrontarla, nel corso del tempo ho guardato varie metodologie, una di quelle che ha soddisfatto di più le mie limitate necessità è stato il pattern "Publisher/Subscriber", per il quale esiste una specifica libreria python: pypubsub.
In sostanza, si tratta di inserire un "oggetto" condiviso dai vari threads che si incarica di "smistare" messaggi a processi che si sono iscritti (subscribers) per riceverli.
... anche, tale libreria, nel tempo, la trovai ridondante per le mie limitate necessità e da un po' mi diletto ad utilizzare una versione minimale, un "postino", scritta da me, tale classe è contenuta nel modulo "utils.py", il cui codice è :

CODICE
# -*- coding: utf-8 -*-

from functools import wraps

def singleton(o_cls):
  orig_new = o_cls.__new__
  inst_field = "__instance"

  @wraps(o_cls.__new__)
  def __new__(cls, *args, **kwargs):
      if (instance := getattr(cls, inst_field, None)) is None:
          instance = orig_new(cls)
          if hasattr(cls, "__init__"):
              cls.__init__(instance, *args, **kwargs)
              delattr(cls, "__init__")
          setattr(cls, inst_field, instance)
      return instance

  o_cls.__new__ = __new__
  return o_cls


@singleton
class Delivery:
  ''' Un "postino" tra moduli'''
 
  def __init__(self):
      self.subscr = {}
 
  def subscribe(self, group: str, method: any) -> None:
      ''' Aggiunge un subscriber ad un dato gruppo '''
      if not group in self.subscr.keys():
          self.subscr[group] = []
      if method in self.subscr[group]:
          raise RuntimeError('Metodo già acquisito nel gruppo %s' % group)
      self.subscr[group].append(method)

  def unsubscribe(self, group: str, method: any) -> None:
      ''' Rimuove un subscriber ad un dato gruppo, i gruppi vuoti vengono rimossi'''
      if not group in self.subscr.keys():
          raise RuntimeError('Gruppo %s non registrato' % group)
      if method not in self.subscr[group]:
          raise RuntimeError('Metodo non registrato nel gruppo %s' % group)
      self.subscr[group].remove(method)
      if not self.subscr[group]:
          del self.subscr[group]
         
 
  def send_message(self, group:str, message: list) -> None:
      ''' Distribuisce un messaggio ai subscriber di un gruppo '''
      if not group in self.subscr.keys():
          raise RuntimeError('Gruppo %s non registrato' % group)
      for s_func in self.subscr[group]:
          s_func(message)

E consiste nella classe "Delivery" che si occupa di smistare dei messaggi tra gruppi registrati di "utenti", chiamiamoli così ma, in sostanza, sono metodi o funzioni definiti dai vari clients.
Tale classe "Delivery" è un "singleton", vale a dire che malgrado siano presenti più istanze alla classe in vari moduli, funzioni e metodi, esse faranno comunque riferimento allo stesso oggetto che è unico per l'applicazione.
Per far si che un generico oggetto possa ricevere i messaggi smistati da Delivery esso deve iscrivere un suo metodo deputato a riceve il messaggio distribuito, tale messaggio consiste in una lista il cui primo elemento deve essere una stringa definente una operazione/evento, il secondo elemento può mancare oppure essere qualsiasi cosa, oggetti, dati od anche un'altra lista con definite delle ulteriori sub-operazioni/eventi ... ciò permette di definire dei protocolli ad-hoc per ciò che serve alla realizzazione delle proprie specifiche esigenze.
Una iscrizione ad un gruppo non necessariamente deve essere statica, può essere revocata quando non serve più e DEVE essere revocata quando un oggetto con metodo iscritto cessa la sua vita operativa.
Non necessariamente un oggetto che istanza Delivery deve avere un suo metodo iscritto, può emettere messaggi per i vari gruppi registrati senza riceverne.
I metodi principali sono :

  • subscribe(self, group: str, method: any) iscrive il metodo di un oggetto ad un gruppo;

  • unsubscribe(self, group: str, method: any) rimuove il metodo iscritto di un oggetto da un gruppo;

  • send_message(self, group:str, message: list) invia un messaggio a tutti i membri di un gruppo.


È prevista, nella classe Delivery, l'emissione di vari errori di run-time per situazioni improprie, che in una corretta implementazione del codice non dovrebbero accadere, per tali errori dovrebbe essere prevista l'intercettazione, p.e. all'atto di invio di un messaggio, cosa che nel ridotto esempio in essere non è stata fatta per non ridondare il codice.

Lettura messaggi dal database


Nell'esempio implementato, anche per la lettura del database ho dedicato uno specifico modulo, reading.py, 74 righe di codice che, in sostanza, implementano una singola classe "SQLReader", il codice:

CODICE
# -*- coding: utf-8 -*-

import os
import sys
from threading import Thread
import sqlite3
import traceback
import time

from utils import Delivery


class SQLReader:
   def __init__(self, nick: str) -> None:
       self.nick = nick
       self.still = True
       self.conn = False
       self.pub = Delivery()
       self.pub.subscribe('EVENT_OBSERVERS', self.event_observer)

   def event_observer(self, msg: any) -> None:
       event = msg[0]
       match event:
           case 'READ_MESSAGES':
               self._connect_and_read()
           case 'ON_CLOSING':
               if not self.conn:
                   self.pub.send_message('EVENT_OBSERVERS', ['THREAD_END',])
                   self.pub.unsubscribe('EVENT_OBSERVERS', self.event_observer)
                   return
               self.still = False
           case _:
               pass

   def _connect_and_read(self) -> None:
       if self.conn: return
       appdir = os.path.abspath(os.path.dirname(sys.argv[0]))
       db_name = os.path.join(appdir, 'for_trescon.db')
       t = Thread(target=self.read_messages, args=(db_name,))
       t.daemon = True
       t.start()
       
   def read_messages(self, db: str) -> None:
       try:
           conn = sqlite3.connect(db)
           self.conn = True
       except sqlite3.IntegrityError as e:
           txt = traceback.print_exc()
           msg = ['DBERROR', txt]
           self.pub.send_message('EVENT_OBSERVERS', msg)
           return
       while self.still:
           query = '''SELECT * FROM messages A
WHERE NOT EXISTS(SELECT * FROM messages_read B
                WHERE B.message_id = A.id
                AND B.readernick = ?)
ORDER BY a.ID'''
           res = conn.execute(query, (self.nick,))
           for r in res.fetchall():
               text = ' '.join([str(x) for x in r])
               msg = ['MSG_READING', text]
               self.pub.send_message('MESSAGE_OBSERVERS', msg)
               try:
                   query = 'INSERT INTO messages_read VALUES(?, ?)'
                   result = conn.execute(query, (r[0], self.nick))
                   conn.commit()
               except sqlite3.IntegrityError as e:
                   txt = traceback.print_exc()
                   msg = ['DBERROR', txt]
                   self.pub.send_message('EVENT_OBSERVERS', msg)
                   break
           time.sleep(10)
       conn.close()
       self.pub.send_message('EVENT_OBSERVERS', ['THREAD_END',])
       self.pub.unsubscribe('EVENT_OBSERVERS', self.event_observer)


... Qui si incomincia ad intravedere qualcosa circa l'architettura dell'esempio.
Alla sua inizializzazione la classe "SQLReader" DEVE ricevere il nick-name dell'utente lettore dei messaggi registrati nella base dati prima esposta, per poter discernere tra i messaggi registrati a suo nome e gli altri, inoltre, esempre in fase di inizializzazione, provvede ad istanziare un oggetto Delivery memorizzandolo nella variabile di istanza "self.pub" e quindi a registrare il proprio metodo "event_observer" nel gruppo "EVENT_OBSERVERS".
Si noti come SQLReader NON avvii alcuna lettura della base dati al suo instanziamento, sarà un evento proveniente dall'esterno (ossia un messaggio) a dirle di farlo.
Il metodo "def event_observer(self, msg: any) -> None:" iscritto come sopra detto, leggerà il primo elemento della lista "msg" ricevuta (è indicato come "any" per elasticità, ma dovrebbe essere "list" dato l'uso nell'esempio), tale primo elemento viene stabilito quale "evento" che sarà valutato per definire le azioni da intraprendere.
Nel caso l'evento ricevuto sia "READ_MESSAGES", verrà definito il database da aprire (fisso) ed avviato il thread di lettura quale daemon.
IMPORTANTE : è nel thread che viene stabilita la connessione al database SQLite3, tale circostanza è la parte più "delicata" dell'esempio qui riportato, delicata perché la connessione alla base dati deve essere eseguita all'interno del thread di consultazione, non essendo possibile invocare in un thread processi SQLite3 definiti in un altro thread, delicata anche perché la gestione degli errori nell'ambiente di sviluppo (3.10) è cambiata nella versione 3.11 di python.
Comunque, nella prova di test è filato tutto liscio e non ho avuto modo di vedere se le intercettazioni di errore dati predisposte siano buone o meno ... in ogni caso, quesro è solo un esempio.
Il thread avviato punta al metodo "read_messages(self, db: str)", di SQLReader, che si fa carico di stabilire una connessione al database ed ogni 10 secondi leggere i messaggi reicevuti nello stesso database e non letti dal nick associato alla classe, nel caso ne trovi compone un messaggio che invia al gruppo "MESSAGE_OBSERVERS" e registra nel database l'avvenuta lettura dei messaggi da parte del nick associato.
La ripetizione del ciclo di lettura del database è controllata dalla variabile di istanza "self.still", sin quando essa sarà "Vera" il ciclo verrà ripetuto, appena diverrà falsa si chiuderà il ciclo di lettura e verrà comunicato al gruppo EVENT_OBSERVERS un messaggio con "THREAD_END" quale primo (e unico) elemento, il daemon verrà chiuso.
Si noti che, malgrado sia iscritto al gruppo EVENT_OBSERVERS il metodo event_observer di SQLReader non gestisce l'evento THREAD_END, è la finestra utente ad occuparsi di tale evento, viene gestito, invece, l'evento "ON_CLOSING", scatenato dalla finestra utente che "vuole" chiudersi, ricevendo tale "evento" verrà verificata la condizione di connessione alla base dati (self.conn) se vera verrà posto a "False" self.still, altrimenti verrà generato un messaggio di evento THREAD_END ed inviato al gruppo EVENT_OBSERVERS ... si tenga presente questo fatto ;)

... cosa fa La finestra?


In questo esempio la finestra ha un ruolo passivo, come si può evincere dalle figure prima esposte, essa si limita a notificare la volontà di leggere i (nuovi) messaggi ed a riportare quanto ricevuto, è dalla sessione DQLite nella shell che vengono inseriti i messaggi poi letti, ciò per semplificare al massimo l'esempio posto, cercando di renderlo quanto più comprensibile riesca.
Il codice sorgente della finestra (70 righe) io ho denominato il modulo main.py

CODICE
# -*- coding: utf-8 -*-

import tkinter as tk
from tkinter import messagebox as msgb

from utils import Delivery
from reading import SQLReader


class App(tk.Tk):
   def __init__(self):
       super().__init__()
       self.protocol('WM_DELETE_WINDOW', self.__on_close)
       self._populate()
       self.pub = Delivery()
       self.pub.subscribe('EVENT_OBSERVERS', self._messages)
       self.pub.subscribe('MESSAGE_OBSERVERS', self._messages)
       self.reader = SQLReader('trescon')

   def _populate(self) -> None:
       self.title('Esempio per @trescon')
       lbl = tk.Label(self, text='Messaggi rilevati:')
       lbl.grid(row=0, column=0, columnspan=2, padx=5, pady=5, sticky='w')
       self.t_msg = tk.Text(self)
       self.t_msg.grid(row=1, column=0, padx=(5,2), pady=5, sticky='nsew')
       vscroll = tk.Scrollbar(self, orient=tk.VERTICAL,
                              command=self.t_msg.yview)
       vscroll.grid(row=1, column=1, padx=(2,5), pady=5, sticky='ns')
       self.t_msg.configure(yscrollcommand=vscroll.set)
       pn_cmd = tk.Frame(self)
       pn_cmd.grid(row=2, column=0, columnspan=2, padx=5, pady=5, sticky='ew')
       bt_connect = tk.Button(pn_cmd, text='Connetti al database',
                              command=self._on_connect)
       bt_connect.grid(row=0, column=0, padx=2, pady=5, sticky='ew')
       bt_close = tk.Button(pn_cmd, text='Chiudi Applicazione',
                            command=self.__on_close)
       bt_close.grid(row=0, column=1, padx=2, pady=5, sticky='ew')
       pn_cmd.grid_columnconfigure(0, weight=1, uniform='cmd')
       pn_cmd.grid_columnconfigure(1, weight=1, uniform='cmd')
       self.grid_columnconfigure(0, weight=1)
       self.grid_rowconfigure(1, weight=1)

   def __on_close(self) -> None:
       msg = ['ON_CLOSING',]
       self.pub.send_message('EVENT_OBSERVERS', msg)

   def _on_connect(self) -> None:
       msg = ['READ_MESSAGES',]
       self.pub.send_message('EVENT_OBSERVERS', msg)

   def _messages(self, msg: list) -> None:
       op = msg[0]
       match op:
           case 'MSG_READING':
               txt = msg[1] + '\n'
               self.t_msg.insert(tk.END, txt)
           case 'DBERROR':
               txt = msg[1]
               msgb.showerror('Errore dati', txt)
           case 'THREAD_END':
               self.pub.unsubscribe('EVENT_OBSERVERS', self._messages)
               self.pub.unsubscribe('MESSAGE_OBSERVERS', self._messages)
               self.destroy()
           case _:
               pass


In primo luogo si noti, nella inizializzazione della finestra (la classe "App") la direttiva
CODICE
self.protocol('WM_DELETE_WINDOW', self.__on_close)

Tale direttiva altera i processi di chiusura della finestra demandandone la gestione al mettodo privato "__on_close(self)" della classe che NON provvederà a chiudere la finestra, bensì ad emettere un messaggio "ON_CLOSING" verso il gruppo EVENT_OBSERVERS.
Per altro, alla sua inizializzazione, la classe App iscriverà il suo metodo "_messages(self, msg: list)" ai gruppi "EVENT_OBSERVERS" e "MESSAGE_OBSERVERS", oltre ad invocare, quale variabile di istanza, un oggetto SQLReader, passandogli "trescon" quale parametro per il nick-name del lettore del database.

Sarà la prima pressione (le successive non avranno più effetto) del pulsante "Connetti al database" a dare avvio alla ciclica lettura dati dsl database, tramite il metodo di callback "_on_connect" che si limiterà ad inviare un messaggio con "READ_MESSAGES" quale primo (e unico) elemento, al gruppo EVENT_OBSERVERS.

Il metodo _messages(self, msg: list) della classe App interpreterà e reagirà ad alcuni dei messaggi emessi per i gruppi EVENT_OBSERVERS ed MESSAGE_OBSERVERS, nello specifico analizzando il primo elemento della lista riscevuta per definire l'operazione da effettuare, provvedendo per:

MSG_READING a leggere il secondo elemento ed inserirlo nella text-box;
DBERROR a presentare una finestra di errore esponendo l'errore ricevuto;
THREAD_END a cancellare le iscrizioni del metodo _messages e distruggere la finestra.

... credo che l'essenziale sia tutto qui.

Concludendo


L'esempio su posto è una metodologia semplice per far interagire tra loro thread che agiscano in parallelo, i messaggi smistati da "Delivery" permettono di definire "al volo" propri protocolli di comunicazione, permettendo di scambiarsi qualsiasi cosa, dato che sono liste, a condizione di equilibrare attentamente tali protocolli e le azioni che loro vengono associate (vedere la chiusura della finesta e sue ripercussioni), utilizzando tale metodologia è anche possibile definire tkinter un thread come un altro, interagente con parti di programma svolte in altri thread separati.

Nel caso specifico, la lettura periodica del database implica una connessione specifica nel thread di lettura, eventuali eventi di scrittura dovranno essere gestiti con connessioni separate e concorrenti, magari nello stesso thread di tkinter.
È comunque possibile definire più finestre o più oggetti in una stessa finestra iscritti in uno stesso "gruppo" che espongono diversificatamente i dati o compiano azioni diverse con i dati ricevuti, basta subclassare elementi in modo idoneo a ciò che si vuole fare.

Fondamentalmente, ciò che serve, come sempre, è una attenta pianificazione di ciò che si vuole ottenere.

Se si vuol provare l'esempio su si copi il codice proposto in tre file denominati come indicato nella stessa directory, poi, sempre in essa si crei un database denominato come esemplificato subito sotto le immagini e ci si diverta ad inserire messaggi da una shell SQLite3.

Alla prox :)
 
Web  Top
0 replies since 22/4/2024, 12:18   26 views
  Share