Skip to content

bugfix: correct timezone handling in as_dt and iso_tz - #5

Merged
asuiu merged 5 commits into
asuiu:masterfrom
amaximciuc:bugfix/as_dt_awareness
Feb 27, 2026
Merged

bugfix: correct timezone handling in as_dt and iso_tz#5
asuiu merged 5 commits into
asuiu:masterfrom
amaximciuc:bugfix/as_dt_awareness

Conversation

@amaximciuc

Copy link
Copy Markdown
Contributor

depends on #4

Copilot AI review requested due to automatic review settings February 26, 2026 16:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() and iTSus.as_dt() to construct UTC datetimes and convert via astimezone() for correct TZ handling.
  • Adjust an existing iso_tz() expectation and add new tests covering iso_tz()/as_dt() TZ conversions and precision regressions.
  • Modernize several type annotations from Union[...] to tzinfo | 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 to as_dt(tz=tz). For timestamp types that inherit BaseTS.as_dt (notably TS), passing a timezone as a string currently goes through the tz.localize(datetime.fromtimestamp(int(self))) path, which interprets the POSIX timestamp in the machine’s local timezone and then re-labels it as tz (instant shifts / host-dependent results). Consider changing BaseTS.as_dt so that string timezones are handled by converting from a UTC-aware datetime (e.g., build an aware UTC datetime from the POSIX timestamp, then astimezone() 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() treats tzinfo objects differently from string timezone names: if the caller passes a pytz timezone instance (a tzinfo), the current code attaches it via datetime(..., tzinfo=tzinfo), which is incorrect for pytz zones (can yield LMT / wrong offsets). Consider detecting pytz tzinfo objects (or more generally, objects with .localize) and using tzinfo.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 as BaseTS.from_parts: when tzinfo is a pytz timezone instance passed directly, datetime(..., tzinfo=tzinfo) produces incorrect offsets. Prefer localizing a naive datetime when the tzinfo comes from pytz (e.g., has a .localize method) 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.

Comment thread tsx/ts.py
Comment on lines +383 to 386
def as_dt(self, tz: dt_tzinfo | str = timezone.utc) -> datetime:
"""
Returns an "aware" datetime object in UTC by default
"""

Copilot AI Feb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread tsx/ts.py
Comment on lines +469 to 472
def iso_tz(self, tz: dt_tzinfo | str) -> str:
"""
Returns ISO date format with TZ info.
Example: 2021-01-01

Copilot AI Feb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread tsx/ts.py
Comment on lines +1259 to 1262
def as_dt(self, tz: dt_tzinfo | str = timezone.utc) -> datetime:
"""
Returns an "aware" datetime object in UTC by default
"""

Copilot AI Feb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread tests/test_ts.py
Comment on lines +1085 to +1089
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))

Copilot AI Feb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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())

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's why #4 is needed.

@asuiu asuiu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@asuiu
asuiu merged commit 7aca7da into asuiu:master Feb 27, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants