Skip to content

Commit

Permalink
feat(misc): return timezone aware datetime objects (#2246)
Browse files Browse the repository at this point in the history
* feat(http_date_to_dt): return timezone-aware datetime objects

let http_date_to_dt validate timezone information from the
provided http-date and return timezone aware datetime objects
remove tests for timezone naive variants
amend tests

Breaks any application relying on naiveness of datetimes or
interpretation in local time. Closes #2182

* style(misc): Add ValueError to doscstring

* test(misc): Add test for violating timezone

* refactor(tests): Use already defined _utcnow function

* test: update cookie test

* test: remove duplicated import

* chore: fix botched master merge [`test_cookies.py`]
  • Loading branch information
chgad authored Aug 27, 2024
1 parent 321bda1 commit 4f8d639
Show file tree
Hide file tree
Showing 5 changed files with 38 additions and 16 deletions.
2 changes: 2 additions & 0 deletions docs/_newsfragments/2182.breakingchange.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
The function :func:`falcon.http_date_to_dt` now validates http-dates to have the correct
timezone set. It now also returns timezone aware datetime objects.
17 changes: 15 additions & 2 deletions falcon/util/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@

_UNSAFE_CHARS = re.compile(r'[^a-zA-Z0-9.-]')

_ALLOWED_HTTP_TIMEZONES: Tuple[str, ...] = ('GMT', 'UTC')

_UTC_TIMEZONE = datetime.timezone.utc

# PERF(kgriffs): Avoid superfluous namespace lookups
_strptime: Callable[[str, str], datetime.datetime] = datetime.datetime.strptime
_utcnow: Callable[[], datetime.datetime] = functools.partial(
Expand Down Expand Up @@ -170,15 +174,24 @@ def http_date_to_dt(http_date: str, obs_date: bool = False) -> datetime.datetime
Raises:
ValueError: http_date doesn't match any of the available time formats
ValueError: http_date doesn't match allowed timezones
"""
date_timezone_str = http_date[-3:]
has_tz_identifier = not date_timezone_str.isdigit()
if date_timezone_str not in _ALLOWED_HTTP_TIMEZONES and has_tz_identifier:
raise ValueError(
'timezone information of time data %r is not allowed' % http_date
)

if not obs_date:
# PERF(kgriffs): This violates DRY, but we do it anyway
# to avoid the overhead of setting up a tuple, looping
# over it, and setting up exception handling blocks each
# time around the loop, in the case that we don't actually
# need to check for multiple formats.
return _strptime(http_date, '%a, %d %b %Y %H:%M:%S %Z')
return _strptime(http_date, '%a, %d %b %Y %H:%M:%S %Z').replace(
tzinfo=_UTC_TIMEZONE
)

time_formats = (
'%a, %d %b %Y %H:%M:%S %Z',
Expand All @@ -190,7 +203,7 @@ def http_date_to_dt(http_date: str, obs_date: bool = False) -> datetime.datetime
# Loop through the formats and return the first that matches
for time_format in time_formats:
try:
return _strptime(http_date, time_format)
return _strptime(http_date, time_format).replace(tzinfo=_UTC_TIMEZONE)
except ValueError:
continue

Expand Down
15 changes: 9 additions & 6 deletions tests/test_cookies.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import falcon
import falcon.testing as testing
from falcon.util import http_date_to_dt
from falcon.util.misc import _utcnow

UNICODE_TEST_STRING = 'Unicode_\xc3\xa6\xc3\xb8'

Expand Down Expand Up @@ -172,7 +173,7 @@ def test_response_complex_case(client):
assert cookie.domain is None
assert cookie.same_site == 'Lax'

assert cookie.expires < utcnow_naive()
assert cookie.expires < _utcnow()

# NOTE(kgriffs): I know accessing a private attr like this is
# naughty of me, but we just need to sanity-check that the
Expand All @@ -194,7 +195,7 @@ def test(cookie, path, domain, samesite='Lax'):
assert cookie.domain == domain
assert cookie.path == path
assert cookie.same_site == samesite
assert cookie.expires < utcnow_naive()
assert cookie.expires < _utcnow()

test(result.cookies['foo'], path=None, domain=None)
test(result.cookies['bar'], path='/bar', domain=None)
Expand Down Expand Up @@ -232,7 +233,7 @@ def test_set(cookie, value, samesite=None):
def test_unset(cookie, samesite='Lax'):
assert cookie.value == '' # An unset cookie has an empty value
assert cookie.same_site == samesite
assert cookie.expires < utcnow_naive()
assert cookie.expires < _utcnow()

test_unset(result_unset.cookies['foo'], samesite='Strict')
# default: bar is unset with no samesite param, so should go to Lax
Expand All @@ -255,7 +256,7 @@ def test_cookie_expires_naive(client):
cookie = result.cookies['foo']
assert cookie.value == 'bar'
assert cookie.domain is None
assert cookie.expires == datetime(year=2050, month=1, day=1)
assert cookie.expires == datetime(year=2050, month=1, day=1, tzinfo=timezone.utc)
assert not cookie.http_only
assert cookie.max_age is None
assert cookie.path is None
Expand All @@ -268,7 +269,9 @@ def test_cookie_expires_aware(client):
cookie = result.cookies['foo']
assert cookie.value == 'bar'
assert cookie.domain is None
assert cookie.expires == datetime(year=2049, month=12, day=31, hour=23)
assert cookie.expires == datetime(
year=2049, month=12, day=31, hour=23, tzinfo=timezone.utc
)
assert not cookie.http_only
assert cookie.max_age is None
assert cookie.path is None
Expand Down Expand Up @@ -326,7 +329,7 @@ def test_response_unset_cookie():
assert match

expiration = http_date_to_dt(match.group(1), obs_date=True)
assert expiration < utcnow_naive()
assert expiration < _utcnow()


# =====================================================================
Expand Down
2 changes: 1 addition & 1 deletion tests/test_request_attrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -702,7 +702,7 @@ def test_bogus_content_length_neg(self, asgi):
],
)
def test_date(self, asgi, header, attr):
date = datetime.datetime(2013, 4, 4, 5, 19, 18)
date = datetime.datetime(2013, 4, 4, 5, 19, 18, tzinfo=datetime.timezone.utc)
date_str = 'Thu, 04 Apr 2013 05:19:18 GMT'

headers = {header: date_str}
Expand Down
18 changes: 11 additions & 7 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,29 +129,30 @@ def test_http_now(self):

def test_dt_to_http(self):
assert (
falcon.dt_to_http(datetime(2013, 4, 4)) == 'Thu, 04 Apr 2013 00:00:00 GMT'
falcon.dt_to_http(datetime(2013, 4, 4, tzinfo=timezone.utc))
== 'Thu, 04 Apr 2013 00:00:00 GMT'
)

assert (
falcon.dt_to_http(datetime(2013, 4, 4, 10, 28, 54))
falcon.dt_to_http(datetime(2013, 4, 4, 10, 28, 54, tzinfo=timezone.utc))
== 'Thu, 04 Apr 2013 10:28:54 GMT'
)

def test_http_date_to_dt(self):
assert falcon.http_date_to_dt('Thu, 04 Apr 2013 00:00:00 GMT') == datetime(
2013, 4, 4
2013, 4, 4, tzinfo=timezone.utc
)

assert falcon.http_date_to_dt('Thu, 04 Apr 2013 10:28:54 GMT') == datetime(
2013, 4, 4, 10, 28, 54
2013, 4, 4, 10, 28, 54, tzinfo=timezone.utc
)

with pytest.raises(ValueError):
falcon.http_date_to_dt('Thu, 04-Apr-2013 10:28:54 GMT')

assert falcon.http_date_to_dt(
'Thu, 04-Apr-2013 10:28:54 GMT', obs_date=True
) == datetime(2013, 4, 4, 10, 28, 54)
) == datetime(2013, 4, 4, 10, 28, 54, tzinfo=timezone.utc)

with pytest.raises(ValueError):
falcon.http_date_to_dt('Sun Nov 6 08:49:37 1994')
Expand All @@ -161,11 +162,14 @@ def test_http_date_to_dt(self):

assert falcon.http_date_to_dt(
'Sun Nov 6 08:49:37 1994', obs_date=True
) == datetime(1994, 11, 6, 8, 49, 37)
) == datetime(1994, 11, 6, 8, 49, 37, tzinfo=timezone.utc)

assert falcon.http_date_to_dt(
'Sunday, 06-Nov-94 08:49:37 GMT', obs_date=True
) == datetime(1994, 11, 6, 8, 49, 37)
) == datetime(1994, 11, 6, 8, 49, 37, tzinfo=timezone.utc)

with pytest.raises(ValueError):
falcon.http_date_to_dt('Thu, 04 Apr 2013 10:28:54 EST')

def test_pack_query_params_none(self):
assert falcon.to_query_str({}) == ''
Expand Down

0 comments on commit 4f8d639

Please sign in to comment.