Basics of Clean Python Code (PEP8, SOLID, OOP) ::: Part 1

It is advisable to use the Sphinx style for writing docstrings. The Sphinx style uses the syntax of the lightweight reStructuredText (reST) markup language.

Example function:

def calculate_percent(num: int | float, percent: int | float) -> int | float:
	"""
	Эта функция высчитывает процент из числа.
	
	:param num: сумма для высчитывания.
	:type num: int | float
	:param percent: процент из суммы который надо высчитать.
	:type percent: int | float

	:rtype: int | float
	:return: процент от числа
	"""
	percentage = (percent * num) / 100

	return percentage

Sphinx uses the same syntax as most programming languages: keyword(reserved word). The most important keywords are:

  • param and type: the value of the parameter and the type of its variable;

  • return and rtype: the return value and its type;

  • :raises: describes any errors that occur in the code;

  • .. seealso::: information for further reading;

  • .. notes::: add note;

  • .. warning::: adding a warning.

Although the order of these keywords is not fixed, it is (again) common practice to stick to the above order throughout the project. The seealso, notes, and warning entries are optional.

OOP principles

The object-oriented paradigm has several principles:

  • Data is structured as objects, each of which has a specific type, that is, belongs to a certain class.

  • Classes are the result of formalizing the problem being solved and highlighting its main aspects.

  • The logic for working with information related to it is encapsulated inside the object.

  • Objects in the program interact with each other, exchanging requests and responses.

  • At the same time, objects of the same type respond to the same requests in a similar way.

  • Objects can be organized into more complex structures, such as containing other objects or inheriting from one or more objects.

Let's first write some OOP code and analyze it:

from enum import Enum
import datetime

DAYS_IN_YEAR = 365


class FuelType(Enum):
	"""
	Enum-класс с типами топлива
	"""
	GASOLINE = "Бензин"
	DIESEL = "Дизель"
	ELECTRIC = "Электричество"
	HYBRID = "Гибрид"


class VehicleStatus(Enum):
	"""
	Enum-класс с статусом состояния транспорта
	"""
	IDEAL = "Идеал"
	LIKE_NEW = "Как новая"
	USED = "Поддержанный"
	DETERIORATING = "Плохой"
	URGENT_REPAIR = "Нужен ремонт"
	BROKEN = "Сломана окончательно"


class Engine:
	"""
	Класс, представляющий собой двигатель
	"""
	def __init__(self, model_name: str, fuel_type: FuelType, max_speed_in_km: float, acceleration_time_in_seconds: float, 
				max_mileage_in_km: float, fuel_consumption: float, max_fuel_capacity: float):
		self.__fuel_type = fuel_type
		self.__model_name = model_name
		self.__max_speed_in_km = max_speed_in_km
		self.__acceleration_time_in_seconds = acceleration_time_in_seconds
		self.__max_mileage_in_km = max_mileage_in_km
		self.current_mileage_in_km = 0.0
		self.__fuel_consumption = fuel_consumption
		self.__max_fuel_capacity = max_fuel_capacity
		self.current_fuel_level = 0.0

	@property
	def fuel_type(self) -> FuelType:
		return self.__fuel_type

	@property
	def model_name(self) -> str:
		return self.__model_name

	@property
	def max_speed_in_km(self) -> float:
		return self.__max_speed_in_km

	@property
	def acceleration_time_in_seconds(self) -> float:
		return self.__acceleration_time_in_seconds

	@property
	def max_mileage_in_km(self) -> float:
		return self.__max_mileage_in_km

	@property
	def fuel_consumption(self) -> float:
		return self.__fuel_consumption

	@property
	def max_fuel_capacity(self) -> float:
		return self.__max_fuel_capacity

	def get_remaining_mileage(self) -> float:
		"""
		Функция получения остатка доступного киломентража
		"""
		return self.__max_mileage_in_km - self.current_mileage_in_km

	def refuel(self, amount: float):
		"""
		Заправка двигателя
		"""
		self.current_fuel_level = min(self.current_fuel_level + amount, self.__max_fuel_capacity)

	def drive(self, distance: float) -> bool:
		"""
		Поездка
		"""
		if distance <= self.get_remaining_mileage() and distance <= self.current_fuel_level / self.__fuel_consumption * 100:
			self.current_mileage_in_km += distance
			self.current_fuel_level -= distance / 100 * self.__fuel_consumption
			return True

		return False


