From c8854f584b4ba216f9706fa5bf42597a4f9c969c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Reni=C3=A9?= Date: Wed, 26 Mar 2014 01:03:47 +0100 Subject: [PATCH] Properly resolve absolute time ranges with custom timezones Refs graphite-project/graphite-web#639. --- docs/releases.rst | 6 ++++++ graphite_api/functions.py | 27 +++++++++++---------------- graphite_api/render/attime.py | 4 ++-- graphite_api/render/datalib.py | 9 ++++----- graphite_api/utils.py | 9 +++++++++ tests/test_render.py | 24 ++++++++++++++++++++++++ 6 files changed, 56 insertions(+), 23 deletions(-) diff --git a/docs/releases.rst b/docs/releases.rst index 491eb2d..f37f5fc 100644 --- a/docs/releases.rst +++ b/docs/releases.rst @@ -1,6 +1,12 @@ Graphite-API releases ===================== +1.0.2 -- **in development** +--------------------------- + +* Proper timezone handling of ``from`` and ``until`` with client-supplied + timezones (`Graphite-web issue #639 `_). + 1.0.1 -- 2014-03-21 ------------------- diff --git a/graphite_api/functions.py b/graphite_api/functions.py index 1166dc8..c820f90 100644 --- a/graphite_api/functions.py +++ b/graphite_api/functions.py @@ -30,7 +30,7 @@ from .render.attime import parseTimeOffset from .render.glyph import format_units from .render.datalib import TimeSeries -from .utils import to_seconds +from .utils import to_seconds, epoch NAN = float('NaN') INF = float('inf') @@ -40,11 +40,6 @@ # Utility functions -def timestamp(datetime): - "Convert a datetime object into epoch time" - return time.mktime(datetime.timetuple()) - - not_none = partial(is_not, None) @@ -1736,7 +1731,7 @@ def limit(requestContext, seriesList, n): """ Takes one metric or a wildcard seriesList followed by an integer N. - Only draw the first N metrics. Useful when testing a wildcard in a + Only draw the first N metrics. Useful when testing a wildcard in a metric. Example:: @@ -2373,8 +2368,8 @@ def constantLine(requestContext, value): &target=constantLine(123.456) """ - start = int(timestamp(requestContext['startTime'])) - end = int(timestamp(requestContext['endTime'])) + start = int(epoch(requestContext['startTime'])) + end = int(epoch(requestContext['endTime'])) step = end - start series = TimeSeries(str(value), start, end, step, [value, value]) return [series] @@ -2510,8 +2505,8 @@ def identity(requestContext, name): where x(t) == t. """ step = 60 - start = int(time.mktime(requestContext["startTime"].timetuple())) - end = int(time.mktime(requestContext["endTime"].timetuple())) + start = int(epoch(requestContext["startTime"])) + end = int(epoch(requestContext["endTime"])) values = range(start, end, step) series = TimeSeries(name, start, end, step, values) series.pathExpression = 'identity("%s")' % name @@ -2989,12 +2984,12 @@ def sinFunction(requestContext, name, amplitude=1): values = [] while when < requestContext["endTime"]: - values.append(math.sin(time.mktime(when.timetuple()))*amplitude) + values.append(math.sin(epoch(when))*amplitude) when += delta series = TimeSeries( - name, int(time.mktime(requestContext["startTime"].timetuple())), - int(time.mktime(requestContext["endTime"].timetuple())), + name, int(epoch(requestContext["startTime"])), + int(epoch(requestContext["endTime"])), step, values) series.pathExpression = 'sin({0})'.format(name) return [series] @@ -3025,8 +3020,8 @@ def randomWalkFunction(requestContext, name): when += delta return [TimeSeries( - name, int(time.mktime(requestContext["startTime"].timetuple())), - int(time.mktime(requestContext["endTime"].timetuple())), + name, int(epoch(requestContext["startTime"])), + int(epoch(requestContext["endTime"])), step, values)] diff --git a/graphite_api/render/attime.py b/graphite_api/render/attime.py index fd6f59c..45f4bba 100644 --- a/graphite_api/render/attime.py +++ b/graphite_api/render/attime.py @@ -52,7 +52,7 @@ def parseATTime(s, tzinfo=None): def parseTimeReference(ref): if not ref or ref == 'now': - return datetime.now() + return datetime.utcnow() # Time-of-day reference i = ref.find(':') @@ -76,7 +76,7 @@ def parseTimeReference(ref): hour, min = 16, 0 ref = ref[7:] - refDate = datetime.now().replace(hour=hour, minute=min, second=0) + refDate = datetime.utcnow().replace(hour=hour, minute=min, second=0) # Day reference if ref in ('yesterday', 'today', 'tomorrow'): # yesterday, today, tomorrow diff --git a/graphite_api/render/datalib.py b/graphite_api/render/datalib.py index b5d86aa..1806ed2 100644 --- a/graphite_api/render/datalib.py +++ b/graphite_api/render/datalib.py @@ -11,11 +11,10 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.""" - -import time - from structlog import get_logger +from ..utils import epoch + logger = get_logger() @@ -83,8 +82,8 @@ def fetchData(requestContext, pathExpr): from ..app import app seriesList = [] - startTime = int(time.mktime(requestContext['startTime'].timetuple())) - endTime = int(time.mktime(requestContext['endTime'].timetuple())) + startTime = int(epoch(requestContext['startTime'])) + endTime = int(epoch(requestContext['endTime'])) def _fetchData(pathExpr, startTime, endTime, requestContext, seriesList): matching_nodes = app.store.find(pathExpr, startTime, endTime) diff --git a/graphite_api/utils.py b/graphite_api/utils.py index b1eb8dd..b696767 100644 --- a/graphite_api/utils.py +++ b/graphite_api/utils.py @@ -11,6 +11,8 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.""" +import calendar +import pytz from flask import request @@ -56,3 +58,10 @@ def getlist(self, key): def to_seconds(delta): return abs(delta.seconds + delta.days * 86400) + + +def epoch(dt): + """ + Returns the epoch timestamp of a timezone-aware datetime object. + """ + return calendar.timegm(dt.astimezone(pytz.utc).timetuple()) diff --git a/tests/test_render.py b/tests/test_render.py index 253a750..55a9ffa 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -67,6 +67,30 @@ def test_render_constant_line(self): for point, ts in data: self.assertEqual(point, 12) + def test_correct_timezone(self): + response = self.app.get(self.url, query_string={ + 'target': 'constantLine(12)', + 'format': 'json', + 'from': '07:00_20140226', + 'until': '08:00_20140226', + # tz is UTC + }) + data = json.loads(response.data.decode('utf-8'))[0]['datapoints'] + + # all the from/until/tz combinations lead to the same window + expected = [[12, 1393398000], [12, 1393401600]] + self.assertEqual(data, expected) + + response = self.app.get(self.url, query_string={ + 'target': 'constantLine(12)', + 'format': 'json', + 'from': '08:00_20140226', + 'until': '09:00_20140226', + 'tz': 'Europe/Berlin', + }) + data = json.loads(response.data.decode('utf-8'))[0]['datapoints'] + self.assertEqual(data, expected) + def test_render_options(self): self.create_db() db2 = os.path.join(WHISPER_DIR, 'foo.wsp')