Refactor timezone handling, raise if fucked on startup

This commit is contained in:
2025-01-26 15:51:08 +01:00
parent 97bb8b760f
commit 9a629ce773
4 changed files with 67 additions and 76 deletions

View File

@ -1,40 +1,32 @@
from __future__ import annotations from __future__ import annotations
import datetime import datetime
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError import os
from zoneinfo import ZoneInfo
import dateparser import dateparser
from loguru import logger from loguru import logger
from discord_reminder_bot.settings import get_timezone
def parse_time(date_to_parse: str | None, timezone: str | None = os.getenv("TIMEZONE")) -> datetime.datetime | None:
def parse_time(date_to_parse: str | None, timezone: str | None = None, use_dotenv: bool = True) -> datetime.datetime | None: # noqa: FBT001, FBT002
"""Parse a date string into a datetime object. """Parse a date string into a datetime object.
Args: Args:
date_to_parse(str): The date string to parse. date_to_parse(str): The date string to parse.
timezone(str, optional): The timezone to use. Defaults timezone from settings. timezone(str, optional): The timezone to use. Defaults timezone from settings.
use_dotenv(bool, optional): Whether to load environment variables from a .env file. Defaults to True
Returns: Returns:
datetime.datetime: The parsed datetime object. datetime.datetime: The parsed datetime object.
""" """
logger.info(f"Parsing date: '{date_to_parse}' with timezone: '{timezone}'")
if not date_to_parse: if not date_to_parse:
logger.error("No date provided to parse.") logger.error("No date provided to parse.")
return None return None
if not timezone: if not timezone:
timezone = get_timezone(use_dotenv) logger.error("No timezone provided to parse date.")
return None
# Check if the timezone is valid logger.info(f"Parsing date: '{date_to_parse}' with timezone: '{timezone}'")
try:
tz = ZoneInfo(timezone)
except (ZoneInfoNotFoundError, ModuleNotFoundError):
logger.error(f"Invalid timezone provided: '{timezone}'. Using {get_timezone(use_dotenv)} instead.")
tz = ZoneInfo("UTC")
try: try:
parsed_date: datetime.datetime | None = dateparser.parse( parsed_date: datetime.datetime | None = dateparser.parse(
@ -43,10 +35,11 @@ def parse_time(date_to_parse: str | None, timezone: str | None = None, use_doten
"PREFER_DATES_FROM": "future", "PREFER_DATES_FROM": "future",
"TIMEZONE": f"{timezone}", "TIMEZONE": f"{timezone}",
"RETURN_AS_TIMEZONE_AWARE": True, "RETURN_AS_TIMEZONE_AWARE": True,
"RELATIVE_BASE": datetime.datetime.now(tz=tz), "RELATIVE_BASE": datetime.datetime.now(tz=ZoneInfo(str(timezone))),
}, },
) )
except (ValueError, TypeError): except (ValueError, TypeError) as e:
logger.error(f"Failed to parse date: '{date_to_parse}' with timezone: '{timezone}'. Error: {e}")
return None return None
logger.debug(f"Parsed date: {parsed_date} from '{date_to_parse}'") logger.debug(f"Parsed date: {parsed_date} from '{date_to_parse}'")

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import pytz import pytz
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
@ -24,15 +25,26 @@ def get_settings(use_dotenv: bool = True) -> dict[str, str | dict[str, SQLAlchem
load_dotenv(verbose=True) load_dotenv(verbose=True)
sqlite_location: str = os.getenv("SQLITE_LOCATION", default="/jobs.sqlite") sqlite_location: str = os.getenv("SQLITE_LOCATION", default="/jobs.sqlite")
config_timezone: str = os.getenv("TIMEZONE", default="UTC")
bot_token: str = os.getenv("BOT_TOKEN", default="")
log_level: str = os.getenv("LOG_LEVEL", default="INFO") log_level: str = os.getenv("LOG_LEVEL", default="INFO")
webhook_url: str = os.getenv("WEBHOOK_URL", default="") webhook_url: str = os.getenv("WEBHOOK_URL", default="")
bot_token: str = os.getenv("BOT_TOKEN", default="")
if not bot_token: if not bot_token:
msg = "Missing bot token. Please set the BOT_TOKEN environment variable." msg = "Missing bot token. Please set the BOT_TOKEN environment variable."
raise ValueError(msg) raise ValueError(msg)
config_timezone: str | None = os.getenv("TIMEZONE")
if not config_timezone:
msg = "Missing timezone. Please set the TIMEZONE environment variable."
raise ValueError(msg)
# Test if the timezone is valid
try:
ZoneInfo(config_timezone)
except (ZoneInfoNotFoundError, ModuleNotFoundError) as e:
msg: str = f"Invalid timezone: {config_timezone}. Error: {e}"
raise ValueError(msg) from e
jobstores: dict[str, SQLAlchemyJobStore] = {"default": SQLAlchemyJobStore(url=f"sqlite://{sqlite_location}")} jobstores: dict[str, SQLAlchemyJobStore] = {"default": SQLAlchemyJobStore(url=f"sqlite://{sqlite_location}")}
job_defaults: dict[str, bool] = {"coalesce": True} job_defaults: dict[str, bool] = {"coalesce": True}
scheduler = AsyncIOScheduler( scheduler = AsyncIOScheduler(
@ -128,29 +140,3 @@ def get_webhook_url(use_dotenv: bool = True) -> str: # noqa: FBT001, FBT002
msg = "The webhook URL is missing from the settings." msg = "The webhook URL is missing from the settings."
raise KeyError(msg) raise KeyError(msg)
def get_timezone(use_dotenv: bool = True) -> str: # noqa: FBT001, FBT002
"""Return the timezone.
Args:
use_dotenv (bool, optional): Whether to load environment variables from a .env file. Defaults to True
Raises:
TypeError: If the timezone is not a string.
KeyError: If the timezone is missing from the settings.
Returns:
str: The timezone.
"""
settings: dict[str, str | dict[str, SQLAlchemyJobStore] | dict[str, bool] | AsyncIOScheduler] = get_settings(use_dotenv)
if config_timezone := settings.get("config_timezone"):
if not isinstance(config_timezone, str):
msg = "The timezone is not a string."
raise TypeError(msg)
return config_timezone
msg = "The timezone is missing from the settings."
raise KeyError(msg)

View File

@ -1,57 +1,68 @@
from __future__ import annotations from __future__ import annotations
import datetime import zoneinfo
from zoneinfo import ZoneInfo from typing import TYPE_CHECKING
from freezegun import freeze_time import pytest
from discord_reminder_bot import settings
from discord_reminder_bot.parser import parse_time from discord_reminder_bot.parser import parse_time
if TYPE_CHECKING:
from datetime import datetime
def test_parse_time_valid_date() -> None:
"""Test the `parse_time` function with a valid date string.""" def test_parse_time_valid_date_and_timezone() -> None:
date_to_parse = "tomorrow at 5pm" """Test the `parse_time` function to ensure it correctly parses a date string into a datetime object."""
date_to_parse = "2023-10-10 10:00:00"
timezone = "UTC" timezone = "UTC"
result: datetime.datetime | None = parse_time(date_to_parse, timezone, use_dotenv=False) result: datetime | None = parse_time(date_to_parse, timezone)
assert result is not None, f"Expected a datetime object, got {result}" assert result is not None
assert result.tzinfo == ZoneInfo(timezone), f"Expected timezone {timezone}, got {result.tzinfo}" assert result.tzinfo is not None
assert result.strftime("%Y-%m-%d %H:%M:%S") == "2023-10-10 10:00:00"
def test_parse_time_no_date() -> None: def test_parse_time_no_date() -> None:
"""Test the `parse_time` function with no date string.""" """Test the `parse_time` function to ensure it correctly handles no date provided."""
date_to_parse: str = "" date_to_parse = None
timezone = "UTC" timezone = "UTC"
result: datetime.datetime | None = parse_time(date_to_parse, timezone, use_dotenv=False) result: datetime | None = parse_time(date_to_parse, timezone)
assert result is None, f"Expected None, got {result}" assert result is None
def test_parse_time_no_timezone() -> None: def test_parse_time_no_timezone() -> None:
"""Test the `parse_time` function with no timezone.""" """Test the `parse_time` function to ensure it correctly handles no timezone provided."""
date_to_parse = "tomorrow at 5pm" date_to_parse = "2023-10-10 10:00:00"
result: datetime.datetime | None = parse_time(date_to_parse, use_dotenv=False) timezone = None
assert result is not None, f"Expected a datetime object, got {result}" result: datetime | None = parse_time(date_to_parse, timezone)
assert result is None
assert_msg: str = f"Expected timezone {settings.get_timezone(use_dotenv=False)}, got {result.tzinfo}"
assert result.tzinfo == ZoneInfo(settings.get_timezone(use_dotenv=False)), assert_msg
def test_parse_time_invalid_date() -> None: def test_parse_time_invalid_date() -> None:
"""Test the `parse_time` function with an invalid date string.""" """Test the `parse_time` function to ensure it correctly handles an invalid date string."""
date_to_parse = "invalid date" date_to_parse = "invalid date"
timezone = "UTC" timezone = "UTC"
result: datetime.datetime | None = parse_time(date_to_parse, timezone, use_dotenv=False) result: datetime | None = parse_time(date_to_parse, timezone)
assert result is None, f"Expected None, got {result}" assert result is None
@freeze_time("2023-01-01 12:00:00")
def test_parse_time_invalid_timezone() -> None: def test_parse_time_invalid_timezone() -> None:
"""Test the `parse_time` function with an invalid timezone.""" """Test the `parse_time` function to ensure it correctly handles an invalid timezone."""
date_to_parse = "tomorrow at 5pm" date_to_parse = "2023-10-10 10:00:00"
timezone = "Invalid/Timezone" timezone = "Invalid/Timezone"
result: datetime.datetime | None = parse_time(date_to_parse, timezone, use_dotenv=False) with pytest.raises(zoneinfo.ZoneInfoNotFoundError):
assert result is not None, f"Expected a datetime object, got {result}" parse_time(date_to_parse, timezone)
assert result.tzinfo == ZoneInfo("UTC"), f"Expected timezone UTC, got {result.tzinfo}"
assert_msg: str = f"Expected {datetime.datetime(2023, 1, 2, 17, 0, tzinfo=ZoneInfo('UTC'))}, got {result}"
assert result == datetime.datetime(2023, 1, 2, 17, 0, tzinfo=ZoneInfo("UTC")), assert_msg def test_parse_time_with_env_timezone(monkeypatch: pytest.MonkeyPatch) -> None:
"""Test the `parse_time` function to ensure it correctly parses a date string into a datetime object using the timezone from the environment.""" # noqa: E501
date_to_parse = "2023-10-10 10:00:00"
result: datetime | None = parse_time(date_to_parse, "UTC")
assert_msg: str = "Expected datetime object, got None"
assert result is not None, assert_msg
assert_msg = "Expected timezone-aware datetime object, got naive datetime object"
assert result.tzinfo is not None, assert_msg
assert_msg = f"Expected 2023-10-10 10:00:00, got {result.strftime('%Y-%m-%d %H:%M:%S')}"
assert result.strftime("%Y-%m-%d %H:%M:%S") == "2023-10-10 10:00:00", assert_msg

View File

@ -57,6 +57,7 @@ def test_get_settings_default_values(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("LOG_LEVEL", raising=False) monkeypatch.delenv("LOG_LEVEL", raising=False)
monkeypatch.delenv("WEBHOOK_URL", raising=False) monkeypatch.delenv("WEBHOOK_URL", raising=False)
monkeypatch.setenv("BOT_TOKEN", "default_token") monkeypatch.setenv("BOT_TOKEN", "default_token")
monkeypatch.setenv("TIMEZONE", "UTC")
settings: dict[str, str | dict[str, SQLAlchemyJobStore] | dict[str, bool] | AsyncIOScheduler] = get_settings(use_dotenv=False) settings: dict[str, str | dict[str, SQLAlchemyJobStore] | dict[str, bool] | AsyncIOScheduler] = get_settings(use_dotenv=False)