class TransportVehicle:
	"""
	Класс, представляющий собой транспорт

	Каждое транспортное средство имеет следующие параметры:
	 + vehicle_type - тип транспорта
	 + brand_name - имя бренда-производителя
	 + model_name - имя модели
	 + release_year - год выпуска
	 + purchase_price - цена покупки
	 + purchase_date - дата покупки
	 + warranty_time_in_days - срок действия гарантии в днях
	 + engine - объект класса двигателя
	 + condition_percentage - процент состояния
	 + condition_status - статус состояния
	"""
	def __init__(self, vehicle_type: str, brand_name: str, model_name: str, release_year: int,
				purchase_price: int, purchase_date: datetime.date, warranty_time_in_days: int,
				engine: Engine, condition_percentage: float):
		self.vehicle_type = vehicle_type
		self.brand_name = brand_name
		self.model_name = model_name
		self.release_year = release_year
		self.purchase_price = purchase_price
		self.purchase_date = purchase_date
		self.warranty_time_in_days = warranty_time_in_days
		self.engine = engine
		self.condition_percentage = condition_percentage
		self.condition_status = self._get_vehicle_condition_status().value

	def drive(self, distance):
		"""
		Поездка. Вызываем метод из Engine и выводим дополнительную информацию
		"""
		if self.engine.drive(distance):
			print(f'Было преодалено {distance}км. Текущий пройденный километраж: '\
				f'{self.engine.current_mileage_in_km}/{self.engine.max_mileage_in_km} '\
				f'(осталось {self.engine.get_remaining_mileage()}), остаток топлива: '\
				f'{self.engine.current_fuel_level}')
			return True
		else:
			print(f'{self.vehicle_type} заглох. Текущий пройденный километраж: '\
				f'{self.engine.current_mileage_in_km}/{self.engine.max_mileage_in_km} '\
				f'(осталось {self.engine.get_remaining_mileage()}), остаток топлива: '\
				f'{self.engine.current_fuel_level}')
			return False

	def refuel(self, amount: float):
		"""
		Заправка. Вызываем метод из Engine и выводим дополнительную информацию
		"""
		print(f'Заправили {amount} топлива.')
		self.engine.refuel(amount)

	def get_info(self):
		"""
		Информация о траспорте
		"""
		description = f'Транспортное средство типа "{self.vehicle_type}": {self.brand_name}' \
					f' {self.model_name} {self.release_year} года выпуска (куплена в {self.purchase_date.year} '\
					f'году за {self.purchase_price}$). Текущее состояние - {self.condition_status} '\
					f'({self.condition_percentage}). Двигатель {self.engine.model_name}: тип топлива '\
					f'{self.engine.fuel_type.value}, максимальная скорость {self.engine.max_speed_in_km}км/ч, '\
					f'время разгона до 100км {self.engine.acceleration_time_in_seconds}сек, максимальный '\
					f'километраж {self.engine.max_mileage_in_km}км, максимальное количество топлива '\
					f'{self.engine.max_fuel_capacity} литров, расход {self.engine.fuel_consumption} на 100км.'

		return description

	def _get_vehicle_condition_status(self):
		if self.condition_percentage >= 90:
			return VehicleStatus.IDEAL
		elif self.condition_percentage >= 80:
			return VehicleStatus.LIKE_NEW
		elif self.condition_percentage >= 50:
			return VehicleStatus.USED
		elif self.condition_percentage >= 30:
			return VehicleStatus.DETERIORATING
		elif self.condition_percentage >= 10:
			return VehicleStatus.URGENT_REPAIR
		else:
			return VehicleStatus.BROKEN

	def get_remaining_warranty_time(self) -> str:
		"""
		Метод получения остатка срока действия гарантии.

		Return:
			строка с информацией о сроке действии гарантии или сообщением что он истек.
		"""
		today_datetime = datetime.date.today()
		warranty_end_date = self.purchase_date + datetime.timedelta(days=self.warranty_time_in_days)
		remaining_warranty_time = warranty_end_date - today_datetime

		if remaining_warranty_time.days < 0:
			return f'Срок действия гарантии ({self.warranty_time_in_days} дней) истек'
		else:
			return f'Срок действия гарантии истечет через {remaining_warranty_time.days} дней'


class Car(TransportVehicle):
	def __init__(self, brand_name: str, model_name: str, release_year: int,
				purchase_price: int, purchase_date: datetime.date, warranty_time_in_days: int,
				engine: Engine, condition_percentage: float):
		super().__init__('Автомобиль', brand_name, model_name, release_year, purchase_price,
						purchase_date, warranty_time_in_days, engine, condition_percentage)

	def drive(self, distance):
		if self.engine.drive(distance):
			print(f'Автомобиль проехал {distance}км. Текущий пройденный километраж: {self.engine.current_mileage_in_km}/{self.engine.max_mileage_in_km} (осталось {self.engine.get_remaining_mileage()}), остаток топлива: {self.engine.current_fuel_level}')
			self.condition_percentage -= (distance * 0.1) / 100
			self.condition_status = self._get_vehicle_condition_status().value
			return True
		else:
			print(f'Автомобиль заглох. Текущий пройденный километраж: {self.engine.current_mileage_in_km}/{self.engine.max_mileage_in_km} (осталось {self.engine.get_remaining_mileage()}), остаток топлива: {self.engine.current_fuel_level}')
			return False


