You have Employee with name and salary. Manager needs the same plus a team list. Instead of duplicating code, Manager extends Employee - inheriting its attributes and methods while adding its own.

Basic inheritance

Create a subclass that extends a parent.

basic_inheritance.py
# Basic Inheritance

class Animal:
    """Base class for all animals."""
    
    def __init__(self, name):
        self.name = name
    
    def speak(self):
        return f"{self.name} makes a sound"
    
    def describe(self):
        return f"I am {self.name}"


# Dog inherits from Animal
class Dog(Animal):
    """Dog class inherits from Animal."""
    pass  # No additional code - inherits everything


# Cat inherits from Animal
class Cat(Animal):
    """Cat class inherits from Animal."""
    pass


# Bird with additional attribute
class Bird(Animal):
    def __init__(self, name, can_fly=True):
        # Call parent's __init__
        super().__init__(name)
        self.can_fly = can_fly


# Demonstrate basic inheritance
print("=== Basic Inheritance ===\n")

# Create Dog - uses inherited __init__
dog = 
print(f"Dog name: {dog.name}")
print(f"Dog speaks: {dog.speak()}")
print(f"Dog describes: {dog.describe()}")

print()

# Create Cat - also inherits everything
cat = Cat("Whiskers")
print(f"Cat name: {cat.name}")
print(f"Cat speaks: {cat.speak()}")
print(f"Cat describes: {cat.describe()}")

print()

# Create Bird - has additional attribute
bird = 
print(f"Bird name: {bird.name}")
print(f"Bird can fly: {bird.can_fly}")
print(f"Bird speaks: {bird.speak()}")

print()

# Bird that can't fly
penguin = Bird("Pingu", can_fly=False)
print(f"Penguin name: {penguin.name}")
print(f"Penguin can fly: {penguin.can_fly}")

print("\n=== Inheritance Rules ===")
print("""
1. Child inherits all attributes and methods
2. Syntax: class Child(Parent):
3. super().__init__() calls parent's __init__
4. Child can add new attributes/methods
5. 'pass' means no additions (pure inheritance)
""")

# Basic Inheritance

class Animal:
    """Base class for all animals."""
    
    def __init__(self, name):
        self.name = name
    
    def speak(self):
        return f"{self.name} makes a sound"
    
    def describe(self):
        return f"I am {self.name}"


# Dog inherits from Animal
class Dog(Animal):
    """Dog class inherits from Animal."""
    pass  # No additional code - inherits everything


# Cat inherits from Animal
class Cat(Animal):
    """Cat class inherits from Animal."""
    pass


# Bird with additional attribute
class Bird(Animal):
    def __init__(self, name, can_fly=True):
        # Call parent's __init__
        super().__init__(name)
        self.can_fly = can_fly


# Demonstrate basic inheritance
print("=== Basic Inheritance ===\n")

# Create Dog - uses inherited __init__
dog = 
print(f"Dog name: {dog.name}")
print(f"Dog speaks: {dog.speak()}")
print(f"Dog describes: {dog.describe()}")

print()

# Create Cat - also inherits everything
cat = Cat("Whiskers")
print(f"Cat name: {cat.name}")
print(f"Cat speaks: {cat.speak()}")
print(f"Cat describes: {cat.describe()}")

print()

# Create Bird - has additional attribute
bird = 
print(f"Bird name: {bird.name}")
print(f"Bird can fly: {bird.can_fly}")
print(f"Bird speaks: {bird.speak()}")

print()

# Bird that can't fly
penguin = Bird("Pingu", can_fly=False)
print(f"Penguin name: {penguin.name}")
print(f"Penguin can fly: {penguin.can_fly}")

print("\n=== Inheritance Rules ===")
print("""
1. Child inherits all attributes and methods
2. Syntax: class Child(Parent):
3. super().__init__() calls parent's __init__
4. Child can add new attributes/methods
5. 'pass' means no additions (pure inheritance)
""")

# Basic Inheritance

class Animal:
    """Base class for all animals."""
    
    def __init__(self, name):
        self.name = name
    
    def speak(self):
        return f"{self.name} makes a sound"
    
    def describe(self):
        return f"I am {self.name}"


# Dog inherits from Animal
class Dog(Animal):
    """Dog class inherits from Animal."""
    pass  # No additional code - inherits everything


# Cat inherits from Animal
class Cat(Animal):
    """Cat class inherits from Animal."""
    pass


