Skip to content

Commit ced8668

Browse files
authored
Safely handle datetime constraint values with and without timezone info (#324)
1 parent b815f6d commit ced8668

File tree

3 files changed

+73
-6
lines changed

3 files changed

+73
-6
lines changed

UnleashClient/constraints/Constraint.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# pylint: disable=invalid-name, too-few-public-methods, use-a-generator
2-
from datetime import datetime
2+
from datetime import datetime, timezone
33
from enum import Enum
44
from typing import Any, Optional, Union
55

@@ -125,6 +125,11 @@ def check_numeric_operators(self, context_value: Union[float, int]) -> bool:
125125
return return_value
126126

127127
def check_date_operators(self, context_value: Union[datetime, str]) -> bool:
128+
if isinstance(context_value, datetime) and context_value.tzinfo is None:
129+
raise ValueError(
130+
"If context_value is a datetime object, it must be timezone (offset) aware."
131+
)
132+
128133
return_value = False
129134
parsing_exception = False
130135

@@ -139,8 +144,16 @@ def check_date_operators(self, context_value: Union[datetime, str]) -> bool:
139144

140145
try:
141146
parsed_date = parse(self.value)
147+
148+
# If parsed date is timezone-naive, assume it is UTC
149+
if parsed_date.tzinfo is None:
150+
parsed_date = parsed_date.replace(tzinfo=timezone.utc)
151+
142152
if isinstance(context_value, str):
143153
context_date = parse(context_value)
154+
# If parsed date is timezone-naive, assume it is UTC
155+
if context_date.tzinfo is None:
156+
context_date = context_date.replace(tzinfo=timezone.utc)
144157
else:
145158
context_date = context_value
146159
except DateUtilParserError:
@@ -197,7 +210,8 @@ def apply(self, context: dict = None) -> bool:
197210

198211
# Set currentTime if not specified
199212
if self.context_name == "currentTime" and not context_value:
200-
context_value = datetime.now()
213+
# Use the current system time in the local timezone (tz-aware)
214+
context_value = datetime.now(timezone.utc).astimezone()
201215

202216
if context_value is not None:
203217
if self.operator in [

tests/unit_tests/test_constraints.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from datetime import datetime
1+
from datetime import datetime, timedelta
22

33
import pytest
44
import pytz
@@ -183,15 +183,61 @@ def test_constraints_DATE_BEFORE():
183183
assert constraint.apply({"currentTime": datetime(2022, 1, 21, tzinfo=pytz.UTC)})
184184

185185

186-
def test_constraints_default():
187-
constraint = Constraint(constraint_dict=mock_constraints.CONSTRAINT_DATE_BEFORE)
186+
def test_constraints_DATE_AFTER_default():
187+
constraint = Constraint(
188+
constraint_dict={
189+
**mock_constraints.CONSTRAINT_DATE_AFTER,
190+
"value": (datetime.now(pytz.UTC) - timedelta(days=1)).isoformat(),
191+
}
192+
)
193+
194+
assert constraint.apply({})
195+
196+
constraint = Constraint(
197+
constraint_dict={
198+
**mock_constraints.CONSTRAINT_DATE_AFTER,
199+
"value": (datetime.now(pytz.UTC) + timedelta(days=1)).isoformat(),
200+
}
201+
)
202+
203+
assert not constraint.apply({})
204+
205+
206+
def test_constraints_DATE_BEFORE_default():
207+
constraint = Constraint(
208+
constraint_dict={
209+
**mock_constraints.CONSTRAINT_DATE_BEFORE,
210+
"value": (datetime.now(pytz.UTC) + timedelta(days=1)).isoformat(),
211+
}
212+
)
213+
214+
assert constraint.apply({})
215+
216+
constraint = Constraint(
217+
constraint_dict={
218+
**mock_constraints.CONSTRAINT_DATE_BEFORE,
219+
"value": (datetime.now(pytz.UTC) - timedelta(days=1)).isoformat(),
220+
}
221+
)
188222

189223
assert not constraint.apply({})
190224

191225

226+
def test_constraints_tz_naive():
227+
constraint = Constraint(constraint_dict=mock_constraints.CONSTRAINT_DATE_TZ_NAIVE)
228+
229+
assert constraint.apply(
230+
{"currentTime": datetime(2022, 1, 22, 0, 10, tzinfo=pytz.UTC)}
231+
)
232+
assert not constraint.apply({"currentTime": datetime(2022, 1, 22, tzinfo=pytz.UTC)})
233+
assert not constraint.apply(
234+
{"currentTime": datetime(2022, 1, 21, 23, 50, tzinfo=pytz.UTC)}
235+
)
236+
237+
192238
def test_constraints_date_error():
193239
constraint = Constraint(constraint_dict=mock_constraints.CONSTRAINT_DATE_ERROR)
194-
assert not constraint.apply({"currentTime": datetime(2022, 1, 23)})
240+
assert not constraint.apply({"currentTime": datetime(2022, 1, 23, tzinfo=pytz.UTC)})
195241

196242

197243
def test_constraints_SEMVER_EQ():

tests/utilities/mocks/mock_constraints.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,13 @@
153153
"inverted": False,
154154
}
155155

156+
CONSTRAINT_DATE_TZ_NAIVE = {
157+
"contextName": "currentTime",
158+
"operator": "DATE_AFTER",
159+
"value": "2022-01-22T00:00:00.000",
160+
"inverted": False,
161+
}
162+
156163

157164
CONSTRAINT_SEMVER_EQ = {
158165
"contextName": "customField",

0 commit comments

Comments
 (0)