Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove dependency on pytz #1110

Merged
merged 5 commits into from
Mar 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
76 changes: 56 additions & 20 deletions lektor/types/primitives.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import re
from datetime import date
from datetime import datetime

from babel.dates import get_timezone
from markupsafe import Markup
from pytz import FixedOffset

from lektor.constants import PRIMARY_ALT
from lektor.i18n import get_i18n_block
Expand Down Expand Up @@ -117,25 +117,61 @@ class DateTimeType(SingleInputType):
def value_from_raw(self, raw):
if raw.value is None:
return raw.missing_value("Missing datetime")

# The previous version of this code allowed a timezone name, followed by a zone
# offset. In that case the zone name would be ignored (unless the combined zone
# name, including the offset, matched an IANA zone key). For the sake of
# backwards compatibility we do the same here.
m = re.match(
r"""
(?P<datetime>
\d{4} - \d\d? - \d\d? # YY-MM-DD
\s+ \d\d? : \d\d (?P<seconds> :\d\d )? # HH:MM[:SS]
)
(?: \s+
(?P<timezone>
# Long timezone keys, and — on Windows — names containing
# certain characters (those that are not allowed in filenames)
# give zoneinfo gas.
# https://github.com/python/cpython/issues/96463
[^<>:"|?*\x00-\x1f]{,100}?
(?P<zoneoffset> [-+] \d\d (?: :? \d\d ){1,2} )? # ±HHMM[SS]
)
)?
\Z""",
raw.value.strip(),
re.DOTALL | re.VERBOSE,
)
if m is None:
return raw.bad_value("Bad datetime format")
timezone, zoneoffset = m.group("timezone", "zoneoffset")
tz = None
if timezone is not None:
try:
tz = get_timezone(timezone)
zoneoffset = None
except LookupError:
if zoneoffset is None:
return raw.bad_value(f"Unknown timezone {timezone!r}")

dt = m["datetime"]
fmt = "%Y-%m-%d %H:%M"
if m["seconds"] is not None:
fmt += ":%S"
if zoneoffset is not None:
dt += f" {zoneoffset}"
fmt += " %z"
try:
chunks = raw.value.split(" ")
date_info = [int(bit) for bit in chunks[0].split("-")]
time_info = [int(bit) for bit in chunks[1].split(":")]
datetime_info = date_info + time_info
result = datetime(*datetime_info)

if len(chunks) > 2:
try:
tz = get_timezone(chunks[-1])
except LookupError:
if len(chunks[-1]) > 5:
chunks[-1] = chunks[-1][-5:]
delta = int(chunks[-1][1:3]) * 60 + int(chunks[-1][3:])
if chunks[-1][0] == "-":
delta *= -1
tz = FixedOffset(delta)
return tz.localize(result)
result = datetime.strptime(dt, fmt)
except ValueError:
return raw.bad_value("Invalid datetime")

if tz is None:
return result
except Exception:
return raw.bad_value("Bad date format")

# as of babel 2.12, get_timezone can return either a pytz timezone
# or a zoneinfo timezone
assert result.tzinfo is None
if hasattr(tz, "localize"): # pytz
return tz.localize(result)
return result.replace(tzinfo=tz)
3 changes: 2 additions & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,10 @@ install_requires =
marshmallow_dataclass>=8.5.9
mistune>=0.7.0,<3
pip
pytz; python_version<"3.9" # favor zoneinfo for python>=3.9
python-slugify
pytz
requests
tzdata; python_version>="3.9" and sys_platform == 'win32'
watchdog
Werkzeug>=2.1.0,<3

Expand Down
211 changes: 69 additions & 142 deletions tests/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import warnings

import pytest
from babel.dates import get_timezone
from markupsafe import escape
from markupsafe import Markup

Expand Down Expand Up @@ -217,161 +216,89 @@ def test_boolean(env, pad):
assert rv is False


def test_datetime(env, pad):
field = make_field(env, "datetime")
dt = datetime.datetime