# Bird with additional attribute
class Bird(Animal):
    def __init__(self, name, can_fly=True):
        # Call parent's __init__
        super().__init__(name)
        self.can_fly = can_fly


# Demonstrate basic inheritance
print("=== Basic Inheritance ===\n")

# Create Dog - uses inherited __init__
dog = 
print(f"Dog name: {dog.name}")
print(f"Dog speaks: {dog.speak()}")
print(f"Dog describes: {dog.describe()}")

print()

# Create Cat - also inherits everything
cat = Cat("Whiskers")
print(f"Cat name: {cat.name}")
print(f"Cat speaks: {cat.speak()}")
print(f"Cat describes: {cat.describe()}")

print()

# Create Bird - has additional attribute
bird = 
print(f"Bird name: {bird.name}")
print(f"Bird can fly: {bird.can_fly}")
print(f"Bird speaks: {bird.speak()}")

print()

# Bird that can't fly
penguin = Bird("Pingu", can_fly=False)
print(f"Penguin name: {penguin.name}")
print(f"Penguin can fly: {penguin.can_fly}")

print("\n=== Inheritance Rules ===")
print("""
1. Child inherits all attributes and methods
2. Syntax: class Child(Parent):
3. super().__init__() calls parent's __init__
4. Child can add new attributes/methods
5. 'pass' means no additions (pure inheritance)
""")

# Basic Inheritance

class Animal:
    """Base class for all animals."""
    
    def __init__(self, name):
        self.name = name
    
    def speak(self):
        return f"{self.name} makes a sound"
    
    def describe(self):
        return f"I am {self.name}"


# Dog inherits from Animal
class Dog(Animal):
    """Dog class inherits from Animal."""
    pass  # No additional code - inherits everything


# Cat inherits from Animal
class Cat(Animal):
    """Cat class inherits from Animal."""
    pass


# Bird with additional attribute
class Bird(Animal):
    def __init__(self, name, can_fly=True):
        # Call parent's __init__
        super().__init__(name)
        self.can_fly = can_fly


# Demonstrate basic inheritance
print("=== Basic Inheritance ===\n")

# Create Dog - uses inherited __init__
dog = 
print(f"Dog name: {dog.name}")
print(f"Dog speaks: {dog.speak()}")
print(f"Dog describes: {dog.describe()}")

print()

# Create Cat - also inherits everything
cat = Cat("Whiskers")
print(f"Cat name: {cat.name}")
print(f"Cat speaks: {cat.speak()}")
print(f"Cat describes: {cat.describe()}")

print()

# Create Bird - has additional attribute
bird = 
print(f"Bird name: {bird.name}")
print(f"Bird can fly: {bird.can_fly}")
print(f"Bird speaks: {bird.speak()}")

print()

# Bird that can't fly
penguin = Bird("Pingu", can_fly=False)
print(f"Penguin name: {penguin.name}")
print(f"Penguin can fly: {penguin.can_fly}")

print("\n=== Inheritance Rules ===")
print("""
1. Child inherits all attributes and methods
2. Syntax: class Child(Parent):
3. super().__init__() calls parent's __init__
4. Child can add new attributes/methods
5. 'pass' means no additions (pure inheritance)
""")

# Basic Inheritance

class Animal:
    """Base class for all animals."""
    
    def __init__(self, name):
        self.name = name
    
    def speak(self):
        return f"{self.name} makes a sound"
    
    def describe(self):
        return f"I am {self.name}"


# Dog inherits from Animal
class Dog(Animal):
    """Dog class inherits from Animal."""
    pass  # No additional code - inherits everything


# Cat inherits from Animal
class Cat(Animal):
    """Cat class inherits from Animal."""
    pass


# Bird with additional attribute
class Bird(Animal):
    def __init__(self, name, can_fly=True):
        # Call parent's __init__
        super().__init__(name)
        self.can_fly = can_fly


# Demonstrate basic inheritance
print("=== Basic Inheritance ===\n")

# Create Dog - uses inherited __init__
dog = 
print(f"Dog name: {dog.name}")
print(f"Dog speaks: {dog.speak()}")
print(f"Dog describes: {dog.describe()}")

print()

# Create Cat - also inherits everything
cat = Cat("Whiskers")
print(f"Cat name: {cat.name}")
print(f"Cat speaks: {cat.speak()}")
print(f"Cat describes: {cat.describe()}")

print()

