Activité pratique

Durée2h

Consignes globales

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.

Architecture du système

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.

Aides techniques Python (fichiers, pause, aléatoire, logs)

Lecture/écriture dans un fichier. Utilisez systématiquement un bloc with, qui referme automatiquement le fichier :

with open("data.txt", 'r') as file:
    line = file.readline()
    # 'r' : lecture, 'w' : écriture (écrase), 'a' : ajout en fin de fichier

Pour rester simple, ouvrez un nouveau descripteur à chaque tentative de lecture/écriture plutôt que de garder le fichier ouvert en continu.

Temporisation. time.sleep(x) met le programme en pause x secondes :

import time
time.sleep(.7)

Génération pseudo-aléatoire.

import random
random.choice(range(10))       # entier entre 0 et 9
random.gauss(mean, std)        # flottant selon une loi gaussienne

Logs.

import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
logger.addHandler(logging.FileHandler("analyzer.log"))
logger.info("message")   # aussi : .debug, .warning, .error

La classe Sensor

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.

Attributs (avec leurs valeurs par défaut) :

__id : str = "sensor"
__mean : float = 90.0
__std : float = 5.0
__sleep_time : float = 3.0
__storage_file_name : str = "data.txt"

Méthodes à implémenter :

Méthode Entrée Sortie Exceptions à gérer
__init__ les 5 attributs ci-dessus, tous optionnels — —
get_id, get_mean, get_std, get_sleep_time, get_storage_file_name — valeur de l’attribut correspondant —
get_value — float compris entre 0 et 150 lève OutofBoundsException si la valeur générée est hors de [0, 150] ; lève NotRespondingException 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 intercepter KeyboardInterrupt (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


class TestSensor(unittest.TestCase):

    def test_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")

    def test_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")

    def test_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 = 0

        for _ in range(number_of_calls):
            try:
                value = sensor.get_value()
                self.assertIsInstance(value, float)
                self.assertTrue(0 <= value <= 150)
                seen_ok += 1
            except NotRespondingException:
                seen_nr += 1
            except 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:
            pass


if __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


class OutofBoundsException(Exception):
    """Raised when a generated value is outside [0, 150]."""


class NotRespondingException(Exception):
    """Raised when the sensor simulates a failure to respond."""


class Sensor:
    """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

    def get_id(self) -> str:
        """Returns: str: the sensor identifier."""
        return self.__id

    def get_mean(self) -> float:
        """Returns: float: the Gaussian mean."""
        return self.__mean

    def get_std(self) -> float:
        """Returns: float: the Gaussian standard deviation."""
        return self.__std

    def get_sleep_time(self) -> float:
        """Returns: float: the time in seconds between two produced values."""
        return self.__sleep_time

    def get_storage_file_name(self) -> str:
        """Returns: str: the name of the storage file."""
        return self.__storage_file_name

    def get_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 < 0 or value > 150:
            raise OutofBoundsException(f"{self.__id} produced an out-of-bounds value: {value}")
        return value

    def run(self) -> None:
        """Continuously generate values and store them in the storage file."""
        try:
            while True:
                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)
        except KeyboardInterrupt:
            print(f"{self.__id} stopped.")
        except IOError as e:
            print(f"Error writing to file {self.__storage_file_name}: {e}")
        except Exception as 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" — —
get_values, get_errors, get_buffer_size, get_logger — valeur de l’attribut correspondant —
add_data value: str None (ajoute value convertie à __values, journalise Added value:<value> en debug ; déclenche log_analysis toutes les __buffer_size valeurs ajoutées) intercepte ValueError : 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


class TestAnalyzer(unittest.TestCase):

    def setUp(self):
        self.__log_file = "test_analyzer.log"

    def tearDown(self):
        if os.path.exists(self.__log_file):
            os.remove(self.__log_file)

    def test_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(), [])

    def test_encapsulation_custom_values(self):
        a = Analyzer(buffer_size=3, log_file=self.__log_file)
        self.assertEqual(a.get_buffer_size(), 3)

    def test_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(), [])

    def test_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(), [])

    def test_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 is not None:
            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.")

    def test_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


class Analyzer:
    """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))

    def get_values(self) -> list[float]:
        """Returns: list[float]: the values received so far."""
        return self.__values

    def get_errors(self) -> list[str]:
        """Returns: list[str]: the invalid values received so far."""
        return self.__errors

    def get_buffer_size(self) -> int:
        """Returns: int: the number of values between two analyses."""
        return self.__buffer_size

    def get_logger(self) -> logging.Logger:
        """Returns: logging.Logger: the logger used by the analyzer."""
        return self.__logger

    def add_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}")
        except ValueError:
            self.__errors.append(value)
            self.__logger.error(f"Error adding value:{value}")
            return
        if len(self.__values) % self.__buffer_size == 0:
            self.log_analysis()

    def log_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.
        """
        if not self.__values:
            self.__logger.warning("No valid values available for analysis.")
            return None
        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.

Attributs :

__analyzer : Analyzer
__sleep_time : float = 1.0
__file_name : str = "data.txt"

Méthodes à implémenter :

Méthode Entrée Sortie Exceptions à gérer
__init__ analyzer: Analyzer, sleep_time: float = 1.0, file_name: str = "data.txt" — —
get_analyzer, get_sleep_time, get_file_name — valeur de l’attribut correspondant —
process_line line: str 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


class TestReader(unittest.TestCase):

    def test_encapsulation_default_values(self):
        r = Reader(Analyzer())
        self.assertEqual(r.get_sleep_time(), 1.0)
        self.assertEqual(r.get_file_name(), "data.txt")

    def test_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")

    def test_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(), [])

    def test_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


class Reader:
    """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

    def get_analyzer(self) -> Analyzer:
        """Returns: Analyzer: the associated analyzer."""
        return self.__analyzer

    def get_sleep_time(self) -> float:
        """Returns: float: the wait time between two read attempts."""
        return self.__sleep_time

    def get_file_name(self) -> str:
        """Returns: str: the name of the file being read."""
        return self.__file_name

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

    def run(self) -> None:
        """Continuously read the data file and process new lines."""
        while True:
            try:
                with open(self.__file_name, 'r') as file:
                    while True:
                        line = file.readline()
                        if line:
                            self.process_line(line)
                        else:
                            time.sleep(self.__sleep_time)
            except KeyboardInterrupt:
                print("Reader stopped.")
                break
            except FileNotFoundError:
                print(f"File '{self.__file_name}' not found, retrying...")
                time.sleep(2 * self.__sleep_time)
            except IOError as e:
                print(f"Error reading '{self.__file_name}': {e}")
                time.sleep(2 * self.__sleep_time)
            except Exception as 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.