
# Fichier: python_cheats/cheatsheets/poo.txt
# Python OOP - Programmation Orientée Objet Complète



# [OK] CLASSE BASIQUE


[OK] DÉFINITION DE CLASSE
    class Person:
        """Documentation de la classe"""
        
        def __init__(self, name, age):
            """Constructeur - Initialise l'instance"""
            self.name = name
            self.age = age
        
        def greet(self):
            """Méthode d'instance"""
            return f"Hello, I'm {self.name}"
        
        def __str__(self):
            """Représentation string pour utilisateur"""
            return f"Person: {self.name}, {self.age} years"
        
        def __repr__(self):
            """Représentation pour développeur/debug"""
            return f"Person(name='{self.name}', age={self.age})"
    
    # Création d'instance
    p = Person("Alice", 30)
    print(p.greet())        # Hello, I'm Alice
    print(p)                # Person: Alice, 30 years
    print(repr(p))          # Person(name='Alice', age=30)

[OK] CLASSE VIDE
    class EmptyClass:
        pass
    
    # Ou avec docstring
    class EmptyClass:
        """Une classe vide"""
        ...


[OK] ATTRIBUTS: PUBLIC, PROTECTED, PRIVATE


[OK] ATTRIBUTS PUBLIC (Convention: pas de underscore)
    class Person:
        def __init__(self, name):
            self.name = name  # PUBLIC: accessible partout
            self.age = 30     # PUBLIC
    
    p = Person("Alice")
    print(p.name)      # [OK] OK - accessible directement
    p.name = "Bob"     # [OK] OK - modifiable directement

[OK] ATTRIBUTS PROTECTED (Convention: un underscore _)
    class Person:
        def __init__(self, name):
            self._name = name           # PROTECTED: usage interne recommandé
            self._internal_id = 12345   # PROTECTED
        
        def _internal_method(self):     # Méthode protected
            return self._internal_id
    
    p = Person("Alice")
    print(p._name)           # [ATTENTION] Possible mais déconseillé
    print(p._internal_id)    # [ATTENTION] Convention: ne pas utiliser hors classe/sous-classes
    
    # Protected signifie: "Utilisez à vos risques, API interne"

[OK] ATTRIBUTS PRIVATE (Convention: double underscore __)
    class BankAccount:
        def __init__(self, balance):
            self.__balance = balance    # PRIVATE: name mangling
            self.__pin = 1234          # PRIVATE
        
        def get_balance(self):
            """Méthode publique pour accéder au private"""
            return self.__balance
        
        def __calculate_interest(self):  # Méthode private
            return self.__balance * 0.05
        
        def deposit(self, amount):
            self.__balance += amount
    
    account = BankAccount(1000)
    print(account.get_balance())     # [OK] OK: 1000
    # print(account.__balance)       # [X] AttributeError
    # print(account.__pin)           # [X] AttributeError
    
    # NAME MANGLING: Python renomme en _ClassName__attribute
    print(account._BankAccount__balance)  # [ATTENTION] Possible mais à éviter
    print(account._BankAccount__pin)      # [ATTENTION] Contournement (hack)

[OK] TABLEAU RÉCAPITULATIF DES CONVENTIONS
    class Example:
        def __init__(self):
            # PUBLIC - Accessible partout
            self.public_attr = "Accessible partout"
            
            # PROTECTED - Usage interne (convention)
            self._protected_attr = "Usage interne recommandé"
            
            # PRIVATE - Name mangling (vraie protection)
            self.__private_attr = "Accès restreint"
        
        # Méthodes publiques
        def public_method(self):
            pass
        
        # Méthodes protected
        def _protected_method(self):
            pass
        
        # Méthodes private
        def __private_method(self):
            pass
    
    """
    Convention      Nom              Accès                   Usage
    ─────────────────────────────────────────────────────────────────
    PUBLIC          name             Partout                 API publique
    PROTECTED       _name            Convention interne      Sous-classes
    PRIVATE         __name           Name mangling           Vraiment privé
    """

[OK] EXEMPLE COMPLET: ENCAPSULATION
    class Employee:
        def __init__(self, name, salary):
            self.name = name                    # PUBLIC
            self._department = "IT"             # PROTECTED
            self.__salary = salary              # PRIVATE
            self.__ssn = "123-45-6789"         # PRIVATE (très sensible)
        
        # Getter pour attribut privé
        @property
        def salary(self):
            """Accès contrôlé au salaire"""
            return self.__salary
        
        # Setter pour attribut privé avec validation
        @property
        def salary(self):
            return self.__salary
        
        @salary.setter
        def salary(self, value):
            if value < 0:
                raise ValueError("Salary cannot be negative")
            self.__salary = value
        
        # Méthode publique
        def give_raise(self, amount):
            """API publique pour modifier le salaire"""
            self.__salary += amount
        
        # Méthode protected
        def _calculate_bonus(self):
            """Usage interne ou sous-classes"""
            return self.__salary * 0.1
        
        # Méthode private
        def __validate_ssn(self):
            """Complètement privée à cette classe"""
            return len(self.__ssn) == 11
    
    emp = Employee("Alice", 50000)
    print(emp.name)          # [OK] PUBLIC: Alice
    print(emp._department)   # [ATTENTION] PROTECTED: possible mais déconseillé
    # print(emp.__salary)    # [X] PRIVATE: AttributeError
    print(emp.salary)        # [OK] Via property: 50000
    emp.give_raise(5000)     # [OK] Via méthode publique

[OK] QUAND UTILISER CHAQUE TYPE?
    # PUBLIC (name)
    # - Attributs destinés à être utilisés par les utilisateurs de la classe
    # - API stable et documentée
    # Exemple: person.name, car.speed, list.append()
    
    # PROTECTED (_name)
    # - Détails d'implémentation qui peuvent changer
    # - Peut être utilisé par les sous-classes
    # - Signal: "Utilisez avec précaution, peut changer"
    # Exemple: _internal_cache, _helper_method()
    
    # PRIVATE (__name)
    # - Vraiment privé, évite les collisions de noms
    # - Détails critiques d'implémentation
    # - Données sensibles
    # Exemple: __password_hash, __encryption_key, __validate()


[OK] ATTRIBUTS DE CLASSE VS INSTANCE


[OK] ATTRIBUTS D'INSTANCE
    class Dog:
        def __init__(self, name, age):
            self.name = name  # Attribut d'instance
            self.age = age    # Attribut d'instance
    
    dog1 = Dog("Rex", 3)
    dog2 = Dog("Buddy", 5)
    
    print(dog1.name)  # Rex (spécifique à dog1)
    print(dog2.name)  # Buddy (spécifique à dog2)

[OK] ATTRIBUTS DE CLASSE
    class Dog:
        species = "Canis familiaris"  # Attribut de classe (partagé)
        count = 0                     # Compteur partagé
        
        def __init__(self, name):
            self.name = name          # Attribut d'instance
            Dog.count += 1            # Modifier l'attribut de classe
    
    dog1 = Dog("Rex")
    dog2 = Dog("Buddy")
    
    print(Dog.species)      # Canis familiaris
    print(dog1.species)     # Canis familiaris (même valeur)
    print(dog2.species)     # Canis familiaris (même valeur)
    print(Dog.count)        # 2 (partagé entre toutes les instances)

[OK] MUTATION D'ATTRIBUTS DE CLASSE
    class Counter:
        count = 0  # Attribut de classe
        
        def __init__(self):
            Counter.count += 1  # [OK] Bon: modifie l'attribut de classe
    
    class WrongCounter:
        count = 0
        
        def __init__(self):
            self.count += 1  # [X] Crée un attribut d'instance!
    
    c1 = Counter()
    c2 = Counter()
    print(Counter.count)  # 2 [OK]
    
    w1 = WrongCounter()
    w2 = WrongCounter()
    print(WrongCounter.count)  # 0 [X] (attributs d'instance créés à la place)

[OK] ATTRIBUTS DE CLASSE MUTABLES (ATTENTION!)
    class Team:
        members = []  # [ATTENTION] Partagé entre toutes les instances!
        
        def __init__(self, name):
            self.name = name
        
        def add_member(self, member):
            self.members.append(member)  # [ATTENTION] Modifie pour toutes!
    
    team1 = Team("Alpha")
    team2 = Team("Beta")
    
    team1.add_member("Alice")
    print(team2.members)  # ['Alice'] [ATTENTION] Partagé!
    
    # [OK] Solution: initialiser dans __init__
    class Team:
        def __init__(self, name):
            self.name = name
            self.members = []  # [OK] Spécifique à l'instance

[OK] CONSTANTES DE CLASSE
    class Config:
        MAX_CONNECTIONS = 100        # Constante (convention UPPERCASE)
        DEFAULT_TIMEOUT = 30
        API_VERSION = "1.0"
        
        def __init__(self):
            self.connections = 0
    
    print(Config.MAX_CONNECTIONS)  # Accès direct


[OK] MÉTHODES: INSTANCE, CLASSE, STATIQUE