# Create Bird - has additional attribute
bird = 
print(f"Bird name: {bird.name}")
print(f"Bird can fly: {bird.can_fly}")
print(f"Bird speaks: {bird.speak()}")

print()

# Bird that can't fly
penguin = Bird("Pingu", can_fly=False)
print(f"Penguin name: {penguin.name}")
print(f"Penguin can fly: {penguin.can_fly}")

print("\n=== Inheritance Rules ===")
print("""
1. Child inherits all attributes and methods
2. Syntax: class Child(Parent):
3. super().__init__() calls parent's __init__
4. Child can add new attributes/methods
5. 'pass' means no additions (pure inheritance)
""")

# Basic Inheritance

class Animal:
    """Base class for all animals."""
    
    def __init__(self, name):
        self.name = name
    
    def speak(self):
        return f"{self.name} makes a sound"
    
    def describe(self):
        return f"I am {self.name}"


# Dog inherits from Animal
class Dog(Animal):
    """Dog class inherits from Animal."""
    pass  # No additional code - inherits everything


# Cat inherits from Animal
class Cat(Animal):
    """Cat class inherits from Animal."""
    pass


# Bird with additional attribute
class Bird(Animal):
    def __init__(self, name, can_fly=True):
        # Call parent's __init__
        super().__init__(name)
        self.can_fly = can_fly


# Demonstrate basic inheritance
print("=== Basic Inheritance ===\n")

# Create Dog - uses inherited __init__
dog = 
print(f"Dog name: {dog.name}")
print(f"Dog speaks: {dog.speak()}")
print(f"Dog describes: {dog.describe()}")

print()

# Create Cat - also inherits everything
cat = Cat("Whiskers")
print(f"Cat name: {cat.name}")
print(f"Cat speaks: {cat.speak()}")
print(f"Cat describes: {cat.describe()}")

print()

# Create Bird - has additional attribute
bird = 
print(f"Bird name: {bird.name}")
print(f"Bird can fly: {bird.can_fly}")
print(f"Bird speaks: {bird.speak()}")

print()

# Bird that can't fly
penguin = Bird("Pingu", can_fly=False)
print(f"Penguin name: {penguin.name}")
print(f"Penguin can fly: {penguin.can_fly}")

print("\n=== Inheritance Rules ===")
print("""
1. Child inherits all attributes and methods
2. Syntax: class Child(Parent):
3. super().__init__() calls parent's __init__
4. Child can add new attributes/methods
5. 'pass' means no additions (pure inheritance)
""")

# Basic Inheritance

class Animal:
    """Base class for all animals."""
    
    def __init__(self, name):
        self.name = name
    
    def speak(self):
        return f"{self.name} makes a sound"
    
    def describe(self):
        return f"I am {self.name}"


# Dog inherits from Animal
class Dog(Animal):
    """Dog class inherits from Animal."""
    pass  # No additional code - inherits everything


# Cat inherits from Animal
class Cat(Animal):
    """Cat class inherits from Animal."""
    pass


# Bird with additional attribute
class Bird(Animal):
    def __init__(self, name, can_fly=True):
        # Call parent's __init__
        super().__init__(name)
        self.can_fly = can_fly


# Demonstrate basic inheritance
print("=== Basic Inheritance ===\n")

# Create Dog - uses inherited __init__
dog = 
print(f"Dog name: {dog.name}")
print(f"Dog speaks: {dog.speak()}")
print(f"Dog describes: {dog.describe()}")

print()

# Create Cat - also inherits everything
cat = Cat("Whiskers")
print(f"Cat name: {cat.name}")
print(f"Cat speaks: {cat.speak()}")
print(f"Cat describes: {cat.describe()}")

print()

# Create Bird - has additional attribute
bird = 
print(f"Bird name: {bird.name}")
print(f"Bird can fly: {bird.can_fly}")
print(f"Bird speaks: {bird.speak()}")

print()

# Bird that can't fly
penguin = Bird("Pingu", can_fly=False)
print(f"Penguin name: {penguin.name}")
print(f"Penguin can fly: {penguin.can_fly}")

print("\n=== Inheritance Rules ===")
print("""
1. Child inherits all attributes and methods
2. Syntax: class Child(Parent):
3. super().__init__() calls parent's __init__
4. Child can add new attributes/methods
5. 'pass' means no additions (pure inheritance)
""")

# Basic Inheritance

