Изучение локализации с помощью Typer, Freezegun, Libfaketime и машины времени

Наши системы привязаны ко времени: когда произошло действие или событие, когда система узнала об этом и когда отреагировала на него.

Таким образом, подделка времени является важной функцией, которую мы используем в нашей повседневной жизни в разработке программного обеспечения / данных. В этой статье мы рассмотрим три способа подделки времени, а также с помощью Typer — самого простого конструктора интерфейса командной строки.

Предположим, у нас есть приложение, которое возвращает сообщение о том, является ли определенная дата последним днем ​​месяца или нет. Следующая реализация доступна на GitHub.

Какой последний день месяца

Сначала мы реализуем функцию, которая вводит параметр datetime и использует сопоставление структурных шаблонов в Python, чтобы определить, является ли datetime последним днем ​​месяца или нет на основе года. Например, он смотрит, високосный ли это год, февраль, нечетный или четный месяц, и день (28 или 29, 30 или 31). Вот код:

def is_last_day_of_month(input_datetime: date) -> bool:
    """
    :param input_datetime: date object
    :return:
        True, if datetime is last day of month
        False, otherwise
    """
    date_year = input_datetime.year
    date_month = input_datetime.month
    date_day = input_datetime.day
    match date_month:
        case odd_month if odd_month in [1, 3, 5, 7, 8, 10, 12]:
            match date_day:
                case 31:
                    return True
                case _:
                    return False
        case even_month if even_month in [4, 6, 9, 11]:
            match date_day:
                case 30:
                    return True
                case _:
                    return False
        case 2:
            match date_year % 4:
                case 0:
                    match date_day:
                        case 29:
                            return True
                        case _:
                            return False
                case _:
                    match date_day:
                        case 28:
                            return True
                        case _:
                            return False
        case _:
            raise ValueError(f"Unexpected values for {input_datetime} {date_year} {date_month} {date_day}")

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

import datetime

import pytest
from faking_time_demo.main import is_last_day_of_month

start_date = datetime.date(2023, 1, 1)

test_dates = [start_date + datetime.timedelta(days=no_days) for no_days in range(365)]

test_scenario_bool = [
    (
        date_,
        True
        if date_
        in [
            datetime.date(2023, 1, 31),
            datetime.date(2023, 2, 28),
            datetime.date(2023, 3, 31),
            datetime.date(2023, 4, 30),
            datetime.date(2023, 5, 31),
            datetime.date(2023, 6, 30),
            datetime.date(2023, 7, 31),
            datetime.date(2023, 8, 31),
            datetime.date(2023, 9, 30),
            datetime.date(2023, 10, 31),
            datetime.date(2023, 11, 30),
            datetime.date(2023, 12, 31),
        ]
        else False,
    )
    for date_ in test_dates
]
test_scenario_bool.append((datetime.date(2024, 2, 29), True))


@pytest.mark.parametrize(("input_datetime", "expected_result"), test_scenario_bool)
def test_is_last_day_of_month(input_datetime, expected_result):
    with freeze_time(input_datetime):
        assert is_last_day_of_month(input_datetime=input_datetime) is expected_result

В приведенном выше коде мы создали список с 366 датами (2023 плюс 29-02-2024 для проверки високосного года). При параметризации мы выполняем один и тот же тест для каждого элемента списка. Вот код:

(faking-time-demo-py3.11) acirtep ~/faking-time-demo [main] $ pytest tests/test_last_day.py::test_get_text_for_last_day 
================================================================================ test session starts ================================================================================
platform darwin -- Python 3.11.0, pytest-7.2.2, pluggy-1.0.0
collected 366 items                                                                                                                                                                 

tests/test_last_day.py ...................................................................................................................................................... [ 40%]
............................................................................................................................................................................. [ 88%]
...........................................                                                                                                                                   [100%]
================================================================================ 366 passed in 0.92s ================================================================================

Скажи мне, если это последний день месяца

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

