Your BankAccount class has a balance. If code can set account.balance = -1000 directly, bugs happen. Encapsulation hides internal data and provides controlled access through methods and properties - protecting your data from misuse.

Public vs private conventions

Python uses naming conventions for access control.

public_vs_private.py
# Public vs Private Attributes

print("=== Public Attributes ===\n")

class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age = age

buddy = Dog("Buddy", 3)

# Direct access - anyone can read/write
print(f"Name: {buddy.name}")
buddy.age = 4  # Changed directly
print(f"Age: {buddy.age}")

# Problem: No validation!
buddy.age = -5  # Oops! Invalid value
print(f"Invalid age: {buddy.age}")

print("\n=== Protected Attributes (Convention) ===")

class Cat:
    def __init__(self, name, age):
        self._name = name
        self._age = age
    
    def get_info(self):
        return f"{self._name} is {self._age} years old"

whiskers = Cat("Whiskers", 5)
print(whiskers.get_info())

# Still accessible, but indicates "internal use"
print(f"Direct access: {whiskers._name}")  # Works, but discouraged
whiskers._age = 6  # Works, but breaks convention

print("\n=== Private Attributes (Name Mangling) ===")

class BankAccount:
    def __init__(self, balance):
        self.__balance = balance
    
    def get_balance(self):
        return self.__balance
    
    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount
            return True
        return False

account = BankAccount(1000)
print(f"Balance: {account.get_balance()}")

# Can't access directly
# print(account.__balance)  # AttributeError!

# Name mangling - still accessible but harder
print(f"Mangled name: {account._BankAccount__balance}")  # Works!

print("\n=== Why Use Privacy? ===")

class Counter:
    def __init__(self):
        self.__count = 0
        self.__min = 0
        self.__max = 100
    
    def increment(self):
        if self.__count < self.__max:
            self.__count += 1
    
    def decrement(self):
        if self.__count > self.__min:
            self.__count -= 1
    
    def value(self):
        return self.__count

counter = Counter()
counter.increment()
counter.increment()
counter.increment()
print(f"Count: {counter.value()}")

# Can't bypass validation
# counter.__count = 999  # Creates new attribute, not the real one!
counter.increment()
print(f"Count: {counter.value()}")  # Still 4, not 999

print("\n=== Access Summary ===")

class Example:
    def __init__(self):
        self.public = "Anyone can access"
        self._protected = "Convention: internal"
        self.__private = "Name mangled"

obj = Example()
print(f"public: {obj.public}")
print(f"_protected: {obj._protected}")  # Accessible
# print(f"__private: {obj.__private}")  # Error!
print(f"_Example__private: {obj._Example__private}")  # Name mangled

_name means "internal". __name means "strongly private" (name mangling).

_underscore Single underscore prefix: convention for internal use. Not enforced.
__dunder Double underscore prefix: name mangling. `__attr` becomes `_ClassName__attr`.

Traditional getters and setters

Methods to read and write private data.

getters_setters.py
# Traditional Getters and Setters

print("=== Basic Getter and Setter ===\n")

class Person:
    def __init__(self, name, age):
        self._name = name
        self._age = age
    
    def get_name(self):
        return self._name
    
    def set_name(self, name):
        if len(name) >= 2:
            self._name = name
        else:
            print("Name too short!")
    
    def get_age(self):
        return self._age
    
    def set_age(self, age):
        if 0 <= age <= 150:
            self._age = age
        else:
            print(f"Invalid age: {age}")

person = Person("Alice", 30)

# Using getters
print(f"Name: {person.get_name()}")
print(f"Age: {person.get_age()}")

# Using setters
person.set_name("Bob")
person.set_age(25)
print(f"Updated: {person.get_name()}, {person.get_age()}")

# Validation prevents bad values
person.set_age(-5)  # Rejected
person.set_name("X")  # Rejected
print(f"Still: {person.get_name()}, {person.get_age()}")

print("\n=== Getter with Logic ===")

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius
    
    def get_celsius(self):
        return self._celsius
    
    def get_fahrenheit(self):
        return self._celsius * 9/5 + 32
    
    def get_kelvin(self):
        return self._celsius + 273.15
    
    def set_celsius(self, value):
        if value >= -273.15:  # Absolute zero
            self._celsius = value
        else:
            print("Below absolute zero!")

temp = Temperature(25)
print(f"Celsius: {temp.get_celsius()}")
print(f"Fahrenheit: {temp.get_fahrenheit()}")
print(f"Kelvin: {temp.get_kelvin()}")

print("\n=== Setter with Side Effects ===")

class Logger:
    def __init__(self):
        self._log_level = "INFO"
        self._changes = []
    
    def get_log_level(self):
        return self._log_level
    
    def set_log_level(self, level):
        valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR"]
        if level.upper() in valid_levels:
            old = self._log_level
            self._log_level = level.upper()
            self._changes.append(f"{old} -> {self._log_level}")
            print(f"Log level changed: {old} -> {self._log_level}")
        else:
            print(f"Invalid level: {level}")
    
    def get_history(self):
        return self._changes.copy()

