Практика · Тема 22 · Дати й час¶

Лекція: lecture.html · Тест: quiz.html · Домашнє завдання: homework.md

Наскрізний приклад той самий, що в лекції, — журнал замовлень невеликої крамниці. Замовлення №4417 оформлене 28 березня 2026 року о 14:05:09 за київським часом.

Що зробимо руками:

  1. розкладемо момент на поля й переконаємось, що обʼєкт незмінний;
  2. побачимо справжній TypeError при порівнянні наївного з обізнаним — і полагодимо його;
  3. переведемо одну мить у чотири пояси й доведемо assert-ом, що це та сама мить;
  4. відтворимо помилку, через яку прибрали utcnow();
  5. порахуємо вік у днях і різницю дат;
  6. упремося в те, що в timedelta немає місяців, і напишемо «плюс місяць» самі;
  7. виміряємо добу переходу на літній час — у ній 23 години;
  8. відформатуємо дату українською й розберемо рядки журналу назад;
  9. відсортуємо дати як рядки й побачимо, чому так робити не можна;
  10. напишемо власний підрахунок днів між датами й звіримо його з бібліотечним.

Усі дати тут зашиті числами. Жодного now() — тому зошит дає однаковий вивід при кожному запуску, і його результат можна перевіряти очима.

1 · Дані, з якими працюємо¶

Журнал прийшов текстом: кожен рядок — «номер;дата;час». Дати записані звичним нам форматом дд.мм.рррр, і саме він потім створить нам проблему при сортуванні.

In [1]:
рядки_журналу = [
    "4417;28.03.2026;14:05:09",
    "4418;02.11.2025;09:30:00",
    "4419;15.01.2026;23:45:12",
    "4420;31.12.2024;18:00:00",
    "4421;05.03.2026;07:15:40",
    "4422;09.07.2025;12:00:00",
]

print("рядків у журналі:", len(рядки_журналу))
for рядок in рядки_журналу:
    print(" ", рядок)
рядків у журналі: 6
  4417;28.03.2026;14:05:09
  4418;02.11.2025;09:30:00
  4419;15.01.2026;23:45:12
  4420;31.12.2024;18:00:00
  4421;05.03.2026;07:15:40
  4422;09.07.2025;12:00:00

2 · Три типи: date, time, datetime¶

date знає лише календар, time — лише циферблат, datetime — і те, і те. Зібрати перші два в третій допомагає datetime.combine.

In [2]:
from datetime import date, time, datetime

дата_замовлення = date(2026, 3, 28)
час_замовлення = time(14, 5, 9)
момент_замовлення = datetime.combine(дата_замовлення, час_замовлення)

print("date     :", дата_замовлення)
print("time     :", час_замовлення)
print("datetime :", момент_замовлення)
print("той самий datetime напряму:", datetime(2026, 3, 28, 14, 5, 9))
date     : 2026-03-28
time     : 14:05:09
datetime : 2026-03-28 14:05:09
той самий datetime напряму: 2026-03-28 14:05:09

Поля читаються через крапку, а сам обʼєкт незмінний¶

Спроба присвоїти полю значення дасть AttributeError. Щоб отримати змінений момент, роблять копію методом replace — оригінал при цьому лишається цілим.

In [3]:
print("рік    :", момент_замовлення.year)
print("місяць :", момент_замовлення.month)
print("день   :", момент_замовлення.day)
print("година :", момент_замовлення.hour)

# replace повертає НОВИЙ обʼєкт — тому оригінал можна безпечно передавати у функції
перенесене = момент_замовлення.replace(hour=18, minute=0, second=0)
print()
print("після replace :", перенесене)
print("оригінал      :", момент_замовлення, "— не змінився")

assert момент_замовлення.hour == 14, "replace не мав чіпати оригінал!"
print("✅ оригінал справді незмінний")
рік    : 2026
місяць : 3
день   : 28
година : 14

після replace : 2026-03-28 18:00:00
оригінал      : 2026-03-28 14:05:09 — не змінився
✅ оригінал справді незмінний

3 · Наївний проти обізнаного¶

У наївного моменту поле tzinfo порожнє. Обʼєкт знає, що написано на циферблаті, і не знає, чий це циферблат.

