Перейти к основному содержимому

Полезные модули

Часть задач в любом проекте решается без единой внешней библиотеки - работа с путями, датами, текстом по шаблону и настройками уже есть в стандартной библиотеке Python
Эта статья - про пять модулей, которые пригодятся почти в любом проекте: pathlib, os, re, datetime и python-dotenv

Теория​

pathlib - пути как объекты, а не строки​

Старый способ работать с путями - собирать строки руками через os.path.join
pathlib заменяет строку на объект Path, у которого склейка путей - это оператор /:

from pathlib import Path

logs_dir = Path("logs")
first_log = logs_dir / "app.log"
print(first_log) # logs/app.log
print(first_log.exists()) # True, если файл правда есть
print(first_log.name) # app.log
print(first_log.suffix) # .log
print(first_log.parent) # logs

Самое полезное для повседневных задач:

  • .exists() / .is_file() / .is_dir() - проверки, не поднимающие исключение, если пути нет
  • .read_text() / .write_text() - открыть, прочитать/записать и закрыть файл одной строкой, без ручного with open(...)
  • .glob("*.log") / .rglob("*.log") - найти файлы по шаблону, rglob ещё и рекурсивно по вложенным папкам
  • .mkdir(parents=True, exist_ok=True) - создать папку вместе с промежуточными родителями, не упав, если она уже есть

В этом курсе ruff включает PTH (flake8-use-pathlib) - он ловит именно связку "старый os.path там, где место pathlib", например PTH118 на os.path.join(...)
Это не запрет на os целиком - смотри следующий раздел

os - когда всё-таки нужен​

pathlib не отменяет os
Пути - лишь часть того, что делает os, и заменять им имеет смысл только это:

  • переменные окружения читаются через os.environ / os.getenv, у pathlib для этого нет аналога
  • os.cpu_count(), os.getpid() и прочее взаимодействие с процессом и ОС pathlib тоже не покрывает

Так что практическое правило простое: если результат - путь к файлу или папке, нужен pathlib
Если нужно прочитать настройку окружения или спросить что-то у ОС напрямую - нужен os

re - поиск по шаблону​

Модуль re ищет и разбирает текст по регулярному выражению - шаблону, который описывает форму строки, а не её точное содержимое:

import re

line = "2026-09-15 14:33:02 ERROR user_id=42 message=payment failed"

match = re.search(r"user_id=(\d+)", line)
if match:
print(match.group(1)) # 42

Базовый набор:

  • re.match - проверяет совпадение только с начала строки
  • re.search - ищет совпадение в любом месте строки, возвращает первое найденное
  • re.findall - возвращает список всех совпадений
  • re.sub - заменяет совпадения на другую строку

Именованные группы (?P<name>...) избавляют от подсчёта скобок по номерам - можно спросить match.group("user_id") вместо match.group(1):

pattern = re.compile(r"user_id=(?P<user_id>\d+)")
match = pattern.search(line)
print(match.group("user_id")) # 42

Шаблон стоит собирать через re.compile заранее, а не передавать строку в re.search каждый раз внутри цикла - re.compile разбирает шаблон один раз, а не при каждом вызове
Строку шаблона почти всегда стоит писать как raw-строку (r"...") - без r каждый \d пришлось бы экранировать как \\d, что быстро становится нечитаемым

datetime - время и даты​

from datetime import datetime, timedelta

now = datetime(2026, 9, 26, 10, 0, 0)
deadline = now + timedelta(hours=3, minutes=15)
print(deadline.strftime("%Y-%m-%d %H:%M")) # 2026-09-26 13:15

parsed = datetime.strptime("2026-09-15 14:33:02", "%Y-%m-%d %H:%M:%S")
print(parsed.year, parsed.month, parsed.day) # 2026 9 15

strftime и strptime - зеркальные операции: strftime форматирует объект datetime в строку по шаблону, strptime разбирает строку обратно в datetime по тому же шаблону
timedelta - разница во времени, которую можно прибавлять и вычитать из datetime напрямую

Отдельная тонкость - datetime(...) без указания часового пояса создаёт "наивный" объект, который ничего не знает про UTC или локальное время
Для учебных задач и логов на одном сервере этого обычно достаточно, но в проде, где сервер и пользователи могут быть в разных часовых поясах, наивные datetime - частый источник багов, и стоит явно задавать tzinfo (datetime.now(tz=...)) - это уже за рамками этой статьи

python-dotenv - настройки, а не хардкод​

Секреты и адреса, зависящие от окружения (токен API, адрес БД, порог логирования), не должны лежать прямо в коде - иначе они попадают в git вместе с ним
Конвенция - выносить их в файл .env в корне проекта:

ALERT_LEVEL=ERROR

и читать через os.getenv после load_dotenv():

import os
from dotenv import load_dotenv

load_dotenv() # подхватывает .env из текущей папки
alert_level = os.getenv("ALERT_LEVEL", "WARNING") # WARNING, если переменной нет вообще

Важная деталь: load_dotenv() по умолчанию не переопределяет переменную, если она уже задана в окружении процесса - .env дополняет окружение, а не затирает его
Чтобы .env имел приоритет принудительно, нужен load_dotenv(override=True)

Файл .env всегда должен быть в .gitignore - в репозиторий кладут только .env.example с именами переменных без реальных значений