logger = Logger()
logger.set_log_level("DEBUG")
logger.set_log_level("ERROR")
logger.set_log_level("invalid")  # Rejected
print(f"History: {logger.get_history()}")

print("\n=== Why This Style is Verbose ===")

class Point:
    def __init__(self, x, y):
        self._x = x
        self._y = y
    
    def get_x(self): return self._x
    def set_x(self, x): self._x = x
    def get_y(self): return self._y
    def set_y(self, y): self._y = y

p = Point(3, 4)
# Verbose usage:
x = p.get_x()
y = p.get_y()
p.set_x(x + 1)
print(f"Point: ({p.get_x()}, {p.get_y()})")

print("""
Traditional getters/setters are verbose:
  p.get_x()     instead of  p.x
  p.set_x(5)    instead of  p.x = 5

Python offers @property for cleaner syntax!
""")

get_balance() and set_balance() control access. Java-style.

Python properties

The Pythonic way to control attribute access.

properties.py
# Python Properties

print("=== Basic @property ===\n")

class Circle:
    def __init__(self, radius):
        self._radius = radius
    
    @property
    def radius(self):
        """Get the radius."""
        return self._radius
    
    @radius.setter
    def radius(self, value):
        """Set the radius with validation."""
        if value > 0:
            self._radius = value
        else:
            print(f"Invalid radius: {value}")

circle = 

# Access like attribute
print(f"Radius: {circle.radius}")  # Calls getter

# Assign like attribute
circle.radius = 10  # Calls setter
print(f"New radius: {circle.radius}")

# Validation still works
circle.radius = -5  # Rejected
print(f"Still: {circle.radius}")

print("\n=== Properties vs Direct Access ===")

# Compare syntax:
print("""
Traditional:
  value = obj.get_attribute()
  obj.set_attribute(value)

With @property:
  value = obj.attribute
  obj.attribute = value

Same control, cleaner syntax!
""")

print("=== Multiple Properties ===")

class Rectangle:
    def __init__(self, width, height):
        self._width = width
        self._height = height
    
    @property
    def width(self):
        return self._width
    
    @width.setter
    def width(self, value):
        if value > 0:
            self._width = value
    
    @property
    def height(self):
        return self._height
    
    @height.setter
    def height(self, value):
        if value > 0:
            self._height = value
    
    @property
    def area(self):
        """Computed property - no setter."""
        return self._width * self._height
    
    @property
    def perimeter(self):
        return 2 * (self._width + self._height)

rect = 
print(f"Size: {rect.width} x {rect.height}")
print(f"Area: {rect.area}")
print(f"Perimeter: {rect.perimeter}")

# Change dimensions
rect.width = 10
print(f"\nAfter resize:")
print(f"Size: {rect.width} x {rect.height}")
print(f"Area: {rect.area}")  # Automatically updated!

print("\n=== Property with Deleter ===")

class User:
    def __init__(self, email):
        self._email = email
    
    @property
    def email(self):
        return self._email
    
    @email.setter
    def email(self, value):
        if "@" in value:
            self._email = value
    
    @email.deleter
    def email(self):
        print("Removing email...")
        self._email = None

user = User("alice@example.com")
print(f"Email: {user.email}")

del user.email
print(f"After delete: {user.email}")

print("\n=== Property Naming Patterns ===")

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius
    
    @property
    def celsius(self):
        return self._celsius
    
    @celsius.setter
    def celsius(self, value):
        if value >= -273.15:
            self._celsius = value
    
    @property
    def fahrenheit(self):
        return self._celsius * 9/5 + 32
    
    @fahrenheit.setter
    def fahrenheit(self, value):
        self._celsius = (value - 32) * 5/9

temp = Temperature(0)
print(f"0°C = {temp.fahrenheit}°F")

temp.fahrenheit = 212  # Set via Fahrenheit
print(f"{temp.fahrenheit}°F = {temp.celsius}°C")
# Python Properties

print("=== Basic @property ===\n")

class Circle:
    def __init__(self, radius):
        self._radius = radius
    
    @property
    def radius(self):
        """Get the radius."""
        return self._radius
    
    @radius.setter
    def radius(self, value):
        """Set the radius with validation."""
        if value > 0:
            self._radius = value
        else:
            print(f"Invalid radius: {value}")

circle = 

# Access like attribute
print(f"Radius: {circle.radius}")  # Calls getter

# Assign like attribute
circle.radius = 10  # Calls setter
print(f"New radius: {circle.radius}")

# Validation still works
circle.radius = -5  # Rejected
print(f"Still: {circle.radius}")

print("\n=== Properties vs Direct Access ===")

# Compare syntax:
print("""
Traditional:
  value = obj.get_attribute()
  obj.set_attribute(value)

With @property:
  value = obj.attribute
  obj.attribute = value

Same control, cleaner syntax!
""")

print("=== Multiple Properties ===")

class Rectangle:
    def __init__(self, width, height):
        self._width = width
        self._height = height
    
    @property
    def width(self):
        return self._width
    
    @width.setter
    def width(self, value):
        if value > 0:
            self._width = value
    
    @property
    def height(self):
        return self._height
    
    @height.setter
    def height(self, value):
        if value > 0:
            self._height = value
    
    @property
    def area(self):
        """Computed property - no setter."""
        return self._width * self._height
    
    @property
    def perimeter(self):
        return 2 * (self._width + self._height)