In [4]:
наївний = datetime(2026, 3, 28, 14, 5, 9)

print("наївний момент :", наївний)
print("його tzinfo    :", наївний.tzinfo)
print("чи наївний     :", наївний.tzinfo is None)
наївний момент : 2026-03-28 14:05:09
його tzinfo    : None
чи наївний     : True

Тепер зробимо обізнаний — і спробуємо їх порівняти¶

Наступна клітинка навмисно падає. Це та сама помилка, заради якої написано половину лекції: порівняти напис без адреси з конкретною миттю неможливо.

In [5]:
from zoneinfo import ZoneInfo

# ZoneInfo читає системну базу поясів IANA.
# Якщо на твоїй машині її немає (типово для Windows) — постав пакет: pip install tzdata
київ = ZoneInfo("Europe/Kyiv")
обізнаний = datetime(2026, 3, 28, 14, 5, 9, tzinfo=київ)

print("обізнаний момент :", обізнаний)
print("його tzinfo      :", обізнаний.tzinfo)
print("зміщення від UTC :", обізнаний.utcoffset())
обізнаний момент : 2026-03-28 14:05:09+02:00
його tzinfo      : Europe/Kyiv
зміщення від UTC : 2:00:00
In [6]:
# навмисна помилка: порівняння наївного з обізнаним
наївний < обізнаний
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[6], line 2
      1 # навмисна помилка: порівняння наївного з обізнаним
----> 2 наївний < обізнаний

TypeError: can't compare offset-naive and offset-aware datetimes

Та сама операція під try — і тиха пастка поруч¶

Оператор < чесно зупиняє програму. А от == помилки не кидає: він мовчки повертає False. Саме через це перевірка на рівність із наївним моментом ніколи не спрацьовує і ніяк про це не повідомляє.

In [7]:
try:
    наївний < обізнаний
except TypeError as помилка:
    print("TypeError:", помилка)

# а тут помилки не буде — і це найгірше
print()
print("наївний == обізнаний ->", наївний == обізнаний)
print("хоча на циферблатах у них однакові 14:05:09")

assert (наївний == обізнаний) is False, "рівність із наївним не мала спрацювати"
print("✅ пастка відтворена: мовчазний False замість помилки")
TypeError: can't compare offset-naive and offset-aware datetimes

наївний == обізнаний -> False
хоча на циферблатах у них однакові 14:05:09
✅ пастка відтворена: мовчазний False замість помилки

4 · Одна мить у чотирьох поясах¶

astimezone не змінює момент — він переписує ту саму мить іншим циферблатом. Доведемо це assert-ом: unix-час усіх чотирьох записів мусить збігатися до секунди.

In [8]:
пояси = {
    "Київ":      ZoneInfo("Europe/Kyiv"),
    "Лондон":    ZoneInfo("Europe/London"),
    "Токіо":     ZoneInfo("Asia/Tokyo"),
    "Нью-Йорк":  ZoneInfo("America/New_York"),
}

for місто, пояс in пояси.items():
    місцевий = обізнаний.astimezone(пояс)
    print(f"{місто:<10} {місцевий}")

# головна перевірка: усі чотири записи позначають ОДНУ мить
мітки = [обізнаний.astimezone(пояс).timestamp() for пояс in пояси.values()]
assert len(set(мітки)) == 1, "переведення в пояс не має зсувати мить!"
print()
print("✅ unix-час усіх чотирьох записів однаковий:", мітки[0])
Київ       2026-03-28 14:05:09+02:00
Лондон     2026-03-28 12:05:09+00:00
Токіо      2026-03-28 21:05:09+09:00
Нью-Йорк   2026-03-28 08:05:09-04:00

✅ unix-час усіх чотирьох записів однаковий: 1774699509.0

5 · Чому прибрали utcnow()¶

datetime.utcnow() повертав наївний обʼєкт із часом за UTC — тобто мовчав про найважливіше. Відтворимо помилку без виклику самого методу: візьмімо два наївні моменти, які насправді позначають одну й ту саму мить.

In [9]:
# 15 червня 2026, 09:00 UTC — це рівно 12:00 у Києві (влітку +03:00)
так_дав_би_utcnow = datetime(2026, 6, 15, 9, 0)    # наївний, у полях час за UTC
так_дав_би_now = datetime(2026, 6, 15, 12, 0)      # наївний, у полях київський час

