Как стать автором
Обновить
61.67
Wunder Fund
Мы занимаемся высокочастотной торговлей на бирже

Как организовать код в Python-проекте, чтобы потом не пожалеть

Время на прочтение10 мин
Количество просмотров69K
Автор оригинала: Guilherme Latrova

Каждая минута, потраченная на организацию своей деятельности, экономит вам целый час.
Бенджамин Франклин

Python отличается от таких языков программирования, как C# или Java, заставляющих программиста давать классам имена, соответствующие именам файлов, в которых находится код этих классов.

Python — это самый гибкий язык программирования из тех, с которыми мне приходилось сталкиваться. А когда имеешь дело с чем-то «слишком гибким» — возрастает вероятность принятия неправильных решений.

  • Хотите держать все классы проекта в единственном файле main.py? Да, это возможно.

  • Надо читать переменную окружения? Берите и читайте там, где это нужно.

  • Требуется модифицировать поведение функции? Почему бы не прибегнуть к декоратору!?

Применение многих идей, которые легко реализовать, может привести к негативным последствиям, к появлению кода, который очень тяжело поддерживать.

Но, если вы точно знаете о том, что делаете, последствия гибкости Python не обязательно окажутся плохими.

Здесь я собираюсь представить вашему вниманию рекомендации по организации Python-кода, которые сослужили мне хорошую службу, когда я работал в разных компаниях и взаимодействовал со многими людьми.

Структура Python-проекта

Сначала обратим внимание на структуру директорий проекта, на именование файлов и организацию модулей.

Рекомендую держать все файлы модулей в директории src, а тесты — в поддиректории tests этой директории:

