Изучение локализации с помощью 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 используемых утилит:
- Тайпер
- Заморозка
- Либфейктайм
- "Машина времени"
Код выше доступен на Faking-time-demo.
В качестве последнего совета обычно рекомендуется сохранять метки времени в их значении UTC или использовать специальные типы, такие как timestamp
с часовым поясом, согласно Postgres.
Статья впервые опубликована @ownyourdata.ai.