# обидва наївні, тому Python порівнює їх БЕЗ помилки — просто зіставляє цифри
різниця = так_дав_би_now - так_дав_би_utcnow
print("Python каже, що між ними:", різниця)
print("а насправді це одна й та сама мить")
Python каже, що між ними: 3:00:00
а насправді це одна й та сама мить
In [10]:
from datetime import timezone

# правильний спосіб: обидва моменти обізнані
правильно_utc = datetime(2026, 6, 15, 9, 0, tzinfo=timezone.utc)
правильно_київ = datetime(2026, 6, 15, 12, 0, tzinfo=ZoneInfo("Europe/Kyiv"))

print("UTC-варіант   :", правильно_utc)
print("Київський     :", правильно_київ)
print("справжня різниця:", правильно_київ - правильно_utc)

assert правильно_київ == правильно_utc, "це мала бути одна й та сама мить"
print("✅ з поясами Python бачить, що це одна мить")
UTC-варіант   : 2026-06-15 09:00:00+00:00
Київський     : 2026-06-15 12:00:00+03:00
справжня різниця: 0:00:00
✅ з поясами Python бачить, що це одна мить

6 · Вік у днях і різниця дат¶

Відніми одну дату від іншої — отримаєш timedelta. Поле .days дає повні доби. Дату «сьогодні» беремо фіксовану: інакше зошит давав би різний результат щодня.

In [11]:
дата_народження = date(2010, 9, 14)
умовне_сьогодні = date(2026, 3, 28)      # фіксуємо замість date.today()

прожито = умовне_сьогодні - дата_народження
print("тип різниці двох дат:", type(прожито).__name__)
print("прожито діб         :", прожито.days)
print("це приблизно років  :", round(прожито.days / 365.2425, 2))

assert прожито.days == 5674, "перевір арифметику дат"
print("✅ вік у днях порахований")
тип різниці двох дат: timedelta
прожито діб         : 5674
це приблизно років  : 15.53
✅ вік у днях порахований

7 · У timedelta немає місяців¶

Наступна клітинка навмисно падає. Місяць не має сталої довжини — 28, 29, 30 або 31 день — тому «плюс місяць» не є проміжком часу.

In [12]:
from datetime import timedelta

timedelta(months=1)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[12], line 3
      1 from datetime import timedelta
----> 3 timedelta(months=1)

TypeError: 'months' is an invalid keyword argument for __new__()

Отже «плюс місяць» доводиться описати самому¶

І одразу стає видно, чому цього немає в стандартній бібліотеці: доводиться самому вирішити, що робити з 31 січня. Ми оберемо найпоширеніше правило — обрізати день до останнього наявного в цільовому місяці.

In [13]:
import calendar


def додати_місяць(дата):
    """Той самий день наступного місяця; якщо такого дня немає — останній наявний."""
    місяць = дата.month + 1
    рік = дата.year
    if місяць > 12:                     # грудень переносить нас у наступний рік
        місяць = 1
        рік += 1
    # monthrange повертає (день тижня 1-го числа, кількість днів у місяці)
    останній_день = calendar.monthrange(рік, місяць)[1]
    return дата.replace(year=рік, month=місяць, day=min(дата.day, останній_день))


for проба in [date(2026, 1, 31), date(2024, 1, 31), date(2026, 3, 28), date(2026, 12, 15)]:
    print(проба, "+ місяць ->", додати_місяць(проба))

# 2024-й високосний, тому 31 січня їде на 29 лютого, а не на 28
assert додати_місяць(date(2024, 1, 31)) == date(2024, 2, 29)
assert додати_місяць(date(2026, 1, 31)) == date(2026, 2, 28)
print()
print("✅ обрізання дня працює і у високосний рік, і у звичайний")
2026-01-31 + місяць -> 2026-02-28
2024-01-31 + місяць -> 2024-02-29
2026-03-28 + місяць -> 2026-04-28
2026-12-15 + місяць -> 2027-01-15

✅ обрізання дня працює і у високосний рік, і у звичайний

8 · Доба, у якій 23 години¶

29 березня 2026 року Київ переводить годинники: о третій ночі стрілки стрибають на четверту. Порівняємо два способи «додати добу» — і побачимо, що вони дають різні відповіді.