@pytest.mark.parametrize(
"value, expected",
[
("2016-04-30 01:02:03", dt(2016, 4, 30, 1, 2, 3)),
("1970-1-1 12:34", dt(1970, 1, 1, 12, 34)),
("1970-01-02 12:34", dt(1970, 1, 2, 12, 34)),
("2020-02-03 01:02:03", dt(2020, 2, 3, 1, 2, 3)),
],
)
def test_datetime_no_timezone(env, pad, value, expected):
field = make_field(env, "datetime")
with Context(pad=pad):
# default
rv = field.deserialize_value("2016-04-30 01:02:03", pad=pad)
assert isinstance(rv, datetime.datetime)
assert rv.year == 2016
assert rv.month == 4
assert rv.day == 30
assert rv.hour == 1
assert rv.minute == 2
assert rv.second == 3
assert rv.tzinfo is None

# It is not datetime, it is None
rv = field.deserialize_value(None, pad=pad)
assert isinstance(rv, Undefined)
rv = field.deserialize_value(value, pad=pad)

# It is not datetime, it is empty string
rv = field.deserialize_value("", pad=pad)
assert isinstance(rv, BadValue)
assert rv.replace(tzinfo=None) == expected
assert rv.tzinfo is None

# It is not datetime, it is date
rv = field.deserialize_value("2016-04-30", pad=pad)
assert isinstance(rv, BadValue)

def utc(*args):
return datetime.datetime(*args, tzinfo=datetime.timezone.utc)

def test_datetime_timezone_utc(env, pad):
field = make_field(env, "datetime")
with Context(pad=pad):

@pytest.mark.parametrize(
"value, expected",
[
# Known timezone name, UTC
rv = field.deserialize_value("2016-04-30 01:02:03 UTC", pad=pad)
assert isinstance(rv, datetime.datetime)
assert rv.year == 2016
assert rv.month == 4
assert rv.day == 30
assert rv.hour == 1
assert rv.minute == 2
assert rv.second == 3
assert rv.tzinfo is get_timezone("UTC")


def test_datetime_timezone_est(env, pad):
field = make_field(env, "datetime")
with Context(pad=pad):
("2016-04-30 01:02:03 UTC", utc(2016, 4, 30, 1, 2, 3)),
# Known timezone name, EST
rv = field.deserialize_value("2016-04-30 01:02:03 EST", pad=pad)
assert isinstance(rv, datetime.datetime)
assert rv.year == 2016
assert rv.month == 4
assert rv.day == 30
assert rv.hour == 1
assert rv.minute == 2
assert rv.second == 3
assert rv.tzinfo is get_timezone("EST")


def test_datetime_timezone_location(env, pad):
field = make_field(env, "datetime")
with Context(pad=pad):
("2016-04-30 01:02:03 EST", utc(2016, 4, 30, 6, 2, 3)),
# Known location name, Asia/Seoul
rv = field.deserialize_value("2016-04-30 01:02:03 Asia/Seoul", pad=pad)
assert isinstance(rv, datetime.datetime)
assert rv.year == 2016
assert rv.month == 4
assert rv.day == 30
assert rv.hour == 1
assert rv.minute == 2
assert rv.second == 3
tzinfos = get_timezone("Asia/Seoul")._tzinfos # pylint: disable=no-member
assert rv.tzinfo in tzinfos.values()


def test_datetime_timezone_kst(env, pad):
field = make_field(env, "datetime")
with Context(pad=pad):
("2016-04-30 01:02:03 Asia/Seoul", utc(2016, 4, 29, 16, 2, 3)),
# KST - http://www.timeanddate.com/time/zones/kst
rv = field.deserialize_value("2016-04-30 01:02:03 +0900", pad=pad)
assert isinstance(rv, datetime.datetime)
assert rv.year == 2016
assert rv.month == 4
assert rv.day == 30
assert rv.hour == 1
assert rv.minute == 2
assert rv.second == 3
assert rv.tzinfo._offset == datetime.timedelta(0, 9 * 60 * 60)


def test_datetime_timezone_acst(env, pad):
field = make_field(env, "datetime")
with Context(pad=pad):
("2016-04-30 01:02:03 +0900", utc(2016, 4, 29, 16, 2, 3)),
# ACST - http://www.timeanddate.com/time/zones/acst
rv = field.deserialize_value("2016-04-30 01:02:03 +0930", pad=pad)
assert isinstance(rv, datetime.datetime)
assert rv.year == 2016
assert rv.month == 4
assert rv.day == 30
assert rv.hour == 1
assert rv.minute == 2
assert rv.second == 3
assert rv.tzinfo._offset == datetime.timedelta(0, (9 * 60 + 30) * 60)


def test_datetime_timezone_mst(env, pad):
("2016-04-30 01:02:03 +0930", utc(2016, 4, 29, 15, 32, 3)),
# MST - http://www.timeanddate.com/time/zones/mst
("2016-04-30 01:02:03 -0700", utc(2016, 4, 30, 8, 2, 3)),
# MART - http://www.timeanddate.com/time/zones/mart
("2016-04-30 01:02:03 -0930", utc(2016, 4, 30, 10, 32, 3)),
# with (ignored) timezone name (case 1)
("2016-04-30 01:02:03 KST +0900", utc(2016, 4, 29, 16, 2, 3)),
# with (ignored) timezone name (case 2)
("2016-04-30 01:02:03 KST+0900", utc(2016, 4, 29, 16, 2, 3)),
],
)
def test_datetime_timezone(env, pad, value, expected):
field = make_field(env, "datetime")
with Context(pad=pad):
# MST - http://www.timeanddate.com/time/zones/mst
rv = field.deserialize_value("2016-04-30 01:02:03 -0700", pad=pad)
assert isinstance(rv, datetime.datetime)
assert rv.year == 2016
assert rv.month == 4
assert rv.day == 30
assert rv.hour == 1
assert rv.minute == 2
assert rv.second == 3
assert rv.tzinfo._offset == datetime.timedelta(0, -7 * 60 * 60)