rect = 
print(f"Size: {rect.width} x {rect.height}")
print(f"Area: {rect.area}")
print(f"Perimeter: {rect.perimeter}")

# Change dimensions
rect.width = 10
print(f"\nAfter resize:")
print(f"Size: {rect.width} x {rect.height}")
print(f"Area: {rect.area}")  # Automatically updated!

print("\n=== Property with Deleter ===")

class User:
    def __init__(self, email):
        self._email = email
    
    @property
    def email(self):
        return self._email
    
    @email.setter
    def email(self, value):
        if "@" in value:
            self._email = value
    
    @email.deleter
    def email(self):
        print("Removing email...")
        self._email = None

user = User("alice@example.com")
print(f"Email: {user.email}")

del user.email
print(f"After delete: {user.email}")

print("\n=== Property Naming Patterns ===")

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius
    
    @property
    def celsius(self):
        return self._celsius
    
    @celsius.setter
    def celsius(self, value):
        if value >= -273.15:
            self._celsius = value
    
    @property
    def fahrenheit(self):
        return self._celsius * 9/5 + 32
    
    @fahrenheit.setter
    def fahrenheit(self, value):
        self._celsius = (value - 32) * 5/9

temp = Temperature(0)
print(f"0°C = {temp.fahrenheit}°F")

temp.fahrenheit = 212  # Set via Fahrenheit
print(f"{temp.fahrenheit}°F = {temp.celsius}°C")
# Python Properties

print("=== Basic @property ===\n")

class Circle:
    def __init__(self, radius):
        self._radius = radius
    
    @property
    def radius(self):
        """Get the radius."""
        return self._radius
    
    @radius.setter
    def radius(self, value):
        """Set the radius with validation."""
        if value > 0:
            self._radius = value
        else:
            print(f"Invalid radius: {value}")

circle = 

# Access like attribute
print(f"Radius: {circle.radius}")  # Calls getter

# Assign like attribute
circle.radius = 10  # Calls setter
print(f"New radius: {circle.radius}")

# Validation still works
circle.radius = -5  # Rejected
print(f"Still: {circle.radius}")

print("\n=== Properties vs Direct Access ===")

# Compare syntax:
print("""
Traditional:
  value = obj.get_attribute()
  obj.set_attribute(value)

With @property:
  value = obj.attribute
  obj.attribute = value

Same control, cleaner syntax!
""")

print("=== Multiple Properties ===")

class Rectangle:
    def __init__(self, width, height):
        self._width = width
        self._height = height
    
    @property
    def width(self):
        return self._width
    
    @width.setter
    def width(self, value):
        if value > 0:
            self._width = value
    
    @property
    def height(self):
        return self._height
    
    @height.setter
    def height(self, value):
        if value > 0:
            self._height = value
    
    @property
    def area(self):
        """Computed property - no setter."""
        return self._width * self._height
    
    @property
    def perimeter(self):
        return 2 * (self._width + self._height)

rect = 
print(f"Size: {rect.width} x {rect.height}")
print(f"Area: {rect.area}")
print(f"Perimeter: {rect.perimeter}")

# Change dimensions
rect.width = 10
print(f"\nAfter resize:")
print(f"Size: {rect.width} x {rect.height}")
print(f"Area: {rect.area}")  # Automatically updated!

print("\n=== Property with Deleter ===")

class User:
    def __init__(self, email):
        self._email = email
    
    @property
    def email(self):
        return self._email
    
    @email.setter
    def email(self, value):
        if "@" in value:
            self._email = value
    
    @email.deleter
    def email(self):
        print("Removing email...")
        self._email = None

user = User("alice@example.com")
print(f"Email: {user.email}")

del user.email
print(f"After delete: {user.email}")

print("\n=== Property Naming Patterns ===")

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius
    
    @property
    def celsius(self):
        return self._celsius
    
    @celsius.setter
    def celsius(self, value):
        if value >= -273.15:
            self._celsius = value
    
    @property
    def fahrenheit(self):
        return self._celsius * 9/5 + 32
    
    @fahrenheit.setter
    def fahrenheit(self, value):
        self._celsius = (value - 32) * 5/9

temp = Temperature(0)
print(f"0°C = {temp.fahrenheit}°F")

temp.fahrenheit = 212  # Set via Fahrenheit
print(f"{temp.fahrenheit}°F = {temp.celsius}°C")
# Python Properties

print("=== Basic @property ===\n")

class Circle:
    def __init__(self, radius):
        self._radius = radius
    
    @property
    def radius(self):
        """Get the radius."""
        return self._radius
    
    @radius.setter
    def radius(self, value):
        """Set the radius with validation."""
        if value > 0:
            self._radius = value
        else:
            print(f"Invalid radius: {value}")

circle = 

# Access like attribute
print(f"Radius: {circle.radius}")  # Calls getter

# Assign like attribute
circle.radius = 10  # Calls setter
print(f"New radius: {circle.radius}")

# Validation still works
circle.radius = -5  # Rejected
print(f"Still: {circle.radius}")