In [14]:
старт = datetime(2026, 3, 28, 14, 5, tzinfo=київ)

# спосіб 1 — настінна арифметика: додаємо до полів, циферблат лишається на 14:05
завтра_о_тій_самій_порі = старт + timedelta(days=1)

# спосіб 2 — фізичний час: рахуємо в UTC, де переходів не буває
через_добу = (старт.astimezone(timezone.utc) + timedelta(hours=24)).astimezone(київ)

print("старт                      :", старт)
print("спосіб 1 (+timedelta у поясі):", завтра_о_тій_самій_порі)
print("спосіб 2 (+24 години в UTC)  :", через_добу)
старт                      : 2026-03-28 14:05:00+02:00
спосіб 1 (+timedelta у поясі): 2026-03-29 14:05:00+03:00
спосіб 2 (+24 години в UTC)  : 2026-03-29 15:05:00+03:00
In [15]:
# скільки часу минуло НАСПРАВДІ — рахуємо в UTC, інакше відповідь буде настінною
def справжня_тривалість(від, до):
    """Фізичний час між двома обізнаними моментами, без впливу переходів."""
    return до.astimezone(timezone.utc) - від.astimezone(timezone.utc)


# total_seconds() зручніший за друк timedelta: «1 day» і «23:00:00» на око не порівняти
print("спосіб 1 — минуло годин:", справжня_тривалість(старт, завтра_о_тій_самій_порі).total_seconds() / 3600)
print("спосіб 2 — минуло годин:", справжня_тривалість(старт, через_добу).total_seconds() / 3600)
print()
print("а ось наївне віднімання в одному поясі бреше:")
print("  завтра_о_тій_самій_порі - старт =", завтра_о_тій_самій_порі - старт)

assert справжня_тривалість(старт, завтра_о_тій_самій_порі) == timedelta(hours=23)
assert справжня_тривалість(старт, через_добу) == timedelta(hours=24)
assert завтра_о_тій_самій_порі - старт == timedelta(days=1)   # ось вона, настінна відповідь
print()
print("✅ доба переходу справді має 23 години")
спосіб 1 — минуло годин: 23.0
спосіб 2 — минуло годин: 24.0

а ось наївне віднімання в одному поясі бреше:
  завтра_о_тій_самій_порі - старт = 1 day, 0:00:00

✅ доба переходу справді має 23 години

9 · Форматування: strftime¶

Коди %B і %A дають назви місяця й дня тижня мовою локалі процесу — за замовчуванням англійською. Українські назви надійніше зробити своїм списком: результат тоді не залежить від налаштувань чужого сервера.

In [16]:
шаблони = [
    "%Y-%m-%d",
    "%d.%m.%Y",
    "%H:%M",
    "%Y-%m-%d %H:%M:%S",
    "%d.%m.%Y о %H:%M",
]

for шаблон in шаблони:
    print(f"{шаблон:<22} -> {момент_замовлення.strftime(шаблон)}")

print()
print("залежить від локалі:", момент_замовлення.strftime("%A, %d %B %Y"))
%Y-%m-%d               -> 2026-03-28
%d.%m.%Y               -> 28.03.2026
%H:%M                  -> 14:05
%Y-%m-%d %H:%M:%S      -> 2026-03-28 14:05:09
%d.%m.%Y о %H:%M       -> 28.03.2026 о 14:05

залежить від локалі: Saturday, 28 March 2026
In [17]:
МІСЯЦІ_УКР = ["січня", "лютого", "березня", "квітня", "травня", "червня",
              "липня", "серпня", "вересня", "жовтня", "листопада", "грудня"]
ДНІ_УКР = ["понеділок", "вівторок", "середа", "четвер", "пʼятниця", "субота", "неділя"]


def українською(момент):
    """Дата словами — без залежності від локалі системи."""
    назва_місяця = МІСЯЦІ_УКР[момент.month - 1]
    назва_дня = ДНІ_УКР[момент.weekday()]     # weekday(): понеділок = 0
    return f"{момент.day} {назва_місяця} {момент.year}, {назва_дня}"


print(українською(момент_замовлення))
print(українською(datetime(2026, 12, 31, 23, 59)))

