Skip to content

Commit

Permalink
Merge pull request #251 from xen0n/timezone-awareness
Browse files Browse the repository at this point in the history
Make `ruyi` config datetimes timezone-aware
  • Loading branch information
xen0n authored Dec 28, 2024
2 parents 3d23ce4 + 55bf4e1 commit 3b0789c
Show file tree
Hide file tree
Showing 6 changed files with 104 additions and 8 deletions.
13 changes: 12 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ rich = ">=11.2.0"
semver = ">=2.10"
tomlkit = ">=0.9"
tomli = { version = ">=1.2", python = "<3.11" }
tzdata = { version = "^2024.2", platform = "win32" }

[tool.poetry.group.dev.dependencies]
mypy = "^1.9.0"
Expand Down
6 changes: 4 additions & 2 deletions ruyi/config/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,17 @@ def __repr__(self) -> str:
class InvalidConfigValueError(ValueError):
def __init__(
self,
key: str | Sequence[str],
key: str | Sequence[str] | type,
val: object | None,
) -> None:
super().__init__()
self._key = key
self._val = val

def __str__(self) -> str:
return f"invalid value for config key {self._key}: {self._val}"
if isinstance(self._key, type):
return f"invalid config value for type {self._key}: {self._val}"
return f"invalid config value for key {self._key}: {self._val}"

def __repr__(self) -> str:
return f"InvalidConfigValueError({self._key:!r}, {self._val:!r})"
Expand Down
24 changes: 20 additions & 4 deletions ruyi/config/schema.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import datetime
import sys
from typing import Final, Sequence

from .errors import (
Expand Down Expand Up @@ -151,19 +152,29 @@ def encode_value(v: object) -> str:
elif isinstance(v, str):
return v
elif isinstance(v, datetime.datetime):
return v.isoformat()
if v.tzinfo is None:
raise ValueError("only timezone-aware datetimes are supported for safety")
s = v.isoformat()
if s.endswith("+00:00"):
# use the shorter 'Z' suffix for UTC
return f"{s[:-6]}Z"
return s
else:
raise NotImplementedError(f"invalid type for config value: {type(v)}")


def decode_value(
key: str | Sequence[str],
key: str | Sequence[str] | type,
val: str,
) -> object:
"""Decodes the given string representation of a config value into a Python
value, directed by type information implied by the config key."""

expected_type = get_expected_type_for_config_key(key)
if isinstance(key, type):
expected_type = key
else:
expected_type = get_expected_type_for_config_key(key)

if expected_type is bool:
if val in ("true", "yes", "1"):
return True
Expand All @@ -176,6 +187,11 @@ def decode_value(
elif expected_type is str:
return val
elif expected_type is datetime.datetime:
return datetime.datetime.fromisoformat(val)
if sys.version_info < (3, 11) and val.endswith("Z"):
# datetime.fromisoformat() did not support the 'Z' suffix until
# Python 3.11
val = f"{val[:-1]}+00:00"
v = datetime.datetime.fromisoformat(val)
return v.astimezone() if v.tzinfo is None else v
else:
raise NotImplementedError(f"invalid type for config value: {expected_type}")
2 changes: 1 addition & 1 deletion ruyi/telemetry/telemetry_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def configure_args(cls, p: argparse.ArgumentParser) -> None:

@classmethod
def main(cls, cfg: config.GlobalConfig, args: argparse.Namespace) -> int:
now = datetime.datetime.now()
now = datetime.datetime.now().astimezone()
with ConfigEditor.work_on_user_local_config(cfg) as ed:
ed.set_value((schema.SECTION_TELEMETRY, schema.KEY_TELEMETRY_MODE), "on")
ed.set_value(
Expand Down
66 changes: 66 additions & 0 deletions tests/config/test_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import datetime

import pytest

from ruyi.config.errors import InvalidConfigValueError
from ruyi.config.schema import decode_value, encode_value


def test_decode_value_bool() -> None:
assert decode_value("installation.externally_managed", "true") is True

assert decode_value(bool, "true") is True
assert decode_value(bool, "false") is False
assert decode_value(bool, "yes") is True
assert decode_value(bool, "no") is False
assert decode_value(bool, "1") is True
assert decode_value(bool, "0") is False
with pytest.raises(InvalidConfigValueError):
decode_value(bool, "invalid")
with pytest.raises(InvalidConfigValueError):
decode_value(bool, "x")
with pytest.raises(InvalidConfigValueError):
decode_value(bool, "True")


def test_decode_value_str() -> None:
assert decode_value("repo.branch", "main") == "main"
assert decode_value(str, "main") == "main"


def test_decode_value_datetime() -> None:
tz_aware_dt = datetime.datetime(2024, 12, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
assert (
decode_value("telemetry.upload_consent", "2024-12-01T12:00:00Z") == tz_aware_dt
)
assert decode_value(datetime.datetime, "2024-12-01T12:00:00Z") == tz_aware_dt
assert decode_value(datetime.datetime, "2024-12-01T12:00:00+00:00") == tz_aware_dt

# naive datetimes are decoded using the implicit local timezone
decode_value(datetime.datetime, "2024-12-01T12:00:00")


def test_encode_value_bool() -> None:
assert encode_value(True) == "true"
assert encode_value(False) == "false"


def test_encode_value_int() -> None:
assert encode_value(123) == "123"


def test_encode_value_str() -> None:
assert encode_value("") == ""
assert encode_value("main") == "main"


def test_encode_value_datetime() -> None:
tz_aware_dt = datetime.datetime(2024, 12, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
assert encode_value(tz_aware_dt) == "2024-12-01T12:00:00Z"

# specifically check that naive datetimes are rejected
tz_naive_dt = datetime.datetime(2024, 12, 1, 12, 0, 0)
with pytest.raises(
ValueError, match="only timezone-aware datetimes are supported for safety"
):
encode_value(tz_naive_dt)

0 comments on commit 3b0789c

Please sign in to comment.