Skip to content

Commit 6dce961

Browse files
authored
Merge pull request #3197 from perspective-dev/date-timezone
Fix `date` type timezone error
2 parents 875b1ab + 2e2c631 commit 6dce961

15 files changed

Lines changed: 459 additions & 252 deletions

File tree

docs/md/how_to/python/table.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,12 @@ Python `datetime` objects are serialized to strings before parsing. Naive
7979
`datetime` objects (without `tzinfo`) produce strings without timezone
8080
information and are therefore treated as UTC. Timezone-aware `datetime` objects
8181
include their offset in the serialized string, which is used to convert to UTC.
82+
83+
`"date"` values are timezone-agnostic calendar days with no time component.
84+
They are _output_ as integer timestamps at _UTC midnight_ of the calendar day
85+
(equivalent to Arrow `date32` day arithmetic), and integer timestamp _input_ to
86+
a `"date"` column is likewise interpreted as UTC. The host process timezone
87+
never affects `"date"` values — a `Viewer` renders them in UTC, recovering the
88+
stored calendar day exactly. Datetime expression functions such as
89+
`bucket("x", 'D')`, `day_of_week("x")` and `hour_of_day("x")` also compute in
90+
UTC.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2+
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3+
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4+
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5+
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6+
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7+
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
8+
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9+
// ┃ This file is part of the Perspective library, distributed under the terms ┃
10+
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11+
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12+
13+
import { test, expect } from "@perspective-dev/test";
14+
import perspective from "../perspective_client";
15+
16+
const JAN_1_2024_UTC = 1704067200000;
17+
18+
test.describe("Date timezone invariance", function () {
19+
let SAVED_TZ: string | undefined;
20+
21+
test.beforeEach(() => {
22+
SAVED_TZ = process.env.TZ;
23+
process.env.TZ = "Europe/Amsterdam";
24+
});
25+
26+
test.afterEach(() => {
27+
process.env.TZ = SAVED_TZ;
28+
});
29+
30+
test("date strings serialize to UTC midnight epoch ms", async function () {
31+
const table = await perspective.table({ d: "date" });
32+
await table.update({ d: ["2024-01-01"] });
33+
const view = await table.view();
34+
const cols = (await view.to_columns()) as { d: number[] };
35+
expect(cols.d).toEqual([JAN_1_2024_UTC]);
36+
await view.delete();
37+
await table.delete();
38+
});
39+
40+
test("date epoch ms input round-trips exactly", async function () {
41+
const table = await perspective.table({ d: "date" });
42+
await table.update({ d: [JAN_1_2024_UTC] });
43+
const view = await table.view();
44+
const cols = (await view.to_columns()) as { d: number[] };
45+
expect(cols.d).toEqual([JAN_1_2024_UTC]);
46+
await view.delete();
47+
await table.delete();
48+
});
49+
50+
test("formatted output prints the stored calendar day", async function () {
51+
const table = await perspective.table({ d: "date" });
52+
await table.update({ d: ["2024-01-01"] });
53+
const view = await table.view();
54+
expect(await view.to_columns_string({ formatted: true })).toEqual(
55+
'{"d":["2024-01-01"]}',
56+
);
57+
expect(await view.to_csv()).toEqual('"d"\n2024-01-01\n');
58+
await view.delete();
59+
await table.delete();
60+
});
61+
62+
test("day_bucket stays on the UTC calendar day at the day boundary", async function () {
63+
const table = await perspective.table({ t: "datetime" });
64+
65+
// 23:59 UTC is already the next day in Europe/Amsterdam
66+
await table.update({ t: ["2020-01-31 23:59:00"] });
67+
const view = await table.view({
68+
expressions: { bucket: `bucket("t", 'D')` },
69+
});
70+
71+
const cols = (await view.to_columns()) as { bucket: number[] };
72+
73+
// 2020-01-31T00:00:00Z
74+
expect(cols.bucket).toEqual([1580428800000]);
75+
await view.delete();
76+
await table.delete();
77+
});
78+
79+
test("day_of_week and hour_of_day compute in UTC", async function () {
80+
const table = await perspective.table({ t: "datetime" });
81+
await table.update({ t: ["2020-01-31 23:59:00"] });
82+
const view = await table.view({
83+
expressions: {
84+
dow: `day_of_week("t")`,
85+
hod: `hour_of_day("t")`,
86+
},
87+
});
88+
89+
const cols = (await view.to_columns()) as {
90+
dow: string[];
91+
hod: number[];
92+
};
93+
94+
// Friday 23:00 UTC, not Saturday 00:59 Amsterdam
95+
expect(cols.dow).toEqual(["6 Friday"]);
96+
expect(cols.hod).toEqual([23]);
97+
await view.delete();
98+
await table.delete();
99+
});
100+
101+
test("JSON date output agrees with Arrow date32 day arithmetic", async function () {
102+
const table = await perspective.table({ d: "date" });
103+
await table.update({ d: ["2024-01-01"] });
104+
const view = await table.view();
105+
const cols = (await view.to_columns()) as { d: number[] };
106+
expect(cols.d[0] % 86400000).toEqual(0);
107+
expect(cols.d[0] / 86400000).toEqual(19723); // days since epoch
108+
await view.delete();
109+
await table.delete();
110+
});
111+
});