assert українською(момент_замовлення) == "28 березня 2026, субота"
print()
print("✅ українські назви не залежать від локалі")
28 березня 2026, субота
31 грудня 2026, четвер

✅ українські назви не залежать від локалі

10 · Розбір рядків журналу: strptime¶

Дзеркальна операція. Шаблон той самий, але тепер він описує, що ми очікуємо побачити в рядку. Якщо рядок не збігається з шаблоном — буде ValueError.

In [18]:
def розібрати_запис(рядок):
    """Перетворює "номер;дд.мм.рррр;гг:хх:сс" на пару (номер, обізнаний момент)."""
    номер, текст_дати, текст_часу = рядок.split(";")
    момент = datetime.strptime(f"{текст_дати} {текст_часу}", "%d.%m.%Y %H:%M:%S")
    # журнал вели за київським часом — дописуємо пояс, поки момент не поїхав далі
    return номер, момент.replace(tzinfo=київ)


замовлення = [розібрати_запис(рядок) for рядок in рядки_журналу]

for номер, момент in замовлення:
    print(f"№{номер}  {момент}")

assert len(замовлення) == 6
assert замовлення[0][1] == datetime(2026, 3, 28, 14, 5, 9, tzinfo=київ)
print()
print("✅ усі шість рядків розібрано")
№4417  2026-03-28 14:05:09+02:00
№4418  2025-11-02 09:30:00+02:00
№4419  2026-01-15 23:45:12+02:00
№4420  2024-12-31 18:00:00+02:00
№4421  2026-03-05 07:15:40+02:00
№4422  2025-07-09 12:00:00+03:00

✅ усі шість рядків розібрано
In [19]:
# а тепер рядок із чужого журналу — там дата в американському порядку
битий_рядок = "4423;03/04/2026;10:00:00"

try:
    розібрати_запис(битий_рядок)
except ValueError as помилка:
    print("ValueError:", помилка)

print()
print("І найгірше: неоднозначний рядок розберуть ОБИДВА шаблони, без помилки —")
print("  як %d/%m/%Y ->", datetime.strptime("03/04/2026", "%d/%m/%Y").date())
print("  як %m/%d/%Y ->", datetime.strptime("03/04/2026", "%m/%d/%Y").date())
print("Це і є класична причина мовчазного псування дат при імпорті чужих таблиць.")
ValueError: time data '03/04/2026 10:00:00' does not match format '%d.%m.%Y %H:%M:%S'

І найгірше: неоднозначний рядок розберуть ОБИДВА шаблони, без помилки —
  як %d/%m/%Y -> 2026-04-03
  як %m/%d/%Y -> 2026-03-04
Це і є класична причина мовчазного псування дат при імпорті чужих таблиць.

11 · ISO 8601: формат для зберігання¶

isoformat() і fromisoformat() працюють без жодних шаблонів і зберігають пояс. Перевіримо, що обіг «обʼєкт → рядок → обʼєкт» повертає рівно те саме.

In [20]:
рядок_iso = обізнаний.isoformat()
назад = datetime.fromisoformat(рядок_iso)

print("обʼєкт      :", обізнаний)
print("isoformat() :", рядок_iso)
print("назад       :", назад)

assert назад == обізнаний, "обіг через ISO мав повернути той самий момент"
print()
print("✅ ISO-обіг без втрат — на відміну від %d.%m.%Y, де зникає пояс і секунди")

# з Python 3.11 читається і скорочений запис UTC літерою Z
print("літера Z теж читається:", datetime.fromisoformat("2026-03-28T12:05:09Z"))
обʼєкт      : 2026-03-28 14:05:09+02:00
isoformat() : 2026-03-28T14:05:09+02:00
назад       : 2026-03-28 14:05:09+02:00

✅ ISO-обіг без втрат — на відміну від %d.%m.%Y, де зникає пояс і секунди
літера Z теж читається: 2026-03-28 12:05:09+00:00

12 · Чому дати не сортують як рядки¶

Сортування рядків посимвольне: воно порівнює перший символ, при рівності — другий. У записі 28.03.2026 першим стоїть день місяця — тому весь журнал вишикується за днем, а роки перемішаються.