class Animal:
    """Base class for all animals."""
    
    def __init__(self, name):
        self.name = name
    
    def speak(self):
        return f"{self.name} makes a sound"
    
    def describe(self):
        return f"I am {self.name}"


# Dog inherits from Animal
class Dog(Animal):
    """Dog class inherits from Animal."""
    pass  # No additional code - inherits everything


# Cat inherits from Animal
class Cat(Animal):
    """Cat class inherits from Animal."""
    pass


# Bird with additional attribute
class Bird(Animal):
    def __init__(self, name, can_fly=True):
        # Call parent's __init__
        super().__init__(name)
        self.can_fly = can_fly


# Demonstrate basic inheritance
print("=== Basic Inheritance ===\n")

# Create Dog - uses inherited __init__
dog = 
print(f"Dog name: {dog.name}")
print(f"Dog speaks: {dog.speak()}")
print(f"Dog describes: {dog.describe()}")

print()

# Create Cat - also inherits everything
cat = Cat("Whiskers")
print(f"Cat name: {cat.name}")
print(f"Cat speaks: {cat.speak()}")
print(f"Cat describes: {cat.describe()}")

print()

# Create Bird - has additional attribute
bird = 
print(f"Bird name: {bird.name}")
print(f"Bird can fly: {bird.can_fly}")
print(f"Bird speaks: {bird.speak()}")

print()

# Bird that can't fly
penguin = Bird("Pingu", can_fly=False)
print(f"Penguin name: {penguin.name}")
print(f"Penguin can fly: {penguin.can_fly}")

print("\n=== Inheritance Rules ===")
print("""
1. Child inherits all attributes and methods
2. Syntax: class Child(Parent):
3. super().__init__() calls parent's __init__
4. Child can add new attributes/methods
5. 'pass' means no additions (pure inheritance)
""")

# Basic Inheritance

class Animal:
    """Base class for all animals."""
    
    def __init__(self, name):
        self.name = name
    
    def speak(self):
        return f"{self.name} makes a sound"
    
    def describe(self):
        return f"I am {self.name}"


# Dog inherits from Animal
class Dog(Animal):
    """Dog class inherits from Animal."""
    pass  # No additional code - inherits everything


# Cat inherits from Animal
class Cat(Animal):
    """Cat class inherits from Animal."""
    pass


# Bird with additional attribute
class Bird(Animal):
    def __init__(self, name, can_fly=True):
        # Call parent's __init__
        super().__init__(name)
        self.can_fly = can_fly


# Demonstrate basic inheritance
print("=== Basic Inheritance ===\n")

# Create Dog - uses inherited __init__
dog = 
print(f"Dog name: {dog.name}")
print(f"Dog speaks: {dog.speak()}")
print(f"Dog describes: {dog.describe()}")

print()

# Create Cat - also inherits everything
cat = Cat("Whiskers")
print(f"Cat name: {cat.name}")
print(f"Cat speaks: {cat.speak()}")
print(f"Cat describes: {cat.describe()}")

print()

# Create Bird - has additional attribute
bird = 
print(f"Bird name: {bird.name}")
print(f"Bird can fly: {bird.can_fly}")
print(f"Bird speaks: {bird.speak()}")

print()

# Bird that can't fly
penguin = Bird("Pingu", can_fly=False)
print(f"Penguin name: {penguin.name}")
print(f"Penguin can fly: {penguin.can_fly}")

print("\n=== Inheritance Rules ===")
print("""
1. Child inherits all attributes and methods
2. Syntax: class Child(Parent):
3. super().__init__() calls parent's __init__
4. Child can add new attributes/methods
5. 'pass' means no additions (pure inheritance)
""")

class Child(Parent): inherits all of Parent's methods and attributes.

inheritance Subclass gets parent's methods and attributes. Add or override as needed.

Call parent constructor

Initialize parent attributes with super().

super_init.py
# Using super() to Call Parent's __init__

class Person:
    """Base class for people."""
    
    def __init__(self, name, age):
        self.name = name
        self.age = age
        print(f"  Person.__init__ called: {name}, {age}")
    
    def introduce(self):
        return f"I'm {self.name}, {self.age} years old"


class Student(Person):
    """Student inherits from Person."""
    
    def __init__(self, name, age, student_id):
        print(f"  Student.__init__ starting...")
        super().__init__(name, age)
        self.student_id = student_id
        print(f"  Student.__init__ completed: id={student_id}")
    
    def introduce(self):
        return f"I'm {self.name}, student #{self.student_id}"