print("\n=== Properties vs Direct Access ===")

# Compare syntax:
print("""
Traditional:
  value = obj.get_attribute()
  obj.set_attribute(value)

With @property:
  value = obj.attribute
  obj.attribute = value

Same control, cleaner syntax!
""")

print("=== Multiple Properties ===")

class Rectangle:
    def __init__(self, width, height):
        self._width = width
        self._height = height
    
    @property
    def width(self):
        return self._width
    
    @width.setter
    def width(self, value):
        if value > 0:
            self._width = value
    
    @property
    def height(self):
        return self._height
    
    @height.setter
    def height(self, value):
        if value > 0:
            self._height = value
    
    @property
    def area(self):
        """Computed property - no setter."""
        return self._width * self._height
    
    @property
    def perimeter(self):
        return 2 * (self._width + self._height)

rect = 
print(f"Size: {rect.width} x {rect.height}")
print(f"Area: {rect.area}")
print(f"Perimeter: {rect.perimeter}")

# Change dimensions
rect.width = 10
print(f"\nAfter resize:")
print(f"Size: {rect.width} x {rect.height}")
print(f"Area: {rect.area}")  # Automatically updated!

print("\n=== Property with Deleter ===")

class User:
    def __init__(self, email):
        self._email = email
    
    @property
    def email(self):
        return self._email
    
    @email.setter
    def email(self, value):
        if "@" in value:
            self._email = value
    
    @email.deleter
    def email(self):
        print("Removing email...")
        self._email = None

user = User("alice@example.com")
print(f"Email: {user.email}")

del user.email
print(f"After delete: {user.email}")

print("\n=== Property Naming Patterns ===")

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius
    
    @property
    def celsius(self):
        return self._celsius
    
    @celsius.setter
    def celsius(self, value):
        if value >= -273.15:
            self._celsius = value
    
    @property
    def fahrenheit(self):
        return self._celsius * 9/5 + 32
    
    @fahrenheit.setter
    def fahrenheit(self, value):
        self._celsius = (value - 32) * 5/9

temp = Temperature(0)
print(f"0°C = {temp.fahrenheit}°F")

temp.fahrenheit = 212  # Set via Fahrenheit
print(f"{temp.fahrenheit}°F = {temp.celsius}°C")
# Python Properties

print("=== Basic @property ===\n")

class Circle:
    def __init__(self, radius):
        self._radius = radius
    
    @property
    def radius(self):
        """Get the radius."""
        return self._radius
    
    @radius.setter
    def radius(self, value):
        """Set the radius with validation."""
        if value > 0:
            self._radius = value
        else:
            print(f"Invalid radius: {value}")

circle = 

# Access like attribute
print(f"Radius: {circle.radius}")  # Calls getter

# Assign like attribute
circle.radius = 10  # Calls setter
print(f"New radius: {circle.radius}")

# Validation still works
circle.radius = -5  # Rejected
print(f"Still: {circle.radius}")

print("\n=== Properties vs Direct Access ===")

# Compare syntax:
print("""
Traditional:
  value = obj.get_attribute()
  obj.set_attribute(value)

With @property:
  value = obj.attribute
  obj.attribute = value

Same control, cleaner syntax!
""")

print("=== Multiple Properties ===")

class Rectangle:
    def __init__(self, width, height):
        self._width = width
        self._height = height
    
    @property
    def width(self):
        return self._width
    
    @width.setter
    def width(self, value):
        if value > 0:
            self._width = value
    
    @property
    def height(self):
        return self._height
    
    @height.setter
    def height(self, value):
        if value > 0:
            self._height = value
    
    @property
    def area(self):
        """Computed property - no setter."""
        return self._width * self._height
    
    @property
    def perimeter(self):
        return 2 * (self._width + self._height)

rect = 
print(f"Size: {rect.width} x {rect.height}")
print(f"Area: {rect.area}")
print(f"Perimeter: {rect.perimeter}")

# Change dimensions
rect.width = 10
print(f"\nAfter resize:")
print(f"Size: {rect.width} x {rect.height}")
print(f"Area: {rect.area}")  # Automatically updated!

print("\n=== Property with Deleter ===")

class User:
    def __init__(self, email):
        self._email = email
    
    @property
    def email(self):
        return self._email
    
    @email.setter
    def email(self, value):
        if "@" in value:
            self._email = value
    
    @email.deleter
    def email(self):
        print("Removing email...")
        self._email = None

user = User("alice@example.com")
print(f"Email: {user.email}")

del user.email
print(f"After delete: {user.email}")

print("\n=== Property Naming Patterns ===")

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius
    
    @property
    def celsius(self):
        return self._celsius
    
    @celsius.setter
    def celsius(self, value):
        if value >= -273.15:
            self._celsius = value
    
    @property
    def fahrenheit(self):
        return self._celsius * 9/5 + 32
    
    @fahrenheit.setter
    def fahrenheit(self, value):
        self._celsius = (value - 32) * 5/9

temp = Temperature(0)
print(f"0°C = {temp.fahrenheit}°F")

temp.fahrenheit = 212  # Set via Fahrenheit
print(f"{temp.fahrenheit}°F = {temp.celsius}°C")
# Python Properties