def get_text_for_day(input_timezone: AvailableTimeZone = None):
    timezone_to_set = input_timezone or time.tzname[0]
    current_datetime = datetime.now(tz=pytz.timezone(timezone_to_set))
    is_last_day = is_last_day_of_month(current_datetime)
    typer.echo(typer.style(f"{current_datetime} is expressed in {timezone_to_set}"))
    if is_last_day:
        message = f"{current_datetime} is last day of the month"
        typer.echo(typer.style(message, fg=typer.colors.GREEN))
    else:
        message = f"{current_datetime} is NOT last day of the month"
        typer.echo(typer.style(message, fg=typer.colors.RED))
    return message


if __name__ == "__main__":
    typer.run(get_text_for_day)

В приведенном выше коде мы реализовали интерфейс командной строки типа со следующим определением:

(faking-time-demo-py3.11) acirtep ~/faking-time-demo [main] $python ./faking_time_demo/main.py --help              
Usage: main.py [OPTIONS]

Options:
  --input-timezone [UTC|Europe/Amsterdam|Europe/Bucharest|US/Eastern]
                                  [default: AvailableTimeZone.UTC]
  --help                          Show this message and exit.

И здесь мы использовали Enum с именем AvailableTimeZone в качестве аргумента, который принимает следующие четыре часовых пояса:

from enum import Enum

class AvailableTimeZone(str, Enum):
    UTC = "UTC"
    NETHERLANDS = "Europe/Amsterdam"
    ROMANIA = "Europe/Bucharest"
    NEW_YORK = "US/Eastern"

Приведенная выше команда напечатает сообщение зеленым или красным цветом, если текущий datetime является последним днем ​​месяца или нет.

(faking-time-demo-py3.11) acirtep ~/faking-time-demo [main] $ python ./faking_time_demo/main.py --input-timezone UTC
2023-03-27 15:38:26.323668+00:00 is NOT last day of the month

Время заморозки, секретное оружие

В то время как тестирование функции is_last_day_of_month было простым, поскольку datetime было входным параметром, тестирование get_text_for_day отличается тем, что оно использует datetime.now().

def test_get_text_for_day():
    message = get_text_for_day(input_timezone=AvailableTimeZone.UTC)
    assert "is NOT last day" in message

При написании приведенного выше определения я знал, что дата, когда я выполнил тест, не была последним днем, но будет ли это иметь место в будущих запусках? Неа. Это ненадежный тест, который провалится в конце любого месяца.

Итак, на помощь приходит freezegun, который замораживает время на основе нашего ввода.

Во-первых, нам нужно определить набор сценариев для проверки результата. Вот как это сделать:

test_scenario_message = [
    (
        date_,
        "is last day of the month"
        if date_
        in [
            datetime.date(2023, 1, 31),
            datetime.date(2023, 2, 28),
            datetime.date(2023, 3, 31),
            datetime.date(2023, 4, 30),
            datetime.date(2023, 5, 31),
            datetime.date(2023, 6, 30),
            datetime.date(2023, 7, 31),
            datetime.date(2023, 8, 31),
            datetime.date(2023, 9, 30),
            datetime.date(2023, 10, 31),
            datetime.date(2023, 11, 30),
            datetime.date(2023, 12, 31),
        ]
        else "is NOT last day of the month",
    )
    for date_ in test_dates
]
test_scenario_message.append((datetime.date(2024, 2, 29), "is last day of the month"))

С приведенными выше тестовыми данными и freeze_time мы можем запустить следующий тест и ожидать согласованности между запусками:

import pytest
from freezegun import freeze_time

from faking_time_demo.constants import AvailableTimeZone
from faking_time_demo.main import get_text_for_day

@pytest.mark.parametrize(("input_datetime", "expected_result"), test_scenario_message)
def test_get_text_for_day(input_datetime, expected_result):
    with freeze_time(input_datetime):
        message = get_text_for_day(input_timezone=AvailableTimeZone.UTC)
        assert expected_result in message