class Truck(TransportVehicle):
	def __init__(self, brand_name: str, model_name: str, release_year: int,
				purchase_price: int, purchase_date: datetime.date, warranty_time_in_days: int,
				engine: Engine, condition_percentage: float):
		super().__init__('Грузовик', brand_name, model_name, release_year, purchase_price,
						purchase_date, warranty_time_in_days, engine, condition_percentage)

	def drive(self, distance):
		if self.engine.drive(distance):
			print(f'Грузовик проехал {distance}км. Текущий пройденный километраж: {self.engine.current_mileage_in_km}/{self.engine.max_mileage_in_km} (осталось {self.engine.get_remaining_mileage()}), остаток топлива: {self.engine.current_fuel_level}')
			self.condition_percentage -= (distance * 0.1) / 100
			self.condition_status = self._get_vehicle_condition_status().value
			return True
		else:
			print(f'Грузовик заглох. Текущий пройденный километраж: {self.engine.current_mileage_in_km}/{self.engine.max_mileage_in_km} (осталось {self.engine.get_remaining_mileage()}), остаток топлива: {self.engine.current_fuel_level}')
			return False


class Helicopter(TransportVehicle):
	def __init__(self, brand_name: str, model_name: str, release_year: int,
				purchase_price: int, purchase_date: datetime.date, warranty_time_in_days: int,
				engine: Engine, condition_percentage: float):
		super().__init__('Вертолет', brand_name, model_name, release_year, purchase_price,
						purchase_date, warranty_time_in_days, engine, condition_percentage)

	def drive(self, distance):
		if self.engine.drive(distance):
			print(f'Вертолет пролетел {distance}км. Текущий пройденный километраж: {self.engine.current_mileage_in_km}/{self.engine.max_mileage_in_km} (осталось {self.engine.get_remaining_mileage()}), остаток топлива: {self.engine.current_fuel_level}')
			self.condition_percentage -= (distance * 0.1) / 100
			self.condition_status = self._get_vehicle_condition_status().value
			return True
		else:
			print(f'Вертолет заглох. Текущий пройденный километраж: {self.engine.current_mileage_in_km}/{self.engine.max_mileage_in_km} (осталось {self.engine.get_remaining_mileage()}), остаток топлива: {self.engine.current_fuel_level}')
			return False


truck_engine = Engine("TruckEngine X100", FuelType.DIESEL, 180, 30, 800000, 25, 250)
truck = Truck('MAZ', 'KAMAZ', 2015, 10000, datetime.date(2000, 3, 3), DAYS_IN_YEAR * 20, truck_engine, 90)
print(truck.get_remaining_warranty_time())
print(truck.get_info())
truck.refuel(100)

while truck.drive(100):
	print('Проезжаем 100км...')

print(truck.get_info())

# >>> вывод
Срок действия гарантии (7300 дней) истек
Транспортное средство типа "Грузовик": MAZ KAMAZ 2015 года выпуска (куплена в 2000 году за 10000$). Текущее состояние - Идеал (90). Двигатель TruckEngine X100: тип топлива Дизель, максимальная скорость 180км/ч, время разгона до 100км 30сек, максимальный километраж 800000км, максимальное количество топлива 250 литров, расход 25 на 100км.
Заправили 100 топлива.
Грузовик проехал 100км. Текущий пройденный километраж: 100.0/800000 (осталось 799900.0), остаток топлива: 75.0
Проезжаем 100км...
Грузовик проехал 100км. Текущий пройденный километраж: 200.0/800000 (осталось 799800.0), остаток топлива: 50.0
Проезжаем 100км...
Грузовик проехал 100км. Текущий пройденный километраж: 300.0/800000 (осталось 799700.0), остаток топлива: 25.0
Проезжаем 100км...
Грузовик проехал 100км. Текущий пройденный километраж: 400.0/800000 (осталось 799600.0), остаток топлива: 0.0
Проезжаем 100км...
Грузовик заглох. Текущий пройденный километраж: 400.0/800000 (осталось 799600.0), остаток топлива: 0.0
Транспортное средство типа "Грузовик": MAZ KAMAZ 2015 года выпуска (куплена в 2000 году за 10000$). Текущее состояние - Как новая (89.60000000000002). Двигатель TruckEngine X100: тип топлива Дизель, максимальная скорость 180км/ч, время разгона до 100км 30сек, максимальный километраж 800000км, максимальное количество топлива 250 литров, расход 25 на 100км.