print("=== Basic @property ===\n")

class Circle:
    def __init__(self, radius):
        self._radius = radius
    
    @property
    def radius(self):
        """Get the radius."""
        return self._radius
    
    @radius.setter
    def radius(self, value):
        """Set the radius with validation."""
        if value > 0:
            self._radius = value
        else:
            print(f"Invalid radius: {value}")

circle = 

# Access like attribute
print(f"Radius: {circle.radius}")  # Calls getter

# Assign like attribute
circle.radius = 10  # Calls setter
print(f"New radius: {circle.radius}")

# Validation still works
circle.radius = -5  # Rejected
print(f"Still: {circle.radius}")

print("\n=== Properties vs Direct Access ===")

# Compare syntax:
print("""
Traditional:
  value = obj.get_attribute()
  obj.set_attribute(value)

With @property:
  value = obj.attribute
  obj.attribute = value

Same control, cleaner syntax!
""")

print("=== Multiple Properties ===")

class Rectangle:
    def __init__(self, width, height):
        self._width = width
        self._height = height
    
    @property
    def width(self):
        return self._width
    
    @width.setter
    def width(self, value):
        if value > 0:
            self._width = value
    
    @property
    def height(self):
        return self._height
    
    @height.setter
    def height(self, value):
        if value > 0:
            self._height = value
    
    @property
    def area(self):
        """Computed property - no setter."""
        return self._width * self._height
    
    @property
    def perimeter(self):
        return 2 * (self._width + self._height)

rect = 
print(f"Size: {rect.width} x {rect.height}")
print(f"Area: {rect.area}")
print(f"Perimeter: {rect.perimeter}")

# Change dimensions
rect.width = 10
print(f"\nAfter resize:")
print(f"Size: {rect.width} x {rect.height}")
print(f"Area: {rect.area}")  # Automatically updated!

print("\n=== Property with Deleter ===")

class User:
    def __init__(self, email):
        self._email = email
    
    @property
    def email(self):
        return self._email
    
    @email.setter
    def email(self, value):
        if "@" in value:
            self._email = value
    
    @email.deleter
    def email(self):
        print("Removing email...")
        self._email = None

user = User("alice@example.com")
print(f"Email: {user.email}")

del user.email
print(f"After delete: {user.email}")

print("\n=== Property Naming Patterns ===")

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius
    
    @property
    def celsius(self):
        return self._celsius
    
    @celsius.setter
    def celsius(self, value):
        if value >= -273.15:
            self._celsius = value
    
    @property
    def fahrenheit(self):
        return self._celsius * 9/5 + 32
    
    @fahrenheit.setter
    def fahrenheit(self, value):
        self._celsius = (value - 32) * 5/9

temp = Temperature(0)
print(f"0°C = {temp.fahrenheit}°F")

temp.fahrenheit = 212  # Set via Fahrenheit
print(f"{temp.fahrenheit}°F = {temp.celsius}°C")
# Python Properties

print("=== Basic @property ===\n")

class Circle:
    def __init__(self, radius):
        self._radius = radius
    
    @property
    def radius(self):
        """Get the radius."""
        return self._radius
    
    @radius.setter
    def radius(self, value):
        """Set the radius with validation."""
        if value > 0:
            self._radius = value
        else:
            print(f"Invalid radius: {value}")

circle = 

# Access like attribute
print(f"Radius: {circle.radius}")  # Calls getter

# Assign like attribute
circle.radius = 10  # Calls setter
print(f"New radius: {circle.radius}")

# Validation still works
circle.radius = -5  # Rejected
print(f"Still: {circle.radius}")

print("\n=== Properties vs Direct Access ===")

# Compare syntax:
print("""
Traditional:
  value = obj.get_attribute()
  obj.set_attribute(value)

With @property:
  value = obj.attribute
  obj.attribute = value

Same control, cleaner syntax!
""")

print("=== Multiple Properties ===")

class Rectangle:
    def __init__(self, width, height):
        self._width = width
        self._height = height
    
    @property
    def width(self):
        return self._width
    
    @width.setter
    def width(self, value):
        if value > 0:
            self._width = value
    
    @property
    def height(self):
        return self._height
    
    @height.setter
    def height(self, value):
        if value > 0:
            self._height = value
    
    @property
    def area(self):
        """Computed property - no setter."""
        return self._width * self._height
    
    @property
    def perimeter(self):
        return 2 * (self._width + self._height)

rect = 
print(f"Size: {rect.width} x {rect.height}")
print(f"Area: {rect.area}")
print(f"Perimeter: {rect.perimeter}")

# Change dimensions
rect.width = 10
print(f"\nAfter resize:")
print(f"Size: {rect.width} x {rect.height}")
print(f"Area: {rect.area}")  # Automatically updated!

print("\n=== Property with Deleter ===")

class User:
    def __init__(self, email):
        self._email = email
    
    @property
    def email(self):
        return self._email
    
    @email.setter
    def email(self, value):
        if "@" in value:
            self._email = value
    
    @email.deleter
    def email(self):
        print("Removing email...")
        self._email = None

user = User("alice@example.com")
print(f"Email: {user.email}")

