Preserve the instant when deserializing a timestamp into AwareDateTime - #3032
Preserve the instant when deserializing a timestamp into AwareDateTime#3032yousaf-360 wants to merge 2 commits into
Conversation
`AwareDateTime(format="timestamp", default_timezone=tz)` returned the wrong
moment in time. `from_timestamp` computes the UTC wall time and strips the
tzinfo, and `_deserialize` then relabelled that naive value with
`default_timezone` instead of converting it, which shifts the instant by
that zone's offset.
>>> field = fields.AwareDateTime(format="timestamp", default_timezone=central)
>>> field.deserialize(1384043025).timestamp()
1384064625.0 # six hours later than the value that went in
Relabelling is right for the string formats, where a naive value is a wall
time whose zone the caller is supplying. It is wrong for the timestamp
formats, where the naive value is UTC wall time and the instant is already
known, so convert instead for those.
|
Thanks @yousaf-360 for the detailed report. Indeed, there's a bug, here. Maybe we shouldn't have mixed timestamp and datetime this way, the logic differs too much. A timestamp is neither naive or aware, but it points to a specific moment in time, so it can be converted to an aware or naive datetime (given a timezone). In our current implementation, the default timezone is meant for naive datetimes, it doesn't replace the timezone on an aware datetime. We could do that with some e.g. In the fix you propose, you use the default timezone to convert the datetime from UTC. This is not really in line with the behaviour with datetimes. If we say a timestamp is UTC (which is debatable), it should be returned as UTC. We could change your fix to force UTC and keep it as is whatever the default timezone: if not utils.is_aware(ret):
if (
self.format or self.DEFAULT_FORMAT
) in self.UTC_FORMATS and not isinstance(value, dt.datetime):
ret = ret.replace(tzinfo=tz.UTC)And we could add that conversion parameter I suggest above, if needed. Not sure that's the way to go, just thinking out loud. This needs a bit of thought. (Also, I'm not sure the case of value being a datetime is meant to be supported. I remember discussions about allowing already deserialized data to be deserialized, but I thought we decided not to.) |
Adopt the approach lafrech suggested in review. `default_timezone` is documented as supplying a timezone that a naive value is missing, and it does not re-express aware values, so using it as a conversion target for timestamps was out of step with how it behaves everywhere else. A POSIX timestamp already fixes the instant, so there is nothing missing for `default_timezone` to supply. Timestamp formats now deserialize to UTC, and `default_timezone` does not apply to them. This also settles the case the first version left alone: with no `default_timezone`, `AwareDateTime(format="timestamp")` previously rejected every input it could ever be given, and now returns UTC.
|
That's a better framing than mine, and I've pushed it as 88e6386. You're right that using Two things worth knowing before you decide. It settles the no-default case too.
Test impact: the block in
It works today — Happy to go back to the conversion version, or to split the no-default change out, if either lands better. |
I gave it a quick look and from a few other fields, it looks like it was meant to work this way. I guess we decided the cost was low so we might as well support that. Custom fields may not. Let's keep this as it is. Sorry for bringing this up. Regarding the issue, we could go further and treat timestamps as explicit UTC. It doesn't make much sense to turn a timestamp into a naive datetime without a default datetime to convert to (unless we assume UTC is the default default datetime).
Basically, this means removing This implementation seems more "pure" to me. We may have to balance ideal solution vs. breaking changes. I like to think of the ideal solution first, then figure out whether we need to find a less breaking fix for current stable version. (Now, I'm wondering about serialization of naive datetimes. Should we keep it UTC or use the field timezone so that it round trips correctly? The timezone is optional so it would fail if none is provided. We could raise at (Another point that we may want to take into account it default Python behaviour, which is generally sensible and can be the ground for user expectations. In this case, I don't find the use of the local system TZ sensible. It may make sense for local use, but not in an API served by a server for which the system TZ is irrelevant. This is the reason why I had to overload timestamp/fromtimestamp in utils.py. I'd rather stick to that.) |
AwareDateTime(format="timestamp", default_timezone=tz)returns a different moment in time than the one it was given.1384043025is2013-11-10 00:23:45+00:00. What comes back is2013-11-10 00:23:45-06:00, which is06:23:45UTC. The value silently identifies a different instant, and the drift is whatever the configured zone's offset happens to be.Cause
utils.from_timestampresolves the timestamp against UTC and then drops the tzinfo, leaving UTC wall time in a naive object:AwareDateTime._deserializethen relabels whatever naive value it gets:For
iso,rfcandstrftimeformats that is the correct reading ofdefault_timezone: the parsed value really is a bare wall time, and the caller is supplying the zone it should be interpreted in. For the timestamp formats it is not — the instant was never in doubt, and the naive object is UTC wall time. Relabelling it reinterprets a UTC reading as a local one.Change
DateTimenow records which deserialization formats yield UTC wall time, andAwareDateTimeconverts rather than relabels for those:The string formats are untouched, and so are
DateTimeandNaiveDateTime. The guard also skips values that arrive asdatetimeobjects rather than through the wire format, since those did not come from a timestamp and the existing relabelling behaviour is right for them.After the change the instant survives, expressed in the requested zone:
Tests
Added
test_aware_timestamp_deserialization_preserves_instant, which asserts the round trip directly for both timestamp formats. It fails onmainand passes with the change.I also had to amend the existing assertion in
test_timestamp_field_deserialization, which pinned the old behaviour:It now checks that the instant matches and that the result carries the requested zone. I want to flag that clearly, since it means this PR changes behaviour that was deliberately covered — but the covered behaviour returns the wrong instant, so I do not think it can be kept as is.
Full suite: 1189 passed. The one failure,
test_from_timestamp_with_overflow_value, is #2999 and fails the same way on a clean checkout here (Windows message mismatch).ruff checkandruff formatare clean on both files.Two things I left alone
AwareDateTime(format="timestamp")with nodefault_timezonestill rejects every input, because the value it is handed is naive. Since a POSIX timestamp is always UTC, defaulting to UTC there would make the field usable rather than guaranteed to fail, but that is a larger behaviour question than the bug above and the current behaviour is at least not wrong. Happy to fold it in if you would like it.I have not added a CHANGELOG entry, since entries reference the PR number. Glad to push one under "Bug fixes" now that this has a number.