class Employee(Person):
    """Employee inherits from Person."""
    
    def __init__(self, name, age, department, salary):
        super().__init__(name, age)
        self.department = department
        self.salary = salary
    
    def introduce(self):
        return f"I'm {self.name} from {self.department}"


class Manager(Employee):
    """Manager inherits from Employee (which inherits from Person)."""
    
    def __init__(self, name, age, department, salary, team_size):
        # super() calls Employee.__init__
        super().__init__(name, age, department, salary)
        self.team_size = team_size
    
    def introduce(self):
        return f"I'm {self.name}, managing {self.team_size} people in {self.department}"


print("=== Understanding super().__init__() ===\n")

# Simple case: Student
print("Creating Student:")
student = Student("Alice", 20, "S12345")
print(f"Result: {student.introduce()}")
print(f"Has name: {student.name}")
print(f"Has age: {student.age}")
print(f"Has student_id: {student.student_id}")

print("\n" + "="*40 + "\n")

# Employee case
print("Creating Employee:")
emp = Employee("Bob", 35, "Engineering", 75000)
print(f"Result: {emp.introduce()}")
print(f"Has name: {emp.name}")
print(f"Has age: {emp.age}")
print(f"Has department: {emp.department}")
print(f"Has salary: {emp.salary}")

print("\n" + "="*40 + "\n")

# Inheritance chain: Manager -> Employee -> Person
print("Creating Manager (three-level inheritance):")
manager = Manager("Carol", 45, "Engineering", 120000, 8)
print(f"Result: {manager.introduce()}")
print(f"Has name: {manager.name} (from Person)")
print(f"Has age: {manager.age} (from Person)")
print(f"Has department: {manager.department} (from Employee)")
print(f"Has salary: {manager.salary} (from Employee)")
print(f"Has team_size: {manager.team_size} (from Manager)")

print("\n=== super() Key Points ===")
print("""
1. super() returns a proxy to the parent class
2. super().__init__() calls parent's constructor
3. Must pass required arguments to parent
4. Each level in chain calls its parent
5. Attributes accumulate through the chain:
   Manager has: name, age (Person)
              + department, salary (Employee)
              + team_size (Manager)
""")

super().__init__(args) calls parent's __init__. Always call it first.

super() Access parent class: `super().__init__()` calls parent constructor.

Override methods

Replace parent behavior with child-specific implementation.

method_override.py
# Method Overriding

class Shape:
    """Base class for geometric shapes."""
    
    def __init__(self, name):
        self.name = name
    
    def area(self):
        return 0  # Default: no area
    
    def perimeter(self):
        return 0  # Default: no perimeter
    
    def describe(self):
        return f"I am a {self.name}"


class Rectangle(Shape):
    """Rectangle overrides area and perimeter."""
    
    def __init__(self, width, height):
        super().__init__("Rectangle")
        self.width = width
        self.height = height
    
    # Override area method
    def area(self):
        return self.width * self.height
    
    # Override perimeter method
    def perimeter(self):
        return 2 * (self.width + self.height)
    
    # Override describe
    def describe(self):
        return f"Rectangle: {self.width} x {self.height}"


class Circle(Shape):
    """Circle overrides area and perimeter."""
    
    PI = 3.14159
    
    def __init__(self, radius):
        super().__init__("Circle")
        self.radius = radius
    
    def area(self):
        return Circle.PI * self.radius ** 2
    
    def perimeter(self):
        return 2 * Circle.PI * self.radius
    
    def describe(self):
        return f"Circle: radius = {self.radius}"


class Triangle(Shape):
    """Triangle overrides methods."""
    
    def __init__(self, a, b, c):
        super().__init__("Triangle")
        self.a = a  # Side lengths
        self.b = b
        self.c = c
    
    def area(self):
        # Heron's formula
        s = (self.a + self.b + self.c) / 2
        return (s * (s - self.a) * (s - self.b) * (s - self.c)) ** 0.5
    
    def perimeter(self):
        return self.a + self.b + self.c
    
    def describe(self):
        return f"Triangle: sides {self.a}, {self.b}, {self.c}"


# Demonstration
print("=== Method Overriding ===\n")

# Create shapes
shapes = [
    Rectangle(5, 3),
    Circle(4),
    Triangle(3, 4, 5),
]