rust/perspective-python/docs/lib.md

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -153,15 +153,14 @@ table = perspective.table(data, index="index")
153153

154154
#### Time Zone Handling
155155

156-
When parsing `"datetime"` strings, times are assumed _local time_ unless an
157-
explicit timezone offset is parsed. All `"datetime"` columns (regardless of
158-
input time zone) are _output_ to the user as `datetime.datetime` objects in
159-
_local time_ according to the Python runtime.
160-
161-
This behavior is consistent with Perspective's behavior in JavaScript. For more
162-
details, see this in-depth
163-
[explanation](https://github.com/perspective-dev/perspective/pull/867) of
164-
`perspective-python` semantics around time zone handling.
156+
When parsing `"datetime"` strings, times without an explicit timezone offset are
157+
interpreted as _UTC_; strings with an offset are converted to UTC. All
158+
`"datetime"` values are stored internally as milliseconds since the Unix epoch
159+
and are _output_ as integer timestamps from methods like `to_columns()` and
160+
`to_json()`. `"date"` values are timezone-agnostic calendar days, output as
161+
integer timestamps at _UTC midnight_ of the calendar day. The host process
162+
timezone never affects engine results; localization is the display layer's
163+
responsibility.
165164

166165
### Callbacks and Events
167166

rust/perspective-python/perspective/tests/conftest.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
1111
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
1212

13-
from datetime import datetime, date
13+
from datetime import datetime, date, timezone
1414

1515
import numpy as np
1616
import pandas as pd
@@ -165,10 +165,23 @@ def make_dictionary_arrow(names, data, types=None, legacy=False):
165165

166166
@staticmethod
167167
def to_timestamp(obj):
168-
"""Return an integer timestamp based on a date/datetime object."""
168+
"""Return an integer timestamp based on a date/datetime object.
169+
170+
A `date` is a timezone-agnostic calendar day and serializes to epoch
171+
ms at *UTC* midnight regardless of the host process timezone, so it
172+
is converted here in UTC. A naive `datetime` is a wall-clock reading
173+
in the process-local timezone (callers express expected instants
174+
this way, e.g. `aware.astimezone(TZ).replace(tzinfo=None)`), so it is
175+
converted via local `timestamp()`.
176+
"""
169177
classname = obj.__class__.__name__
170178
if classname == "date":
171-
return int(datetime(obj.year, obj.month, obj.day).timestamp() * 1000)
179+
return int(
180+
datetime(
181+
obj.year, obj.month, obj.day, tzinfo=timezone.utc
182+
).timestamp()
183+
* 1000
184+
)
172185
elif classname == "datetime":
173186
return int(obj.timestamp() * 1000)
174187
else:

0 commit comments

Comments
 (0)