del user.email
print(f"After delete: {user.email}")

print("\n=== Property Naming Patterns ===")

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius
    
    @property
    def celsius(self):
        return self._celsius
    
    @celsius.setter
    def celsius(self, value):
        if value >= -273.15:
            self._celsius = value
    
    @property
    def fahrenheit(self):
        return self._celsius * 9/5 + 32
    
    @fahrenheit.setter
    def fahrenheit(self, value):
        self._celsius = (value - 32) * 5/9

temp = Temperature(0)
print(f"0°C = {temp.fahrenheit}°F")

temp.fahrenheit = 212  # Set via Fahrenheit
print(f"{temp.fahrenheit}°F = {temp.celsius}°C")
# Python Properties

print("=== Basic @property ===\n")

class Circle:
    def __init__(self, radius):
        self._radius = radius
    
    @property
    def radius(self):
        """Get the radius."""
        return self._radius
    
    @radius.setter
    def radius(self, value):
        """Set the radius with validation."""
        if value > 0:
            self._radius = value
        else:
            print(f"Invalid radius: {value}")

circle = 

# Access like attribute
print(f"Radius: {circle.radius}")  # Calls getter

# Assign like attribute
circle.radius = 10  # Calls setter
print(f"New radius: {circle.radius}")

# Validation still works
circle.radius = -5  # Rejected
print(f"Still: {circle.radius}")

print("\n=== Properties vs Direct Access ===")

# Compare syntax:
print("""
Traditional:
  value = obj.get_attribute()
  obj.set_attribute(value)

With @property:
  value = obj.attribute
  obj.attribute = value

Same control, cleaner syntax!
""")

print("=== Multiple Properties ===")

class Rectangle:
    def __init__(self, width, height):
        self._width = width
        self._height = height
    
    @property
    def width(self):
        return self._width
    
    @width.setter
    def width(self, value):
        if value > 0:
            self._width = value
    
    @property
    def height(self):
        return self._height
    
    @height.setter
    def height(self, value):
        if value > 0:
            self._height = value
    
    @property
    def area(self):
        """Computed property - no setter."""
        return self._width * self._height
    
    @property
    def perimeter(self):
        return 2 * (self._width + self._height)

rect = 
print(f"Size: {rect.width} x {rect.height}")
print(f"Area: {rect.area}")
print(f"Perimeter: {rect.perimeter}")

# Change dimensions
rect.width = 10
print(f"\nAfter resize:")
print(f"Size: {rect.width} x {rect.height}")
print(f"Area: {rect.area}")  # Automatically updated!

print("\n=== Property with Deleter ===")

class User:
    def __init__(self, email):
        self._email = email
    
    @property
    def email(self):
        return self._email
    
    @email.setter
    def email(self, value):
        if "@" in value:
            self._email = value
    
    @email.deleter
    def email(self):
        print("Removing email...")
        self._email = None

user = User("alice@example.com")
print(f"Email: {user.email}")

del user.email
print(f"After delete: {user.email}")

print("\n=== Property Naming Patterns ===")

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius
    
    @property
    def celsius(self):
        return self._celsius
    
    @celsius.setter
    def celsius(self, value):
        if value >= -273.15:
            self._celsius = value
    
    @property
    def fahrenheit(self):
        return self._celsius * 9/5 + 32
    
    @fahrenheit.setter
    def fahrenheit(self, value):
        self._celsius = (value - 32) * 5/9

temp = Temperature(0)
print(f"0°C = {temp.fahrenheit}°F")

temp.fahrenheit = 212  # Set via Fahrenheit
print(f"{temp.fahrenheit}°F = {temp.celsius}°C")
# Python Properties

print("=== Basic @property ===\n")

class Circle:
    def __init__(self, radius):
        self._radius = radius
    
    @property
    def radius(self):
        """Get the radius."""
        return self._radius
    
    @radius.setter
    def radius(self, value):
        """Set the radius with validation."""
        if value > 0:
            self._radius = value
        else:
            print(f"Invalid radius: {value}")

circle = 

# Access like attribute
print(f"Radius: {circle.radius}")  # Calls getter

# Assign like attribute
circle.radius = 10  # Calls setter
print(f"New radius: {circle.radius}")

# Validation still works
circle.radius = -5  # Rejected
print(f"Still: {circle.radius}")

print("\n=== Properties vs Direct Access ===")

# Compare syntax:
print("""
Traditional:
  value = obj.get_attribute()
  obj.set_attribute(value)

With @property:
  value = obj.attribute
  obj.attribute = value

Same control, cleaner syntax!
""")

print("=== Multiple Properties ===")

class Rectangle:
    def __init__(self, width, height):
        self._width = width
        self._height = height
    
    @property
    def width(self):
        return self._width
    
    @width.setter
    def width(self, value):
        if value > 0:
            self._width = value
    
    @property
    def height(self):
        return self._height
    
    @height.setter
    def height(self, value):
        if value > 0:
            self._height = value
    
    @property
    def area(self):
        """Computed property - no setter."""
        return self._width * self._height
    
    @property
    def perimeter(self):
        return 2 * (self._width + self._height)