[OK] MÉTHODE D'INSTANCE (Par défaut)
    class Calculator:
        def __init__(self, base):
            self.base = base
        
        def add(self, x):
            """Méthode d'instance - accède à self"""
            return self.base + x
        
        def multiply(self, x, y):
            """Peut modifier l'état de l'instance"""
            result = x * y
            self.base = result  # Modifie l'instance
            return result
    
    calc = Calculator(10)
    print(calc.add(5))  # 15 (utilise self.base)

[OK] MÉTHODE DE CLASSE (@classmethod)
    class Person:
        population = 0
        
        def __init__(self, name, age):
            self.name = name
            self.age = age
            Person.population += 1
        
        @classmethod
        def from_birth_year(cls, name, birth_year):
            """Factory method - crée une instance"""
            age = 2024 - birth_year
            return cls(name, age)  # cls = Person (ou sous-classe)
        
        @classmethod
        def get_population(cls):
            """Accède aux attributs de classe"""
            return cls.population
        
        @classmethod
        def reset_population(cls):
            """Modifie les attributs de classe"""
            cls.population = 0
    
    # Usage
    p1 = Person("Alice", 30)
    p2 = Person.from_birth_year("Bob", 1990)  # Factory method
    print(Person.get_population())  # 2

[OK] MÉTHODE STATIQUE (@staticmethod)
    class MathUtils:
        """Pas d'accès à self ni cls"""
        
        @staticmethod
        def is_even(n):
            """Fonction utilitaire - pas besoin de la classe"""
            return n % 2 == 0
        
        @staticmethod
        def is_prime(n):
            if n < 2:
                return False
            for i in range(2, int(n ** 0.5) + 1):
                if n % i == 0:
                    return False
            return True
    
    # Usage (pas besoin d'instance)
    print(MathUtils.is_even(4))   # True
    print(MathUtils.is_prime(7))  # True

[OK] COMPARAISON DES TROIS TYPES
    class Example:
        class_var = "class variable"
        
        def __init__(self, value):
            self.instance_var = value
        
        # MÉTHODE D'INSTANCE - accède à self (instance)
        def instance_method(self):
            return f"Instance: {self.instance_var}"
        
        # MÉTHODE DE CLASSE - accède à cls (classe)
        @classmethod
        def class_method(cls):
            return f"Class: {cls.class_var}"
        
        # MÉTHODE STATIQUE - pas d'accès à self ou cls
        @staticmethod
        def static_method():
            return "Static: no access to instance or class"
    
    obj = Example("test")
    print(obj.instance_method())   # Instance: test
    print(Example.class_method())  # Class: class variable
    print(Example.static_method()) # Static: no access to instance or class

[OK] QUAND UTILISER CHAQUE TYPE?
    # MÉTHODE D'INSTANCE (self)
    # - Besoin d'accéder/modifier les attributs de l'instance
    # - Comportement spécifique à chaque objet
    # Exemple: person.calculate_age(), account.withdraw()
    
    # MÉTHODE DE CLASSE (@classmethod)
    # - Factory methods (constructeurs alternatifs)
    # - Accès/modification des attributs de classe
    # - Héritage (cls permet d'instancier la bonne sous-classe)
    # Exemple: Date.from_string(), Person.get_count()
    
    # MÉTHODE STATIQUE (@staticmethod)
    # - Fonction utilitaire liée conceptuellement à la classe
    # - Pas besoin d'accès à l'instance ou la classe
    # - Peut être une fonction, mais on la met dans la classe pour organisation
    # Exemple: Math.is_prime(), Validator.is_email()


[OK] PROPERTIES (GETTERS/SETTERS/DELETERS)


[OK] PROPERTY BASIQUE
    class Person:
        def __init__(self, name):
            self._name = name  # Protected
        
        @property
        def name(self):
            """Getter - lecture seule par défaut"""
            print("Getting name")
            return self._name
    
    p = Person("Alice")
    print(p.name)  # Getting name \n Alice (appelle le getter)
    # p.name = "Bob"  # [X] AttributeError (pas de setter)

[OK] PROPERTY AVEC SETTER
    class Person:
        def __init__(self, name):
            self._name = name
        
        @property
        def name(self):
            """Getter"""
            return self._name
        
        @name.setter
        def name(self, value):
            """Setter avec validation"""
            if not value or not isinstance(value, str):
                raise ValueError("Name must be a non-empty string")
            self._name = value
    
    p = Person("Alice")
    print(p.name)      # Alice (getter)
    p.name = "Bob"     # Setter avec validation
    # p.name = ""      # [X] ValueError

[OK] PROPERTY AVEC DELETER
    class Person:
        def __init__(self, name):
            self._name = name
        
        @property
        def name(self):
            return self._name
        
        @name.setter
        def name(self, value):
            if not value:
                raise ValueError("Name cannot be empty")
            self._name = value
        
        @name.deleter
        def name(self):
            """Deleter"""
            print(f"Deleting name: {self._name}")
            del self._name
    
    p = Person("Alice")
    del p.name  # Deleting name: Alice (appelle le deleter)
    # print(p.name)  # [X] AttributeError

[OK] PROPERTY EN LECTURE SEULE
    class Circle:
        def __init__(self, radius):
            self._radius = radius
        
        @property
        def radius(self):
            return self._radius
        
        @property
        def area(self):
            """Propriété calculée (lecture seule)"""
            return 3.14159 * self._radius ** 2
        
        @property
        def circumference(self):
            """Autre propriété calculée"""
            return 2 * 3.14159 * self._radius
    
    c = Circle(5)
    print(c.area)           # 78.53975 (calculé)
    print(c.circumference)  # 31.4159 (calculé)
    # c.area = 100          # [X] AttributeError (pas de setter)

[OK] PROPERTY AVEC VALIDATION COMPLEXE
    class BankAccount:
        def __init__(self, balance=0):
            self._balance = balance
            self._transactions = []
        
        @property
        def balance(self):
            return self._balance
        
        @balance.setter
        def balance(self, value):
            if value < 0:
                raise ValueError("Balance cannot be negative")
            if value > 1000000:
                raise ValueError("Balance too high - contact manager")
            
            # Log transaction
            old_balance = self._balance
            self._balance = value
            self._transactions.append(f"{old_balance} -> {value}")
        
        @property
        def transactions(self):
            """Lecture seule des transactions"""
            return self._transactions.copy()  # Retourne une copie
    
    account = BankAccount(100)
    account.balance = 500  # OK
    # account.balance = -10  # [X] ValueError
    print(account.transactions)  # Liste des transactions

[OK] PROPERTY VS MÉTHODE - QUAND UTILISER?
    # PROPERTY (@property)
    # - Accès comme un attribut (sans parenthèses)
    # - Calcul léger et rapide
    # - Pas d'arguments (sauf self)
    # Exemple: person.age, circle.area, account.balance
    
    # MÉTHODE (def method())
    # - Appel avec parenthèses
    # - Calcul potentiellement coûteux
    # - Peut prendre des arguments
    # Exemple: account.withdraw(100), file.read(), list.append(item)

[OK] ALTERNATIVE: property() FUNCTION
    class Person:
        def __init__(self, name):
            self._name = name
        
        def get_name(self):
            return self._name
        
        def set_name(self, value):
            if not value:
                raise ValueError("Name cannot be empty")
            self._name = value
        
        def del_name(self):
            del self._name
        
        # Créer la property avec la fonction
        name = property(get_name, set_name, del_name, "Person's name")
    
    # Équivalent au décorateur @property


[OK] HÉRITAGE


[OK] HÉRITAGE SIMPLE
    class Animal:
        def __init__(self, name, species):
            self.name = name
            self.species = species
        
        def speak(self):
            return "Some sound"
        
        def info(self):
            return f"{self.name} is a {self.species}"
    
    class Dog(Animal):
        def __init__(self, name, breed):
            super().__init__(name, "Dog")  # Appel constructeur parent
            self.breed = breed
        
        def speak(self):
            """Override (redéfinition)"""
            return "Woof! Woof!"
        
        def fetch(self):
            """Nouvelle méthode spécifique"""
            return f"{self.name} is fetching!"
    
    dog = Dog("Rex", "Labrador")
    print(dog.speak())   # Woof! Woof! (méthode overridée)
    print(dog.info())    # Rex is a Dog (méthode héritée)
    print(dog.fetch())   # Rex is fetching! (nouvelle méthode)

[OK] SUPER() DÉTAILLÉ
    class Parent:
        def __init__(self, name):
            print("Parent __init__")
            self.name = name
        
        def method(self):
            return "Parent method"
    
    class Child(Parent):
        def __init__(self, name, age):
            print("Child __init__")
            super().__init__(name)  # Appelle Parent.__init__
            self.age = age
        
        def method(self):
            # Appeler la méthode du parent puis étendre
            parent_result = super().method()
            return f"{parent_result} + Child method"
    
    c = Child("Alice", 30)
    # Output:
    # Child __init__
    # Parent __init__
    
    print(c.method())  # Parent method + Child method

