TL;DR

Encapsulation refers to the process of hiding the implementation details of a process, as well as the data that an object might have.

We can encapsulate attributes (meaning, make them private) by using double underscore (__) in the name of the attribute.

class Person:
	def __init__(self, public_name, private_name):
		self.public_name = public_name
		self.__private_name = private_name

We can also encapsulate methods, using the double underscore technique. The use-case for method encapsulation, however, is different: private methods are generally intended for internal use only, such as helper methods that a client doesn’t need to know about:

class Recipient:
    def __init__(self, name: str, email: str):
        self.__name = name
        if self.__check_email(email):
            self.__email = email
        else:
            raise ValueError("The email address is not valid")
 
    def __check_email(self, email: str):
        # A simple check: the address must be over 5 characters long 
        # and contain a dot and an @ character
        return len(email) > 5 and "." in email and "@" in email

Explanation

In object oriented programming the word client comes up from time to time. This is used to refer to a section of code which creates an object and uses the service provided by its methods. When the data contained in an object is used only through the methods it provides, the internal integrity of the object is guaranteed. In practice this means that, for example, a BankAccount class offers methods to handle the balance attribute, so the balance is never accessed directly by the client. These methods can then verify that the balance is not allowed to go below zero, for instance.

An example of how this would work:

class BankAccount:
 
    def __init__(self, account_number: str, owner: str, balance: float, annual_interest: float):
        self.account_number = account_number
        self.owner = owner
        self.balance = balance
        self.annual_interest = annual_interest
 
    # This method adds the annual interest to the balance of the account
    def add_interest(self):
        self.balance += self.balance * self.annual_interest
 
    # This method "withdraws" money from the account
    # If the withdrawal is successful the method returns True, and False otherwise
    def withdraw(self, amount: float):
        if amount <= self.balance:
            self.balance -= amount
            return True
 
        return False
 
peters_account = BankAccount("12345-678", "Peter Python", 1500.0, 0.015)
 
if peters_account.withdraw(1000):
    print("The withdrawal was successful, the balance is now", peters_account.balance)
else:
    print("The withdrawal was unsuccessful, the balance is insufficient")
 
# Yritetään uudestaan
if peters_account.withdraw(1000):
    print("The withdrawal was successful, the balance is now", peters_account.balance)
else:
    print("The withdrawal was unsuccessful, the balance is insufficient")

Maintaining the internal integrity of the object and offering suitable methods to ensure this is called encapsulation. The idea is that the inner workings of the object are hidden from the client, but the object offers methods which can be used to access the data stored in the object.

Maintaining state

Because the integrity of an object means that its state is always acceptable, we must ensure that the attributes of that object are acceptable. For example, an object that represents a date must never have 13 as the value of the month, or a person must not have negative values as their age.

Consider the following:

```python
class Student:
    def __init__(self, name: str, student_number: str):
        self.name = name
        self.student_number = student_number
        self.study_credits = 0
 
    def add_credits(self, study_credits):
        if study_credits > 0:
            self.study_credits += study_credits

Here, the Student object offers its clients a method called add_credits, which allows credits to be added and to ensure that object.study_credits remains above zero.

Nonetheless, this can easy break, since it is still possible to access the study_credits attribute directly:

sally = Student("Sally Student", "12345")
sally.study_credits = -100 # This does work, but sets the attribute to an Undesired State

Private classes

In OOP, it is common for classes to hide their attributes by making them private. In Python this is accomplished by adding two underscores (__) to the beginning of an attribute name:

class CreditCard:
    # the attribute number is private, while the attribute name is accessible
    def __init__(self, number: str, name: str):
        self.__number = number
        self.name = name

Trying to access the double-underscored attribute will cause an error:

card = CreditCard("123456","Randy Riches")
print(card.__number)
AttributeError: 'CreditCard' object has no attribute '__number'

Note

By design, Python has no truly private attributes, and there are ways around the double underscore method. Other languages, like Java, have truly private variables, but we can simply think of Python ones in the same way.