In [21]:
дати_текстом = [рядок.split(";")[1] for рядок in рядки_журналу]
дати_обʼєктами = [момент.date() for _, момент in замовлення]

print("як записано в журналі:", дати_текстом)
print()
print("sorted() над рядками :", sorted(дати_текстом))
print("а правильний порядок :", [d.strftime('%d.%m.%Y') for d in sorted(дати_обʼєктами)])
як записано в журналі: ['28.03.2026', '02.11.2025', '15.01.2026', '31.12.2024', '05.03.2026', '09.07.2025']

sorted() над рядками : ['02.11.2025', '05.03.2026', '09.07.2025', '15.01.2026', '28.03.2026', '31.12.2024']
а правильний порядок : ['31.12.2024', '09.07.2025', '02.11.2025', '15.01.2026', '05.03.2026', '28.03.2026']
In [22]:
# ISO-рядки — єдиний формат дати, який можна сортувати як текст
дати_iso = [d.isoformat() for d in дати_обʼєктами]

порядок_за_рядками_iso = sorted(дати_iso)
порядок_за_обʼєктами = [d.isoformat() for d in sorted(дати_обʼєктами)]

print("sorted() над ISO-рядками :", порядок_за_рядками_iso)
print("sorted() над обʼєктами   :", порядок_за_обʼєктами)

assert порядок_за_рядками_iso == порядок_за_обʼєктами, "ISO-рядки мали збігтися з датами"
assert sorted(дати_текстом) != [d.strftime('%d.%m.%Y') for d in sorted(дати_обʼєктами)]
print()
print("✅ ISO збігається з хронологією, дд.мм.рррр — ні")
sorted() над ISO-рядками :
 ['2024-12-31', '2025-07-09', '2025-11-02', '2026-01-15', '2026-03-05', '2026-03-28']
sorted() над обʼєктами   : ['2024-12-31', '2025-07-09', '2025-11-02', '2026-01-15', '2026-03-05', '2026-03-28']

✅ ISO збігається з хронологією, дд.мм.рррр — ні

13 · Наша реалізація проти бібліотечної¶

Найцікавіше наостанок. Порахуємо кількість днів між двома датами самі — через григоріанський календар, без жодного timedelta — і звіримо з тим, що дає бібліотека. Усередині date немає магії: там рівно ця арифметика.

In [23]:
ДОВЖИНИ_МІСЯЦІВ = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]


def високосний(рік):
    """Григоріанське правило трьох поділів."""
    return рік % 4 == 0 and (рік % 100 != 0 or рік % 400 == 0)