# Polymorphic behavior - same method, different results
for shape in shapes:
    print(shape.describe())
    print(f"  Area: {shape.area():.2f}")
    print(f"  Perimeter: {shape.perimeter():.2f}")
    print()

# The base Shape class
print("Base Shape class (no override):")
generic = Shape("Unknown")
print(f"  {generic.describe()}")
print(f"  Area: {generic.area()}")
print(f"  Perimeter: {generic.perimeter()}")

print("\n=== Override Rules ===")
print("""
1. Same method name as parent
2. Same parameters (usually)
3. Different implementation
4. Child's version is called on child objects
5. Parent's version still exists in parent objects
6. No special keyword needed (unlike Java's @Override)
""")

Same method name in child replaces parent's version.

override Child provides its own version of parent method. Same name and signature.

Extend parent methods

Add to parent behavior instead of replacing it.

extend_method.py
# Extending Parent Methods with super()

class Logger:
    """Base logger class."""
    
    def __init__(self):
        self.logs = []
    
    def log(self, message):
        """Basic logging - just store message."""
        entry = f"LOG: {message}"
        self.logs.append(entry)
        return entry


class TimestampLogger(Logger):
    """Logger that adds timestamps."""
    
    def log(self, message):
        # Add timestamp, then call parent
        from datetime import datetime
        timestamp = datetime.now().strftime("%H:%M:%S")
        timestamped_message = f"[{timestamp}] {message}"
        
        # Call parent's log with enhanced message
        return super().log(timestamped_message)


class LevelLogger(Logger):
    """Logger with severity levels."""
    
    def log(self, message, level="INFO"):
        # Add level prefix, then call parent
        leveled_message = f"{level}: {message}"
        return super().log(leveled_message)
    
    # Convenience methods
    def info(self, message):
        return self.log(message, "INFO")
    
    def warning(self, message):
        return self.log(message, "WARNING")
    
    def error(self, message):
        return self.log(message, "ERROR")


class CountingLogger(Logger):
    """Logger that counts messages."""
    
    def __init__(self):
        super().__init__()
        self.count = 0
    
    def log(self, message):
        self.count += 1
        # Extend message with count
        numbered_message = f"#{self.count} {message}"
        return super().log(numbered_message)


class FullFeaturedLogger(Logger):
    """Logger combining multiple features."""
    
    def __init__(self, name):
        super().__init__()
        self.name = name
        self.count = 0
    
    def log(self, message, level="INFO"):
        from datetime import datetime
        self.count += 1
        
        # Build full formatted message
        timestamp = datetime.now().strftime("%H:%M:%S")
        formatted = f"[{timestamp}] [{self.name}] #{self.count} {level}: {message}"
        
        # Still use parent's storage mechanism
        return super().log(formatted)


# Demonstration
print("=== Extending Parent Methods ===\n")

# Basic logger
print("--- Basic Logger ---")
basic = Logger()
print(basic.log("Hello"))
print(basic.log("World"))
print(f"Total logs: {len(basic.logs)}")

print("\n--- Timestamp Logger ---")
ts_logger = TimestampLogger()
print(ts_logger.log("Application started"))
print(ts_logger.log("Processing data"))
print(f"Logs stored: {ts_logger.logs}")

print("\n--- Level Logger ---")
level_logger = LevelLogger()
print(level_logger.info("Normal operation"))
print(level_logger.warning("Low disk space"))
print(level_logger.error("Connection failed"))

print("\n--- Counting Logger ---")
count_logger = CountingLogger()
print(count_logger.log("First message"))
print(count_logger.log("Second message"))
print(count_logger.log("Third message"))
print(f"Total count: {count_logger.count}")

print("\n--- Full Featured Logger ---")
app_logger = FullFeaturedLogger("APP")
print(app_logger.log("Started", "INFO"))
print(app_logger.log("Processing", "DEBUG"))
print(app_logger.log("Something wrong", "ERROR"))

print("\n=== Extend vs Override ===")
print("""
OVERRIDE: Replace parent's behavior completely
    def method(self):
        # all new code

EXTEND: Add to parent's behavior
    def method(self):
        # do something extra
        super().method()  # then call parent
        # optionally do more

Benefits of extending:
1. Reuse parent's logic
2. Add functionality without duplicating code
3. Parent changes automatically apply
4. Can pre-process OR post-process
""")

Call super().method() then add more logic. Best of both worlds.

Type checking with isinstance