Примеры​

Ниже - маленький анализатор логов, который использует все пять модулей вместе: находит файлы логов, разбирает строки по шаблону, парсит время и решает, что считать "тревогой", по настройке из .env

Структура проекта:

.
├── .env
└── logs/
├── app.log
└── worker.log

.env:

ALERT_LEVEL=ERROR

logs/app.log:

2026-09-15 14:32:07 INFO user_id=11 message=login ok
2026-09-15 14:33:02 ERROR user_id=42 message=payment failed
2026-09-15 15:01:19 WARNING user_id=7 message=slow response

Сам анализатор:

import os
import re
from datetime import datetime
from pathlib import Path

from dotenv import load_dotenv

load_dotenv()

LOG_DIR = Path("logs")
PATTERN = re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) "
r"(?P<level>\w+) user_id=(?P<user_id>\d+) message=(?P<message>.+)"
)


def find_alerts(log_dir: Path, alert_level: str) -> list[tuple[str, datetime, str, str]]:
alerts = []
for log_file in sorted(log_dir.glob("*.log")):
for line in log_file.read_text(encoding="utf-8").splitlines():
match = PATTERN.match(line)
if not match or match.group("level") != alert_level:
continue
ts = datetime.strptime(match.group("ts"), "%Y-%m-%d %H:%M:%S")
alerts.append((log_file.name, ts, match.group("user_id"), match.group("message")))
return alerts


if __name__ == "__main__":
level = os.getenv("ALERT_LEVEL", "ERROR")
for name, ts, user_id, message in find_alerts(LOG_DIR, level):
print(f"{name}: {ts:%d.%m %H:%M} user={user_id} - {message}")

Вывод для примера выше (в logs/ ещё и worker.log с одной строкой ERROR):

app.log: 15.09 14:33 user=42 - payment failed
worker.log: 15.09 16:02 user=3 - queue timeout

Поменять ALERT_LEVEL в .env на WARNING - и без единой правки в коде анализатор станет считать тревогами уже другие строки

Проверь себя​

1. Чем Path("a") / "b" лучше, чем "a" + "/" + "b" или os.path.join("a", "b")?

Результат - не строка, а объект Path, у которого сразу есть .exists(), .read_text(), .glob() и другие методы
Не нужно думать о разделителе пути для конкретной ОС - /-оператор Path сам собирает корректный путь

2. В чём разница между .glob("*.log") и .rglob("*.log")?

.glob ищет только в самой указанной папке
.rglob ищет ещё и во всех вложенных подпапках рекурсивно

3. Почему ruff в этом курсе ругается на os.path.join, но не на os.getenv?

Правило PTH118 (flake8-use-pathlib) конкретно про пути - os.path.join дублирует то, что делает Path с оператором /
os.getenv работает с переменными окружения, а не с путями - у pathlib для этого просто нет аналога, так что правило его не касается

4. re.match("user", "the user") находит совпадение?

Нет
re.match проверяет совпадение только с начала строки, а "the user" начинается не с user - для поиска в любом месте строки нужен re.search

5. Зачем нужны именованные группы (?P&lt;name&gt;...), если можно match.group(1)?

Номер группы зависит от того, сколько скобок стоит перед ней в шаблоне - при любой правке шаблона номера могут съехать
Имя группы не меняется от правок в других частях шаблона и делает код разбора самодокументируемым

6. Зачем заранее делать re.compile(...), если re.search(pattern, text) работает и без него?

re.search со строковым шаблоном разбирает этот шаблон заново при каждом вызове
re.compile делает это один раз, а дальше переиспользует готовый объект - заметно на шаблоне, который прогоняется по каждой строке в цикле или файле

7. strftime и strptime - в чём разница по направлению?

strftime - из datetime в строку (форматирование)
strptime - из строки в datetime (разбор) - буква p можно запомнить как "parse"

8. Если в .env есть ALERT_LEVEL=ERROR, а до load_dotenv() уже стоит os.environ["ALERT_LEVEL"] = "CRITICAL" - что вернёт os.getenv("ALERT_LEVEL")?

"CRITICAL"
load_dotenv() без override=True не трогает переменные, которые уже есть в окружении процесса

  • значение из .env в этом случае проигрывает
9. Почему .env нельзя коммитить в git?

В нём реальные секреты и адреса конкретного окружения (токены, пароли к БД, ключи API)
Попав в git, они остаются в истории репозитория даже после удаления файла - в репозиторий кладут .env.example с именами переменных без значений, а .env добавляют в .gitignore

Итог / чек-лист​

  • pathlib - пути как объекты
    • / вместо склейки строк
    • .exists(), .read_text()/.write_text(), .glob()/.rglob(), .mkdir(parents=True, exist_ok=True)
  • os - для переменных окружения и процесса, не для путей
  • re - поиск и разбор текста по шаблону
    • match (с начала) vs search (где угодно) vs findall (все совпадения) vs sub (замена)
    • именованные группы и re.compile для читаемости и переиспользования
  • datetime - strftime форматирует, strptime разбирает, timedelta - разница во времени
  • python-dotenv - настройки и секреты в .env, а не в коде
    • load_dotenv() не переопределяет уже заданные переменные без override=True
    • .env - в .gitignore, .env.example - в репозитории

Дополнительно​