[OK] HÉRITAGE MULTIPLE
    class A:
        def method(self):
            return "A"
    
    class B:
        def method(self):
            return "B"
    
    class C(A, B):  # Hérite de A puis B
        pass
    
    c = C()
    print(c.method())  # "A" (ordre: C -> A -> B)
    print(C.__mro__)   # Method Resolution Order
    # (<class 'C'>, <class 'A'>, <class 'B'>, <class 'object'>)

[OK] HÉRITAGE MULTIPLE - EXEMPLE RÉEL
    class Flyable:
        def fly(self):
            return f"{self.name} is flying"
    
    class Swimmable:
        def swim(self):
            return f"{self.name} is swimming"
    
    class Duck(Flyable, Swimmable):
        def __init__(self, name):
            self.name = name
        
        def quack(self):
            return "Quack!"
    
    duck = Duck("Donald")
    print(duck.fly())    # Donald is flying
    print(duck.swim())   # Donald is swimming
    print(duck.quack())  # Quack!

[OK] DIAMOND PROBLEM ET MRO
    class A:
        def method(self):
            return "A"
    
    class B(A):
        def method(self):
            return "B"
    
    class C(A):
        def method(self):
            return "C"
    
    class D(B, C):  # Diamond problem
        pass
    
    d = D()
    print(d.method())  # "B" (MRO: D -> B -> C -> A)
    print(D.__mro__)
    # (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)
    
    # Python utilise C3 Linearization pour résoudre l'ordre

[OK] SUPER() DANS HÉRITAGE MULTIPLE
    class A:
        def __init__(self):
            print("A init")
            super().__init__()
    
    class B:
        def __init__(self):
            print("B init")
            super().__init__()
    
    class C(A, B):
        def __init__(self):
            print("C init")
            super().__init__()
    
    c = C()
    # Output (suit le MRO):
    # C init
    # A init
    # B init

[OK] VÉRIFIER L'HÉRITAGE
    class Animal:
        pass
    
    class Dog(Animal):
        pass
    
    dog = Dog()
    
    # isinstance() - est une instance de?
    print(isinstance(dog, Dog))     # True
    print(isinstance(dog, Animal))  # True (héritage)
    print(isinstance(dog, object))  # True (tout hérite d'object)
    
    # issubclass() - est une sous-classe de?
    print(issubclass(Dog, Animal))  # True
    print(issubclass(Dog, Dog))     # True
    print(issubclass(Animal, Dog))  # False


[OK] MÉTHODES MAGIQUES (__dunder__)


[OK] MÉTHODES DE CONSTRUCTION ET REPRÉSENTATION
    class Point:
        def __init__(self, x, y):
            """Constructeur - initialise l'objet"""
            self.x = x
            self.y = y
        
        def __new__(cls, x, y):
            """Crée l'instance (rarement redéfini)"""
            print("Creating instance")
            instance = super().__new__(cls)
            return instance
        
        def __str__(self):
            """str(obj) - représentation pour utilisateur"""
            return f"Point({self.x}, {self.y})"
        
        def __repr__(self):
            """repr(obj) - représentation pour développeur"""
            return f"Point(x={self.x}, y={self.y})"
        
        def __format__(self, format_spec):
            """format(obj, spec) - formatage personnalisé"""
            if format_spec == 'polar':
                r = (self.x**2 + self.y**2)**0.5
                return f"r={r:.2f}"
            return str(self)
    
    p = Point(3, 4)
    print(str(p))         # Point(3, 4)
    print(repr(p))        # Point(x=3, y=4)
    print(f"{p:polar}")   # r=5.00

[OK] MÉTHODES DE COMPARAISON
    class Person:
        def __init__(self, name, age):
            self.name = name
            self.age = age
        
        def __eq__(self, other):
            """== (égalité)"""
            if not isinstance(other, Person):
                return False
            return self.name == other.name and self.age == other.age
        
        def __ne__(self, other):
            """!= (inégalité)"""
            return not self.__eq__(other)
        
        def __lt__(self, other):
            """< (inférieur)"""
            return self.age < other.age
        
        def __le__(self, other):
            """<= (inférieur ou égal)"""
            return self.age <= other.age
        
        def __gt__(self, other):
            """> (supérieur)"""
            return self.age > other.age
        
        def __ge__(self, other):
            """>= (supérieur ou égal)"""
            return self.age >= other.age
        
        def __hash__(self):
            """hash(obj) - pour utiliser dans set/dict"""
            return hash((self.name, self.age))
    
    p1 = Person("Alice", 30)
    p2 = Person("Bob", 25)
    p3 = Person("Alice", 30)
    
    print(p1 == p3)  # True
    print(p1 > p2)   # True (30 > 25)
    print(p1 < p2)   # False