def днів_від_початку_ери(рік, місяць, день):
    """Скільки повних діб минуло від 1 січня 1 року до цієї дати."""
    # 1. усі повні роки до поточного: 365 днів кожен плюс високосні дні
    повних_років = рік - 1
    діб = (повних_років * 365
           + повних_років // 4          # раз на чотири роки додаємо день
           - повних_років // 100        # але не в роки, кратні 100
           + повних_років // 400)       # хіба що вони кратні ще й 400
    # 2. повні місяці цього року
    for номер_місяця in range(1, місяць):
        діб += ДОВЖИНИ_МІСЯЦІВ[номер_місяця - 1]
        if номер_місяця == 2 and високосний(рік):
            діб += 1
    # 3. повні дні цього місяця
    діб += день - 1
    return діб


print("1 січня 1 року         ->", днів_від_початку_ери(1, 1, 1))
print("1 січня 1970 року      ->", днів_від_початку_ери(1970, 1, 1))
print("28 березня 2026 року   ->", днів_від_початку_ери(2026, 3, 28))
1 січня 1 року         -> 0
1 січня 1970 року      -> 719162
28 березня 2026 року   -> 739702
In [24]:
def днів_між(перша, друга):
    """Наш власний підрахунок різниці двох дат, без timedelta."""
    return (днів_від_початку_ери(друга.year, друга.month, друга.day)
            - днів_від_початку_ери(перша.year, перша.month, перша.day))


пари_для_перевірки = [
    (date(2010, 9, 14), date(2026, 3, 28)),    # вік із розділу 6
    (date(2024, 2, 28), date(2024, 3, 1)),     # через 29 лютого високосного року
    (date(2026, 2, 28), date(2026, 3, 1)),     # той самий проміжок, але рік звичайний
    (date(1899, 12, 31), date(1900, 3, 1)),    # через 1900-й: він НЕ високосний
    (date(1999, 12, 31), date(2000, 3, 1)),    # через 2000-й: а він високосний
]

for перша, друга in пари_для_перевірки:
    наш = днів_між(перша, друга)
    бібліотечний = (друга - перша).days
    print(f"{перша} → {друга}: наш {наш:>5}, бібліотека {бібліотечний:>5}")
    assert наш == бібліотечний, "розрахунок розійшовся!"

print()
print("✅ збігається — усередині date рівно ця арифметика, без магії")
2010-09-14 → 2026-03-28: наш  5674, бібліотека  5674
2024-02-28 → 2024-03-01: наш     2, бібліотека     2
2026-02-28 → 2026-03-01: наш     1, бібліотека     1
1899-12-31 → 1900-03-01: наш    60, бібліотека    60
1999-12-31 → 2000-03-01: наш    61, бібліотека    61

✅ збігається — усередині date рівно ця арифметика, без магії
In [25]:
# і остаточна перевірка на широкому діапазоні: кожна дата з 1900 по 2100 рік
початок = date(1900, 1, 1)
розбіжностей = 0
проба = початок
while проба <= date(2100, 12, 31):
    if днів_між(початок, проба) != (проба - початок).days:
        розбіжностей += 1
    проба += timedelta(days=1)

print("перевірено дат:", (date(2100, 12, 31) - початок).days + 1)
print("розбіжностей  :", розбіжностей)

assert розбіжностей == 0, "наша формула розійшлася з бібліотечною"
print("✅ 200 років поспіль — жодної розбіжності")
перевірено дат: 73414
розбіжностей  : 0
✅ 200 років поспіль — жодної розбіжності

14 · Що ми з'ясували¶

  • наївний момент — це напис без адреси; порівняти його з обізнаним не можна, а == при цьому мовчки повертає False;
  • astimezone не рухає мить, а лише переписує її іншим циферблатом — це підтвердив однаковий unix-час у чотирьох поясах;
  • у timedelta немає місяців, бо в місяця немає сталої довжини; «плюс місяць» довелося описати самим і самим вирішити, що робити з 31 січня;
  • доба переходу на літній час має 23 години, і два різні способи «додати добу» дають різні відповіді — обидві правильні;
  • ISO-рядки сортуються як дати, дд.мм.рррр — ні;
  • усередині date немає магії: наш підрахунок днів збігся з бібліотечним на кожній з 73 414 дат.

Завдання трьох рівнів¶

🟢 Рівень 1 — База¶

Додай у журнал ще три записи зі своїми датами й порахуй, скільки днів минуло між найранішим і найпізнішим замовленням. Виведи обидві дати українською через функцію українською.

Зроблено, якщо: число днів збігається з (макс - мін).days, а обидві дати надруковані словами.

🟡 Рівень 2 — Плюс¶

Напиши функцію робочих_днів(перша, друга), яка рахує кількість буднів (понеділок–пʼятниця) між двома датами. Перевір її assert-ом на тижні, який ти можеш порахувати руками, і на проміжку, що містить 29 лютого 2024 року.

Зроблено, якщо: обидва assert проходять, а функція правильно поводиться, коли перша дата пізніша за другу.

🔴 Рівень 3 — Виклик¶

Знайди всі моменти переходу на літній час у поясі Europe/Kyiv за 2020–2030 роки, не заглядаючи в довідники: перебирай години в UTC і лови зміну utcoffset(). Для кожного переходу надрукуй дату, місцевий час до та після, і довжину тієї доби в годинах.

Зроблено, якщо: знайдено рівно 22 переходи (по два на рік), у весняних добах 23 години, в осінніх — 25, і жодна дата не зашита в коді руками.

Підказки¶

  • date.weekday() дає 0 для понеділка й 6 для неділі — будні це < 5.
  • Щоб пройти рік по годинах, зручно почати з datetime(рік, 1, 1, tzinfo=timezone.utc) і додавати timedelta(hours=1), щоразу дивлячись на .astimezone(київ).utcoffset().
  • Довжину місцевої доби рахують як різницю двох опівночей у UTC: саме тому вона й виходить не завжди 24 години.