rect = 
print(f"Size: {rect.width} x {rect.height}")
print(f"Area: {rect.area}")
print(f"Perimeter: {rect.perimeter}")

# Change dimensions
rect.width = 10
print(f"\nAfter resize:")
print(f"Size: {rect.width} x {rect.height}")
print(f"Area: {rect.area}")  # Automatically updated!

print("\n=== Property with Deleter ===")

class User:
    def __init__(self, email):
        self._email = email
    
    @property
    def email(self):
        return self._email
    
    @email.setter
    def email(self, value):
        if "@" in value:
            self._email = value
    
    @email.deleter
    def email(self):
        print("Removing email...")
        self._email = None

user = User("alice@example.com")
print(f"Email: {user.email}")

del user.email
print(f"After delete: {user.email}")

print("\n=== Property Naming Patterns ===")

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius
    
    @property
    def celsius(self):
        return self._celsius
    
    @celsius.setter
    def celsius(self, value):
        if value >= -273.15:
            self._celsius = value
    
    @property
    def fahrenheit(self):
        return self._celsius * 9/5 + 32
    
    @fahrenheit.setter
    def fahrenheit(self, value):
        self._celsius = (value - 32) * 5/9

temp = Temperature(0)
print(f"0°C = {temp.fahrenheit}°F")

temp.fahrenheit = 212  # Set via Fahrenheit
print(f"{temp.fahrenheit}°F = {temp.celsius}°C")

@property creates a getter. @name.setter creates a setter. Clean syntax.

property Attribute that runs code on access: `@property` decorator.

Read-only properties

Attributes that can't be changed after creation.

readonly.py
# Read-Only and Computed Properties

print("=== Read-Only Properties ===\n")

class ImmutablePoint:
    def __init__(self, x, y):
        self._x = x
        self._y = y
    
    @property
    def x(self):
        return self._x
    
    @property
    def y(self):
        return self._y
    
    # No setters! Can't modify x or y

point = ImmutablePoint(3, 4)
print(f"Point: ({point.x}, {point.y})")

# Reading works
print(f"X coordinate: {point.x}")

# Writing fails
try:
    point.x = 10  # AttributeError!
except AttributeError as e:
    print(f"Error: can't set x - {e}")

print("\n=== Computed Read-Only Properties ===")

class Circle:
    def __init__(self, radius):
        self._radius = radius
    
    @property
    def radius(self):
        return self._radius
    
    @radius.setter
    def radius(self, value):
        if value > 0:
            self._radius = value
    
    @property
    def diameter(self):
        return self._radius * 2
    
    @property
    def circumference(self):
        import math
        return 2 * math.pi * self._radius
    
    @property
    def area(self):
        import math
        return math.pi * self._radius ** 2

circle = Circle(5)
print(f"Radius: {circle.radius}")
print(f"Diameter: {circle.diameter}")
print(f"Circumference: {circle.circumference:.2f}")
print(f"Area: {circle.area:.2f}")

# Change radius - computed properties update
circle.radius = 10
print(f"\nAfter radius = 10:")
print(f"Diameter: {circle.diameter}")
print(f"Area: {circle.area:.2f}")

print("\n=== ID and Timestamp Properties ===")

import time

class Document:
    _next_id = 1
    
    def __init__(self, title, content):
        self._id = Document._next_id
        Document._next_id += 1
        self._created = time.time()
        self._title = title
        self._content = content
    
    @property
    def id(self):
        return self._id
    
    @property
    def created(self):
        return self._created
    
    @property
    def title(self):
        return self._title
    
    @title.setter
    def title(self, value):
        self._title = value
    
    @property
    def content(self):
        return self._content
    
    @content.setter
    def content(self, value):
        self._content = value

doc = Document("Report", "Initial content")
print(f"ID: {doc.id}")
print(f"Created: {doc.created}")
print(f"Title: {doc.title}")

# Can modify title and content
doc.title = "Updated Report"
print(f"New title: {doc.title}")

# Can't modify id or created
try:
    doc.id = 999
except AttributeError:
    print("Can't change ID!")

print("\n=== Derived Properties ===")

class Person:
    def __init__(self, first_name, last_name, birth_year):
        self._first = first_name
        self._last = last_name
        self._birth_year = birth_year
    
    @property
    def first_name(self):
        return self._first
    
    @property
    def last_name(self):
        return self._last
    
    @property
    def full_name(self):
        return f"{self._first} {self._last}"
    
    @property
    def initials(self):
        return f"{self._first[0]}.{self._last[0]}."
    
    @property
    def age(self):
        return 2024 - self._birth_year

person = Person("John", "Doe", 1990)
print(f"Name: {person.full_name}")
print(f"Initials: {person.initials}")
print(f"Age: {person.age}")

print("\n=== Conditional Properties ===")

class Order:
    def __init__(self, items, discount=0):
        self._items = items
        self._discount = discount
    
    @property
    def subtotal(self):
        return sum(item["price"] * item["qty"] for item in self._items)
    
    @property
    def discount_amount(self):
        if self.subtotal > 100:  # Only if over $100
            return self.subtotal * (self._discount / 100)
        return 0
    
    @property
    def total(self):
        return self.subtotal - self.discount_amount
    
    @property
    def is_eligible_for_discount(self):
        return self.subtotal > 100