def test_datetime_timezone_mart(env, pad):
rv = field.deserialize_value(value, pad=pad)
assert rv.astimezone(expected.tzinfo) == expected


@pytest.mark.parametrize(
"value",
[
"",
"197",
"1970",
"1970-01",
"1970-01-01",
"1970-01-01 12",
"1970-01-01 12.23",
"1970-01 01:02:03",
"1970-01-01 12:34 *0800",
"1970-01-01 12:34 -081",
"1970-01-01 12:34 a\\b",
"1970-01-01 12:34 very/unknown/timezone",
"1970-01-01 12:34 very/long/timezone" + "e" * 1024,
],
)
def test_datetime_invalid(env, pad, value):
field = make_field(env, "datetime")
with Context(pad=pad):
# MART - http://www.timeanddate.com/time/zones/mart
rv = field.deserialize_value("2016-04-30 01:02:03 -0930", pad=pad)
assert isinstance(rv, datetime.datetime)
assert rv.year == 2016
assert rv.month == 4
assert rv.day == 30
assert rv.hour == 1
assert rv.minute == 2
assert rv.second == 3
assert rv.tzinfo._offset == datetime.timedelta(0, -(9 * 60 + 30) * 60)


def test_datetime_timezone_name(env, pad):
rv = field.deserialize_value(value, pad=pad)
assert isinstance(rv, BadValue)


def test_datetime_missing(env, pad):
field = make_field(env, "datetime")
with Context(pad=pad):
# with timezone name (case 1)
rv = field.deserialize_value("2016-04-30 01:02:03 KST +0900", pad=pad)
assert isinstance(rv, datetime.datetime)
assert rv.year == 2016
assert rv.month == 4
assert rv.day == 30
assert rv.hour == 1
assert rv.minute == 2
assert rv.second == 3
assert rv.tzinfo._offset == datetime.timedelta(0, 9 * 60 * 60)

# with timezone name (case 2)
rv = field.deserialize_value("2016-04-30 01:02:03 KST+0900", pad=pad)
assert isinstance(rv, datetime.datetime)
assert rv.year == 2016
assert rv.month == 4
assert rv.day == 30
assert rv.hour == 1
assert rv.minute == 2
assert rv.second == 3
assert rv.tzinfo._offset == datetime.timedelta(0, 9 * 60 * 60)
rv = field.deserialize_value(None, pad=pad)
assert isinstance(rv, Undefined)
assert "Missing value" in rv._undefined_hint
7 changes: 6 additions & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
minversion = 4.1
envlist =
lint
{py37,py38,py39,py310,py311}{,-mistune0},py311-noutils
{py37,py38,py39,py310,py311}{,-mistune0}
py311-noutils
py311-pytz
py311-tzdata
cover-{clean,report}
isolated_build = true

Expand All @@ -24,6 +27,8 @@ deps =
pytest-mock
coverage[toml]
mistune0: mistune<2
pytz: pytz
tzdata: tzdata
depends =
py{37,38,39,310,311}{,-mistune0,-noutils}: cover-clean
cover-report: py{37,38,39,310,311}{,-mistune0,-noutils}
Expand Down