[OK] OPÉRATEURS ARITHMÉTIQUES
    class Vector:
        def __init__(self, x, y):
            self.x = x
            self.y = y
        
        def __add__(self, other):
            """+ (addition)"""
            return Vector(self.x + other.x, self.y + other.y)
        
        def __sub__(self, other):
            """- (soustraction)"""
            return Vector(self.x - other.x, self.y - other.y)
        
        def __mul__(self, scalar):
            """* (multiplication)"""
            return Vector(self.x * scalar, self.y * scalar)
        
        def __truediv__(self, scalar):
            """/ (division)"""
            return Vector(self.x / scalar, self.y / scalar)
        
        def __floordiv__(self, scalar):
            """// (division entière)"""
            return Vector(self.x // scalar, self.y // scalar)
        
        def __mod__(self, scalar):
            """% (modulo)"""
            return Vector(self.x % scalar, self.y % scalar)
        
        def __pow__(self, power):
            """** (puissance)"""
            return Vector(self.x ** power, self.y ** power)
        
        def __neg__(self):
            """- (négation unaire)"""
            return Vector(-self.x, -self.y)
        
        def __abs__(self):
            """abs() (valeur absolue)"""
            return (self.x**2 + self.y**2)**0.5
        
        def __str__(self):
            return f"Vector({self.x}, {self.y})"
    
    v1 = Vector(3, 4)
    v2 = Vector(1, 2)
    
    print(v1 + v2)   # Vector(4, 6)
    print(v1 - v2)   # Vector(2, 2)
    print(v1 * 2)    # Vector(6, 8)
    print(-v1)       # Vector(-3, -4)
    print(abs(v1))   # 5.0

[OK] OPÉRATEURS ARITHMÉTIQUES INVERSÉS ET IN-PLACE
    class Number:
        def __init__(self, value):
            self.value = value
        
        def __add__(self, other):
            """n + other"""
            return Number(self.value + other)
        
        def __radd__(self, other):
            """other + n (quand other ne supporte pas +)"""
            return Number(other + self.value)
        
        def __iadd__(self, other):
            """n += other (in-place)"""
            self.value += other
            return self
        
        def __str__(self):

 n = Number(5)
    print(n + 3)    # 8 (__add__)
    print(3 + n)    # 8 (__radd__)
    n += 2          # (__iadd__)
    print(n)        # 7

[OK] OPÉRATEURS DE CONTENEUR
    class CustomList:
        def __init__(self, items=None):
            self.items = items if items else []
        
        def __len__(self):
            """len(obj)"""
            return len(self.items)
        
        def __getitem__(self, index):
            """obj[index]"""
            return self.items[index]
        
        def __setitem__(self, index, value):
            """obj[index] = value"""
            self.items[index] = value
        
        def __delitem__(self, index):
            """del obj[index]"""
            del self.items[index]
        
        def __contains__(self, item):
            """item in obj"""
            return item in self.items
        
        def __iter__(self):
            """for item in obj"""
            return iter(self.items)
        
        def __reversed__(self):
            """reversed(obj)"""
            return reversed(self.items)
    
    cl = CustomList([1, 2, 3, 4, 5])
    print(len(cl))        # 5
    print(cl[0])          # 1
    cl[0] = 10            # Modifie
    print(3 in cl)        # True
    del cl[1]             # Supprime
    
    for item in cl:       # Itération
        print(item)

[OK] CALLABLE ET CONTEXT MANAGER
    class Multiplier:
        def __init__(self, factor):
            self.factor = factor
        
        def __call__(self, x):
            """Rend l'objet callable comme une fonction"""
            return x * self.factor
    
    multiply_by_3 = Multiplier(3)
    print(multiply_by_3(5))  # 15 (objet appelé comme fonction)
    print(multiply_by_3(10)) # 30
    
    class FileManager:
        def __init__(self, filename, mode):
            self.filename = filename
            self.mode = mode
            self.file = None
        
        def __enter__(self):
            """Entrée du context manager (with)"""
            print(f"Opening {self.filename}")
            self.file = open(self.filename, self.mode)
            return self.file
        
        def __exit__(self, exc_type, exc_val, exc_tb):
            """Sortie du context manager"""
            print(f"Closing {self.filename}")
            if self.file:
                self.file.close()
            # Retourner True pour supprimer l'exception
            return False
    
    with FileManager('test.txt', 'w') as f:
        f.write('Hello World')

[OK] AUTRES MÉTHODES MAGIQUES UTILES
    class Custom:
        def __init__(self, value):
            self.value = value
        
        def __bool__(self):
            """bool(obj) - True/False"""
            return self.value != 0
        
        def __int__(self):
            """int(obj)"""
            return int(self.value)
        
        def __float__(self):
            """float(obj)"""
            return float(self.value)
        
        def __str__(self):
            """str(obj)"""
            return f"Custom({self.value})"
        
        def __bytes__(self):
            """bytes(obj)"""
            return str(self.value).encode()
        
        def __sizeof__(self):
            """sys.getsizeof(obj)"""
            import sys
            return sys.getsizeof(self.value)
    
    c = Custom(42)
    print(bool(c))    # True
    print(int(c))     # 42
    print(float(c))   # 42.0

[OK] LISTE COMPLÈTE DES MÉTHODES MAGIQUES
    # CRÉATION ET REPRÉSENTATION
    __new__         # Création de l'instance
    __init__        # Initialisation de l'instance
    __del__         # Destructeur (garbage collection)
    __repr__        # repr(obj) - représentation développeur
    __str__         # str(obj) - représentation utilisateur
    __format__      # format(obj, spec)
    __bytes__       # bytes(obj)
    __hash__        # hash(obj)
    __bool__        # bool(obj)
    __sizeof__      # sys.getsizeof(obj)
    
    # COMPARAISON
    __eq__          # ==
    __ne__          # !=
    __lt__          # <
    __le__          # <=
    __gt__          # >
    __ge__          # >=
    
    # ARITHMÉTIQUE
    __add__         # +
    __sub__         # -
    __mul__         # *
    __truediv__     # /
    __floordiv__    # //
    __mod__         # %
    __pow__         # **
    __matmul__      # @ (multiplication matricielle)
    
    # ARITHMÉTIQUE INVERSÉE (right-hand)
    __radd__        # other + obj
    __rsub__        # other - obj
    __rmul__        # other * obj
    # ... (toutes les opérations ont une version __r__)
    
    # ARITHMÉTIQUE IN-PLACE
    __iadd__        # +=
    __isub__        # -=
    __imul__        # *=
    # ... (toutes les opérations ont une version __i__)
    
    # UNAIRE
    __neg__         # -obj
    __pos__         # +obj
    __abs__         # abs(obj)
    __invert__      # ~obj
    
    # CONVERSION
    __int__         # int(obj)
    __float__       # float(obj)
    __complex__     # complex(obj)
    __round__       # round(obj)
    __trunc__       # math.trunc(obj)
    __floor__       # math.floor(obj)
    __ceil__        # math.ceil(obj)
    
    # CONTENEUR
    __len__         # len(obj)
    __getitem__     # obj[key]
    __setitem__     # obj[key] = value
    __delitem__     # del obj[key]
    __contains__    # item in obj
    __iter__        # for item in obj
    __reversed__    # reversed(obj)
    __next__        # next(obj)
    
    # ATTRIBUTS
    __getattr__     # obj.attr (si attr n'existe pas)
    __setattr__     # obj.attr = value
    __delattr__     # del obj.attr
    __getattribute__ # obj.attr (toujours appelé)
    __dir__         # dir(obj)
    
    # CALLABLE
    __call__        # obj()
    
    # CONTEXT MANAGER
    __enter__       # with obj:
    __exit__        # with obj:
    
    # DESCRIPTEURS
    __get__         # obj.descriptor
    __set__         # obj.descriptor = value
    __delete__      # del obj.descriptor
    __set_name__    # Appelé lors de la création de la classe


[OK] CLASSES ABSTRAITES (ABC)


[OK] CLASSE ABSTRAITE BASIQUE
    from abc import ABC, abstractmethod
    
    class Shape(ABC):
        """Classe abstraite - ne peut pas être instanciée"""
        
        @abstractmethod
        def area(self):
            """Méthode abstraite - doit être implémentée"""
            pass
        
        @abstractmethod
        def perimeter(self):
            pass
        
        def describe(self):
            """Méthode concrète - héritée par toutes les sous-classes"""
            return f"This is a shape with area {self.area()}"
    
    # shape = Shape()  # [X] TypeError: Can't instantiate abstract class
    
    class Rectangle(Shape):
        def __init__(self, width, height):
            self.width = width
            self.height = height
        
        def area(self):
            return self.width * self.height
        
        def perimeter(self):
            return 2 * (self.width + self.height)
    
    rect = Rectangle(5, 3)
    print(rect.area())       # 15
    print(rect.describe())   # This is a shape with area 15

[OK] PROPERTY ABSTRAITE
    from abc import ABC, abstractmethod
    
    class Vehicle(ABC):
        @property
        @abstractmethod
        def max_speed(self):
            """Propriété abstraite"""
            pass
        
        @abstractmethod
        def start(self):
            pass
    
    class Car(Vehicle):
        def __init__(self):
            self._max_speed = 200
        
        @property
        def max_speed(self):
            return self._max_speed
        
        def start(self):
            return "Car engine started"
    
    car = Car()
    print(car.max_speed)  # 200

[OK] CLASSMETHOD ET STATICMETHOD ABSTRAITS
    from abc import ABC, abstractmethod
    
    class Database(ABC):
        @classmethod
        @abstractmethod
        def connect(cls, connection_string):
            """Classmethod abstraite"""
            pass
        
        @staticmethod
        @abstractmethod
        def validate_query(query):
            """Staticmethod abstraite"""
            pass
    
    class PostgreSQL(Database):
        @classmethod
        def connect(cls, connection_string):
            return f"Connected to PostgreSQL: {connection_string}"
        
        @staticmethod
        def validate_query(query):
            return "SELECT" in query.upper()

[OK] ABSTRACT BASE CLASS AVEC ENREGISTREMENT
    from abc import ABC
    
    class PluginInterface(ABC):
        @abstractmethod
        def process(self, data):
            pass
    
    # Enregistrer une classe qui ne hérite pas directement
    class ThirdPartyPlugin:
        def process(self, data):
            return f"Processing: {data}"
    
    PluginInterface.register(ThirdPartyPlugin)
    
    plugin = ThirdPartyPlugin()
    print(isinstance(plugin, PluginInterface))  # True
    print(issubclass(ThirdPartyPlugin, PluginInterface))  # True

[OK] INTERFACE COMPLÈTE EXEMPLE
    from abc import ABC, abstractmethod
    from typing import List
    
    class PaymentProcessor(ABC):
        """Interface de traitement de paiement"""
        
        @abstractmethod
        def authorize(self, amount: float) -> bool:
            """Autoriser un paiement"""
            pass
        
        @abstractmethod
        def capture(self, amount: float) -> str:
            """Capturer un paiement"""
            pass
        
        @abstractmethod
        def refund(self, transaction_id: str, amount: float) -> bool:
            """Rembourser un paiement"""
            pass
    
    class StripeProcessor(PaymentProcessor):
        def authorize(self, amount: float) -> bool:
            print(f"Authorizing ${amount} with Stripe")
            return True
        
        def capture(self, amount: float) -> str:
            return f"stripe_txn_{amount}"
        
        def refund(self, transaction_id: str, amount: float) -> bool:
            print(f"Refunding {transaction_id}: ${amount}")
            return True
    
    class PayPalProcessor(PaymentProcessor):
        def authorize(self, amount: float) -> bool:
            print(f"Authorizing ${amount} with PayPal")
            return True
        
        def capture(self, amount: float) -> str:
            return f"paypal_txn_{amount}"
        
        def refund(self, transaction_id: str, amount: float) -> bool:
            print(f"Refunding {transaction_id}: ${amount}")
            return True


[OK] COMPOSITION VS HÉRITAGE


[OK] COMPOSITION (HAS-A)
    class Engine:
        def __init__(self, horsepower):
            self.horsepower = horsepower
        
        def start(self):
            return f"Engine with {self.horsepower}HP started"
        
        def stop(self):
            return "Engine stopped"
    
    class Wheel:
        def __init__(self, size):
            self.size = size
    
    class Car:
        def __init__(self, brand, horsepower):
            self.brand = brand
            self.engine = Engine(horsepower)  # Composition
            self.wheels = [Wheel(17) for _ in range(4)]  # Composition
        
        def start(self):
            return self.engine.start()
        
        def info(self):
            return f"{self.brand} with {len(self.wheels)} wheels"
    
    car = Car("Toyota", 150)
    print(car.start())  # Engine with 150HP started
    print(car.info())   # Toyota with 4 wheels

[OK] HÉRITAGE (IS-A) VS COMPOSITION
    # [X] MAUVAIS: Héritage inapproprié
    class Stack(list):  # Stack IS-A list (pas vraiment approprié)
        def push(self, item):
            self.append(item)
        
        def pop(self):
            return super().pop()
    
    # Problème: hérite de TOUTES les méthodes de list
    stack = Stack()
    stack.push(1)
    stack.insert(0, 99)  # [X] Casse l'abstraction de stack!
    
    # [OK] BON: Composition
    class Stack:
        def __init__(self):
            self._items = []  # HAS-A list (composition)
        
        def push(self, item):
            self._items.append(item)
        
        def pop(self):
            return self._items.pop()
        
        def is_empty(self):
            return len(self._items) == 0
    
    # Seules les méthodes définies sont exposées
    stack = Stack()
    stack.push(1)
    # stack.insert(0, 99)  # [X] AttributeError (bien!)

[OK] EXEMPLE RÉEL: COMPOSITION FAVORISÉE
    # Pattern Strategy avec Composition
    class PaymentStrategy:
        def pay(self, amount):
            pass
    
    class CreditCardPayment(PaymentStrategy):
        def __init__(self, card_number):
            self.card_number = card_number
        
        def pay(self, amount):
            return f"Paid ${amount} with credit card {self.card_number}"
    
    class PayPalPayment(PaymentStrategy):
        def __init__(self, email):
            self.email = email
        
        def pay(self, amount):
            return f"Paid ${amount} with PayPal {self.email}"
    
    class ShoppingCart:
        def __init__(self):
            self.items = []
            self.payment_strategy = None  # Composition
        
        def add_item(self, item, price):
            self.items.append((item, price))
        
        def set_payment_strategy(self, strategy):
            self.payment_strategy = strategy
        
        def checkout(self):
            total = sum(price for _, price in self.items)
            return self.payment_strategy.pay(total)
    
    cart = ShoppingCart()
    cart.add_item("Book", 20)
    cart.add_item("Pen", 5)
    
    # Changer de stratégie dynamiquement
    cart.set_payment_strategy(CreditCardPayment("1234-5678"))
    print(cart.checkout())  # Paid $25 with credit card 1234-5678
    
    cart.set_payment_strategy(PayPalPayment("user@email.com"))
    print(cart.checkout())  # Paid $25 with PayPal user@email.com


[OK] DESIGN PATTERNS


[OK] SINGLETON
    class Singleton:
        _instance = None
        
        def __new__(cls, *args, **kwargs):
            if cls._instance is None:
                cls._instance = super().__new__(cls)
            return cls._instance
        
        def __init__(self, value=None):
            if not hasattr(self, 'initialized'):
                self.value = value
                self.initialized = True
    
    s1 = Singleton(10)
    s2 = Singleton(20)
    print(s1 is s2)      # True (même instance)
    print(s1.value)      # 10 (première initialisation)
    
    # Alternative: décorateur
    def singleton(cls):
        instances = {}
        def get_instance(*args, **kwargs):
            if cls not in instances:
                instances[cls] = cls(*args, **kwargs)
            return instances[cls]
        return get_instance
    
    @singleton
    class DatabaseConnection:
        def __init__(self, host):
            self.host = host

[OK] FACTORY
    class Animal:
        def speak(self):
            pass
    
    class Dog(Animal):
        def speak(self):
            return "Woof!"
    
    class Cat(Animal):
        def speak(self):
            return "Meow!"
    
    class AnimalFactory:
        @staticmethod
        def create_animal(animal_type):
            animals = {
                'dog': Dog,
                'cat': Cat
            }
            animal_class = animals.get(animal_type.lower())
            if not animal_class:
                raise ValueError(f"Unknown animal type: {animal_type}")
            return animal_class()
    
    dog = AnimalFactory.create_animal('dog')
    cat = AnimalFactory.create_animal('cat')
    print(dog.speak())  # Woof!
    print(cat.speak())  # Meow!

[OK] BUILDER
    class Pizza:
        def __init__(self):
            self.size = None
            self.cheese = False
            self.pepperoni = False
            self.mushrooms = False
        
        def __str__(self):
            return f"Pizza: size={self.size}, cheese={self.cheese}, pepperoni={self.pepperoni}, mushrooms={self.mushrooms}"
    
    class PizzaBuilder:
        def __init__(self):
            self.pizza = Pizza()
        
        def set_size(self, size):
            self.pizza.size = size
            return self  # Return self pour chaining
        
        def add_cheese(self):
            self.pizza.cheese = True
            return self
        
        def add_pepperoni(self):
            self.pizza.pepperoni = True
            return self
        
        def add_mushrooms(self):
            self.pizza.mushrooms = True
            return self
        
        def build(self):
            return self.pizza
    
    # Utilisation avec method chaining
    pizza = (PizzaBuilder()
             .set_size("large")
             .add_cheese()
             .add_pepperoni()
             .build())
    print(pizza)

[OK] OBSERVER
    class Subject:
        def __init__(self):
            self._observers = []
        
        def attach(self, observer):
            if observer not in self._observers:
                self._observers.append(observer)
        
        def detach(self, observer):
            self._observers.remove(observer)
        
        def notify(self, *args, **kwargs):
            for observer in self._observers:
                observer.update(self, *args, **kwargs)
    
    class Observer:
        def update(self, subject, *args, **kwargs):
            pass
    
    class NewsPublisher(Subject):
        def __init__(self):
            super().__init__()
            self._latest_news = None
        
        def add_news(self, news):
            self._latest_news = news
            self.notify(news)
    
    class EmailSubscriber(Observer):
        def __init__(self, email):
            self.email = email
        
        def update(self, subject, news):
            print(f"Email to {self.email}: {news}")
    
    class SMSSubscriber(Observer):
        def __init__(self, phone):
            self.phone = phone
        
        def update(self, subject, news):
            print(f"SMS to {self.phone}: {news}")
    
    # Utilisation
    publisher = NewsPublisher()
    email_sub = EmailSubscriber("user@example.com")
    sms_sub = SMSSubscriber("123-456-7890")
    
    publisher.attach(email_sub)
    publisher.attach(sms_sub)
    
    publisher.add_news("Breaking news!")
    # Email to user@example.com: Breaking news!
    # SMS to 123-456-7890: Breaking news!

[OK] DECORATOR PATTERN
    class Component:
        def operation(self):
            pass
    
    class ConcreteComponent(Component):
        def operation(self):
            return "ConcreteComponent"
    
    class Decorator(Component):
        def __init__(self, component):
            self._component = component
        
        def operation(self):
            return self._component.operation()
    
    class ConcreteDecoratorA(Decorator):
        def operation(self):
            return f"ConcreteDecoratorA({self._component.operation()})"
    
    class ConcreteDecoratorB(Decorator):
        def operation(self):
            return f"ConcreteDecoratorB({self._component.operation()})"
    
    # Utilisation
    component = ConcreteComponent()
    decorated1 = ConcreteDecoratorA(component)
    decorated2 = ConcreteDecoratorB(decorated1)
    
    print(component.operation())    # ConcreteComponent
    print(decorated1.operation())   # ConcreteDecoratorA(ConcreteComponent)
    print(decorated2.operation())   # ConcreteDecoratorB(ConcreteDecoratorA(ConcreteComponent))


[OK] DESCRIPTEURS


[OK] DESCRIPTEUR BASIQUE
    class Descriptor:
        def __init__(self, name=None):
            self.name = name
        
        def __set_name__(self, owner, name):
            """Appelé automatiquement lors de la création de la classe"""
            self.name = name
        
        def __get__(self, obj, objtype=None):
            """Appelé lors de l'accès à l'attribut"""
            if obj is None:
                return self
            return obj.__dict__.get(self.name)
        
        def __set__(self, obj, value):
            """Appelé lors de la modification de l'attribut"""
            obj.__dict__[self.name] = value
        
        def __delete__(self, obj):
            """Appelé lors de la suppression de l'attribut"""
            del obj.__dict__[self.name]
    
    class MyClass:
        attr = Descriptor()
        
        def __init__(self, value):
            self.attr = value
    
    obj = MyClass(10)
    print(obj.attr)  # 10 (__get__)
    obj.attr = 20    # (__set__)
    del obj.attr     # (__delete__)

[OK] DESCRIPTEUR DE VALIDATION
    class Validator:
        def __init__(self, min_value=None, max_value=None):
            self.min_value = min_value
            self.max_value = max_value
        
        def __set_name__(self, owner, name):
            self.name = f"_{name}"
        
        def __get__(self, obj, objtype=None):
            if obj is None:
                return self
            return getattr(obj, self.name, None)
        
        def __set__(self, obj, value):
            if self.min_value is not None and value < self.min_value:
                raise ValueError(f"{self.name} must be >= {self.min_value}")
            if self.max_value is not None and value > self.max_value:
                raise ValueError(f"{self.name} must be <= {self.max_value}")
            setattr(obj, self.name, value)
    
    class Person:
        age = Validator(0, 150)
        height = Validator(0, 300)
        
        def __init__(self, age, height):
            self.age = age
            self.height = height
    
    person = Person(30, 175)
    # person.age = -5  # [X] ValueError
    # person.age = 200 # [X] ValueError

[OK] DESCRIPTEUR TYPE-CHECKING
    class TypedProperty:
        def __init__(self, expected_type):
            self.expected_type = expected_type
        
        def __set_name__(self, owner, name):
            self.name = f"_{name}"
        
        def __get__(self, obj, objtype=None):
            if obj is None:
                return self
            return getattr(obj, self.name, None)
        
        def __set__(self, obj, value):
            if not isinstance(value, self.expected_type):
                raise TypeError(f"{self.name} must be {self.expected_type}")
            setattr(obj, self.name, value)
    
    class Product:
        name = TypedProperty(str)
        price = TypedProperty((int, float))
        quantity = TypedProperty(int)
        
        def __init__(self, name, price, quantity):
            self.name = name
            self.price = price
            self.quantity = quantity
    
    product = Product("Book", 19.99, 10)
    # product.name = 123  # [X] TypeError
    # product.price = "expensive"  # [X] TypeError


[OK] __SLOTS__ (OPTIMISATION)


[OK] SLOTS BASIQUE
    class Point:
        __slots__ = ['x', 'y']  # Seuls ces attributs sont autorisés
        
        def __init__(self, x, y):
            self.x = x
            self.y = y
    
    p = Point(3, 4)
    print(p.x)  # 3
    # p.z = 5   # [X] AttributeError: 'Point' object has no attribute 'z'
    # print(p.__dict__)  # [X] AttributeError: 'Point' object has no attribute '__dict__'

[OK] SLOTS AVANTAGES
    # Économie de mémoire (pas de __dict__)
    # Accès plus rapide aux attributs
    # Empêche l'ajout dynamique d'attributs
    
    import sys
    
    class WithDict:
        def __init__(self, x, y):
            self.x = x
            self.y = y
    
    class WithSlots:
        __slots__ = ['x', 'y']
        
        def __init__(self, x, y):
            self.x = x
            self.y = y
    
    obj_dict = WithDict(1, 2)
    obj_slots = WithSlots(1, 2)
    
    print(sys.getsizeof(obj_dict.__dict__))  # ~120 bytes
    print(sys.getsizeof(obj_slots))          # ~64 bytes

[OK] SLOTS AVEC HÉRITAGE
    class Base:
        __slots__ = ['x']
        
        def __init__(self, x):
            self.x = x
    
    class Derived(Base):
        __slots__ = ['y']  # Ajoute 'y' aux slots de Base
        
        def __init__(self, x, y):
            super().__init__(x)
            self.y = y
    
    d = Derived(1, 2)
    print(d.x, d.y)  # 1 2

[OK] SLOTS AVEC __DICT__
    class Hybrid:
        __slots__ = ['x', 'y', '__dict__']  # Permet attributs dynamiques
        
        def __init__(self, x, y):
            self.x = x
            self.y = y
    
    h = Hybrid(1, 2)
    h.z = 3  # [OK] OK grâce à __dict__
    print(h.__dict__)  # {'z': 3}


[OK] MÉTACLASSES


[OK] MÉTACLASSE BASIQUE
    class Meta(type):
        def __new__(cls, name, bases, dct):
            """Création de la classe"""
            print(f"Creating class {name}")
            # Modifier le dictionnaire de la classe
            dct['created_by_meta'] = True
            return super().__new__(cls, name, bases, dct)
        
        def __init__(cls, name, bases, dct):
            """Initialisation de la classe"""
            print(f"Initializing class {name}")
            super().__init__(name, bases, dct)
        
        def __call__(cls, *args, **kwargs):
            """Création d'instance de la classe"""
            print(f"Creating instance of {cls.__name__}")
            return super().__call__(*args, **kwargs)
    
    class MyClass(metaclass=Meta):
        def __init__(self, value):
            self.value = value
    
    # Output lors de la définition:
    # Creating class MyClass
    # Initializing class MyClass
    
    print(MyClass.created_by_meta)  # True
    obj = MyClass(10)  # Creating instance of MyClass

[OK] MÉTACLASSE POUR SINGLETON
    class SingletonMeta(type):
        _instances = {}
        
        def __call__(cls, *args, **kwargs):
            if cls not in cls._instances:
                cls._instances[cls] = super().__call__(*args, **kwargs)
            return cls._instances[cls]
    
    class Database(metaclass=SingletonMeta):
        def __init__(self, host):
            self.host = host
    
    db1 = Database("localhost")
    db2 = Database("remotehost")
    print(db1 is db2)  # True
    print(db1.host)    # localhost (première initialisation)

[OK] MÉTACLASSE POUR VALIDATION
    class ValidatorMeta(type):
        def __new__(cls, name, bases, dct):
            # Vérifier que toutes les méthodes ont des docstrings
            for key, value in dct.items():
                if callable(value) and not key.startswith('_'):
                    if not value.__doc__:
                        raise TypeError(f"Method {key} must have a docstring")
            return super().__new__(cls, name, bases, dct)
    
    class MyClass(metaclass=ValidatorMeta):
        def method_with_doc(self):
            """This method has a docstring"""
            pass
        
        # def method_without_doc(self):  # [X] TypeError
        #     pass

[OK] MÉTACLASSE POUR ENREGISTREMENT
    class RegistryMeta(type):
        registry = {}
        
        def __new__(cls, name, bases, dct):
            new_class = super().__new__(cls, name, bases, dct)
            # Enregistrer la classe
            if name != 'Plugin':  # Ne pas enregistrer la classe de base
                cls.registry[name] = new_class
            return new_class
        
        @classmethod
        def get_plugins(cls):
            return cls.registry
    
    class Plugin(metaclass=RegistryMeta):
        pass
    
    class AudioPlugin(Plugin):
        pass
    
    class VideoPlugin(Plugin):
        pass
    
    print(RegistryMeta.get_plugins())
    # {'AudioPlugin': <class 'AudioPlugin'>, 'VideoPlugin': <class 'VideoPlugin'>}


[OK] DATACLASS (Python 3.7+)


[OK] DATACLASS BASIQUE
    from dataclasses import dataclass
    
    @dataclass
    class Person:
        name: str
        age: int
        city: str = "Unknown"  # Valeur par défaut
    
    # __init__, __repr__, __eq__ générés automatiquement
    p1 = Person("Alice", 30)
    p2 = Person("Bob", 25, "Paris")
    
    print(p1)           # Person(name='Alice', age=30, city='Unknown')
    print(p1 == p2)     # False
    print(p1.name)      # Alice

[OK] DATACLASS OPTIONS
    from dataclasses import dataclass, field
    
    @dataclass(
        init=True,           # Générer __init__ (défaut: True)
        repr=True,           # Générer __repr__ (défaut: True)
        eq=True,             # Générer __eq__ (défaut: True)
        order=False,         # Générer __lt__, __le__, __gt__, __ge__ (défaut: False)
        frozen=False,        # Rendre immuable (défaut: False)
        unsafe_hash=False    # Générer __hash__ (défaut: False)
    )
    class Point:
        x: int
        y: int
    
    # Exemple avec frozen (immuable)
    @dataclass(frozen=True)
    class ImmutablePoint:
        x: int
        y: int
    
    p = ImmutablePoint(3, 4)
    # p.x = 5  # [X] FrozenInstanceError

[OK] DATACLASS AVEC FIELD
    from dataclasses import dataclass, field
    from typing import List
    
    @dataclass
    class Inventory:
        name: str
        items: List[str] = field(default_factory=list)  # [OK] Liste mutable
        count: int = field(default=0, init=False)       # Pas dans __init__
        _internal: str = field(default="", repr=False)  # Pas dans __repr__
        
        def __post_init__(self):
            """Appelé après __init__"""
            self.count = len(self.items)
    
    inv1 = Inventory("Store1", ["item1", "item2"])
    inv2 = Inventory("Store2")
    
    print(inv1)        # Inventory(name='Store1', items=['item1', 'item2'], count=2)
    print(inv1.count)  # 2 (calculé dans __post_init__)

[OK] DATACLASS AVEC MÉTHODES
    from dataclasses import dataclass
    from typing import ClassVar
    
    @dataclass
    class Product:
        name: str
        price: float
        quantity: int = 0
        
        # Variable de classe (non incluse dans __init__)
        tax_rate: ClassVar[float] = 0.2
        
        def total_price(self) -> float:
            """Méthode personnalisée"""
            return self.price * self.quantity * (1 + self.tax_rate)
        
        def __str__(self) -> str:
            """Redéfinir __str__ si nécessaire"""
            return f"{self.name}: ${self.price} x {self.quantity}"
    
    product = Product("Laptop", 1000, 2)
    print(product.total_price())  # 2400.0

[OK] DATACLASS AVEC HÉRITAGE
    from dataclasses import dataclass
    
    @dataclass
    class Person:
        name: str
        age: int
    
    @dataclass
    class Employee(Person):
        employee_id: str
        salary: float
    
    emp = Employee("Alice", 30, "E123", 50000)
    print(emp)  # Employee(name='Alice', age=30, employee_id='E123', salary=50000.0)

[OK] DATACLASS ORDER (COMPARAISON)
    from dataclasses import dataclass
    
    @dataclass(order=True)
    class Person:
        name: str
        age: int
    
    p1 = Person("Alice", 30)
    p2 = Person("Bob", 25)
    p3 = Person("Alice", 35)
    
    print(p1 < p2)   # False (compare d'abord 'name', puis 'age')
    print(p1 < p3)   # True ('Alice' == 'Alice', mais 30 < 35)
    
    # Trier une liste
    people = [p2, p3, p1]
    people.sort()
    print(people)  # [Person(name='Alice', age=30), Person(name='Alice', age=35), Person(name='Bob', age=25)]

[OK] DATACLASS CONVERSIONS
    from dataclasses import dataclass, asdict, astuple
    
    @dataclass
    class Person:
        name: str
        age: int
        city: str
    
    p = Person("Alice", 30, "Paris")
    
    # Convertir en dict
    print(asdict(p))
    # {'name': 'Alice', 'age': 30, 'city': 'Paris'}
    
    # Convertir en tuple
    print(astuple(p))
    # ('Alice', 30, 'Paris')


[OK] CONTEXT MANAGERS


[OK] CONTEXT MANAGER AVEC CLASSE
    class FileManager:
        def __init__(self, filename, mode):
            self.filename = filename
            self.mode = mode
            self.file = None
        
        def __enter__(self):
            """Entrée du context (with)"""
            print(f"Opening {self.filename}")
            self.file = open(self.filename, self.mode)
            return self.file
        
        def __exit__(self, exc_type, exc_val, exc_tb):
            """Sortie du context"""
            print(f"Closing {self.filename}")
            if self.file:
                self.file.close()
            
            # Gestion des exceptions
            if exc_type is not None:
                print(f"Exception occurred: {exc_type.__name__}: {exc_val}")
            
            # Return True pour supprimer l'exception
            # Return False (ou None) pour propager l'exception
            return False
    
    with FileManager('test.txt', 'w') as f:
        f.write('Hello World')

[OK] CONTEXT MANAGER AVEC CONTEXTLIB
    from contextlib import contextmanager
    
    @contextmanager
    def file_manager(filename, mode):
        """Context manager avec générateur"""
        print(f"Opening {filename}")
        file = open(filename, mode)
        try:
            yield file  # Point de suspension
        finally:
            print(f"Closing {filename}")
            file.close()
    
    with file_manager('test.txt', 'w') as f:
        f.write('Hello World')

[OK] CONTEXT MANAGER POUR TIMER
    import time
    from contextlib import contextmanager
    
    @contextmanager
    def timer(name):
        """Mesurer le temps d'exécution"""
        start = time.time()
        print(f"{name} started")
        try:
            yield
        finally:
            end = time.time()
            print(f"{name} took {end - start:.2f} seconds")
    
    with timer("My operation"):
        time.sleep(1)
        print("Doing work...")
    # My operation started
    # Doing work...
    # My operation took 1.00 seconds

[OK] CONTEXT MANAGER POUR DATABASE
    class DatabaseConnection:
        def __init__(self, connection_string):
            self.connection_string = connection_string
            self.connection = None
        
        def __enter__(self):
            print(f"Connecting to {self.connection_string}")
            # self.connection = connect(self.connection_string)
            self.connection = "Connected"
            return self.connection
        
        def __exit__(self, exc_type, exc_val, exc_tb):
            print("Closing database connection")
            if self.connection:
                # self.connection.close()
                self.connection = None
            
            if exc_type is not None:
                print(f"Rolling back due to error: {exc_val}")
                # Rollback transaction
                return False  # Propager l'exception
            else:
                print("Committing transaction")
                # Commit transaction
                return True
    
    with DatabaseConnection("localhost:5432") as db:
        print(f"Using connection: {db}")
        # Do database operations

[OK] CONTEXT MANAGER RÉUTILISABLE
    class Suppressor:
        """Supprime les exceptions spécifiées"""
        def __init__(self, *exceptions):
            self.exceptions = exceptions
        
        def __enter__(self):
            return self
        
        def __exit__(self, exc_type, exc_val, exc_tb):
            # Return True si l'exception doit être supprimée
            return exc_type is not None and issubclass(exc_type, self.exceptions)
    
    # Utilisation
    with Suppressor(ValueError, TypeError):
        print("Before error")
        raise ValueError("This will be suppressed")
        print("This won't print")
    
    print("Program continues")  # S'exécute

[OK] CONTEXT MANAGER POUR CHANGEMENT TEMPORAIRE
    import os
    from contextlib import contextmanager
    
    @contextmanager
    def temporary_directory_change(path):
        """Changer de répertoire temporairement"""
        original_dir = os.getcwd()
        try:
            os.chdir(path)
            yield
        finally:
            os.chdir(original_dir)
    
    print(f"Current dir: {os.getcwd()}")
    with temporary_directory_change('/tmp'):
        print(f"Inside context: {os.getcwd()}")
    print(f"After context: {os.getcwd()}")


[OK] ITERATEURS ET GÉNÉRATEURS


[OK] ITÉRATEUR PERSONNALISÉ
    class Counter:
        def __init__(self, start, end):
            self.current = start
            self.end = end
        
        def __iter__(self):
            """Retourne l'itérateur (self)"""
            return self
        
        def __next__(self):
            """Retourne le prochain élément"""
            if self.current >= self.end:
                raise StopIteration
            self.current += 1
            return self.current - 1
    
    # Utilisation
    for num in Counter(1, 5):
        print(num)  # 1, 2, 3, 4

[OK] ITÉRATEUR INFINI
    class InfiniteCounter:
        def __init__(self, start=0):
            self.current = start
        
        def __iter__(self):
            return self
        
        def __next__(self):
            value = self.current
            self.current += 1
            return value
    
    counter = InfiniteCounter(10)
    print(next(counter))  # 10
    print(next(counter))  # 11
    print(next(counter))  # 12

[OK] ITÉRATEUR REVERSE
    class ReverseList:
        def __init__(self, data):
            self.data = data
            self.index = len(data)
        
        def __iter__(self):
            return self
        
        def __next__(self):
            if self.index == 0:
                raise StopIteration
            self.index -= 1
            return self.data[self.index]
    
    for item in ReverseList([1, 2, 3, 4]):
        print(item)  # 4, 3, 2, 1

[OK] GÉNÉRATEUR (Méthode simple)
    def counter(start, end):
        """Générateur avec yield"""
        current = start
        while current < end:
            yield current
            current += 1
    
    for num in counter(1, 5):
        print(num)  # 1, 2, 3, 4

[OK] CLASSE ITÉRABLE AVEC GÉNÉRATEUR
    class Range:
        def __init__(self, start, end):
            self.start = start
            self.end = end
        
        def __iter__(self):
            """Retourne un générateur"""
            current = self.start
            while current < self.end:
                yield current
                current += 1
    
    for num in Range(1, 5):
        print(num)  # 1, 2, 3, 4

[OK] ITÉRATEUR AVEC __REVERSED__
    class MyList:
        def __init__(self, data):
            self.data = data
        
        def __iter__(self):
            return iter(self.data)
        
        def __reversed__(self):
            """Support de reversed()"""
            return reversed(self.data)
    
    my_list = MyList([1, 2, 3, 4])
    print(list(my_list))              # [1, 2, 3, 4]
    print(list(reversed(my_list)))    # [4, 3, 2, 1]


[OK] MIXINS


[OK] MIXIN BASIQUE
    class JSONMixin:
        """Mixin pour ajouter la sérialisation JSON"""
        def to_json(self):
            import json
            return json.dumps(self.__dict__)
        
        @classmethod
        def from_json(cls, json_str):
            import json
            data = json.loads(json_str)
            return cls(**data)
    
    class ReprMixin:
        """Mixin pour __repr__ automatique"""
        def __repr__(self):
            class_name = self.__class__.__name__
            attrs = ', '.join(f"{k}={v!r}" for k, v in self.__dict__.items())
            return f"{class_name}({attrs})"
    
    class Person(JSONMixin, ReprMixin):
        def __init__(self, name, age):
            self.name = name
            self.age = age
    
    p = Person("Alice", 30)
    print(repr(p))        # Person(name='Alice', age=30)
    
    json_str = p.to_json()
    print(json_str)       # {"name": "Alice", "age": 30}
    
    p2 = Person.from_json(json_str)
    print(p2.name)        # Alice

[OK] MIXIN POUR COMPARAISON
    class ComparableMixin:
        """Mixin pour comparaisons basées sur un attribut"""
        def __eq__(self, other):
            return self._compare_value() == other._compare_value()
        
        def __lt__(self, other):
            return self._compare_value() < other._compare_value()
        
        def __le__(self, other):
            return self._compare_value() <= other._compare_value()
        
        def __gt__(self, other):
            return self._compare_value() > other._compare_value()
        
        def __ge__(self, other):
            return self._compare_value() >= other._compare_value()
        
        def _compare_value(self):
            """À redéfinir dans la sous-classe"""
            raise NotImplementedError
    
    class Person(ComparableMixin):
        def __init__(self, name, age):
            self.name = name
            self.age = age
        
        def _compare_value(self):
            return self.age
    
    p1 = Person("Alice", 30)
    p2 = Person("Bob", 25)
    
    print(p1 > p2)   # True (30 > 25)
    print(p1 == p2)  # False

[OK] MIXIN POUR TIMESTAMP
    from datetime import datetime
    
    class TimestampMixin:
        """Ajoute created_at et updated_at"""
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self.created_at = datetime.now()
            self.updated_at = datetime.now()
        
        def touch(self):
            """Met à jour updated_at"""
            self.updated_at = datetime.now()
    
    class User(TimestampMixin):
        def __init__(self, username):
            super().__init__()
            self.username = username
    
    user = User("alice")
    print(user.created_at)
    user.touch()
    print(user.updated_at)


[OK] PROTOCOLES ET DUCK TYPING


[OK] PROTOCOLE (Python 3.8+ avec typing.Protocol)
    from typing import Protocol
    
    class Drawable(Protocol):
        """Protocole - pas besoin d'hériter explicitement"""
        def draw(self) -> str:
            ...
    
    class Circle:
        def draw(self) -> str:
            return "Drawing Circle"
    
    class Square:
        def draw(self) -> str:
            return "Drawing Square"
    
    def render(shape: Drawable) -> None:
        print(shape.draw())
    
    # Fonctionne car Circle et Square implémentent draw()
    render(Circle())  # Drawing Circle
    render(Square())  # Drawing Square

[OK] DUCK TYPING
    # "Si ça marche comme un canard et ça fait coin-coin comme un canard, c'est un canard"
    
    class Duck:
        def quack(self):
            return "Quack!"
        
        def fly(self):
            return "Flying"
    
    class Person:
        def quack(self):
            return "I'm quacking like a duck!"
        
        def fly(self):
            return "I'm flapping my arms"
    
    def make_it_quack(thing):
        """Accepte n'importe quoi avec une méthode quack()"""
        print(thing.quack())
    
    duck = Duck()
    person = Person()
    
    make_it_quack(duck)    # Quack!
    make_it_quack(person)  # I'm quacking like a duck!

[OK] VÉRIFICATION AVEC hasattr()
    def process(obj):
        """Traite l'objet selon ses capacités"""
        if hasattr(obj, 'save'):
            obj.save()
        
        if hasattr(obj, 'validate'):
            if obj.validate():
                print("Valid")
        
        if callable(getattr(obj, 'process', None)):
            obj.process()


[OK] CLASSES IMMUABLES


[OK] CLASSE IMMUABLE AVEC __SLOTS__
    class ImmutablePoint:
        __slots__ = ('_x', '_y')
        
        def __init__(self, x, y):
            object.__setattr__(self, '_x', x)
            object.__setattr__(self, '_y', y)
        
        @property
        def x(self):
            return self._x
        
        @property
        def y(self):
            return self._y
        
        def __setattr__(self, name, value):
            raise AttributeError("ImmutablePoint is immutable")
        
        def __delattr__(self, name):
            raise AttributeError("ImmutablePoint is immutable")
    
    p = ImmutablePoint(3, 4)
    print(p.x)  # 3
    # p.x = 5   # [X] AttributeError

[OK] CLASSE IMMUABLE AVEC DATACLASS
    from dataclasses import dataclass
    
    @dataclass(frozen=True)
    class Point:
        x: int
        y: int
    
    p = Point(3, 4)
    # p.x = 5  # [X] FrozenInstanceError

[OK] CLASSE IMMUABLE AVEC NAMEDTUPLE
    from collections import namedtuple
    
    Point = namedtuple('Point', ['x', 'y'])
    
    p = Point(3, 4)
    print(p.x)  # 3
    # p.x = 5   # [X] AttributeError


[OK] BONUS: TECHNIQUES AVANCÉES


[OK] MONKEY PATCHING (À ÉVITER!)
    class MyClass:
        def method(self):
            return "Original"
    
    # Remplacer une méthode (mauvaise pratique)
    def new_method(self):
        return "Patched"
    
    MyClass.method = new_method
    
    obj = MyClass()
    print(obj.method())  # Patched

[OK] __GETATTR__ VS __GETATTRIBUTE__
    class MyClass:
        def __init__(self):
            self.existing = "I exist"
        
        def __getattr__(self, name):
            """Appelé seulement si l'attribut n'existe pas"""
            return f"Attribute {name} not found"
        
        def __getattribute__(self, name):
            """Appelé pour TOUS les accès aux attributs"""
            print(f"Accessing {name}")
            return super().__getattribute__(name)
    
    obj = MyClass()
    print(obj.existing)      # Accessing existing \n I exist
    print(obj.non_existing)  # Accessing non_existing \n Attribute non_existing not found

[OK] __CALL__ POUR CLASSES CALLABLE
    class Multiplier:
        def __init__(self, factor):
            self.factor = factor
        
        def __call__(self, x):
            return x * self.factor
    
    times_three = Multiplier(3)
    print(times_three(5))  # 15 (objet appelé comme fonction)

[OK] CLASSE AVEC __MISSING__ (dict subclass)
    class DefaultDict(dict):
        def __init__(self, default_value):
            super().__init__()
            self.default_value = default_value
        
        def __missing__(self, key):
            """Appelé quand une clé n'existe pas"""
            return self.default_value
    
    d = DefaultDict("Not found")
    d['a'] = 1
    print(d['a'])          # 1
    print(d['missing'])    # Not found

[OK] COPY VS DEEPCOPY
    import copy
    
    class Person:
        def __init__(self, name, friends):
            self.name = name
            self.friends = friends
    
    p1 = Person("Alice", ["Bob", "Charlie"])
    
    # Shallow copy
    p2 = copy.copy(p1)
    p2.friends.append("Diana")
    print(p1.friends)  # ['Bob', 'Charlie', 'Diana'] [ATTENTION] Affecté!
    
    # Deep copy
    p3 = copy.deepcopy(p1)
    p3.friends.append("Eve")
    print(p1.friends)  # ['Bob', 'Charlie', 'Diana'] [OK] Non affecté

[OK] __INIT_SUBCLASS__ (Personnalisation d'héritage)
    class Plugin:
        plugins = []
        
        def __init_subclass__(cls, **kwargs):
            """Appelé quand une sous-classe est créée"""
            super().__init_subclass__(**kwargs)
            cls.plugins.append(cls)
            print(f"Registered plugin: {cls.__name__}")
    
    class AudioPlugin(Plugin):
        pass
    
    class VideoPlugin(Plugin):
        pass
    
    # Output lors de la définition:
    # Registered plugin: AudioPlugin
    # Registered plugin: VideoPlugin
    
    print(Plugin.plugins)  # [AudioPlugin, VideoPlugin]


[OK] BONNES PRATIQUES OOP


"""
1. PRINCIPE DE RESPONSABILITÉ UNIQUE (SRP)
   - Une classe = une responsabilité
   - Si une classe fait trop de choses, la diviser

2. PRINCIPE OUVERT/FERMÉ (OCP)
   - Ouvert à l'extension, fermé à la modification
   - Utiliser l'héritage et les interfaces

3. PRINCIPE DE SUBSTITUTION DE LISKOV (LSP)
   - Les sous-classes doivent pouvoir remplacer leurs parents
   - Ne pas violer les contrats des méthodes parentes

4. PRINCIPE DE SÉGRÉGATION DES INTERFACES (ISP)
   - Plusieurs petites interfaces > une grosse interface
   - Les clients ne doivent pas dépendre de méthodes inutilisées

5. PRINCIPE D'INVERSION DES DÉPENDANCES (DIP)
   - Dépendre des abstractions, pas des implémentations
   - Utiliser ABC et protocols

6. COMPOSITION > HÉRITAGE
   - Préférer "HAS-A" à "IS-A" quand possible
   - Plus flexible et moins couplé

7. ENCAPSULATION
   - Utiliser properties pour contrôler l'accès
   - Protected (_) et Private (__) selon les besoins
   - Exposer une API claire et stable

8. NOMMAGE
   - Classes: PascalCase (Person, BankAccount)
   - Méthodes/attributs: snake_case (get_balance, first_name)
   - Constantes: UPPER_CASE (MAX_SIZE, API_KEY)
   - Private: __name, Protected: _name

9. DOCUMENTATION
   - Docstrings pour toutes les classes et méthodes publiques
   - Type hints pour la clarté
   - Commentaires pour la logique complexe

10. TESTS
    - Tester les interfaces publiques
    - Mock les dépendances
    - Tests unitaires pour chaque classe
"""


[OK] RESSOURCES ET RÉFÉRENCES


"""
Documentation Python OOP:
https://docs.python.org/3/tutorial/classes.html

PEP 8 - Style Guide:
https://peps.python.org/pep-0008/

PEP 484 - Type Hints:
https://peps.python.org/pep-0484/

PEP 557 - Data Classes:
https://peps.python.org/pep-0557/

Design Patterns in Python:
https://refactoring.guru/design-patterns/python

SOLID Principles:
https://en.wikipedia.org/wiki/SOLID
"""


# FIN DU CHEATSHEET OOP COMPLET