items = [
    {"name": "Book", "price": 25, "qty": 2},
    {"name": "Pen", "price": 5, "qty": 10}
]
order = Order(items, discount=10)

print(f"Subtotal: ${order.subtotal}")
print(f"Eligible for discount: {order.is_eligible_for_discount}")
print(f"Discount (10%): ${order.discount_amount}")
print(f"Total: ${order.total}")

Provide getter without setter. Attempts to set raise AttributeError.

Properties with validation

Validate data before setting.

validation_props.py
# Properties with Validation

print("=== Type Validation ===\n")

class Product:
    def __init__(self, name, price):
        self._name = None
        self._price = None
        # Use setters for validation
        self.name = name
        self.price = price
    
    @property
    def name(self):
        return self._name
    
    @name.setter
    def name(self, value):
        if not isinstance(value, str):
            raise TypeError("Name must be a string")
        if len(value) < 1:
            raise ValueError("Name cannot be empty")
        self._name = value
    
    @property
    def price(self):
        return self._price
    
    @price.setter
    def price(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError("Price must be a number")
        if value < 0:
            raise ValueError("Price cannot be negative")
        self._price = round(value, 2)

product = Product("Laptop", 999.999)
print(f"Product: {product.name}, ${product.price}")

# Validation in action
try:
    product.price = "free"
except TypeError as e:
    print(f"Type error: {e}")

try:
    product.price = -50
except ValueError as e:
    print(f"Value error: {e}")

print("\n=== Range Validation ===")

class Temperature:
    MIN_CELSIUS = -273.15
    MAX_CELSIUS = 1000
    
    def __init__(self, celsius):
        self._celsius = None
        self.celsius = celsius
    
    @property
    def celsius(self):
        return self._celsius
    
    @celsius.setter
    def celsius(self, value):
        if value < self.MIN_CELSIUS:
            raise ValueError(f"Below absolute zero: {value}")
        if value > self.MAX_CELSIUS:
            raise ValueError(f"Too hot: {value}")
        self._celsius = value

temp = Temperature(25)
print(f"Temperature: {temp.celsius}°C")

try:
    temp.celsius = -300  # Below absolute zero
except ValueError as e:
    print(f"Rejected: {e}")

print("\n=== Format Validation ===")

class User:
    def __init__(self, email, phone):
        self._email = None
        self._phone = None
        self.email = email
        self.phone = phone
    
    @property
    def email(self):
        return self._email
    
    @email.setter
    def email(self, value):
        value = value.strip().lower()
        if "@" not in value:
            raise ValueError("Invalid email: missing @")
        parts = value.split("@")
        if len(parts) != 2 or not parts[1]:
            raise ValueError("Invalid email format")
        self._email = value
    
    @property
    def phone(self):
        return self._phone
    
    @phone.setter
    def phone(self, value):
        # Remove non-digits
        digits = ''.join(c for c in value if c.isdigit())
        if len(digits) < 10:
            raise ValueError("Phone must have at least 10 digits")
        self._phone = digits

user = User("  Alice@Example.COM  ", "(555) 123-4567")
print(f"Email: {user.email}")
print(f"Phone: {user.phone}")

print("\n=== Interdependent Validation ===")

class Rectangle:
    def __init__(self, width, height, max_area=10000):
        self._width = 0
        self._height = 0
        self._max_area = max_area
        self.width = width  # Validates
        self.height = height  # Validates
    
    @property
    def width(self):
        return self._width
    
    @width.setter
    def width(self, value):
        if value <= 0:
            raise ValueError("Width must be positive")
        if value * self._height > self._max_area:
            raise ValueError(f"Area would exceed {self._max_area}")
        self._width = value
    
    @property
    def height(self):
        return self._height
    
    @height.setter
    def height(self, value):
        if value <= 0:
            raise ValueError("Height must be positive")
        if self._width * value > self._max_area:
            raise ValueError(f"Area would exceed {self._max_area}")
        self._height = value
    
    @property
    def area(self):
        return self._width * self._height

rect = Rectangle(50, 100)
print(f"Rectangle: {rect.width}x{rect.height}, area={rect.area}")

try:
    rect.width = 200  # Would make area 20000
except ValueError as e:
    print(f"Rejected: {e}")

print("\n=== Validation with Warnings ===")

class Score:
    def __init__(self, value):
        self._value = 0
        self._warnings = []
        self.value = value
    
    @property
    def value(self):
        return self._value
    
    @value.setter
    def value(self, value):
        self._warnings.clear()
        
        if value < 0:
            self._warnings.append(f"Adjusted {value} to 0")
            value = 0
        elif value > 100:
            self._warnings.append(f"Adjusted {value} to 100")
            value = 100
        
        self._value = value
    
    @property
    def warnings(self):
        return self._warnings.copy()

score = Score(150)
print(f"Score: {score.value}")
print(f"Warnings: {score.warnings}")

score.value = -20
print(f"Score: {score.value}")
print(f"Warnings: {score.warnings}")

Setter can check value and raise exception if invalid.

Exercise: practical.py

Build a class with encapsulated state and validation