В приведенном выше коде freeze_time будет mock datetime с указанной датой. Следовательно, всякий раз, когда мы выполняем get_text_for_day, datetime.now() будет выполняться с датой, привязанной к входным данным, заданным для freeze_time.

Какая дата без часового пояса?

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

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

Давайте поэкспериментируем с приведенным выше интерфейсом командной строки на двух контейнерах Docker с разными часовыми поясами. Они настраиваются с помощью переменной env TZ в docker-compose. Для каждого значения, настроенного в AvailableTimeZone, создается сервис, как показано ниже:

services:
  faking_time_utc:
    container_name: faking_time_utc
    restart: on-failure
    build:
      context: .
      dockerfile: Dockerfile
    environment:
      - PYTHONPATH=/app
      - TZ=UTC
    volumes:
      - ./faking_time_demo:/app/faking_time_demo
      - ./tests:/app/tests
    command:
      - bash
      - -c
      - tail -f /dev/null
...

Выполните docker-compose up и четыре контейнера, доступных для тестирования. Вот код:

$ docker exec -it faking_time_est python /app/faking_time_demo/main.py 
2023-03-27 14:37:34.357152-05:00 is expressed in EST
2023-03-27 14:37:34.357152-05:00 is NOT last day of the month
$ docker exec -it faking_time_utc python /app/faking_time_demo/main.py 
2023-03-27 19:37:43.613762+00:00 is expressed in UTC
2023-03-27 19:37:43.613762+00:00 is NOT last day of the month
$ docker exec -it faking_time_ro python /app/faking_time_demo/main.py 
2023-03-27 22:40:05.918796+03:00 is expressed in EET
2023-03-27 22:40:05.918796+03:00 is NOT last day of the month
$ docker exec -it faking_time_nl python /app/faking_time_demo/main.py 
2023-03-27 21:40:13.214218+02:00 is expressed in CET
2023-03-27 21:40:13.214218+02:00 is NOT last day of the month

Проверяя тест функции get_text, мы видим, что она всегда проверяет часовой пояс UTC:

@pytest.mark.parametrize(("input_datetime", "expected_result"), test_scenario_message)
def test_get_text_for_day(input_datetime, expected_result):
    with freeze_time(input_datetime):
        message = get_text_for_day(input_timezone=AvailableTimeZone.UTC)
        assert expected_result in message

Это происходит потому, что datetime по умолчанию для freeze_time — это UTC. Если мы удалим значение по умолчанию input_timezone и выполним pytest для каждого контейнера, мы увидим, что в зависимости от часового пояса контейнера тесты прошли успешно для UTC, CET и EET и не прошли для EST.

$ docker exec -it faking_time_nl pytest -v -rP /app/tests/test_last_day.py::test_get_text_for_day
================================================================================================================================================= test session starts ==================================================================================================================================================
2023-01-01 01:00:00+01:00 is expressed in CET
2023-01-01 01:00:00+01:00 is NOT last day of the month
================================================================================================================================================== 1 passed in 0.08s ===================================================================================================================================================


$ docker exec -it faking_time_ro pytest -v -rP /app/tests/test_last_day.py::test_get_text_for_day
================================================================================================================================================= test session starts ==================================================================================================================================================
2023-01-01 02:00:00+02:00 is expressed in EET
2023-01-01 02:00:00+02:00 is NOT last day of the month
================================================================================================================================================== 1 passed in 0.07s ===================================================================================================================================================


$ docker exec -it faking_time_utc pytest -v -rP /app/tests/test_last_day.py::test_get_text_for_day
================================================================================================================================================= test session starts ==================================================================================================================================================
2023-01-01 00:00:00+00:00 is expressed in UTC
2023-01-01 00:00:00+00:00 is NOT last day of the month
================================================================================================================================================== 1 passed in 0.09s ===================================================================================================================================================


$ docker exec -it faking_time_est pytest -v -rP /app/tests/test_last_day.py::test_get_text_for_day
================================================================================================================================================= test session starts ==================================================================================================================================================
platform linux -- Python 3.11.0, pytest-7.2.2, pluggy-1.0.0 -- /usr/local/bin/python
cachedir: .pytest_cache
rootdir: /app
collected 1 item                                                                                                                                                                                                                                                                                                       

tests/test_last_day.py::test_get_text_for_day[input_datetime0-is NOT last day of the month] FAILED                                                                                                                                                                                                               [100%]

======================================================================================================================================================= FAILURES =======================================================================================================================================================
_________________________________________________________________________________________________________________________ test_get_text_for_day[input_datetime0-is NOT last day of the month] __________________________________________________________________________________________________________________________

input_datetime = datetime.date(2023, 1, 1), expected_result = 'is NOT last day of the month'

    @pytest.mark.parametrize(("input_datetime", "expected_result"), test_scenario_message[0:1])
    def test_get_text_for_day(input_datetime, expected_result):
        with freeze_time(input_datetime):
            message = get_text_for_day()
>           assert expected_result in message
E           AssertionError: assert 'is NOT last day of the month' in '2022-12-31 19:00:00-05:00 is last day of the month'

tests/test_last_day.py:87: AssertionError
------------------------------------------------------------------------------------------------------------------------------------------------- Captured stdout call -------------------------------------------------------------------------------------------------------------------------------------------------
2022-12-31 19:00:00-05:00 is expressed in EST
2022-12-31 19:00:00-05:00 is last day of the month
================================================================================================================================================== 1 failed in 0.09s ===================================================================================================================================================

Чтобы исправить вышеуказанную ошибку, нам нужно настроить смещение для freeze_time. Вот как это сделать:

def get_offset(tzname):
    match tzname:
        case "UTC":
            return 0
        case "EST":
            return 5
        case "CET":
            return -1
        case "EET":
            return -2
        case _:
            raise Exception("Unknown timezone")


@pytest.mark.parametrize(("input_datetime", "expected_result"), test_scenario_message[0:1])
def test_get_text_for_day(input_datetime, expected_result):
    with freeze_time(input_datetime, tz_offset=get_offset(time.tzname[0])):
        message = get_text_for_day()
        assert expected_result in message

Мы имитируем отметку времени системы с помощью приведенного выше кода, назначая смещение по сравнению с UTC. После повторного выполнения тестов получаем следующее:

$ docker exec -it faking_time_nl pytest -v -rP /app/tests/test_last_day.py::test_get_text_for_day 
================================================================================================================================================= test session starts ==================================================================================================================================================
2023-01-01 00:00:00+01:00 is expressed in CET
2023-01-01 00:00:00+01:00 is NOT last day of the month
================================================================================================================================================== 1 passed in 0.06s ===================================================================================================================================================


$ docker exec -it faking_time_ro pytest -v -rP /app/tests/test_last_day.py::test_get_text_for_day 
================================================================================================================================================= test session starts ==================================================================================================================================================
2023-01-01 00:00:00+02:00 is expressed in EET
2023-01-01 00:00:00+02:00 is NOT last day of the month
================================================================================================================================================== 1 passed in 0.06s ===================================================================================================================================================


$ docker exec -it faking_time_utc pytest -v -rP /app/tests/test_last_day.py::test_get_text_for_day
================================================================================================================================================= test session starts ==================================================================================================================================================
2023-01-01 00:00:00+00:00 is expressed in UTC
2023-01-01 00:00:00+00:00 is NOT last day of the month
================================================================================================================================================== 1 passed in 0.06s ===================================================================================================================================================


