import socket
# en plus de socket, on utilise les threads
from threading import Thread

# fonction qui va gérer un client (boucle avec sortie si 'exit' reçu)
# cette fonction sera appelée dans un nouveau thread à chaque connexion

def gereClient(sockclient,addr):
    sock=sockclient
    while True:
        data = sock.recv(BUFSIZ).decode("Utf8")
        if data == 'exit':
            break
        else:
            msg = 'echo : ' + data # notre serveur est tjs le même
        sock.send(msg.encode("Utf8"))
        
    sock.send("FIN".encode("Utf8"))
    sock.close()

BUFSIZ = 1024
#HOST = socket.gethostname()
HOST='0.0.0.0' # Toutes les addresses de la machine à l'écoute
PORT = 4567
ADDR = (HOST, PORT)
sockserveur = socket.socket()
sockserveur.bind(ADDR)
# on peut éventuellement mettre un paramètre plus grand à listen
# si on veut que le serveur ne refuse pas une connexion
# alors qu'il est en train d'en traiter une autre
# (temps de passage de la connexion à un nouveau thread)
sockserveur.listen(1)
# boucle pour les connexions des clients, sans fin
while True:
    print ("Serveur à l'écoute…")
    sockclient, addr = sockserveur.accept()
    print ('...connexion de : ', addr)
    # quand un client se connecte, on crée un thread "pour lui" 
    # contenant la fonction de gestion de client
    th=Thread(target=gereClient,args=(sockclient,addr))
    th.start()

Explications

La commande de reception de donnée depuis le réseau est

sock.recv(BUFSIZ).decode("Utf8")
Cette commande est bloquante, ce qui signifie que lorsque le serveur est en attente d'un message en provenance d'un client, il ne peut rien faire d'autre, en particulier, il ne peut pas traiter les demandes d'autres clients éventuels.

Cette situation est bien sûr intenable dans le cadre d'un usage classique. Pour contourner cette difficulté, nous intégrons cette commande bloquante dans une fonction qui sera exécutée en parallèle du programme principal qui restera disponible pour traiter les autres connexions clients.

Pour exécuter une fonction en parallèle, on fait appel à la librairie Threading de Python : Un thread étant un morceau de programme s'éxécutant en parallèle du programme qui l'appelle. Nous créons donc grâce à la commande

Thread(target=gereClient,args=(sockclient,addr))
un appel non bloquant à la fonction gereClient, et ce pour chaque client qui se connecte.

Nous pouvons donc traiter simultanément la connexion de plusieurs clients au même serveur.

Le client

from tkinter import *
from socket import *
from threading import Thread

liaison = socket(AF_INET, SOCK_STREAM) # socket client

def gestionClient():
    # Communication client exécuté en parallèle dans un thread
    message="" 
    while message.upper() != "FIN" :
        message = liaison.recv(1024).decode("utf8") # Commande bloquante
        listeMsg.insert(END, message) # On affiche le message reçu
        
    etatStr.set("Connexion terminée." )
    liaison.close()

def connect():
    SERVEUR=ipAddr.get()
    PORT=eval(ipPort.get())
    try:
        liaison.connect((SERVEUR, PORT))
        etatStr.set("Connexion établie")
        th=Thread(target=gestionClient)
        th.start()
    except error:
        etatStr.set("La connexion a échoué.")
        liaison.close()

def envoi():
    liaison.send(msgStr.get().encode("utf8"))
    msgStr.set("")
    
fenetre=Tk()
fenetre.title="Client réseau"

## textes variables
ipAddr=StringVar()
ipAddr.set('127.0.0.1')
ipPort=StringVar()
ipPort.set("4567")
etatStr=StringVar()
etatStr.set("Etat de la connection...")
msgStr=StringVar()

## Interface graphique
connFrame=Frame(fenetre,bd=1, relief=SUNKEN) # Cadre de connection
msgFrame=Frame(fenetre,bd=1, relief=SUNKEN) # Cadre d'envoi
ipEntry=Entry(connFrame,textvariable=ipAddr)
portEntry=Entry(connFrame,textvariable=ipPort)
btnConnect=Button(connFrame,text="Connexion",command=connect)
etatLbl=Label(fenetre,textvariable=etatStr)
listeMsg = Listbox(fenetre)
msgLbl=Label(msgFrame,text="Message")
msgEntry=Entry(msgFrame,textvariable=msgStr)
msgSend=Button(msgFrame,text="Envoyer",command=envoi)

## positionnement des widgets
connFrame.pack(padx=5,pady=5)
ipEntry.pack(side=LEFT,padx=5,pady=5)
portEntry.pack(side=LEFT,padx=5,pady=5)
btnConnect.pack(side=LEFT,padx=5,pady=5)
etatLbl.pack(padx=5,pady=5)
listeMsg.pack(fill=BOTH, expand=1,padx=5,pady=5)
msgFrame.pack(fill=BOTH, expand=1,padx=5,pady=5)
msgLbl.pack(side=LEFT,padx=5,pady=5)
msgEntry.pack(fill=BOTH, expand=1,side=LEFT,padx=5,pady=5)
msgSend.pack(side=LEFT,padx=5,pady=5)

fenetre.mainloop()

Explications

Dans ce programme client, la majeure partie correspond au design de l'interface graphique. Nous utilisons ici le widget Frame de TKinter permettant de créer des zones dans l'interface dans laquelle la mise en page sera différente :

pack(side=LEFT,padx=5,pady=5)
permet de placer les composants les un à coté des autres dans les différents cadres. Les cadres eux même sont disposés avec la disposition par défaut c'est à dire verticalement.

La problématique principale de ce programme est de gérer à la fois l'écoute de messages en provenance du serveur et la réactivité de l'interface graphique. En effet, la commande

message = liaison.recv(1024).decode("utf8")
est bloquante, ce qui signifie que quand le client est en attente d'un message du serveur, il ne peut rien faire d'autre. En particulier, il ne peut pas réagir aux événements en provenance de l'utilisateur sur l'interface graphique. L'application est alors figée.

Pour se prémunir de ce problème, comme pour le serveur, nous devrons intégrer la commande de reception de message dans un Thread dédié qui tournera en parallèle de notre programme qui sera alors en capacité de gérer l'interface graphique.

Pour ce faire, dès que le client se connecte au serveur on crée un thread par la commande

Thread(target=gestionClient)
qui sera en charge de receotionner les messages du serveur et de les afficher dans la zone de texte (Listbox)