Dans cette session, vous allez concevoir, un petit système d’analyse de données issues d’un capteur de température. Votre application ne doit jamais s’arrêter sur une erreur, même en cas :
de données corrompues,
de valeurs aberrantes,
de panne du capteur.
Pour cela, vous allez appliquer trois principes tout au long de la session :
la programmation défensive : chaque méthode gère explicitement les exceptions qui peuvent survenir,
le développement guidé par les tests : les tests fournis décrivent le comportement attendu avant que vous n’écriviez le code,
la documentation : chaque méthode publique est documentée (paramètres, valeur de retour, exceptions levées) avec des docstrings.
Important
Le but de cette session est de vous aider à maîtriser des notions importantes en informatique. Un assistant de programmation intelligent tel que GitHub Copilot, que vous avez peut-être déjà installé, sera capable de vous fournir une solution à ces exercices basée uniquement sur un nom de fichier judicieusement choisi.
Dans un but d’entraînement, nous vous conseillons de désactiver ces outils en premier.
À la fin de l’activité pratique, nous suggérons que vous travailliez sur l’exercice à nouveau avec ces outils activés. Suivre ces deux étapes améliorera vos compétences à la fois fondamentalement et pratiquement.
Contenu de l’activité
Préparation de l’environnement
Avant de commencer, créez un projet uv dédié à cette séance. Dans un terminal, placez-vous dans le dossier de la séance (par exemple imt/s5/info/prog/session2), puis lancez la commande suivante :
uv init --no-package
uv init --no-package
uv init --no-package
uv init --no-package
Vous écrirez tous les fichiers de cette séance dans ce projet, en ajoutant les bibliothèques dont vous avez besoin avec uv add et en exécutant vos programmes dans l’environnement virtuel associé. Au besoin, revoyez la fiche Gestion moderne de projet Python avec uv.
Organisation
Vous allez écrire trois fichiers Python, tous placés dans le même répertoire de travail :
sensor.py : une classe Sensor qui génère des mesures de température et les écrit dans un fichier,
analyzer.py : une classe Analyzer qui calcule des statistiques sur les mesures reçues et les trace dans un fichier de log,
reader.py : une classe Reader qui lit le fichier de mesures et transmet chaque ligne à un Analyzer.
Pour chaque classe, un fichier de tests unitaires est fourni ci-dessous : copiez-collez-le tel quel dans un fichier tests_<nom>.py du même répertoire, puis complétez la classe correspondante jusqu’à ce que tous ces tests passent.
Implémentez une classe Sensor qui simule un capteur de données suivant une loi gaussienne (moyenne, écart-type) et qui tombe en panne une fois sur dix en moyenne.
lèveOutofBoundsException si la valeur générée est hors de [0, 150] ; lèveNotRespondingException une fois sur dix en moyenne (avant même de générer une valeur)
run
—
None (boucle infinie ; écrit dans __storage_file_name chaque valeur produite par get_value, ou OoB/NR si une exception est levée)
doit intercepterKeyboardInterrupt (arrêt propre du programme), IOError (fichier illisible/inaccessible, message d’erreur puis arrêt) et toute autre Exception (message d’erreur puis arrêt)
OutofBoundsException et NotRespondingException sont deux classes d’exception à définir vous-même dans sensor.py (héritant de Exception).
Copiez-collez le fichier de tests suivant dans tests_sensor.py :
import unittest
import random
from sensor import Sensor, OutofBoundsException, NotRespondingException
classTestSensor(unittest.TestCase):
deftest_encapsulation_default_values(self):
s = Sensor()
self.assertEqual(s.get_id(), "sensor")
self.assertEqual(s.get_mean(), 90.0)
self.assertEqual(s.get_std(), 5.0)
self.assertEqual(s.get_sleep_time(), 3.0)
self.assertEqual(s.get_storage_file_name(), "data.txt")
deftest_encapsulation_custom_values(self):
s = Sensor(id="s1", mean=50.0, std=2.0, sleep_time=1.0, storage_file_name="out.txt")
self.assertEqual(s.get_id(), "s1")
self.assertEqual(s.get_mean(), 50.0)
self.assertEqual(s.get_std(), 2.0)
self.assertEqual(s.get_sleep_time(), 1.0)
self.assertEqual(s.get_storage_file_name(), "out.txt")
deftest_get_value(self):
# Test de la fréquence de non-réponse : environ 10 % random.seed(1)
sensor = Sensor()
number_of_calls =10 seen_ok =0 seen_nr =0for _ in range(number_of_calls):
try:
value = sensor.get_value()
self.assertIsInstance(value, float)
self.assertTrue(0<= value <=150)
seen_ok +=1except NotRespondingException:
seen_nr +=1except OutofBoundsException:
self.fail("Une valeur par défaut ne devrait presque jamais être hors limites")
self.assertGreater(seen_ok, 0)
self.assertGreater(seen_nr, 0)
# Test d'une valeur supérieure à 150 random.seed(1)
sensor = Sensor(mean=151.0)
try:
sensor.get_value()
self.fail("OutofBoundsException aurait dû être levée")
except OutofBoundsException:
pass# Test d'une valeur inférieure à 0 random.seed(1)
sensor = Sensor(mean=-10.0)
try:
sensor.get_value()
self.fail("OutofBoundsException aurait dû être levée")
except OutofBoundsException:
passif __name__ =='__main__':
_ = unittest.main(verbosity=2)
À faire
Exécutez uv run python tests_sensor.py jusqu’à ce que tous les tests passent.
Correction
Cliquez ici pour afficher la correction.
"""Simulation of a temperature sensor."""import random
import time
classOutofBoundsException(Exception):
"""Raised when a generated value is outside [0, 150]."""classNotRespondingException(Exception):
"""Raised when the sensor simulates a failure to respond."""classSensor:
"""A sensor generating Gaussian-distributed temperature values."""def__init__(self, id: str ="sensor", mean: float =90.0, std: float =5.0,
sleep_time: float =3.0, storage_file_name: str ="data.txt") ->None:
"""Initialize the sensor.
Args:
id (str): Sensor identifier.
mean (float): Mean of the Gaussian distribution.
std (float): Standard deviation of the Gaussian distribution.
sleep_time (float): Time in seconds between two produced values.
storage_file_name (str): Name of the file where values are stored.
""" self.__id = id
self.__mean = mean
self.__std = std
self.__sleep_time = sleep_time
self.__storage_file_name = storage_file_name
defget_id(self) -> str:
"""Returns: str: the sensor identifier."""return self.__id
defget_mean(self) -> float:
"""Returns: float: the Gaussian mean."""return self.__mean
defget_std(self) -> float:
"""Returns: float: the Gaussian standard deviation."""return self.__std
defget_sleep_time(self) -> float:
"""Returns: float: the time in seconds between two produced values."""return self.__sleep_time
defget_storage_file_name(self) -> str:
"""Returns: str: the name of the storage file."""return self.__storage_file_name
defget_value(self) -> float:
"""Generate a new temperature value.
Returns:
float: a value in [0, 150].
Raises:
NotRespondingException: simulated failure (~10% of calls).
OutofBoundsException: the generated value is outside [0, 150].
"""if random.choice(range(10)) ==0:
raise NotRespondingException(f"{self.__id} is not responding")
value = random.gauss(self.__mean, self.__std)
if value <0or value >150:
raise OutofBoundsException(f"{self.__id} produced an out-of-bounds value: {value}")
return value
defrun(self) ->None:
"""Continuously generate values and store them in the storage file."""try:
whileTrue:
with open(self.__storage_file_name, 'a') as file:
try:
file.write(f"{self.get_value()}\n")
except NotRespondingException:
file.write("NR\n")
except OutofBoundsException:
file.write("OoB\n")
time.sleep(self.__sleep_time)
exceptKeyboardInterrupt:
print(f"{self.__id} stopped.")
exceptIOErroras e:
print(f"Error writing to file {self.__storage_file_name}: {e}")
exceptExceptionas e:
print(f"Unexpected error in {self.__id}: {e}")
Avant de continuer, vérifiez :
OutofBoundsException et NotRespondingException sont bien levées et interceptées aux bons endroits
uv run python tests_sensor.py passe sans échec
chaque méthode publique de Sensor a une docstring précisant paramètres, valeur de retour et exceptions levées
La classe Analyzer
Implémentez une classe Analyzer qui reçoit des valeurs (sous forme de texte) une par une, les convertit en float, et calcule des statistiques toutes les __buffer_size valeurs reçues.
Attributs :
__buffer_size : int =10# nombre de valeurs entre deux analyses__values : list[float] = []
__errors : list[str] = []
__logger : logging.Logger
__log_file : str ="analyzer.log"
Méthodes à implémenter :
Méthode
Entrée
Sortie
Exceptions à gérer
__init__
buffer_size: int = 10, log_file: str = "analyzer.log"
None (ajoute value convertie à __values, journalise Added value:<value> en debug ; déclenche log_analysis toutes les __buffer_size valeurs ajoutées)
intercepteValueError : si value n’est pas convertible en float, elle est ajoutée à __errors et journalisée en error (Error adding value:<value>) au lieu d’être ajoutée à __values
log_analysis
—
dict avec les clés average, min, max calculées sur les __buffer_size dernières valeurs (None si __values est vide) ; journalise aussi le résultat en info
—
Copiez-collez le fichier de tests suivant dans tests_analyzer.py :
import os
import unittest
from analyzer import Analyzer
classTestAnalyzer(unittest.TestCase):
defsetUp(self):
self.__log_file ="test_analyzer.log"deftearDown(self):
if os.path.exists(self.__log_file):
os.remove(self.__log_file)
deftest_encapsulation_default_values(self):
a = Analyzer(log_file=self.__log_file)
self.assertEqual(a.get_buffer_size(), 10)
self.assertEqual(a.get_values(), [])
self.assertEqual(a.get_errors(), [])
deftest_encapsulation_custom_values(self):
a = Analyzer(buffer_size=3, log_file=self.__log_file)
self.assertEqual(a.get_buffer_size(), 3)
deftest_add_data_valid_value(self):
a = Analyzer(log_file=self.__log_file)
a.add_data("42.5")
self.assertEqual(a.get_values(), [42.5])
self.assertEqual(a.get_errors(), [])
deftest_add_data_invalid_value(self):
a = Analyzer(log_file=self.__log_file)
a.add_data("not_a_number")
self.assertEqual(a.get_errors(), ["not_a_number"])
self.assertEqual(a.get_values(), [])
deftest_log_analysis_statistics(self):
a = Analyzer(buffer_size=3, log_file=self.__log_file)
a.add_data("10")
a.add_data("20")
# a.add_data("30") # déclenche automatiquement l'analyse stats = a.log_analysis()
if stats isnotNone:
self.assertEqual(stats["average"], 15.0)
self.assertEqual(stats["min"], 10.0)
self.assertEqual(stats["max"], 20.0)
else:
self.fail("log_analysis returned None when it should have returned statistics.")
deftest_log_analysis_no_values(self):
a = Analyzer(log_file=self.__log_file)
self.assertIsNone(a.log_analysis())
if __name__ =='__main__':
_ = unittest.main(verbosity=2)
À faire
Exécutez uv run python tests_analyzer.py jusqu’à ce que tous les tests passent.
Correction
Cliquez ici pour afficher la correction.
"""Analysis of temperature values received from a sensor."""import logging
classAnalyzer:
"""Computes statistics on the latest values received from a sensor."""def__init__(self, buffer_size: int =10, log_file: str ="analyzer.log") ->None:
"""Initialize the analyzer.
Args:
buffer_size (int): Number of values collected between two analyses.
log_file (str): Name of the file receiving the logs.
""" self.__buffer_size = buffer_size
self.__log_file = log_file
self.__values: list[float] = []
self.__errors: list[str] = []
self.__logger = logging.getLogger(f"{__name__}.{id(self)}")
self.__logger.setLevel(logging.DEBUG)
self.__logger.addHandler(logging.FileHandler(self.__log_file))
defget_values(self) -> list[float]:
"""Returns: list[float]: the values received so far."""return self.__values
defget_errors(self) -> list[str]:
"""Returns: list[str]: the invalid values received so far."""return self.__errors
defget_buffer_size(self) -> int:
"""Returns: int: the number of values between two analyses."""return self.__buffer_size
defget_logger(self) -> logging.Logger:
"""Returns: logging.Logger: the logger used by the analyzer."""return self.__logger
defadd_data(self, value: str) ->None:
"""Add a new value coming from the sensor.
Args:
value (str): The raw value read from the data file.
Note:
A value that cannot be converted to float is stored as an
error instead of raising an exception to the caller.
"""try:
converted = float(value)
self.__values.append(converted)
self.__logger.debug(f"Added value:{converted}")
exceptValueError:
self.__errors.append(value)
self.__logger.error(f"Error adding value:{value}")
returnif len(self.__values) % self.__buffer_size ==0:
self.log_analysis()
deflog_analysis(self) -> dict |None:
"""Compute and log statistics on the latest values.
Returns:
dict | None: {"average", "min", "max"} or None if no value
has been received yet.
"""ifnot self.__values:
self.__logger.warning("No valid values available for analysis.")
returnNone window = self.__values[-self.__buffer_size:]
stats = {"average": sum(window) / len(window), "min": min(window), "max": max(window)}
self.__logger.info(f"Analysis results - {stats}")
return stats
Avant de continuer, vérifiez :
la conversion invalide (ValueError) est interceptée et n’interrompt jamais add_data
uv run python tests_analyzer.py passe sans échec
chaque méthode publique de Analyzer a une docstring précisant paramètres, valeur de retour et cas particuliers gérés
La classe Reader
Implémentez une classe Reader qui lit en continu le fichier écrit par le capteur et transmet chaque ligne à un Analyzer.
None (les lignes commençant par # sont ignorées, les autres sont transmises à self.__analyzer.add_data)
—
run
—
None (boucle infinie : ouvre __file_name en lecture, lit une ligne, la traite avec process_line si elle existe, sinon attend __sleep_time secondes)
KeyboardInterrupt → arrêt propre ; FileNotFoundError → message d’erreur puis nouvelle tentative après 2 * __sleep_time secondes ; IOError → idem ; Exception → idem
Copiez-collez le fichier de tests suivant dans tests_reader.py :
import unittest
from reader import Reader
from analyzer import Analyzer
classTestReader(unittest.TestCase):
deftest_encapsulation_default_values(self):
r = Reader(Analyzer())
self.assertEqual(r.get_sleep_time(), 1.0)
self.assertEqual(r.get_file_name(), "data.txt")
deftest_encapsulation_custom_values(self):
r = Reader(Analyzer(), sleep_time=0.5, file_name="in.txt")
self.assertEqual(r.get_sleep_time(), 0.5)
self.assertEqual(r.get_file_name(), "in.txt")
deftest_process_line_ignores_comments(self):
a = Analyzer()
r = Reader(a)
r.process_line("#Sensor ID: sensor\n")
self.assertEqual(a.get_values(), [])
self.assertEqual(a.get_errors(), [])
deftest_process_line_forwards_value(self):
a = Analyzer()
r = Reader(a)
r.process_line("42.0\n")
self.assertEqual(a.get_values(), [42.0])
if __name__ =='__main__':
_ = unittest.main(verbosity=2)
À faire
Exécutez uv run python tests_reader.py jusqu’à ce que tous les tests passent.
Correction
Cliquez ici pour afficher la correction.
"""Reads temperature data written by a Sensor and forwards it to an Analyzer."""import time
from analyzer import Analyzer
classReader:
"""Continuously reads a data file and forwards each line to an Analyzer."""def__init__(self, analyzer: Analyzer, sleep_time: float =1.0, file_name: str ="data.txt") ->None:
"""Initialize the reader.
Args:
analyzer (Analyzer): The analyzer receiving the read values.
sleep_time (float): Time in seconds to wait when no new line is available.
file_name (str): Name of the file to read from.
""" self.__analyzer = analyzer
self.__sleep_time = sleep_time
self.__file_name = file_name
defget_analyzer(self) -> Analyzer:
"""Returns: Analyzer: the associated analyzer."""return self.__analyzer
defget_sleep_time(self) -> float:
"""Returns: float: the wait time between two read attempts."""return self.__sleep_time
defget_file_name(self) -> str:
"""Returns: str: the name of the file being read."""return self.__file_name
defprocess_line(self, line: str) ->None:
"""Forward a data line to the analyzer, ignoring comment lines.
Args:
line (str): A line read from the data file.
"""if line.startswith('#'):
return self.__analyzer.add_data(line.strip())
defrun(self) ->None:
"""Continuously read the data file and process new lines."""whileTrue:
try:
with open(self.__file_name, 'r') as file:
whileTrue:
line = file.readline()
if line:
self.process_line(line)
else:
time.sleep(self.__sleep_time)
exceptKeyboardInterrupt:
print("Reader stopped.")
breakexceptFileNotFoundError:
print(f"File '{self.__file_name}' not found, retrying...")
time.sleep(2* self.__sleep_time)
exceptIOErroras e:
print(f"Error reading '{self.__file_name}': {e}")
time.sleep(2* self.__sleep_time)
exceptExceptionas e:
print(f"Unexpected error: {e}")
time.sleep(2* self.__sleep_time)
Avant de continuer, vérifiez :
les quatre exceptions de run sont interceptées séparément, avec le bon comportement pour chacune
uv run python tests_reader.py passe sans échec
chaque méthode publique de Reader a une docstring précisant paramètres, valeur de retour et exceptions gérées
Test de bout en bout
Ajoutez à la fin de sensor.py :
if __name__ =="__main__":
Sensor().run()
et à la fin de reader.py :
if __name__ =="__main__":
Reader(Analyzer()).run()
Ouvrez deux terminaux dans votre répertoire de travail et lancez-y respectivement python sensor.py et python reader.py, dans n’importe quel ordre. Le lecteur doit patienter tant que le fichier n’existe pas ou est vide, puis afficher/journaliser les analyses au fur et à mesure. Arrêtez et relancez le capteur (Ctrl-C puis nouvelle exécution) : le lecteur ne doit pas s’arrêter pour autant.
Avant de conclure, vérifiez :
le lecteur survit à l’absence, puis à l’arrêt, du capteur sans planter
pylint sensor.py analyzer.py reader.py ne signale plus de violation majeure de la PEP8
la documentation générée par pydoc -w sensor analyzer reader est complète et compréhensible sans lire le code
Pour aller plus loin
Si le temps le permet :
Plusieurs types de capteurs. Ajoutez un second type de capteur qui retourne un booléen (test de bon fonctionnement) au lieu d’un réel, en préfixant chaque écriture par le nom et le type du capteur (ex. sensor1:BinarySensor:1). Documentez et vérifiez la PEP8 ; les tests fournis ne couvrent pas cette extension, à vous d’en écrire de nouveaux si vous le souhaitez.
Plusieurs analyseurs. Faites en sorte qu’un Reader puisse envoyer les données de chaque capteur à un analyseur dédié (statistiques par capteur, et un analyseur d’erreurs séparé déclenché à l’arrêt du programme plutôt qu’à intervalle régulier).
Parallélisme. Plutôt que deux terminaux séparés, utilisez le module threading pour exécuter le capteur et le lecteur en parallèle dans un même programme. Vous découvrirez en S6 des mécanismes de communication inter-processus (IPC) plus adaptés qu’un fichier partagé pour ce genre d’échange.