<project>
├── src
│   ├── <module>/*
│   │    ├── __init__.py
│   │    └── many_files.py
│   │
│   └── tests/*
│        └── many_tests.py
│
├── .gitignore
├── pyproject.toml
└── README.md

Здесь <module> — это главный модуль проекта. Если вы не знаете точно — какой именно модуль у вас главный — подумайте о том, что пользователи проекта будут устанавливать командой pip install, и о том, как, по вашему мнению, должна выглядеть команда import для вашего модуля.

Часто имя главного модуля совпадает с именем всего проекта. Но это — не некое жёсткое правило.

Аргументы в пользу директории src

Я видел множество проектов, устроенных по-другому.

Например, в проекте может отсутствовать директория src, а все модули будут просто лежать в его корневой директории:

non_recommended_project
├── <module_a>/*
│     ├── __init__.py
│     └── many_files.py
│
├── .gitignore
│
├── tests/*
│    └── many_tests.py
│
├── pyproject.toml
│
├── <module_b>/*
│     ├── __init__.py
│     └── many_files.py
│
└── README.md

Уныло смотрится проект, в структуре которого нет никакого порядка из-за того, что его папки и файлы просто расположены по алфавиту, в соответствии с правилами сортировки объектов в IDE.

Главная причина, по которой рекомендуется пользоваться папкой src, заключается в том, чтобы активный код проекта был бы собран в одной директории, а настройки, параметры CI/CD, метаданные проекта находились бы за пределами этой директории.

Единственный минус такого подхода заключается в том, что, без дополнительных усилий, не получится воспользоваться в своём коде командой вида import module_a. Для этого потребуется кое-что сделать. Ниже мы поговорим о том, как решить эту проблему.

Именование файлов

Правило №1: тут нет файлов

Во-первых — в Python нет таких сущностей, как «файлы», и я заметил, что это — главный источник путаницы для новичков.

Если вы находитесь в директории, содержащей файл __init__.py, то это — директория, включающая в себя модули, а не файлы.

Рассматривайте каждый модуль, как пространство имён.

Я говорю о «пространстве имён», так как нельзя сказать с уверенностью — имеется ли в модуле множество функций и классов, или только константы. В нём может присутствовать практически всё что угодно, или лишь несколько сущностей пары видов.

Правило №2: если нужно — держите сущности в одном месте

Совершенно нормально, когда в одном модуле имеется несколько классов. Так и стоит организовывать код (но, конечно, только если классы связаны с модулем).

Выделяйте классы в отдельные модули только в том случае, если модуль становится слишком большим, или если его разные части направлены на решение различных задач.

Часто встречается мнение, что это — пример неудачного приёма работы. Те, кто так считают, находятся под влиянием опыта, полученного после использования других языков программирования, которые принуждают к другим решениям (например — это Java и C#).

Правило №3: давайте модулям имена, представляющие собой существительные во множественном числе

Давая модулям имена, следуйте общему правилу, в соответствии с которым эти имена должны представлять собой существительные во множественном числе. При этом они должны отражать особенности предметной области проекта.

Правда, у этого правила есть и исключение. Модули могут называться core, main.py или похожим образом, что указывает на то, что они представляют собой некую единичную сущность. Подбирая имена модулей, руководствуйтесь здравым смыслом, а если сомневаетесь — придерживайтесь вышеприведённого правила.

Реальный пример именования модулей

Вот мой проект — Google Maps Crawler, созданный в качестве примера.

Этот проект направлен на сбор данных из Google Maps с использованием Selenium и на их представление в виде, удобном для дальнейшей обработки (тут, если интересно, можно об этом почитать).

Вот текущее состояние дерева проекта (тут выделены исключения из правила №3):

gmaps_crawler
├── src
│   └── gmaps_crawler
│        ├── __init__.py
│        ├── config.py (форма единственного числа)
│        ├── drivers.py
│        ├── entities.py
│        ├── exceptions.py
│        ├── facades.py
│        ├── main.py (форма единственного числа)
│        └── storages.py
│
├── .gitignore
├── pyproject.toml
└── README.md

Весьма естественным кажется такой импорт классов и функций:

from gmaps_crawler.storages import get_storage
from gmaps_crawler.entities import Place
from gmaps_crawler.exceptions import CantEmitPlace

Можно понять, что в exceptions может иметься как один, так и множество классов исключений.

Именование модулей существительными множественного числа отличается следующими приятными особенностями:

  • Модули не слишком «малы» (в том смысле, что предполагается, что один модуль может включать в себя несколько классов).

  • Их, если нужно, в любой момент можно разбить на более мелкие модули.

  • «Множественные» имена дают программисту сильное ощущение того, что он знает о том, что может быть внутри соответствующих модулей.

Именование классов, функций и переменных

Некоторые программисты считают, что давать сущностям имена — это непросто. Но если заранее определиться с правилами именования, эта задача становится уже не такой сложной.

Имена функций и методов должны быть глаголами

Функции и методы представляют собой действия, или нечто, выполняющее действия.

Функция или метод — это не просто нечто «существующее». Это — нечто «действующее».

Действия чётко определяются глаголами.

Вот — несколько удачных примеров из реального проекта, над которым я раньше работал:

def get_orders():
    ...
def acknowledge_event():
    ...
def get_delivery_information():
    ...
def publish():
    ...

А вот — несколько неудачных примеров:

def email_send():
    ...
def api_call():
   ...
def specific_stuff():
   ...

Тут не очень ясно — возвращают ли функции объект, позволяющий выполнить обращение к API, или они сами выполняют какие-то действия, например — отправку письма.

Я могу представить себе такой сценарий использования функции с неудачным именем:

email_send.title = "title"
email_send.dispatch()

У рассмотренного правила есть и некоторые исключения:

  • Создание функции main(), которую вызовут в главной точке входа приложения — это хороший повод нарушить это правило.

  • Использование @property для того, чтобы обращаться с методом класса как с атрибутом, тоже допустимо.

Имена переменных и констант должны быть существительными

Имена переменных и констант всегда должны быть существительными и никогда — глаголами (это позволяет чётко отделить их от функций).

Вот примеры удачных имён:

plane = Plane()
customer_id = 5
KEY_COMPARISON = "abc"

Вот — неудачные имена:

fly = Plane()
get_customer_id = 5
COMPARE_KEY = "abc"

А если переменная или константа представляют собой список или коллекцию — им подойдёт имя, представленное существительным во множественном числе:

planes: list[Plane] = [Plane()] # Даже если содержит всего один элемент
customer_ids: set[int] = {5, 12, 22}
KEY_MAP: dict[str, str] = {"123": "abc"} # Имена словарей остаются существительными в единственном числе

Имена классов должны говорить сами за себя, но использование суффиксов — это нормально

Отдавайте предпочтение именам классов, понятным без дополнительных пояснений. При этом можно использовать и суффиксы, вроде ServiceStrategyMiddleware, но — только в крайнем случае, когда они необходимы для чёткого описания цели существования класса.

Всегда давайте классам имена в единственном, а не во множественном числе. Имена во множественном числе напоминают имена коллекций элементов (например — если я вижу имя orders, то я полагаю, что это — список или итерируемый объект). Поэтому, выбирая имя класса, напоминайте себе, что после создания экземпляра класса в нашем распоряжении оказывается единственный объект.

Классы представляют собой некие сущности

Классы, представляющие нечто из бизнес-среды, должны называться в соответствии с названиями связанных с ними сущностей (и имена должны быть существительными!). Например — OrderSaleStoreRestaurant и так далее.

Пример использования суффиксов

Представим, что надо создать класс, ответственный за отправку электронных писем. Если назвать его просто Email, цель его существования будет неясна.

Кто-то может решить, что он может олицетворять некую сущность:

email = Email() # Предполагаемый пример использования
email.title = "Title"
email.body = create_body()
email.send_to = "guilatrova.dev"

send_email(email)

Такой класс следует назвать EmailSender или EmailService.

Соглашения по именованию сущностей

Следуйте этим соглашениям по именованию сущностей:

Тип

Общедоступный

Внутренний

Пакеты (директории)

lower_with_under

Модули (файлы)

lower_with_under.py

Классы

CapWords

Функции и методы

lower_with_under()

_lower_with_under()

Константы

ALL_CAPS_UNDER

_ALL_CAPS_UNDER

Отступление о «приватных» методах

Если имя метода выглядит как __method(self) (любой метод, имя которого начинается с двух символов подчёркивания), то Python не позволит внешним классам/методам вызывать этот метод обычным образом. Некоторые, узнавая об этом, считают, что это нормально. 

Тем, кто, вроде меня, пришёл в Python из C#, может показаться странным то, что (пользуясь вышеприведённым руководством) метод класса нельзя защитить.

Но у Гвидо ван Россума есть достойная причина считать, что на это есть веские основания: «Мы все тут взрослые, ответственные люди».

Это значит, что если вы знаете, что не должны вызывать метод, тогда вы и не будете этого делать — если только не абсолютно уверены в своих действиях.

В конце концов, если вы и правда решите вызвать некий приватный метод, то вы для этого сделаете что-то неординарное (в C# это называется Reflection).

Поэтому давайте своим приватным методам/функциям имена, начинающиеся с одного символа подчёркивания, указывающего на то, что они предназначены лишь для внутреннего использования, и смиритесь с этим.

Когда создавать функцию, а когда — класс?

Мне несколько раз задавали вопрос, вынесенный в заголовок этого раздела.

Если вы следуете рекомендациям, приведённым выше, то ваши модули будут понятными, а понятные модули — это эффективный способ организации функций:

from gmaps_crawler import storages

storages.get_storage()  # Похоже на класс, но экземпляр не создаётся, а имя - это существительное во множественном числе
storages.save_to_storage()  # Так может называться функция, хранящаяся в модуле

Иногда в модуле можно разглядеть некое подмножество чем-то связанных функций. В таких случаях подобные функции имеет смысл выделить в класс.

Пример группировки подмножества функций

Предположим, имеется уже встречавшийся нам модуль storages с 4 функциями:

def format_for_debug(some_data):
    ...

def save_debug(some_data):
    """Выводит данные на экран"""
    formatted_data = format_for_debug(some_data)
    print(formatted_data)


def create_s3(bucket):
    """Создаёт бакет s3, если он не существует"""
    ...

def save_s3(some_data):
    s3 = create_s3("bucket_name")
    ...

S3 — это облачное хранилище Amazon (AWS), подходящее для хранения любых данных. Это — нечто вроде Google Drive для программ.

Проанализировав этот код, мы можем сказать следующее:

  • Разработчик может сохранять данные в режиме отладки (save_debug) (они просто выводятся на экран), или в S3 (save_s3) (они попадают в облако).

  • Функция save_debug использует функцию format_for_debug.

  • Функция save_s3 использует функцию create_s3.

Тут я вижу две группы функций, но не нахожу причины хранить их код в разных модулях, так как они, вроде бы, невелики. Поэтому меня устроит их оформление в виде классов:

class DebugStorage:
    def format_for_debug(self, some_data):
        ...

    def save_debug(self, some_data):
        """Выводит данные на экран"""
        formatted_data = self.format_for_debug(some_data)
        print(formatted_data)


class S3Storage:
    def create_s3(self, bucket):
        """Создаёт бакет s3, если он не существует"""
        ...

    def save_s3(self, some_data):
        s3 = self.create_s3("bucket_name")
        ...

Вот эмпирическое правило, помогающее решить вопрос о функциях и классах:

  • Всегда начинайте с функций.

  • Переходите к классам в том случае, если у вас возникает ощущение, что вы можете сгруппировать различные подмножества функций.

Создание модулей и точки входа в приложение

У каждого приложения есть точка входа.

То есть — имеется единственный модуль (другими словами — файл), который запускает приложение. Это может быть как отдельный скрипт, так и большой модуль.

Когда бы вы ни создавали точку входа в приложение — обязательно добавьте в код проверку на то, что этот код выполняется, а не импортируется:

def execute_main():
    ...


if __name__ == "__main__":  # Добавьте это условие
    execute_main()

Сделав это, вы обеспечите то, что импорт этого кода не приведёт к его случайному выполнению. Выполняться он будет только в том случае, если будет запущен явным образом.

Файл __main__.py

Вы, возможно, заметили, что некоторые Python-пакеты можно вызывать, пользуясь ключом -m:

python -m pytest
python -m tryceratops
python -m faust
python -m flake8
python -m black

Система относится к таким пакетам почти как к обычным утилитам командной строки, так как запускать их ещё можно так:

pytest
tryceratops
faust
flake8
black

Для того чтобы оснастить ваш проект такой возможностью — нужно добавить файл __main.py__ в главный модуль:

<project>
├── src
│   ├── example_module Главный модуль
│   │    ├── __init__.py
│   │    ├── __main__.py Добавьте сюда этот файл
│   │    └── many_files.py
│   │
│   └── tests/*
│        └── many_tests.py
│
├── .gitignore
├── pyproject.toml
└── README.md

И не забудьте, что и тут, в файле __main__.py, понадобится проверка __name__ == "__main__".

Когда вы установите свой модуль — вы сможете запускать его командой вида python -m example_module.

О, а приходите к нам работать? 🤗 💰

Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.

Мы предлагаем интересные и сложные задачи по анализу данных и low latency разработке для увлеченных исследователей и программистов. Гибкий график и никакой бюрократии, решения быстро принимаются и воплощаются в жизнь.

Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.

Присоединяйтесь к нашей команде.

Теги:
Хабы:
+32
Комментарии34

Публикации

Информация

Сайт
wunderfund.io
Дата регистрации
Дата основания
Численность
11–30 человек
Местоположение
Россия
Представитель
xopxe