Когда один файл — мало
К этому моменту ваш learning-проект, скорее всего, выглядит как один main.py со всеми функциями. Это нормально для упражнений. Но когда задача растёт — ETL читает API, парсит JSON, валидирует, грузит в Postgres — один файл на 500 строк превращается в кошмар: ничего не найти, тесты невозможно изолировать, переиспользовать функции в другом скрипте — больно.
Решение — разбить программу на модули. В этом уроке: что такое модуль, пакет, как импортировать, какие импорты безопасны, какие — нет.
Модуль = файл .py
Любой .py-файл в Python — это модуль. Имя модуля = имя файла без расширения.
Создадим parse.py:
# parse.py
def parse_record(raw: dict) -> dict:
"""Очищает входящую запись."""
return {k: v.strip() if isinstance(v, str) else v for k, v in raw.items()}
def parse_amount(s: str) -> float:
return float(s.replace(",", ".").strip())
И main.py рядом:
# main.py
import parse
record = {"name": " Анна ", "city": " Москва "}
print(parse.parse_record(record))
# {'name': 'Анна', 'city': 'Москва'}
import parse — Python ищет файл parse.py рядом с main.py (точнее, в путях из sys.path), исполняет его (все def создают функции в пространстве имён модуля), и привязывает результат к имени parse. Дальше функции доступны как parse.parse_record(...).
Три формы import
# 1. import module — берём всё под именем модуля
import parse
parse.parse_record(...)
# 2. from module import name — берём конкретные имена
from parse import parse_record, parse_amount
parse_record(...)
# 3. as — переименовать при импорте
import pandas as pd
from datetime import datetime as dt
# смешанно
from parse import parse_record as pr
Когда какую использовать:
import x— когда из модуля нужно много разных функций или хочется явности «откуда что взято».from x import y— для часто вызываемых:parse_record(...)короче, чемparse.parse_record(...).as— для устранения конфликтов имён или сокращений (всегдаimport pandas as pd).
Чего избегать:
# плохо — * выгружает все публичные имена в текущий namespace
from parse import *
После такого читатель не понимает, откуда взялась функция, IDE подсветка ломается, при обновлении модуля могут добавиться имена, перекрывающие ваши локальные. В реальных проектах import * — anti-pattern; ruff/pylint ругают по умолчанию.
Пакет = папка с __init__.py
Когда модулей много, их объединяют в пакет — папку с файлом __init__.py. Этот файл может быть пустым; его наличие делает папку пакетом.
Типовая структура DE-проекта:
my_etl/
├── pyproject.toml
├── main.py
└── etl/
├── __init__.py
├── extract.py
├── transform.py
└── load.py
Теперь импорты могут быть полноценно «модульными»:
# main.py
from etl.extract import fetch_from_api
from etl.transform import clean_records
from etl.load import write_to_postgres
records = fetch_from_api()
clean = clean_records(records)
write_to_postgres(clean)
etl.extract означает «модуль extract.py внутри пакета etl». Каждая точка — спуск на один уровень вложенности.
Что класть в __init__.py
Чаще всего — пусто, и это нормально. Файл нужен только как маркер «это пакет».
Иногда в __init__.py пишут
# etl/__init__.py
from .extract import fetch_from_api
from .transform import clean_records
from .load import write_to_postgres
__all__ = ["fetch_from_api", "clean_records", "write_to_postgres"]
Теперь внешний пользователь пишет from etl import fetch_from_api, не зная и не заботясь о внутренней структуре пакета. Это публичный контракт.
__all__ — список имён, который Python подхватит при from etl import *. Не делает имена приватными или публичными — просто документирует «вот наш интерфейс».
Из Python 3.3 существует понятие
__init__.py. На уровне junior не нужны, ставьте __init__.py всегда.Стандартная библиотека
В Python с собой идёт огромная
import os, sys
import json
import csv
from datetime import datetime, date, timedelta
from pathlib import Path
import re
import logging
import sqlite3
Что полезно знать DE из stdlib (мы коснёмся подробнее в модуле 6):
pathlib— современная работа с путями (Path("/data/raw") / "file.csv").json,csv— базовые форматы.datetime,zoneinfo— даты, время, часовые пояса.re— регулярные выражения.logging— логирование (неprint).dataclasses— лёгкие классы для данных.collections—Counter,defaultdict,deque.itertools,functools— функциональные утилиты.pathlib,os— файловая система.subprocess— запуск внешних процессов.urllib.parse— разбор URL.
Перед тем, как добавить очередную зависимость через uv add — проверьте, нет ли нужной утилиты в stdlib. Часто есть. Чем меньше зависимостей, тем меньше шансов на конфликты версий.
Третьесторонние пакеты
Когда нужного в stdlib нет — ставим через uv add:
uv add requests
uv add pydantic
uv add psycopg
uv добавит пакет в pyproject.toml, обновит lockfile uv.lock, поставит библиотеку. Дальше — обычный import:
import requests
import pydantic
Главное: в pyproject.toml фиксируется ваш список зависимостей, в uv.lock — точные версии. Это позволяет на любой машине воссоздать тот же environment командой uv sync. Подробнее — в модуле 2 «Окружение».
Absolute vs relative imports
Внутри пакета можно ссылаться на соседние модули двумя способами:
# etl/transform.py
# absolute — полный путь от корня
from etl.parse import parse_record
# relative — относительно текущего пакета
from .parse import parse_record # из соседнего модуля
from ..core import config # из родительского пакета
Точка . означает «текущий пакет», .. — «родительский». Похоже на путь в файловой системе.
Какой использовать? Общая рекомендация (PEP 8): absolute по умолчанию, relative — для очевидно «соседних» модулей внутри одного пакета.
Почему absolute надёжнее:
- Видно, откуда импорт, даже без знания текущего файла.
- При переименовании пакета сломаются только относительные импорты — абсолютные правятся глобальной заменой.
- Если файл случайно запустить как скрипт (
python etl/transform.py), relative-импорты упадут с ошибкой «attempted relative import beyond top-level package».
Relative — нормально для внутрипакетных утилит, когда переименование не планируется:
# etl/transform.py
from .types import Record # типы прямо рядом
from .parse import parse_record # вспомогательный парсер
В DE-проектах обычно жёсткое правило: absolute imports везде, кроме __init__.py. Меньше думать.
if __name__ == "__main__":
Когда вы запускаете файл командой python script.py, его атрибут __name__ равен строке "__main__". Когда тот же файл импортируется как модуль — __name__ равен имени модуля (например, "parse").
Идиома проверки:
# parse.py
def parse_record(raw):
...
def main():
print("Запуск тестов разбора")
test = {"name": " Anna "}
print(parse_record(test))
if __name__ == "__main__":
main()
Что это даёт:
- При запуске
python parse.pyсработает блокmain()— полезно для smoke-тестов и быстрых проверок. - При
import parseблок не выполняется — мы получаем только функции, без побочных эффектов.
В DE-коде эта проверка обязательна для скриптов, которые могут быть и точкой входа, и модулем. Без неё import parse начнёт выполнять весь смоук-код.
Когда импорт случается
Важно понимать: import выполняет код модуля. Каждый def создаёт функцию, каждое верхнеуровневое присваивание — переменную, каждый верхнеуровневый print() — печатает.
# config.py
print("Загружается config") # сработает при import config!
DATABASE_URL = "postgresql://..."
def get_url():
return DATABASE_URL
При первом import config в проекте напечатается «Загружается config». При повторных import config (в другом файле, в той же программе) — нет, Python кэширует загруженные модули в sys.modules. Модуль выполняется ровно один раз за время жизни процесса.
Из этого следует:
- Не делайте дорогих операций на верхнем уровне модуля — не дёргайте API, не читайте файлы, не подключайтесь к БД. Эти операции — внутрь функций, чтобы они вызывались по необходимости.
- Импорты в начало файла — стандарт. Иногда (для «ленивого» импорта тяжёлых библиотек или против циклических зависимостей) импорт делают внутри функции, но это исключение, не правило.
Circular imports — почему ломается
Опасная ситуация:
# module_a.py
from module_b import b_func
def a_func():
return b_func() + 1
# module_b.py
from module_a import a_func
def b_func():
return a_func() * 2
module_a начинает импортировать module_b. module_b начинает импортировать module_a (но a_func ещё не определён, потому что мы в середине импорта). Ошибка: ImportError: cannot import name 'a_func'.
Как избежать:
1. Лучший способ — рефакторинг. Если два модуля так тесно зависят друг от друга, скорее всего они должны быть одним модулем или должна быть третья «общая часть», которую оба импортируют. Не уворачивайтесь от проблемы — решайте архитектурно.
2. Ленивый импорт внутри функции.
# module_a.py
def a_func():
from module_b import b_func # импорт «по требованию»
return b_func() + 1
Импорт случится при первом вызове a_func(), когда module_a уже полностью загружен и module_b сможет его импортировать. Это рабочий приём, но злоупотреблять не стоит — он маскирует проблему дизайна.
DE-кейс: разбиение ETL на модули
Скрипт-монолит:
# etl_monolith.py — 300 строк, всё подряд
import requests, json, csv, psycopg
def fetch_from_api(...):
...
def parse_record(...):
...
def clean_records(...):
...
def write_to_postgres(...):
...
if __name__ == "__main__":
raw = fetch_from_api(...)
clean = clean_records(raw)
write_to_postgres(clean)
Разбиваем по этапам ETL:
my_etl/
├── pyproject.toml
├── main.py
└── etl/
├── __init__.py
├── extract.py # все функции работы с API
├── transform.py # парсинг, очистка, валидация
└── load.py # запись в БД
# etl/extract.py
import requests
def fetch_from_api(url: str) -> list[dict]:
response = requests.get(url, timeout=30)
response.raise_for_status()
return response.json()
# etl/transform.py
def clean_records(raw: list[dict]) -> list[dict]:
return [
{k: v.strip() for k, v in r.items() if v is not None}
for r in raw
]
# etl/load.py
import psycopg
def write_to_postgres(rows: list[dict], dsn: str) -> None:
with psycopg.connect(dsn) as conn:
with conn.cursor() as cur:
for r in rows:
cur.execute(
"INSERT INTO events(id, name) VALUES (%s, %s)",
(r["id"], r["name"]),
)
# main.py
from etl.extract import fetch_from_api
from etl.transform import clean_records
from etl.load import write_to_postgres
def main() -> None:
raw = fetch_from_api("https://api.example.com/events")
clean = clean_records(raw)
write_to_postgres(clean, dsn="postgresql://...")
if __name__ == "__main__":
main()
Преимущества разбиения:
- Каждый модуль можно тестировать отдельно (см. модуль 9 «Тесты»).
extract.pyможно переиспользовать в другом скрипте.- При баге в трансформации лезете только в
transform.py, не разгребая 300 строк.
Упражнение
В вашем python-01-playground создайте структуру:
.
├── main.py
└── tools/
├── __init__.py
├── parse.py
└── format.py
Содержимое:
# tools/parse.py
def parse_money(s: str) -> float:
"""1 234,56 ₽ → 1234.56"""
return float(s.replace(" ", "").replace(",", ".").replace("₽", "").strip())
if __name__ == "__main__":
print(parse_money("1 234,56 ₽"))
# tools/format.py
def format_money(v: float) -> str:
"""1234.56 → '1 234,56 ₽'"""
return f"{v:,.2f} ₽".replace(",", " ").replace(".", ",")
# main.py
from tools.parse import parse_money
from tools.format import format_money
raw = "1 234,56 ₽"
parsed = parse_money(raw)
print(f"parsed = {parsed}")
print(f"back = {format_money(parsed)}")
Запустите тремя способами:
uv run main.py— увидите оба числа.uv run python -m tools.parse— увидите результат__main__-блока.uv run python -c "from tools.parse import parse_money; print(parse_money('100,50 ₽'))".
Критерии:
- Все три команды работают.
- Поняли, чем отличается запуск файла напрямую от импорта.
- В
format.pyнетif __name__ == "__main__":блока, и при импорте оттуда ничего лишнего не выполняется.
В следующем уроке — последняя большая тема модуля: исключения и обработка ошибок.