$ docker exec -it faking_time_est pytest -v -rP /app/tests/test_last_day.py::test_get_text_for_day
================================================================================================================================================= test session starts ==================================================================================================================================================
2023-01-01 00:00:00-05:00 is expressed in EST
2023-01-01 00:00:00-05:00 is NOT last day of the month
================================================================================================================================================== 1 passed in 0.06s ===================================================================================================================================================

Подделка системного времени

Хотя замораживание является мощной библиотекой для манипулирования временем в целях тестирования, может быть случай, когда вам также потребуется манипулировать системным временем во время выполнения. Для этого libfaketime можно установить в образ докера с помощью этой команды:

RUN apt-get update && \
      apt-get -y install libpq-dev python3-dev gcc libfaketime

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

root@c7e6aad858cc:/app# LD_PRELOAD=/usr/lib/aarch64-linux-gnu/faketime/libfaketime.so.1 FAKETIME="2023-09-29 22:00:00" python /app/faking_time_demo/main.py --input-timezone Europe/Amsterdam
2023-09-30 00:00:00+02:00 is expressed in AvailableTimeZone.NETHERLANDS
2023-09-30 00:00:00+02:00 is last day of the month

Вот результаты для остальных часовых поясов:

Хм, подождите минутку! Со временем заморозки смещение UTC для Амстердама составляет +1, а теперь с libfaketime — +2. Это потому, что мы также должны знать о переходе на летнее время. Если вам интересно разобраться в смещении UTC, рекомендую начать со страницы Википедии.

Альтернатива фризегану

Имея некоторые проблемы с тестированием UTC datetime с заморозкой, я обнаружил машину времени. Вместо использования freeze_time мы можем использовать time_machine.travel:

import time_machine


@pytest.mark.parametrize(("input_datetime", "expected_result"), test_scenario_message)
def test_get_text_for_day_time_machine(input_datetime, expected_result):
    input_datetime = input_datetime.replace(tzinfo=timezone(time.tzname[0]))
    with time_machine.travel(input_datetime):
        message = get_text_for_day()
        assert expected_result in message

Преимущество машины времени в том, что она быстрее фризгана, как описывает автор здесь. Скорость легко прослеживается и в наших демо-тестах: тесты с time_machine выполняются за 0,23 секунды, а с заморозкой за 1,06 секунды.

$ docker exec -it faking_time_est pytest /app/tests/test_last_day.py::test_get_text_for_day_freeze_gun 
================================================================================================================================================= test session starts ==================================================================================================================================================
platform linux -- Python 3.11.0, pytest-7.2.2, pluggy-1.0.0
rootdir: /app
plugins: time-machine-2.9.0
collected 366 items       
tests/test_last_day.py ......................................................................................................................................................................................................................................................................................... [ 76%]
.....................................................................................                                                                                                                                                                                                                            [100%]
================================================================================================================================================= 366 passed in 1.06s ==================================================================================================================================================

$ docker exec -it faking_time_est pytest /app/tests/test_last_day.py::test_get_text_for_day_time_machine
================================================================================================================================================= test session starts ==================================================================================================================================================
platform linux -- Python 3.11.0, pytest-7.2.2, pluggy-1.0.0
rootdir: /app
plugins: time-machine-2.9.0
collected 366 items                                                                                                                                                                                                                                                                                                    
tests/test_last_day.py ......................................................................................................................................................................................................................................................................................... [ 76%]
.....................................................................................                                                                                                                                                                                                                            [100%]
================================================================================================================================================= 366 passed in 0.23s ==================================================================================================================================================                                                                                                                                                                                                                                                                                             

Заключение

В этой статье мы рассмотрели простую реализацию функции, проверяющей, является ли определенное datetime последним днем ​​месяца, поэкспериментировали с временем заморозки, libfaketime и протестировали с разными часовыми поясами. Репозитории GitHub используемых утилит:

  1. Тайпер
  2. Заморозка
  3. Либфейктайм
  4. "Машина времени"

Код выше доступен на Faking-time-demo.

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

Статья впервые опубликована @ownyourdata.ai.