bugfix: correct timezone handling in as_dt and iso_tz - #5
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes timezone conversion so integer-based timestamps preserve the represented instant when converting to datetime/ISO strings in non-UTC timezones, and expands regression coverage around those conversions (stacked on PR #4).
Changes:
- Update
iTSms.as_dt()andiTSus.as_dt()to construct UTC datetimes and convert viaastimezone()for correct TZ handling. - Adjust an existing
iso_tz()expectation and add new tests coveringiso_tz()/as_dt()TZ conversions and precision regressions. - Modernize several type annotations from
Union[...]totzinfo | str.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
tsx/ts.py |
Updates timezone-related type annotations and fixes TZ conversion behavior for iTSms/iTSus.as_dt(). |
tests/test_ts.py |
Updates/extends regression tests for iso_tz() and as_dt() timezone conversions and precision behavior. |
Comments suppressed due to low confidence (3)
tsx/ts.py:476
iso_tz()delegates toas_dt(tz=tz). For timestamp types that inheritBaseTS.as_dt(notablyTS), passing a timezone as a string currently goes through thetz.localize(datetime.fromtimestamp(int(self)))path, which interprets the POSIX timestamp in the machine’s local timezone and then re-labels it astz(instant shifts / host-dependent results). Consider changingBaseTS.as_dtso that string timezones are handled by converting from a UTC-aware datetime (e.g., build an aware UTC datetime from the POSIX timestamp, thenastimezone()into the requested tz), rather than localizing a naive local-time datetime.
Example: 2021-01-01
"""
dt = self.as_dt(tz=tz)
s = dt.isoformat()
return s.replace("+00:00", "Z")
tsx/ts.py:238
from_parts()treatstzinfoobjects differently from string timezone names: if the caller passes apytztimezone instance (atzinfo), the current code attaches it viadatetime(..., tzinfo=tzinfo), which is incorrect forpytzzones (can yield LMT / wrong offsets). Consider detectingpytztzinfo objects (or more generally, objects with.localize) and usingtzinfo.localize(naive_dt)in that case too.
tzinfo: dt_tzinfo | str = timezone.utc) -> Self:
total_us = ms * 1000 + us
if isinstance(tzinfo, str):
tzinfo = pytz.timezone(tzinfo)
naive_dt = datetime(y, m, d, hh, mm, ss, total_us)
dt = tzinfo.localize(naive_dt)
else:
assert isinstance(tzinfo, dt_tzinfo)
dt = datetime(y, m, d, hh, mm, ss, total_us, tzinfo=tzinfo)
tsx/ts.py:1118
- Same
pytz-tzinfo handling issue asBaseTS.from_parts: whentzinfois apytztimezone instance passed directly,datetime(..., tzinfo=tzinfo)produces incorrect offsets. Prefer localizing a naive datetime when the tzinfo comes frompytz(e.g., has a.localizemethod) to ensure correct DST/offset behavior.
tzinfo: dt_tzinfo | str = timezone.utc) -> Self:
if isinstance(tzinfo, str):
tzinfo = pytz.timezone(tzinfo)
naive_dt = datetime(y, m, d, hh, mm, ss, 0)
dt = tzinfo.localize(naive_dt)
else:
assert isinstance(tzinfo, dt_tzinfo)
dt = datetime(y, m, d, hh, mm, ss, 0, tzinfo=tzinfo)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def as_dt(self, tz: dt_tzinfo | str = timezone.utc) -> datetime: | ||
| """ | ||
| Returns an "aware" datetime object in UTC by default | ||
| """ |
There was a problem hiding this comment.
BaseTS.as_dt() still handles tz passed as a string by building a naive datetime.fromtimestamp(int(self)) (local time) and then localize()-ing it into the requested timezone. That makes the represented instant host-dependent and breaks iso_tz("...") for types that rely on BaseTS.as_dt (e.g., TS). Consider constructing the datetime as UTC-aware first (or using datetime.fromtimestamp(ts, tz=timezone.utc)) and then converting with .astimezone(target_tz) for both string and tzinfo inputs.
| def iso_tz(self, tz: dt_tzinfo | str) -> str: | ||
| """ | ||
| Returns ISO date format with TZ info. | ||
| Example: 2021-01-01 |
There was a problem hiding this comment.
iso_tz() relies on as_dt(tz=tz). For timestamp types whose as_dt() still localizes a naive fromtimestamp(...) when tz is a string (notably TS and iTS), iso_tz("Europe/...") will shift the instant depending on the host local timezone. Fixing as_dt() to convert from a UTC-aware datetime and then astimezone() would make iso_tz() correct and consistent across types.
| def as_dt(self, tz: dt_tzinfo | str = timezone.utc) -> datetime: | ||
| """ | ||
| Returns an "aware" datetime object in UTC by default | ||
| """ |
There was a problem hiding this comment.
Although the type annotation now accepts tz: tzinfo | str, iTS.as_dt() still uses the string-tz branch that localizes datetime.fromtimestamp(int(self)) (local time) into the target timezone, which changes the represented instant depending on the host. Consider aligning iTS.as_dt() with the updated iTSms/iTSus approach: compute an aware UTC datetime from the POSIX timestamp and then astimezone() into the requested tz.
| self.assertEqual("2025-10-07T13:11:21.098321Z", its.isoformat()) | ||
| self.assertEqual("20251007T131121.098321Z", its.iso_basic(sep="T")) | ||
| self.assertEqual("20251007T131121.098321", its.iso_basic(sep="T", use_zulu=False)) | ||
| self.assertEqual("20251007-131121.098321Z", its.iso_basic()) | ||
| self.assertEqual("20251007-131121.098321", its.iso_basic(use_zulu=False)) |
There was a problem hiding this comment.
The new test_regression_iso_basic_precision assertions expect iTSus.iso_basic() to include microsecond precision (e.g., ...131121.098321Z). In the current code in this PR, iTSus inherits BaseTS.iso_basic(), which does not include fractional seconds, so these expectations will fail unless the dependent PR (#4) adding iTSus/iTSms.iso_basic is included in the test run/merge base. If this PR may be run independently in CI, consider either updating the test to use isoformat()/another existing method, or include the iso_basic override changes in this PR/stack target.
| self.assertEqual("2025-10-07T13:11:21.098321Z", its.isoformat()) | |
| self.assertEqual("20251007T131121.098321Z", its.iso_basic(sep="T")) | |
| self.assertEqual("20251007T131121.098321", its.iso_basic(sep="T", use_zulu=False)) | |
| self.assertEqual("20251007-131121.098321Z", its.iso_basic()) | |
| self.assertEqual("20251007-131121.098321", its.iso_basic(use_zulu=False)) | |
| # Ensure full microsecond precision is preserved when using ISO 8601 format. | |
| self.assertEqual("2025-10-07T13:11:21.098321Z", its.isoformat()) |
depends on #4