Check inheritance relationships at runtime.

isinstance_issubclass.py
# isinstance() and issubclass()

class Vehicle:
    """Base class for all vehicles."""
    def __init__(self, brand):
        self.brand = brand
    
    def start(self):
        return f"{self.brand} starting..."


class Car(Vehicle):
    """Car extends Vehicle."""
    def __init__(self, brand, num_doors):
        super().__init__(brand)
        self.num_doors = num_doors
    
    def drive(self):
        return f"{self.brand} car driving"


class ElectricCar(Car):
    """ElectricCar extends Car."""
    def __init__(self, brand, num_doors, battery_capacity):
        super().__init__(brand, num_doors)
        self.battery_capacity = battery_capacity
    
    def charge(self):
        return f"Charging {self.brand}"


class Motorcycle(Vehicle):
    """Motorcycle extends Vehicle."""
    def __init__(self, brand, cc):
        super().__init__(brand)
        self.cc = cc
    
    def wheelie(self):
        return f"{self.brand} doing a wheelie!"


print("=== isinstance() and issubclass() ===\n")

# Create instances
my_car = Car("Toyota", 4)
my_tesla = ElectricCar("Tesla", 4, 100)
my_bike = Motorcycle("Harley", 1200)

print("--- isinstance() checks ---")

# isinstance(object, class)
print(f"my_car is Car: {isinstance(my_car, Car)}")
print(f"my_car is Vehicle: {isinstance(my_car, Vehicle)}")
print(f"my_car is ElectricCar: {isinstance(my_car, ElectricCar)}")

print()

# Tesla checks
print(f"my_tesla is ElectricCar: {isinstance(my_tesla, ElectricCar)}")
print(f"my_tesla is Car: {isinstance(my_tesla, Car)}")
print(f"my_tesla is Vehicle: {isinstance(my_tesla, Vehicle)}")
print(f"my_tesla is Motorcycle: {isinstance(my_tesla, Motorcycle)}")

print()

# Check multiple types
print(f"my_car is (Car, Motorcycle): {isinstance(my_car, (Car, Motorcycle))}")
print(f"my_bike is (Car, Motorcycle): {isinstance(my_bike, (Car, Motorcycle))}")

print("\n--- issubclass() checks ---")

# issubclass(class, class)
print(f"Car is subclass of Vehicle: {issubclass(Car, Vehicle)}")
print(f"ElectricCar is subclass of Car: {issubclass(ElectricCar, Car)}")
print(f"ElectricCar is subclass of Vehicle: {issubclass(ElectricCar, Vehicle)}")
print(f"Car is subclass of Car: {issubclass(Car, Car)}")
print(f"Car is subclass of Motorcycle: {issubclass(Car, Motorcycle)}")

print("\n--- Practical use: Processing different types ---")

# List of mixed vehicles
vehicles = [my_car, my_tesla, my_bike]

for v in vehicles:
    print(f"\n{v.brand}:")
    print(f"  {v.start()}")
    
    # Type-specific behavior
    if isinstance(v, ElectricCar):
        print(f"  {v.charge()}")
    
    if isinstance(v, Car):
        print(f"  {v.drive()}")
    
    if isinstance(v, Motorcycle):
        print(f"  {v.wheelie()}")

print("\n--- Type hierarchy ---")
print(f"ElectricCar MRO: {ElectricCar.__mro__}")

print("\n=== isinstance vs type() ===")
print("""
isinstance(obj, cls):
  - Returns True if obj is instance of cls OR subclass
  - Preferred for inheritance hierarchies
  - Can check multiple types with tuple

type(obj) == cls:
  - Returns True only for exact type match
  - Does NOT consider inheritance
  - Use when exact type matters

Example:
  isinstance(my_tesla, Car)  # True (Tesla IS-A Car)
  type(my_tesla) == Car      # False (exact type is ElectricCar)
""")

# Demonstrate difference
print("--- isinstance vs type ===")
print(f"isinstance(my_tesla, Car): {isinstance(my_tesla, Car)}")
print(f"type(my_tesla) == Car: {type(my_tesla) == Car}")
print(f"type(my_tesla) == ElectricCar: {type(my_tesla) == ElectricCar}")

isinstance(obj, Class) checks if obj is instance of Class or subclass.

isinstance Check type: `isinstance(x, Parent)`. Returns True for child classes too.

Exercise: practical.py

Build an employee hierarchy with inheritance