We have an engine class that is passed to the vehicle class. And we also have not just transport – but special classes of different types (car, helicopter, truck), which are inherited from the base transport class.

Engine has private parameters that can be accessed via the property function,

OOP is based on four fundamental principles: encapsulation, inheritance, polymorphism and abstraction.

Encapsulation is a mechanism for hiding the implementation details of a class from other objects. It is achieved by using the public, private, and protected access modifiers, which correspond to public, private, and protected attributes.

Inheritance is the process of creating a new class based on an existing class. The new class, called a subclass or derived class, inherits the properties and methods of the existing class, called the superclass or base class.

Polymorphism is the ability of objects to take different forms. In OOP, polymorphism allows objects of different classes to be treated as if they were objects of the same class.

Abstraction is the process of defining the essential characteristics of an object and ignoring the unimportant characteristics. This allows us to create abstract classes that define the common properties and behavior of a group of objects without specifying the details of each object.

One of the main purposes of using abstraction in OOP is to increase flexibility and simplify development. The abstract approach helps to create interfaces and classes that define only those properties and methods that are necessary to perform a specific task. This allows you to create more flexible and scalable applications that are easy to change and expand.

To work with abstract classes in Python, use the abc module. It provides:

  • abc.ABC – a base class for creating abstract classes. An abstract class contains one or more abstract methods, i.e. methods without definition (empty, without code). These methods must be overridden in subclasses.

  • abc.abstractmethod – a decorator that indicates that a method is abstract. This decorator is applied to a method inside an abstract class. A class that inherits properties and methods from an abstract class must implement all abstract methods, otherwise it will also be considered abstract.

from abc import ABC, abstractmethod

class Recipe(ABC):
    @abstractmethod
    def cook(self):
        pass

class Entree(Recipe):
    def __init__(self, ingredients):
        self.ingredients = ingredients

    def cook(self):
        print(f"Готовим на медленном огне смесь ингредиентов ({', '.join(self.ingredients)}) для основного блюда")

class Dessert(Recipe):
    def __init__(self, ingredients):
        self.ingredients = ingredients

    def cook(self):
        print(f"Смешиваем {', '.join(self.ingredients)} для десерта")

class Appetizer(Recipe):
    pass

class PartyMix(Appetizer):
    def cook(self):
        print("Готовим снеки - выкладываем на поднос орешки, чипсы и крекеры")

This example uses the concepts of polymorphism and inheritance along with abstraction.

The inheritance is that the Entree, Dessert, and PartyMix subclasses inherit the abstract cook() method from the abstract base class Recipe. This means that they all have the same signature (name and parameters) of the cook() method as the abstract method defined in the Recipe class.

Polymorphism occurs when each subclass of Recipe implements the cook() method differently. For example, Entree implements cook() to print instructions for simmering the main dish, while Dessert implements cook() to print instructions for mixing the dessert ingredients. This difference in implementation is an example of polymorphism, where different objects can be treated as the same type but behave differently.

SOLID

SOLID is the most popular OOP principle.

Here's what the acronym SOLID stands for:

  • S: Single Responsibility Principle. Each class or module in a program should have only one reason to change.

  • A: Open-Closed Principle. Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.

  • L: Liskov Substitution Principle. Objects in a program should be replaceable by instances of their subtypes without changing the correctness of the program.

  • I: Interface Segregation Principle Clients should not depend on interfaces they do not use.

  • D: Dependency Inversion Principle. Dependencies within a system should be built on abstractions, not details.

The purpose of using SOLID principles is to simplify development, make it more flexible and error-resistant.

Single responsibility principle

One function, class, program or service is one task. One function should not be responsible for 2 tasks (except in some cases, you should always use your mind first).

If a class has multiple tasks and you need to change one task, you will have to change the entire class.

# Плохой код
class User:
	def __init__(self, name: str, password: str, email: str):
		self.name = name
		self.password = password
		self.email = email
		self.email_is_valid = self.validate_email()

	def save_in_db(self):
		# ...

	def validate_email(self):
		# ...


# Хороший код
class Validator:
	# ...
	def check_email(self):
		# ...
	# ...


class DatabaseManager:
	# ...
	def create_user(self, name, password, email):
		# ...
	# ...


