# Les objets au travail : état, masquage de l'information, properties et une classe complète<no value>

## Dans cette leçon

Cette leçon est construite comme une seule histoire, dans le même ordre que les diapositives. Chaque chapitre s'appuie sur le précédent :

1. **Chapitre 1** — les objets et l'état : de nombreux objets, un état qui change, et un objet qui contient un objet
2. **Chapitre 2** — le masquage de l'information : n'importe quel code peut casser les données, alors nous les cachons derrière une interface
3. **Chapitre 3** — la validation : la syntaxe simple d'un champ, et une vérification à chaque affectation avec `@property`
4. **Chapitre 4** — une classe complète : tout relier en une seule classe avec une responsabilité, des règles et une interface

À la fin de la leçon — de la pratique, et un **mini-projet** qui relie tous les chapitres.

## Chapitre 1 : Les objets et l'état — de nombreux objets et un état qui change

### Qu'allons-nous apprendre ?

- Comment gérer **de nombreux objets** : des listes, la recherche, le filtrage et le tri.
- Comment l'**état** (State) des objets change tout au long du programme.
- **Un objet qui contient un autre objet** (par exemple : une commande qui contient un client).
- **Les références** : deux noms pour le même objet, face à deux objets.

### Pourquoi en avons-nous besoin ?

Un vrai système n'est pas un seul objet. C'est **des centaines d'objets** qui communiquent entre eux : des clients, des commandes, des produits. Il faut savoir trouver le client d'identifiant 7, toutes les commandes non payées, et mettre à jour le stock quand une commande est expédiée. Et aussi comprendre ce qui se passe quand on modifie un objet que plusieurs parties du système « détiennent ».

### Explication à partir des bases

#### Des objets dans une liste

Une liste peut contenir des objets exactement comme elle contient des nombres ou des dicts. Tout ce que nous avons appris dans les thèmes Python fonctionne :

| Action | Avec des dicts (les thèmes Python) | Avec des objets |
|---|---|---|
| Accéder à un champ | `item["price"]` | `item.price` |
| Trier | `sorted(items, key=lambda i: i["price"])` | `sorted(items, key=lambda i: i.price)` |
| Filtrer | `if item["price"] > 100` | `if item.price > 100` |
| Une action | `deposit(account, 50)` | `account.deposit(50)` |

#### L'état au fil du temps

**L'état** = les valeurs actuelles des attributs. Un objet « vit » tout au long du programme, et son état change par ses méthodes : une commande est créée à l'état `"new"`, passe à `"paid"`, puis à `"shipped"`. Chaque objet suit ce parcours **séparément**.

#### Un objet qui contient un objet

Un attribut peut contenir **n'importe quelle valeur**, y compris **un autre objet** :

```python
order.customer = dana      # the order "knows" its customer
```

`order.customer` **est** un objet `Customer`, on peut donc continuer : `order.customer.name`.

#### Les références — un rappel essentiel

Souvenez-vous des thèmes Python : **une variable est une étiquette sur un objet**. C'est aussi vrai pour nos propres objets :

```text
dana ──────────────►┌──────────────┐
order1.customer ───►│ Customer     │   ONE object, three names
order2.customer ───►│ name: "Dana" │
                    └──────────────┘
```

Si Dana change d'adresse (`dana.city = "Tel Aviv"`), **les deux commandes** voient immédiatement la nouvelle adresse. Elles ne contiennent pas **une copie**, elles contiennent **une référence** vers la même Dana. C'est généralement exactement ce que l'on veut : il y a **une seule Dana** dans le système.

{{< box "analogy" "Analogie : un contact dans le téléphone" >}}
Plusieurs groupes WhatsApp contiennent « Dana ». Quand Dana change sa photo de profil, elle est mise à jour **dans tous** les groupes, car les groupes ne contiennent pas **une photo** de Dana. Ils contiennent **un lien** vers la vraie Dana.
{{< /box >}}

### Syntaxe

```python
objects = [ClassName(...), ClassName(...)]

for obj in objects:                       # loop
    obj.method()

found = None
for obj in objects:                       # search
    if obj.attr == target:
        found = obj
        break

matching = [obj for obj in objects if obj.attr > 10]   # filter (list comprehension)

ordered = sorted(objects, key=lambda obj: obj.attr)    # sort

holder.other = some_object                # an attribute that holds another object
holder.other.attr                         # chain through it
```

Une **List Comprehension** (la 3e ligne en partant du bas) est une façon courte de construire une liste filtrée. Elle équivaut à une boucle :

```python
matching = []
for obj in objects:
    if obj.attr > 10:
        matching.append(obj)
```

Utilisez la version qui vous paraît la plus lisible. Les deux sont correctes.

### Premier exemple de code

`week-3/team.py` :

```python
class Player:
    def __init__(self, name, score=0):
        self.name = name
        self.score = score

    def add_points(self, points):
        self.score = self.score + points

    def __str__(self):
        return self.name + ": " + str(self.score)

team = [Player("Dana"), Player("Avi"), Player("Noa")]

team[0].add_points(30)
team[1].add_points(15)
team[2].add_points(40)
team[0].add_points(20)

for player in team:
    print(player)

leader = max(team, key=lambda player: player.score)
print("Leader:", leader.name)

ranking = sorted(team, key=lambda player: player.score, reverse=True)
for place, player in enumerate(ranking, start=1):
    print(place, player)
```

**Sortie :**

```text
Dana: 50
Avi: 15
Noa: 40
Leader: Dana
1 Dana: 50
2 Noa: 40
3 Avi: 15
```

### Analyse du code ligne par ligne

| Code | Explication |
|---|---|
| `team = [Player("Dana"), ...]` | Une liste de 3 objets **séparés**, chacun avec son propre `score`. |
| `team[0].add_points(30)` | `team[0]` est un objet Player. Nous appelons sa méthode. Seul **son** état change. |
| `team[0].add_points(20)` | Le même objet à nouveau. Son score monte à 50. |
| `max(team, key=lambda p: p.score)` | Trouve l'objet au score le plus élevé, et renvoie **l'objet entier**. |
| `sorted(..., reverse=True)` | Une **nouvelle** liste triée. `team` lui-même n'a pas changé. Mais les objets **sont les mêmes objets**, seul leur ordre dans la nouvelle liste est différent. |

### Un autre exemple

**Des clients et des commandes — des objets qui contiennent des objets, et un état qui change.** `week-3/orders.py` :

```python
class Customer:
    def __init__(self, name, city):
        self.name = name
        self.city = city

class Order:
    VALID_STATUSES = ["new", "paid", "shipped"]
    next_number = 1

    def __init__(self, customer, total):
        self.number = Order.next_number
        Order.next_number = Order.next_number + 1
        self.customer = customer
        self.total = total
        self.status = "new"

    def advance(self):
        position = Order.VALID_STATUSES.index(self.status)
        if position == len(Order.VALID_STATUSES) - 1:
            raise ValueError("order " + str(self.number) + " is already shipped")
        self.status = Order.VALID_STATUSES[position + 1]

    def __str__(self):
        return ("Order #" + str(self.number) + " | " + self.customer.name + " (" + self.customer.city
                + ") | " + str(self.total) + " NIS | " + self.status)

dana = Customer("Dana", "Haifa")
avi = Customer("Avi", "Eilat")

orders = [Order(dana, 120), Order(avi, 80), Order(dana, 300)]

orders[0].advance()
orders[0].advance()
orders[2].advance()

dana.city = "Tel Aviv"

for order in orders:
    print(order)

dana_orders = [order for order in orders if order.customer is dana]
print("Dana's total:", sum(order.total for order in dana_orders))

not_shipped = [order.number for order in orders if order.status != "shipped"]
print("Not shipped yet:", not_shipped)
```

**Sortie :**

```text
Order #1 | Dana (Tel Aviv) | 120 NIS | shipped
Order #2 | Avi (Eilat) | 80 NIS | new
Order #3 | Dana (Tel Aviv) | 300 NIS | paid
Dana's total: 420
Not shipped yet: [2, 3]
```

**Analyse :**

- `Order(dana, 120)` — la commande **contient une référence** vers l'objet `dana`. Elle ne copie ni le nom ni la ville.
- `self.customer.name` — **le chaînage** : de la commande au client, et de là au nom du client.
- `dana.city = "Tel Aviv"` — **une seule** modification, et **les deux** commandes de Dana affichent la nouvelle ville. Si nous avions stocké `self.city = customer.city` dans `__init__`, les commandes seraient « bloquées » sur Haïfa.
- `advance()` — une **machine à états** (State Machine) simple : new → paid → shipped, et on ne peut ni sauter ni revenir en arrière. Chaque commande avance **séparément**.
- `order.customer is dana` — `is` est **correct** ici : nous demandons vraiment « est-ce **le même objet** ? » (et non « a-t-il le même nom ? », car il peut y avoir deux clientes nommées Dana).
- `sum(order.total for order in dana_orders)` — une somme en une ligne (une Generator Expression, comme dans le `max` des thèmes Python).

### Résumé

- Les objets dans une liste fonctionnent comme n'importe quelle valeur : parcourir, rechercher, `sorted`/`max` avec `key=lambda obj: obj.attr`.
- Chaque objet contient **son propre état**, qui change par ses méthodes.
- Un attribut peut contenir **un autre objet** (`order.customer`), et on y accède par chaînage : `order.customer.name`.
- C'est **une référence**, pas une copie. Une modification de l'objet est visible par tous ceux qui le détiennent.
- `b = a` = un autre nom, pas un nouvel objet. `is` vérifie « le même objet ».
- Modifier l'état « de l'extérieur » contourne les règles. Au chapitre suivant : l'encapsulation.

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

### Documentation officielle

- [Instance Objects](https://docs.python.org/3/tutorial/classes.html#instance-objects)

<!-- docs:end -->

## Chapitre 2 : Le masquage de l'information — l'encapsulation

### Qu'allons-nous apprendre ?

- Ce qu'est l'**encapsulation** (Encapsulation) et **pourquoi** elle existe.
- Une **interface publique** (Public Interface) face à l'**implémentation** interne.
- Les conventions de Python : **`_protected`** et **`__private`**.
- Ce qu'est le **Name Mangling**.

### Pourquoi en avons-nous besoin ?

Jusqu'ici, nous avons écrit des méthodes avec de belles vérifications, par exemple `withdraw` qui vérifie qu'il y a assez d'argent. Mais :

```python
account = BankAccount("Dana", 100)
account.balance = -1_000_000
```

**Une seule ligne a contourné toutes les règles.** Pas d'erreur, pas de journal. Le `withdraw` soigneusement écrit ne s'est tout simplement pas exécuté. Dans un vrai système, avec des dizaines de développeurs et des centaines de fichiers, **quelqu'un** écrira une ligne comme celle-ci (peut-être vous, dans un mois, « juste pour un test »).

Un autre problème : supposons que nous ayons décidé de stocker le solde en **agorot** (les centimes du shekel, `balance_cents`) pour éviter les problèmes d'arrondi. Si 30 endroits du code accèdent directement à `account.balance`, ils cassent tous. S'ils avaient utilisé des méthodes, nous ne changerions **que les méthodes**.

L'encapsulation résout les deux problèmes.

### Explication à partir des bases

#### Qu'est-ce que l'encapsulation ?

**L'encapsulation** = **emballer** les données avec le code qui les gère, et **cacher** les détails internes, pour que le monde extérieur ne travaille qu'à travers **une interface définie**.

Deux idées :

1. **Protéger les règles (les invariants)** — les données sensibles ne changent **que** par des méthodes qui vérifient qu'elles restent valides. « Le solde n'est pas négatif », « une note est entre 0 et 100 », « le statut ne fait qu'avancer ».
2. **Cacher l'implémentation** — celui qui utilise la classe doit savoir **ce** qu'elle fait, pas **comment**. Ainsi, on peut changer le « comment » sans casser ceux qui l'utilisent.

#### Interface publique face à implémentation

| | Public Interface | Implementation |
|---|---|---|
| Quoi | Les méthodes et attributs **destinés** à être utilisés de l'extérieur | Les données et méthodes internes qui permettent à l'interface de fonctionner |
| Exemple dans un compte bancaire | `deposit()`, `withdraw()`, `get_balance()` | Comment le solde est stocké, la liste des transactions, les vérifications d'aide |
| Stabilité | Change rarement. D'autres en dépendent | Peut changer librement |

{{< box "analogy" "Analogie : un distributeur de billets" >}}
Un distributeur a une **interface publique** : un écran, des touches, une fente pour la carte, une ouverture pour les billets. Vous pouvez « retirer 200 », et le distributeur **vérifie** le code, le solde et la limite quotidienne. **Le coffre à l'intérieur** (l'implémentation) est verrouillé. Si n'importe qui pouvait ouvrir le coffre et prendre l'argent directement, toutes les vérifications n'auraient aucun sens. Et la banque peut remplacer le mécanisme de comptage à l'intérieur sans que vous le remarquiez, car l'interface reste la même.
{{< /box >}}

#### Comment Python le fait : des conventions

Dans des langages comme Java, il y a un vrai « verrouillage » (`private`). **En Python, il n'y a pas de verrouillage absolu.** Il y a des **conventions** que tous les développeurs Python respectent :

| S'écrit | Nom | La signification | Ce que fait Python |
|---|---|---|---|
| `name` | Public | Fait partie de l'interface. Utilisable de l'extérieur | Rien |
| `_name` | Protected (une convention) | **« Interne, ne pas toucher de l'extérieur »** | **Rien**. C'est seulement un message aux développeurs. VS Code ne le proposera pas non plus dans l'autocomplétion |
| `__name` | Private (Name Mangling) | **« Très interne »** | **Change le nom** en coulisses, pour qu'un accès de l'extérieur « par erreur » échoue |

**La philosophie de Python :** « Nous sommes tous des adultes consentants ici ». Le langage vous fait confiance. Un tiret bas dit « ce n'est pas pour toi ». Celui qui y touche quand même en prend la responsabilité.

#### Le Name Mangling

Quand vous écrivez `self.__balance` dans une classe nommée `BankAccount`, Python **change** en fait le nom en `_BankAccount__balance`. C'est pourquoi `account.__balance` de l'extérieur échoue avec une `AttributeError`. Ce n'est pas de la sécurité (on peut toujours y accéder via `account._BankAccount__balance`), mais cela empêche l'accès **par erreur**, et rend l'intention très claire.

**Dans ce cours :** nous utiliserons surtout **`_name`** (un seul tiret bas), qui est la façon la plus répandue dans le vrai code Python. Nous découvrons `__name` pour savoir ce qu'il fait quand nous le rencontrons.

### Syntaxe

```python
class ClassName:
    def __init__(self, value):
        self.public_value = value      # public: part of the interface
        self._internal = []            # protected by convention: "don't touch from outside"
        self.__secret = 0              # private: name-mangled to _ClassName__secret

    def public_method(self):           # public interface
        self._helper()
        return self.__secret

    def _helper(self):                 # internal helper method
        ...
```

### Premier exemple de code

`week-3/encapsulated_account.py` :

```python
class BankAccount:
    def __init__(self, owner, balance=0):
        if balance < 0:
            raise ValueError("initial balance cannot be negative")
        self.owner = owner
        self._balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("deposit must be positive")
        self._balance = self._balance + amount

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("withdrawal must be positive")
        if amount > self._balance:
            raise ValueError("insufficient funds")
        self._balance = self._balance - amount

    def get_balance(self):
        return self._balance

account = BankAccount("Dana", 100)
account.deposit(50)
account.withdraw(30)
print("Balance:", account.get_balance())

try:
    account.withdraw(1000)
except ValueError as error:
    print("Rejected:", error)
print("Balance still:", account.get_balance())
```

**Sortie :**

```text
Balance: 120
Rejected: insufficient funds
Balance still: 120
```

### Analyse du code ligne par ligne

| Code | Explication |
|---|---|
| `self.owner` | **Public**. On peut le lire, et il n'y a pas de règle particulière à protéger. |
| `self._balance` | **Protected (une convention)**. Le tiret bas dit à chaque développeur : « le solde ne se modifie pas directement ». |
| `deposit` / `withdraw` | **L'interface publique pour les modifications** : la **seule** façon respectable de modifier le solde. Chaque modification passe par une vérification. |
| `get_balance()` | **Un Getter** : une méthode qui renvoie la valeur en lecture. De l'extérieur, on peut **lire** le solde, mais pas le **modifier** directement. |
| `account.withdraw(1000)` échoue | Et le solde **n'a pas changé**. La règle a été respectée. |

**Et si quelqu'un écrit quand même `account._balance = -999` ?** Python ne l'arrêtera pas. Mais le tiret bas en fait une **erreur claire et visible** dans le code : quiconque fait une Code Review verra immédiatement « on touche `_balance` de l'extérieur — interdit ». C'est exactement le but.

### Un autre exemple

**`__private` et le Name Mangling en action, et une méthode d'aide interne.** `week-3/secure_vault.py` :

```python
class Vault:
    MAX_ATTEMPTS = 3

    def __init__(self, code):
        self.__code = code
        self._failed_attempts = 0
        self.is_locked = False

    def open(self, code):
        if self.is_locked:
            return "Vault is locked"
        if self._check_code(code):
            self._failed_attempts = 0
            return "Vault opened"
        self._failed_attempts = self._failed_attempts + 1
        if self._failed_attempts >= Vault.MAX_ATTEMPTS:
            self.is_locked = True
            return "Wrong code - vault is now LOCKED"
        return "Wrong code (" + str(self._failed_attempts) + "/" + str(Vault.MAX_ATTEMPTS) + ")"

    def _check_code(self, code):
        return code == self.__code

vault = Vault("1234")
print(vault.open("0000"))
print(vault.open("1234"))

try:
    print(vault.__code)
except AttributeError as error:
    print("AttributeError:", error)

print("Mangled name:", vault._Vault__code)
```

**Sortie :**

```text
Wrong code (1/3)
Vault opened
AttributeError: 'Vault' object has no attribute '__code'
Mangled name: 1234
```

**Analyse :**

- `self.__code` — dans la classe on écrit `__code`, et Python le stocke en fait sous `_Vault__code`.
- `vault.__code` de l'extérieur → `AttributeError`. On ne peut pas y accéder « par erreur ».
- `vault._Vault__code` → fonctionne ! **Le Name Mangling n'est pas de la sécurité**. Un vrai mot de passe n'est jamais conservé dans le code (et certainement pas en clair). Ici, ce n'est qu'un exemple de démonstration.
- `_check_code` — **une méthode d'aide interne**. Elle fait partie de l'implémentation, pas de l'interface. Demain, on pourra la remplacer par une vérification chiffrée, et aucun code extérieur ne cassera, car personne à l'extérieur n'est censé l'appeler.
- `is_locked` — public, en lecture. (De l'extérieur, on peut aussi y écrire, et c'est un petit « trou ». Au chapitre suivant, nous verrons comment rendre un attribut en lecture seule avec `@property`.)

### Résumé

- **L'encapsulation** = les données + le code qui les gère ensemble, en n'exposant qu'**une interface**.
- **Pourquoi :** (1) protéger les règles : les modifications ne passent que par des méthodes qui vérifient. (2) cacher l'implémentation : on peut changer le « comment » sans casser ceux qui l'utilisent.
- `name` = Public. **`_name`** = « interne, ne pas toucher » (une convention, la plus répandue). `__name` = Name Mangling (`_Class__name`).
- Python ne verrouille pas. Les conventions rendent l'intention claire et évitent les erreurs. Ce n'est **pas** de la sécurité.
- Renvoyez une **copie** d'une liste/d'un dict interne, pas l'original.
- Ne cachez pas tout. Cachez quand il y a une règle ou un détail d'implémentation.

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

### Documentation officielle

- [Private Variables](https://docs.python.org/3/tutorial/classes.html#private-variables)

<!-- docs:end -->

## Chapitre 3 : La validation — Getters, Setters et `@property`

### Qu'allons-nous apprendre ?

- Ce que sont un **Getter** et un **Setter**.
- **La validation** : vérifier chaque nouvelle valeur **avant** de l'enregistrer.
- **`@property`** : la façon élégante de le faire en Python.
- **Un attribut en lecture seule** et **un attribut calculé**.

### Pourquoi en avons-nous besoin ?

Dans le chapitre précédent, nous avons protégé `_balance`, car il avait des méthodes naturelles (`deposit`, `withdraw`). Mais qu'en est-il des données qui **sont** censées changer directement, seulement **dans le respect de règles** ?

- L'**âge** d'un utilisateur : on peut le mettre à jour, mais pas à (-5) ni à 300.
- Le **prix** d'un produit : on peut le changer, mais pas en un prix négatif.
- Un **e-mail** : on peut le mettre à jour, mais seulement vers une adresse avec `@`.

Si `age` est un attribut ordinaire, n'importe qui peut écrire `user.age = -5`. Si on le cache (`_age`), il faut une façon **ordonnée** de le lire et de le modifier. C'est le Getter et le Setter.

### Explication à partir des bases

#### Getter et Setter — l'idée

- **Getter** — une méthode qui renvoie la valeur d'une donnée interne. `get_age()`.
- **Setter** — une méthode qui reçoit une nouvelle valeur, **la vérifie**, et seulement si elle est valide, l'enregistre. `set_age(new_age)`.

Ainsi, la classe a **le contrôle total** : chaque lecture et chaque écriture passent par elle.

{{< box "analogy" "Analogie : un employé de banque" >}}
Vous n'allez pas vous-même dans votre dossier aux archives de la banque pour changer votre adresse au stylo. Vous allez voir un employé (un Setter) : il **vérifie** que l'adresse est valide et que vous êtes autorisé, et seulement ensuite il la met à jour. Et celui qui veut voir l'adresse la demande à l'employé (un Getter). L'employé peut décider quoi montrer, et peut-être cacher certains détails.
{{< /box >}}

#### Le problème avec `get_` et `set_` en Python

Cela fonctionne, mais c'est lourd :

```python
user.set_age(user.get_age() + 1)
```

Comparé à :

```python
user.age = user.age + 1
```

De plus, si au départ `age` était un attribut ordinaire et que 50 endroits du code écrivent `user.age`, et que plus tard vous décidez qu'il faut une validation, il faut changer les 50 endroits en `get_`/`set_`.

#### `@property` — la solution de Python

**`@property`** permet de définir une méthode qui ressemble **de l'extérieur à un attribut ordinaire** :

- Quand vous écrivez `user.age`, **la méthode Getter** est appelée.
- Quand vous écrivez `user.age = 30`, **la méthode Setter** est appelée, avec la validation.

Le code qui utilise la classe écrit `user.age`, comme d'habitude, mais en coulisses chaque accès passe par les vérifications. **Le meilleur des deux mondes.**

`@property` est un **décorateur** — exactement ce que nous avons appris dans la leçon sur les décorateurs : une ligne qui commence par `@` au-dessus d'un `def`, et qui « enveloppe » la fonction avec un comportement supplémentaire. Ici, il suffit de connaître le modèle de `@property`. (Dans les thèmes backend et bases de données, FastAPI utilisera des décorateurs comme `@app.get(...)`.)

### Syntaxe

**Version 1 — les méthodes Getter/Setter classiques (l'idée) :**

```python
class ClassName:
    def __init__(self, value):
        self._value = None
        self.set_value(value)          # validation also on creation

    def get_value(self):
        return self._value

    def set_value(self, new_value):
        if not valid(new_value):
            raise ValueError("...")
        self._value = new_value
```

**Version 2 — `@property` (la façon en usage en Python) :**

```python
class ClassName:
    def __init__(self, value):
        self.value = value             # goes through the setter below!

    @property
    def value(self):                   # getter: runs on  obj.value
        return self._value

    @value.setter
    def value(self, new_value):        # setter: runs on  obj.value = x
        if not valid(new_value):
            raise ValueError("...")
        self._value = new_value
```

- Les deux méthodes ont **le même nom** (`value`), le nom de l'« attribut » public.
- La vraie donnée est stockée sous un **autre** nom, avec un tiret bas (`_value`).
- `@property` au-dessus du Getter. `@value.setter` (le nom de la property + `.setter`) au-dessus du Setter.
- **Lecture seule :** ne définissez que `@property`, sans Setter.

### Premier exemple de code

**L'idée — un Getter et un Setter classiques.** `week-3/user_classic.py` :

```python
class User:
    MIN_AGE = 0
    MAX_AGE = 120

    def __init__(self, name, age):
        self.name = name
        self._age = None
        self.set_age(age)

    def get_age(self):
        return self._age

    def set_age(self, new_age):
        if not isinstance(new_age, int):
            raise TypeError("age must be an int, got " + type(new_age).__name__)
        if new_age < User.MIN_AGE or new_age > User.MAX_AGE:
            raise ValueError("age must be between 0 and 120, got " + str(new_age))
        self._age = new_age

dana = User("Dana", 24)
dana.set_age(dana.get_age() + 1)
print(dana.name, dana.get_age())

for bad_value in [-5, "thirty"]:
    try:
        dana.set_age(bad_value)
    except (ValueError, TypeError) as error:
        print("Rejected:", error)

print("Age unchanged:", dana.get_age())
```

**Sortie :**

```text
Dana 25
Rejected: age must be between 0 and 120, got -5
Rejected: age must be an int, got str
Age unchanged: 25
```

### Analyse du code ligne par ligne

| Code | Explication |
|---|---|
| `self._age = None` puis `self.set_age(age)` | Même à la création, l'âge passe **par le Setter**, on ne peut donc pas créer un utilisateur avec un âge invalide. La validation **à un seul endroit**. |
| `get_age()` | Un Getter : renvoie la valeur interne. |
| `set_age(new_age)` | Un Setter : vérifie d'abord le **type** (`isinstance`), puis la **plage**, et seulement ensuite enregistre. |
| `type(new_age).__name__` | Le nom du type sous forme de texte propre (`"str"`), au lieu de `<class 'str'>`. |
| `dana.set_age(dana.get_age() + 1)` | Cela fonctionne, mais c'est lourd. C'est la motivation de `@property`. |
| `Age unchanged: 25` | Les valeurs invalides **n'ont pas été enregistrées**. |

### Un autre exemple

**La même idée avec `@property`, plus un attribut en lecture seule et un attribut calculé.** `week-3/product_property.py` :

```python
class Product:
    def __init__(self, sku, name, price):
        self._sku = sku
        self.name = name
        self.price = price

    @property
    def sku(self):
        return self._sku

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, new_price):
        if not isinstance(new_price, (int, float)):
            raise TypeError("price must be a number")
        if new_price <= 0:
            raise ValueError("price must be positive, got " + str(new_price))
        self._price = new_price

    @property
    def price_with_vat(self):
        return round(self._price * 1.17, 2)

laptop = Product("A1", "Laptop", 3000)
print(laptop.sku, laptop.name, laptop.price, laptop.price_with_vat)

laptop.price = laptop.price * 0.9
print("After discount:", laptop.price, laptop.price_with_vat)

try:
    laptop.price = -100
except ValueError as error:
    print("Rejected:", error)

try:
    laptop.sku = "HACKED"
except AttributeError as error:
    print("Rejected:", error)

try:
    Product("A2", "Ghost", 0)
except ValueError as error:
    print("Cannot create:", error)
```

**Sortie :**

```text
A1 Laptop 3000 3510.0
After discount: 2700.0 3159.0
Rejected: price must be positive, got -100
Rejected: property 'sku' of 'Product' object has no setter
Cannot create: price must be positive, got 0
```

**Analyse :**

- `laptop.price` — **ressemble** à un attribut, mais exécute en fait la méthode Getter. Pas de parenthèses !
- `laptop.price = laptop.price * 0.9` — une syntaxe tout à fait naturelle, mais qui passe par le Setter avec les vérifications.
- `self.price = price` **dans `__init__`** — cela passe aussi par le Setter. C'est pourquoi `Product("A2", "Ghost", 0)` échoue dès la création. Une seule validation pour les deux cas.
- `sku` — `@property` **sans Setter** → **lecture seule**. Un code produit n'est pas censé changer après la création. Une tentative de le modifier lance une `AttributeError`.
- `price_with_vat` — **une property calculée** : elle n'a pas de données propres, et se calcule à partir de `_price` à chaque accès. Toujours à jour (vous vous souvenez de la `description` qui devenait obsolète ? Voici une solution encore plus élégante).
- `name` — reste un attribut ordinaire, car il n'y a aucune règle dessus. **N'ajoutez pas de property sans raison.**

### Résumé

- Un **Getter** renvoie une donnée interne. Un **Setter** reçoit une valeur, la **vérifie**, et l'enregistre. Ainsi, la classe contrôle chaque lecture et chaque écriture.
- En Python, on utilise **`@property`** (Getter) et **`@name.setter`** (Setter) : de l'extérieur, cela ressemble à un attribut ordinaire.
- La vraie donnée est stockée sous un nom avec un tiret bas (`_price`). Le même nom crée une récursion.
- Dans `__init__`, écrivez `self.price = price`, pour que la création passe aussi par la validation.
- Une property **sans Setter** = lecture seule. Une property **calculée** = toujours à jour.
- N'ajoutez pas de property sans règle. Commencez par un attribut ordinaire, et faites évoluer quand c'est nécessaire.

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

### Documentation officielle

- [property()](https://docs.python.org/3/builtins/functions.html#property)

<!-- docs:end -->

## Chapitre 4 : Une classe complète — responsabilité, règles et interface

### Qu'allons-nous apprendre ?

Jusqu'ici, nous avons appris des **parties** : `__init__`, les méthodes, les Class Attributes, l'encapsulation et les properties. Maintenant, nous allons toutes les relier en un seul **processus de travail** : comment construire une bonne classe complète à partir de zéro.

### Pourquoi en avons-nous besoin ?

La vraie difficulté de la POO n'est pas la syntaxe, mais les **décisions** : de quoi la classe est-elle responsable ? Qu'expose-t-on ? Que cache-t-on ? Quelles règles doit-elle respecter ? Les débutants qui sautent directement dans le code obtiennent une classe qui fait tout, expose tout, et enfreint les règles. Un processus fixe l'évite.

### Explication à partir des bases

#### Le processus — 6 étapes

| Étape | Question | Produit |
|---|---|---|
| **1. Responsabilité** | Que fait cette classe, **en une phrase** ? | Une phrase |
| **2. Données** | De quoi doit-elle **se souvenir** ? | Une liste d'attributs |
| **3. Interface publique** | Que doit **faire** le monde extérieur avec elle, et que doit-il **savoir** d'elle ? | Une liste de méthodes et de properties |
| **4. Règles (invariants)** | Qu'est-ce qui **doit rester vrai** toujours ? | Une liste de règles |
| **5. Implémentation** | Comment les données sont-elles conservées à l'intérieur ? Quelles méthodes d'aide ? | Du code |
| **6. Tests** | Chaque règle est-elle respectée ? Et les cas limites ? | Un script de test |

**Invariant** = une condition qui doit être vraie **à chaque instant** de la vie de l'objet. Par exemple « solde ≥ 0 », ou « le nombre d'étudiants inscrits ≤ le nombre de places ». Chaque méthode qui modifie l'état doit laisser tous les invariants vrais.

**« Une seule phrase » est un test de SRP :** si la phrase a besoin de « et aussi... et aussi... », ce sont probablement deux classes.

### Syntaxe

Il n'y a pas de nouvelle syntaxe. Nous allons combiner tout ce que nous avons appris :

```python
class Name:
    CLASS_CONSTANT = ...                 # shared rules / constants

    def __init__(self, ...):             # validation through properties
        ...

    @property                            # read access / read-only / computed
    def value(self): ...

    def command(self, ...):              # changes state, keeps invariants
        ...

    def query(self): ...                 # returns info, changes nothing

    def _helper(self): ...               # internal

    def __str__(self): ...               # human-readable
```

### Premier exemple de code

**La tâche :** un système d'inscription aux cours. Nous allons construire une classe nommée `Course` selon les 6 étapes.

**Étape 1 — la responsabilité :** « `Course` gère l'inscription à un cours : qui est inscrit, combien de places restent, et la liste d'attente. »

**Étape 2 — les données :** un code de cours, un titre, le nombre maximum de places, une liste d'étudiants inscrits, une liste d'attente.

**Étape 3 — l'interface publique :**

| Membre de l'interface | Type | Description |
|---|---|---|
| `code`, `title`, `capacity` | properties (lecture seule) | Les informations du cours |
| `enroll(student)` | Setter | Inscrit. Si c'est complet, place l'étudiant sur la liste d'attente. Renvoie `"enrolled"` ou `"waitlisted"` |
| `drop(student)` | Setter | Annule une inscription. Le premier de la liste d'attente prend la place |
| `is_enrolled(student)` | Getter | |
| `available_seats` | property calculée | |
| `is_full` | property calculée | |
| `roster()` | Getter | Une copie de la liste des étudiants inscrits |
| `__str__` | Affichage | |

**Étape 4 — les règles :**

1. Le nombre d'étudiants inscrits ≤ `capacity`, **toujours**.
2. Un étudiant n'est pas inscrit deux fois, et n'est pas à la fois inscrit et en attente.
3. `capacity` est un nombre entier positif.
4. Quand une place se libère et que des étudiants attendent, **le premier** de la file entre (FIFO, First In First Out).

**Étape 5 — l'implémentation.** `week-3/course.py` :

```python
class Course:
    MAX_CAPACITY = 500

    def __init__(self, code, title, capacity):
        if not isinstance(capacity, int) or capacity <= 0 or capacity > Course.MAX_CAPACITY:
            raise ValueError("capacity must be a whole number between 1 and " + str(Course.MAX_CAPACITY))
        self._code = code
        self._title = title
        self._capacity = capacity
        self._enrolled = []
        self._waitlist = []

    @property
    def code(self):
        return self._code

    @property
    def title(self):
        return self._title

    @property
    def capacity(self):
        return self._capacity

    @property
    def available_seats(self):
        return self._capacity - len(self._enrolled)

    @property
    def is_full(self):
        return self.available_seats == 0

    def is_enrolled(self, student):
        return student in self._enrolled

    def enroll(self, student):
        self._ensure_not_registered(student)
        if self.is_full:
            self._waitlist.append(student)
            return "waitlisted"
        self._enrolled.append(student)
        return "enrolled"

    def drop(self, student):
        if student in self._waitlist:
            self._waitlist.remove(student)
            return
        if student not in self._enrolled:
            raise ValueError(student + " is not registered to " + self._code)
        self._enrolled.remove(student)
        if self._waitlist:
            next_student = self._waitlist.pop(0)
            self._enrolled.append(next_student)

    def roster(self):
        return list(self._enrolled)

    def waitlist(self):
        return list(self._waitlist)

    def _ensure_not_registered(self, student):
        if student in self._enrolled or student in self._waitlist:
            raise ValueError(student + " is already registered to " + self._code)

    def __str__(self):
        return (self._code + " " + self._title + " | " + str(len(self._enrolled)) + "/"
                + str(self._capacity) + " enrolled | waitlist: " + str(len(self._waitlist)))
```

### Analyse du code ligne par ligne

| Partie | Comment elle sert la conception |
|---|---|
| La validation dans `__init__` | Règle 3 : on ne peut pas créer un cours avec une `capacity` invalide. |
| `_enrolled`, `_waitlist` (internes) | L'encapsulation : personne à l'extérieur ne peut faire `append` et contourner la règle 1. |
| `code`, `title`, `capacity` comme properties sans Setter | Lecture seule : les informations du cours ne changent pas après la création. |
| `available_seats`, `is_full` sont calculées | Il n'y a pas de données dupliquées qui risquent de se désynchroniser. Elles dérivent toujours de `_enrolled`. |
| `enroll` | Règle 2 (via `_ensure_not_registered`), et règle 1 (vérification de `is_full` avant l'ajout). |
| `drop` | Règle 4 : `pop(0)` = le premier entré dans la file sort en premier. |
| `roster()` / `waitlist()` renvoient **une copie** | Celui qui a reçu une liste ne peut pas modifier la liste interne. |
| `_ensure_not_registered` | Une méthode d'aide interne : une règle, à un seul endroit. |
| `__str__` | Un instantané lisible, pour le débogage et l'affichage. |

### Un autre exemple

**Étape 6 — les tests.** Un script qui vérifie chaque règle, y compris les cas limites. À la fin de `course.py` :

```python
def main():
    course = Course("PY101", "Intro to Python", 2)

    print(course.enroll("Dana"))
    print(course.enroll("Avi"))
    print(course.enroll("Noa"))
    print(course.enroll("Tom"))
    print(course)

    try:
        course.enroll("Dana")
    except ValueError as error:
        print("Rule 2 holds:", error)

    course.drop("Avi")
    print("After Avi dropped:", course.roster(), "| waitlist:", course.waitlist())

    leaked = course.roster()
    leaked.append("Hacker")
    print("Roster after outside append:", course.roster())

    try:
        course.drop("Ghost")
    except ValueError as error:
        print("Drop unknown:", error)

    try:
        Course("BAD1", "Broken", 0)
    except ValueError as error:
        print("Rule 3 holds:", error)

    print("Seats:", course.available_seats, "| full?", course.is_full)

if __name__ == "__main__":
    main()
```

**Sortie :**

```text
enrolled
enrolled
waitlisted
waitlisted
PY101 Intro to Python | 2/2 enrolled | waitlist: 2
Rule 2 holds: Dana is already registered to PY101
After Avi dropped: ['Dana', 'Noa'] | waitlist: ['Tom']
Roster after outside append: ['Dana', 'Noa']
Drop unknown: Ghost is not registered to PY101
Rule 3 holds: capacity must be a whole number between 1 and 500
Seats: 0 | full? True
```

**Ce que le test a prouvé :**

- Règle 1 : jamais plus de 2 étudiants inscrits. Le troisième et le quatrième sont allés sur la liste d'attente.
- Règle 2 : Dana n'a pas été inscrite deux fois.
- Règle 4 : quand Avi est parti, **Noa** (la première de la file) est entrée, et Tom est resté sur la liste d'attente.
- L'encapsulation : `append` sur la copie n'a pas modifié la vraie liste.
- Règle 3 : un cours avec 0 place n'a pas été créé.

### Résumé

- **Le processus :** responsabilité (une phrase) → données → interface publique → règles (invariants) → implémentation → tests.
- **Invariant** = ce qui doit toujours être vrai. Chaque Setter doit tous les respecter.
- Les données internes sont cachées (`_x`). Les informations fixes sont des properties en lecture seule. Les informations dérivées sont des properties calculées.
- Renvoyez des **copies** des collections internes. Une règle → une méthode d'aide (`_ensure_...`).
- Testez chaque règle et les cas limites, pas seulement le cas où tout va bien.

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

### Documentation officielle

- [Random Remarks](https://docs.python.org/3/tutorial/classes.html#random-remarks)

<!-- docs:end -->

<!-- exercises:start — generated by scripts/intro6/sync_site_exercises.py; edit scripts/intro6/exercises/ -->
## Exercices

Les exercices n'utilisent que ce qui a été vu dans cette leçon et les précédentes. Essayez d'abord seul, et n'ouvrez la solution qu'ensuite.

**Ce que vous pratiquez sur cette page :** une liste d'objets, un objet qui contient un autre objet, le masquage des données (encapsulation), une property avec une vérification, et une classe complète avec des règles.

**À la fin de la page, vous saurez :**
- Parcourir une liste d'objets et en trouver un selon un attribut
- Construire un objet qui contient une référence vers un autre objet
- Cacher des données derrière des méthodes qui vérifient chaque modification
- Écrire une property avec un setter qui impose une règle
- Construire une classe complète qui impose ses propres règles

### Exercice 1: Une liste de joueurs

**Ce que vous construisez :** un tableau des joueurs d'un jeu — et trouver le meneur.

**Ce qu'il faut écrire :**
1. Une classe nommée `Player`. Le `__init__` reçoit un nom, et le score commence à 0.
2. `add_points` — reçoit un nombre de points et les ajoute au score.
3. `__str__` — renvoie le nom et le score, par exemple `Dana: 50`.
4. Après le code de test : parcourez la liste, trouvez le joueur au score le plus élevé, et affichez-le.

**Code de test :**

```python
players = [Player("Dana"), Player("Avi"), Player("Noa")]
players[0].add_points(50)
players[1].add_points(15)
players[2].add_points(40)
```

**La sortie souhaitée :**

```text
Leader: Dana: 50
```

{{< solution >}}
```python
class Player:
    def __init__(self, name):
        self.name = name
        self.score = 0

    def add_points(self, points):
        self.score = self.score + points

    def __str__(self):
        return self.name + ": " + str(self.score)


players = [Player("Dana"), Player("Avi"), Player("Noa")]
players[0].add_points(50)
players[1].add_points(15)
players[2].add_points(40)

leader = players[0]
for player in players:
    if player.score > leader.score:
        leader = player
print("Leader:", leader)
```

```text
Leader: Dana: 50
```
{{< /solution >}}

### Exercice 2: Un objet qui contient un objet

**Ce que vous construisez :** un client et des commandes — quand plusieurs commandes contiennent **le même** client.

**Ce qu'il faut écrire :**
1. Une classe nommée `Customer` avec un nom et une ville.
2. Une classe nommée `Order` avec un client (un objet `Customer`) et un total.
3. Créez un client, et deux commandes qui reçoivent le même objet client.
4. Affichez la ville via les deux commandes, changez la ville du client en Tel Aviv, et affichez à nouveau.

**La sortie souhaitée :**

```text
Haifa Haifa
Tel Aviv Tel Aviv
```

**Ce que vous en apprenez :** la commande ne contient pas une copie du client, mais une référence vers lui.

{{< solution >}}
```python
class Customer:
    def __init__(self, name, city):
        self.name = name
        self.city = city


class Order:
    def __init__(self, customer, total):
        self.customer = customer
        self.total = total


dana = Customer("Dana", "Haifa")
order1 = Order(dana, 120)
order2 = Order(dana, 80)
print(order1.customer.city, order2.customer.city)

dana.city = "Tel Aviv"
print(order1.customer.city, order2.customer.city)
```

```text
Haifa Haifa
Tel Aviv Tel Aviv
```

Les commandes ne contiennent pas **une copie** du client, mais **une référence** vers lui. Une modification du client est visible depuis chaque commande qui le contient.
{{< /solution >}}

### Exercice 3: Un BankAccount protégé

**Ce que vous construisez :** un compte bancaire qu'on ne peut pas casser — le solde ne change que par des méthodes qui le vérifient.

**Ce qu'il faut écrire :**
1. Une classe nommée `BankAccount`, avec un titulaire et un solde. Gardez le solde dans `_balance` — un tiret bas dit « interne, ne pas toucher de l'extérieur ».
2. `deposit` — ajoute un montant.
3. `withdraw` — retire un montant. S'il n'y a pas assez d'argent : une `ValueError` avec le message de la sortie.
4. `get_balance` — renvoie le solde.

**Code de test :**

```python
account = BankAccount("Dana", 100)
account.deposit(50)
try:
    account.withdraw(500)
except ValueError as error:
    print("error:", error)
print(account.get_balance())
```

**La sortie souhaitée :**

```text
error: not enough money
150
```

{{< solution >}}
```python
class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("amount must be positive")
        self._balance = self._balance + amount

    def withdraw(self, amount):
        if amount > self._balance:
            raise ValueError("not enough money")
        self._balance = self._balance - amount

    def get_balance(self):
        return self._balance


account = BankAccount("Dana", 100)
account.deposit(50)
try:
    account.withdraw(500)
except ValueError as error:
    print("error:", error)
print(account.get_balance())
```

```text
error: not enough money
150
```
{{< /solution >}}

### Exercice 4: Un Setter qui vérifie

**Ce que vous construisez :** un utilisateur dont l'âge est toujours valide — avec une property qui vérifie chaque affectation.

**Ce qu'il faut écrire :**
1. Une classe nommée `User`, dont le `__init__` reçoit un âge et l'enregistre via la property.
2. Un Getter avec `@property` nommé `age` — renvoie l'âge enregistré dans `_age`.
3. Un Setter avec `@age.setter` — vérifie que l'âge est entre 0 et 120. Sinon : une `ValueError` avec le message de la sortie.
4. Remarquez : l'affectation dans `__init__` passe aussi par la vérification.

**Code de test :**

```python
user = User(30)
user.age = user.age + 1
print(user.age)
try:
    user.age = -5
except ValueError as error:
    print("error:", error)
print(user.age)
```

**La sortie souhaitée :**

```text
31
error: age must be 0-120
31
```

{{< solution >}}
```python
class User:
    def __init__(self, age):
        self.age = age

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if value < 0 or value > 120:
            raise ValueError("age must be 0-120")
        self._age = value


user = User(30)
user.age = user.age + 1
print(user.age)
try:
    user.age = -5
except ValueError as error:
    print("error:", error)
print(user.age)
```

```text
31
error: age must be 0-120
31
```

- `self.age = age` dans `__init__` **passe par le setter** — un nouvel objet est donc lui aussi vérifié.
- La valeur est enregistrée dans `_age`, et la property l'expose comme s'il s'agissait d'un attribut ordinaire.
{{< /solution >}}

### Exercice 5: L'inscription

**Ce que vous construisez :** l'inscription à un cours avec un nombre de places limité et une liste d'attente.

**Ce qu'il faut écrire :**
1. Une classe nommée `Course`, avec un code de cours, un nom et une capacité, et deux listes internes : les inscrits et les personnes en attente.
2. `enroll` — reçoit le nom d'un étudiant :
   - s'il est déjà inscrit ou en attente : une `ValueError` avec le message de la sortie ;
   - s'il y a une place : l'ajoute aux inscrits et renvoie `"enrolled"` ;
   - sinon : l'ajoute à la liste d'attente et renvoie `"waitlisted"`.
3. `__str__` — le code, le nom, combien sont inscrits sur la capacité, et combien sont en attente.

**Code de test :**

```python
course = Course("PY101", "Intro to Python", 2)
for student in ["Dana", "Avi", "Noa"]:
    print(student, course.enroll(student))
print(course)
try:
    course.enroll("Dana")
except ValueError as error:
    print("error:", error)
```

**La sortie souhaitée :**

```text
Dana enrolled
Avi enrolled
Noa waitlisted
PY101 Intro to Python | 2/2 enrolled | waitlist: 1
error: Dana is already registered
```

{{< solution >}}
```python
class Course:
    def __init__(self, code, name, capacity):
        if capacity <= 0:
            raise ValueError("capacity must be positive")
        self.code = code
        self.name = name
        self.capacity = capacity
        self._enrolled = []
        self._waitlist = []

    def enroll(self, student):
        if student in self._enrolled or student in self._waitlist:
            raise ValueError(student + " is already registered")
        if len(self._enrolled) < self.capacity:
            self._enrolled.append(student)
            return "enrolled"
        self._waitlist.append(student)
        return "waitlisted"

    def __str__(self):
        return (self.code + " " + self.name + " | " + str(len(self._enrolled)) + "/"
                + str(self.capacity) + " enrolled | waitlist: " + str(len(self._waitlist)))


course = Course("PY101", "Intro to Python", 2)
for student in ["Dana", "Avi", "Noa"]:
    print(student, course.enroll(student))
print(course)
try:
    course.enroll("Dana")
except ValueError as error:
    print("error:", error)
```

```text
Dana enrolled
Avi enrolled
Noa waitlisted
PY101 Intro to Python | 2/2 enrolled | waitlist: 1
error: Dana is already registered
```
{{< /solution >}}

## Mini-projet: L'inventaire d'une boutique

**Quel système vous construisez :** un système d'inventaire pour une petite boutique. Chaque produit a un nom, un prix et une quantité en stock. On peut vendre et réapprovisionner, et le système s'assure que rien d'invalide ne se produit — un prix négatif, vendre plus qu'il n'y en a, ou deux produits du même nom. À la fin, il affiche les produits par valeur, et la valeur de tout l'inventaire.

**Ce que vous apprendrez du projet :**
- Cacher des données (l'encapsulation) : ne les modifier que par des méthodes qui vérifient
- Utiliser une property : avec un setter qui vérifie, en lecture seule, et calculée
- Construire une classe qui contient une liste d'autres objets
- Trier des objets selon une valeur calculée

**Ce qu'il faut construire — étape par étape :**

**Partie A — un produit (`Product`)**

**Étape 1 — la création.** Le `__init__` reçoit un nom, un prix et une quantité (0 par défaut). Le nom est enregistré dans `_name`, la quantité dans `_quantity`, et le prix passe par le setter de `price`.

**Étape 2 — le prix.** `price` est une property avec un setter qui vérifie que le prix est supérieur à 0. Sinon : `ValueError`.

**Étape 3 — les champs en lecture seule.** `name` et `quantity` sont des properties sans setter — de l'extérieur, on peut les lire, mais pas les modifier.

**Étape 4 — modifier la quantité.** La quantité ne change que par deux méthodes :
- `add_stock` — ajoute de la quantité. Si le montant n'est pas positif : `ValueError`.
- `sell` — retire de la quantité. S'il n'y a pas assez en stock : `ValueError` avec le nom du produit.

**Étape 5 — une valeur calculée et l'affichage.** `total_value` est une property calculée : le prix multiplié par la quantité. `__str__` affiche le produit comme dans la sortie.

**Partie B — l'inventaire (`Inventory`)**

**Étape 6 — une liste interne.** `Inventory` contient une liste interne nommée `_products`.

**Étape 7 — ajouter et rechercher.** `add` ajoute un produit. S'il existe déjà un produit du même nom : `ValueError`. `find` reçoit un nom et renvoie le produit, ou `None`.

**Étape 8 — les rapports.** `by_value` renvoie une copie de la liste des produits, triée par `total_value` de la plus haute à la plus basse. Le `total_value` de l'inventaire est une property calculée : la somme des valeurs de tous les produits.

**Les messages d'erreur — exactement ainsi :**

```text
price must be positive
amount must be positive
not enough <product name>
<product name> already exists
```

**Code de test :**

```python
shop = Inventory()
shop.add(Product("Pen", 5, 10))
shop.add(Product("Bag", 60, 2))
shop.add(Product("Notebook", 12, 5))

shop.find("Pen").sell(4)
shop.find("Bag").add_stock(1)

try:
    shop.find("Notebook").sell(50)
except ValueError as error:
    print("error:", error)

try:
    shop.add(Product("Pen", 7))
except ValueError as error:
    print("error:", error)

try:
    shop.find("Bag").price = -3
except ValueError as error:
    print("error:", error)

for product in shop.by_value():
    print(product)
print("Total:", shop.total_value)
```

**La sortie souhaitée :**

```text
error: not enough Notebook
error: Pen already exists
error: price must be positive
Bag: 3 x 60 = 180
Notebook: 5 x 12 = 60
Pen: 6 x 5 = 30
Total: 270
```

**À la fin du projet, vous saurez :**
- Construire un objet qui protège ses propres données — de l'extérieur, on ne peut pas le casser
- Choisir entre un champ ordinaire, une property avec vérification, un champ en lecture seule et une valeur calculée
- Construire un « gestionnaire » qui contient des objets, les recherche et les résume

**Bonus :** ajoutez à `Inventory` une méthode `low_stock` qui reçoit une limite, et renvoie les noms des produits dont la quantité lui est inférieure.

{{< solution >}}
```python
class Product:
    def __init__(self, name, price, quantity=0):
        self._name = name
        self.price = price
        self._quantity = quantity

    @property
    def name(self):
        return self._name

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if value <= 0:
            raise ValueError("price must be positive")
        self._price = value

    @property
    def quantity(self):
        return self._quantity

    def add_stock(self, amount):
        if amount <= 0:
            raise ValueError("amount must be positive")
        self._quantity = self._quantity + amount

    def sell(self, amount):
        if amount > self._quantity:
            raise ValueError("not enough " + self._name)
        self._quantity = self._quantity - amount

    @property
    def total_value(self):
        return self._price * self._quantity

    def __str__(self):
        return (self._name + ": " + str(self._quantity) + " x " + str(self._price)
                + " = " + str(self.total_value))


class Inventory:
    def __init__(self):
        self._products = []

    def add(self, product):
        if self.find(product.name) is not None:
            raise ValueError(product.name + " already exists")
        self._products.append(product)

    def find(self, name):
        for product in self._products:
            if product.name == name:
                return product
        return None

    def by_value(self):
        return sorted(self._products, key=lambda p: p.total_value, reverse=True)

    @property
    def total_value(self):
        total = 0
        for product in self._products:
            total = total + product.total_value
        return total


shop = Inventory()
shop.add(Product("Pen", 5, 10))
shop.add(Product("Bag", 60, 2))
shop.add(Product("Notebook", 12, 5))

shop.find("Pen").sell(4)
shop.find("Bag").add_stock(1)

try:
    shop.find("Notebook").sell(50)
except ValueError as error:
    print("error:", error)

try:
    shop.add(Product("Pen", 7))
except ValueError as error:
    print("error:", error)

try:
    shop.find("Bag").price = -3
except ValueError as error:
    print("error:", error)

for product in shop.by_value():
    print(product)
print("Total:", shop.total_value)
```

```text
error: not enough Notebook
error: Pen already exists
error: price must be positive
Bag: 3 x 60 = 180
Notebook: 5 x 12 = 60
Pen: 6 x 5 = 30
Total: 270
```

- `Inventory` **contient** des objets `Product` (un objet qui contient des objets), et `by_value` renvoie une **nouvelle** liste — `sorted` ne touche pas à la liste interne.
- `_name`, `_price`, `_quantity` sont cachés. De l'extérieur, la quantité ne se modifie que par `add_stock` et `sell` — qui vérifient les règles.
- `price` a un setter qui vérifie — y compris dans `__init__`. `name` et `quantity` sont en lecture seule, et `total_value` est calculé à chaque lecture.

**Bonus :**

```python
    def low_stock(self, limit):
        names = []
        for product in self._products:
            if product.quantity < limit:
                names.append(product.name)
        return names
```

(Une méthode dans `Inventory`.) Avec les données ci-dessus, `shop.low_stock(4)` renvoie `['Bag']`.
{{< /solution >}}

<!-- exercises:end -->
