Shanraq.org Shanraq.org
Даты и время: 45 дней, часовой пояс и формат, который читается двумя способами
IT

Python: от данных до своей сводки Урок 14 из 56

Даты и время: 45 дней, часовой пояс и формат, который читается двумя способами

Четырнадцатый урок курса по Python. Между 15 января и 1 марта — 45 дней, и считает их `timedelta`, а не вы. Плюс два подвоха: `01.02.2026` разбирается двумя форматами без единой ошибки — январём и февралём, — а наивное время нельзя вычесть из осознанного, и Python говорит об этом прямо.

Зачем это нужно

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

Дата в выгрузке — текст: 2026-01-15, 15.01.2026, 15 января. Пока она текст, с ней ничего нельзя сделать: не вычесть, не прибавить неделю, не спросить «сколько дней прошло».

А когда её всё-таки разбирают, выясняется, что одна и та же строка читается двумя способами, и оба выглядят правдоподобно. Плюс вопрос, которого в числах не было: девять тридцать — это где?

Сразу целиком

Файл kunder.py. Запуск: python kunder.py из окружения.

Обязательное — первые два блока: строка в дату и разница дат. Третий и четвёртый показывают формат и часовой пояс, ради которых урок и написан.

"""Урок 14: дата — не строка, а точка на оси времени.

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

from datetime import date, datetime, timedelta, timezone

# Из выгрузки даты приходят текстом. ISO-формат читается без объяснений.
raw = ["2026-01-15", "2026-02-01", "2026-03-01"]

print("== строка в дату")
days = []
for text in raw:
    day = date.fromisoformat(text)
    days.append(day)
    print(f"{text}{day} | год {day.year}, месяц {day.month}, день {day.day}")

print()
print("== разница дат — это timedelta")
span = days[-1] - days[0]
print("между первым и последним:", span)
print("дней:", span.days)
print("через неделю после последнего:", days[-1] + timedelta(days=7))

print()
print("== разобрать по своему формату и напечатать по своему")
moment = datetime.strptime("01.02.2026 09:30", "%d.%m.%Y %H:%M")
print("разобрали:", moment)
print("напечатали:", moment.strftime("%d.%m.%Y, %H:%M"))
print("в файл пишут так:", moment.isoformat())
try:
    datetime.strptime("2026-02-01", "%d.%m.%Y")
except ValueError as error:
    print("чужой формат:", error)

print()
print("== часовой пояс: наивное время и осознанное")
naive = datetime(2026, 2, 1, 9, 30)
aware = datetime(2026, 2, 1, 9, 30, tzinfo=timezone.utc)
almaty = timezone(timedelta(hours=5))
print("наивное:   ", naive, "| пояс:", naive.tzinfo)
print("осознанное:", aware, "| пояс:", aware.tzinfo)
print("то же мгновение в Алматы:", aware.astimezone(almaty))
try:
    print(aware - naive)
except TypeError as error:
    print("одно из другого не вычесть:", error)

Выводит:

== строка в дату
2026-01-15 → 2026-01-15 | год 2026, месяц 1, день 15
2026-02-01 → 2026-02-01 | год 2026, месяц 2, день 1
2026-03-01 → 2026-03-01 | год 2026, месяц 3, день 1

== разница дат — это timedelta
между первым и последним: 45 days, 0:00:00
дней: 45
через неделю после последнего: 2026-03-08

== разобрать по своему формату и напечатать по своему
разобрали: 2026-02-01 09:30:00
напечатали: 01.02.2026, 09:30
в файл пишут так: 2026-02-01T09:30:00
чужой формат: time data '2026-02-01' does not match format '%d.%m.%Y'

== часовой пояс: наивное время и осознанное
наивное:    2026-02-01 09:30:00 | пояс: None
осознанное: 2026-02-01 09:30:00+00:00 | пояс: UTC
то же мгновение в Алматы: 2026-02-01 14:30:00+05:00
одно из другого не вычесть: can't subtract offset-naive and offset-aware datetimes

Разбор

Строка становится датой один раз — при разборе

day = date.fromisoformat(text)

fromisoformat читает дату в формате ISO 8601: год-месяц-день, четыре цифры, два, два. Это тот же приём, что int(row["date"]) в прошлом уроке: текст превращают в значение сразу, а не «когда понадобится».

Понадобится сразу. Всё, что нужно от даты, — арифметика: разница, сдвиг, сравнение. Со строками ничего этого нет: "2026-03-01" - "2026-01-15" — ошибка.

Сравнение строк, правда, сработает: "2026-03-01" > "2026-01-15" — верно. Но верно оно случайно: ISO так устроен, что порядок строк совпадает с порядком дат. На 01.02.2026 то же сравнение молча соврёт.

Образ. Почтовый штемпель. Пока дата — оттиск на бумаге, это картинка; чтобы посчитать, сколько письмо шло, её надо прочитать.

Разница дат — это промежуток, а не число

между первым и последним: 45 days, 0:00:00
дней: 45

Вычитание двух дат даёт не число, а timedelta — промежуток. Печатается он по-своему, а число дней берут у него: .days. Рядом живут .seconds и .total_seconds(), когда важны часы и минуты.

Обратно тоже работает: days[-1] + timedelta(days=7) — дата через неделю. Прибавляют промежуток, а не «семь»: у дат нет сложения с числом, и это правильно, потому что «семь» — это семь чего.

Читает strptime, печатает strftime

moment = datetime.strptime("01.02.2026 09:30", "%d.%m.%Y %H:%M")
print(moment.strftime("%d.%m.%Y, %H:%M"))

Мнемоника простая: p — parse, разобрать текст в дату; f — format, напечатать дату текстом. Формат — строка с процентами: %d — день, %m — месяц, %Y — год из четырёх цифр, %H:%M — часы и минуты. Полный список — в документации.

Оба нужны, потому что у даты два адресата. В файл и в API дату пишут в ISO — moment.isoformat(), — и её прочитает кто угодно. Человеку показывают привычное 01.02.2026.

Есть и ловушка вежливости: %B даёт название месяца — но January, а не «январь», пока не настроена локаль системы. Поэтому в файле держат ISO, а название месяца, если оно нужно, собирают сами: у программы три языка, а локаль одна.

Формат, который читается двумя способами

чужой формат: time data '2026-02-01' does not match format '%d.%m.%Y'

Здесь Python сказал вслух: формат не подошёл. Но это везучий случай.

Невезучий — 01.02.2026. В Казахстане и России это первое февраля, в США — второе января. Обе даты существуют, обе разбираются без ошибки, и какая получится, решает не строка, а формат, который назвали вы. Назовёте не тот — не будет ни ошибки, ни предупреждения: будет другая дата. Такую ошибку находят через месяц, когда сходится не то.

Отсюда правило: в файлах и API — ISO 8601, 2026-02-01. Он однозначен, он сортируется как строка, и его понимает fromisoformat. «День.месяц.год» — это для экрана.

Наивное время и осознанное

наивное:    2026-02-01 09:30:00 | пояс: None
осознанное: 2026-02-01 09:30:00+00:00 | пояс: UTC

У datetime есть поле tzinfo, и оно бывает пустым. Пустое — это наивное время: девять тридцать, а где — неизвестно. Тот же самый None, что и в седьмом уроке, только теперь пропущен часовой пояс.

Наивное время не отвечает на вопрос «какое это мгновение». 9:30 в Алматы и 9:30 в Варшаве — разные моменты, между ними четыре часа. Пока время наивное, разница в четыре часа не видна — она просто не посчитана.

Осознанное время знает свой пояс, и его можно перевести: aware.astimezone(almaty) даёт то же мгновение, записанное по-алматински — 14:30. Мгновение одно, записи разные.

Отсюда попытка вычесть одно из другого:

одно из другого не вычесть: can't subtract offset-naive and offset-aware datetimes

Python отказывается — и это лучшее, что он мог сделать. Молчаливая разница в пять часов дороже громкой ошибки.

Правило, которое стоит запомнить целиком: хранят в UTC, показывают в местном. datetime.now() даёт наивное локальное время, datetime.now(timezone.utc) — осознанное; в файл и в базу пишут второе.

Пояс как смещение — timezone(timedelta(hours=5)) — знает только «плюс пять». Пояс по имени — ZoneInfo("Asia/Almaty") из модуля zoneinfo — знает ещё и историю переводов часов, а она в мире меняется чаще, чем кажется.

Чего в timedelta нет

Месяца и года. Промежуток измеряется в днях, часах, секундах — в том, что всегда одинаковой длины. «Месяц» одинаковой длины не бывает: он то 28 дней, то 31.

Прибавить месяц — значит сначала договориться, что делать с 31 января. Стандартная библиотека такого договора не навязывает; чужие библиотеки (dateutil) его предлагают вместе со своими правилами.

Карта урока

Карта урока: строка, промежуток и часовой пояс

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Почему дату превращают в date при разборе, а не оставляют строкой?
  2. Чем strptime отличается от strftime и как это запомнить?
  3. Что такое наивное время и почему Python не даёт вычесть его из осознанного?

Разминка

Три коротких шага перед заданием: предсказать, дописать, починить. Ответы — в конце урока, но сначала ответьте сами.

1. Предскажите. Что напечатает эта программа?

from datetime import date

a = date(2026, 3, 1)
b = date(2026, 1, 15)
print((a - b).days, a > b)

2. Заполните пропуск. Вместо ... поставьте формат, которым разбирается эта строка.

from datetime import datetime

moment = datetime.strptime("01.02.2026 09:30", ...)
print(moment.date())

3. Почините. В выгрузке из Казахстана 01.02.2026 — это первое февраля. Программа не падает, но печатает не тот день. Найдите причину.

from datetime import datetime

day = datetime.strptime("01.02.2026", "%m.%d.%Y")
print(day.date())

Задание

Обязательное. Дано:

rows = [
    ("2026-01-15", 520.0),
    ("2026-02-01", 546.5),
    ("2026-03-01", 498.0),
]
report = "02.03.2026 08:00"

Разберите даты замеров и напечатайте каждую в виде 15.01.2026: 520.0. Затем напечатайте, сколько дней между первым и последним замером; средний промежуток между замерами с двумя знаками; дату следующего замера, если он через тридцать дней после последнего. В конце разберите момент отчёта по формату день.месяц.год часы:минуты, объявите его временем Алматы (UTC+5) и напечатайте дважды: в ISO по Алматы и в UTC.

Ожидаемый вывод:

15.01.2026: 520.0
01.02.2026: 546.5
01.03.2026: 498.0
дней между первым и последним: 45
средний промежуток: 22.50 дня
следующий замер: 31.03.2026
отчёт по Алматы: 2026-03-02T08:00:00+05:00
он же в UTC:     2026-03-02T03:00:00+00:00

Готово, когда: вывод совпадает построчно; даты становятся date или datetime при разборе, а не остаются строками; средний промежуток делится на количество промежутков, а не замеров; момент отчёта получает пояс там же, где разобран, — иначе в UTC он приедет тем же числом.

На своих данных. Возьмите три своих даты — чеки, платежи, что угодно с числом на нём. Посчитайте, сколько дней между первым и последним, и напечатайте каждую дату в привычном виде. Одну из них запишите в чужом формате (15/01/2026 или 15 Jan 2026) и разберите её своим strptime.

По желанию.

  • Разберите 01.02.2026 двумя форматами — %d.%m.%Y и %m.%d.%Y — и напечатайте обе даты рядом.
  • Возьмите ZoneInfo("Asia/Almaty") вместо фиксированного смещения и сравните результат.
  • Попробуйте прибавить к дате timedelta(months=1) и прочитайте, что скажет Python.

Куда это встанет в проекте

У сводки появляется ось времени. Замеры перестают быть просто числами: между ними считаются дни, а «когда собрано» пишется в UTC — не потому, что так красивее, а потому, что сводка когда-нибудь поедет на сервер, и его часы стоят не по Алматы.

Долги. Прибавить месяц нам по-прежнему нечем, и переводы часов фиксированное смещение не знает. Первое решается договором, второе — zoneinfo; к обоим вернёмся, когда сводка начнёт работать по расписанию.

Ответы

Показать ответы

На вопросы

  1. Потому что дальше нужна арифметика: разница, сдвиг на неделю, сравнение. Строка ничего этого не умеет, а сравнение строк совпадает со сравнением дат только в ISO и только случайно.
  2. p — parse: strptime читает текст и делает datetime. f — format: strftime берёт datetime и делает текст. Первый нужен на входе, второй — на выходе.
  3. Наивное — это datetime, у которого tzinfo пуст: время без места. Вычесть его из осознанного нельзя, потому что неизвестно, сколько между ними часов; Python отказывается вслух, вместо того чтобы посчитать разницу с ошибкой в целый пояс.

К разминке

  1. 45 True. Разница дат — timedelta, у него спрашивают .days; сравнивать даты можно напрямую, и 1 марта позже 15 января.
45 True
  1. "%d.%m.%Y %H:%M". Формат описывает входную строку, а не то, что хочется получить: день, точка, месяц, точка, год, пробел, часы, двоеточие, минуты.
from datetime import datetime

moment = datetime.strptime("01.02.2026 09:30", "%d.%m.%Y %H:%M")
print(moment.date())
2026-02-01
  1. Формат назван американским: %m.%d.%Y — сначала месяц. Строка ему подошла, ошибки не было, и получилось второе января вместо первого февраля. Так и выглядит самая дорогая ошибка в датах: не падение, а другая дата.
from datetime import datetime

day = datetime.strptime("01.02.2026", "%d.%m.%Y")
print(day.date())
2026-02-01

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.