class User:
	# ...
	def __init__(self, name: str, password: str, email: str):
		self.name = name
		self.password = password
		self.email = email 
	# ...


validator = Validator()
db_manager = DatabaseManager()

name="John"
password = 'qwerty'
email="johnny@example.com"

if validator.check_email(email):
	db_manager.create_user(name, password, email)

Each class performs one task. The validator checks mail, the DB manager works with models, and the user class is the model.

The principle of openness-closedness

Software entities (classes, modules, functions) should be open for extension, but not for modification.

# Плохой код

clients = {
	'John': 'VIP',
	'Jane': 'favorite',
	'Max': 'plain'
}

for client_name, discount_level in clients.items():
	if discount_level == 'VIP':
		print(f'{client_name} has discount 50%')
	elif discount_level == 'favorite':
		print(f'{client_name} has discount 25%')
	else:
		print(f'{client_name} has discount 10%')

That is, in the code above, which is bad, we will have to change the code itself and its structure to expand it. That is, in order to expand, we need to modify, and this does not correspond to the open/closed principle.

# Хороший код
class Client:
	def __init__(self, name):
		self.name = name
		self.discount = 10


class FavoriteClient:
	def __init__(self, name):
		self.name = name
		self.discount = 25


class VIPClient:
	def __init__(self, name):
		self.name = name
		self.discount = 50


clients = [VIPClient('John'), FavoriteClient('Jane'), Client('Max')]

for client in clients:
	print(client.discount)

Barbara Liskov's Substitution Principle

It is necessary that subclasses be able to serve as replacements for their superclasses.

The purpose of this principle is to allow derived classes to be used in place of the parent classes from which they are derived without breaking the program. If the code happens to check the type of a class, then the substitution principle is violated.

Bad code:

class Client:
	def __init__(self, name: str, status_level: int=0):
		self.status_level = status_level
		self.name = name


class VIPClient(Client):
	def __init__(self, name: str):
		super().__init__(name, 1)


class SuperDuperPuperUltraProVIPClientGoldEdition(Client):
	def __init__(self, name: str):
		super().__init__(name, 2)


clients = [VIPClient('Anton'), SuperDuperPuperUltraProVIPClientGoldEdition('Oleg')]

for client in clients:
	if client.status_level == 1:
		print('discount 10%')
	elif client.status_level == 2:
		print('discount 20%')

Good code:

class Client:
	def __init__(self, name: str, status_level: int=0):
		self.status_level = status_level
		self.name = name

	def print_discount(self):
		print(f'discount {10 * self.status_level}')


class VIPClient(Client):
	def __init__(self, name: str):
		super().__init__(name, 1)


class SuperDuperPuperUltraProVIPClientGoldEdition(Client):
	def __init__(self, name: str):
		super().__init__(name, 2)


clients = [VIPClient('Anton'), SuperDuperPuperUltraProVIPClientGoldEdition('Oleg')]

for client in clients:
	client.print_discount()

Interface Separation Principle

Create highly specialized interfaces that are tailored to a specific client. Clients should not be dependent on interfaces they do not use.

We will not consider this principle using an example, since it does not matter for Python due to the lack of interfaces.

Dependency Inversion Principle

The object of a dependency should be an abstraction, not something concrete.

  1. High-level modules should not depend on lower-level modules. Both types of modules should depend on abstractions.

  2. Abstractions should not depend on details. Details should depend on abstractions.

There comes a point in software development when the functionality of an application no longer fits within a single module. When this happens, we have to solve the problem of module dependencies. As a result, for example, it may turn out that high-level components depend on low-level components.

Here is an example of code that satisfies this principle.

class Connection:
	# ...
	def __init__(self, hostname: str, port: int):
		self.hostname = hostname
		self.port = port

	def request(self):
		# ...
	# ...


class HTTPRequest(Connection):
	def __init__(self, hostname: str, port: int):
		super().__init__(hostname, port)

	def request(self):
		# ...
		status_code = 200
		return status_code


class HTTPSRequest(Connection):
	def __init__(self, hostname: str, port: int, ssl_context):
		super().__init__(hostname, port)
		self.ssl_context = ssl_context

	def request(self):
		# ...
		status_code = 200
		return status_code

It is also worth noting that by following the dependency inversion principle, we also comply with Barbara Liskov's substitution principle.

Conclusion

This is the end of the first part. In the second part we will consider the architecture of creating an application, linters, tools and utilities, how to create a python package, development methodologies and software design patterns in python. There are still many paths ahead, trodden or unsociable.

I would be glad if you joined my telegram channel about Pythonif it's not too much trouble.

Sources

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *