Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
fail-fast: false # Changed to false to see all failures
matrix:
os: [ ubuntu-latest, windows-latest, macos-latest, ubuntu-24.04-arm ]
python-version: [ "3.8","3.9","3.10", "3.11","3.12", "3.13", "3.14" ]
python-version: ["3.10", "3.11","3.12", "3.13", "3.14" ]

steps:
- uses: actions/checkout@v4
Expand Down
14 changes: 7 additions & 7 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v6.0.0
hooks:
- id: check-ast
- id: check-case-conflict
Expand All @@ -19,7 +19,7 @@ repos:
- id: requirements-txt-fixer

- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.15.0
rev: v1.19.1
hooks:
- id: mypy
files: .
Expand All @@ -41,22 +41,22 @@ repos:
]

- repo: https://github.com/psf/black
rev: 25.1.0 # Replace by any tag/version: https://github.com/psf/black/tags
rev: 26.1.0 # Replace by any tag/version: https://github.com/psf/black/tags
hooks:
- id: black
files: ^python/
args: [ '--config', 'python/pyproject.toml' ]
language_version: python3 # Should be a command that runs python3.6+

- repo: https://github.com/pycqa/isort
rev: 6.0.1
rev: 8.0.0
hooks:
- id: isort
files: ^python/
args: [ "--profile", "black", "--settings-file", "python/pyproject.toml" ]

- repo: https://github.com/pycqa/pylint
rev: v3.3.6
rev: v4.0.5
hooks:
- id: pylint
files: ^python/
Expand All @@ -65,15 +65,15 @@ repos:

- repo: https://github.com/charliermarsh/ruff-pre-commit
# Ruff version.
rev: 'v0.11.7'
rev: 'v0.15.4'
hooks:
- id: ruff
files: ^python/
args: [ "--config=python/pyproject.toml",
"--exit-non-zero-on-fix" ] # don't use --fix as it corrupts the files

- repo: https://github.com/kynan/nbstripout
rev: 0.8.1
rev: 0.9.1
hooks:
- id: nbstripout
files: ^python/
4 changes: 2 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
__author__ = 'ASU'

# Bump up this version
VERSION = '0.2.17'
VERSION = '0.2.18'

basedir = path.abspath(path.dirname(__file__))

Expand All @@ -27,7 +27,7 @@
# https://packaging.python.org/en/latest/single_source_version.html
version=VERSION,

description='TimeStam eXtensions for Python',
description='TimeStamp eXtensions for Python',
long_description=long_description,
long_description_content_type="text/markdown",

Expand Down
46 changes: 45 additions & 1 deletion tests/test_ts.py
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,7 @@ def test_from_ints(self):

ts = iTSns.from_parts(2022, 12, 7, 1, 2, 3, 456, 789, 999, tzinfo="Europe/Berlin")
self.assertEqual(ts, iTSns("2022-12-07T00:02:03.456789999Z")) # we don't have enough float precision to represent nanos
self.assertEqual(ts.iso_tz("Europe/Bucharest"), "2022-12-07T00:02:03.456790+02:00") # we don't have enough float precision to represent nanos
self.assertEqual(ts.iso_tz("Europe/Bucharest"), "2022-12-07T02:02:03.456790+02:00") # we don't have enough float precision to represent nanos

ts = iTSms.from_parts(2022, 6, 7, 1, 2, 3, 456, 789, 999, tzinfo="Europe/Bucharest")
self.assertEqual(ts, iTSms("2022-06-06T22:02:03.456Z"))
Expand Down Expand Up @@ -732,6 +732,9 @@ def test_as_iso_tz_standard(self):
res = ts.iso_tz("Europe/Bucharest")
self.assertEqual(res, "2018-03-01T02:00:00+02:00")

res = iTSns("2018-06-01T00:00:00.123456789Z").iso_tz("Europe/Bucharest")
self.assertEqual(res, "2018-06-01T03:00:00.123457+03:00")

def test_as_iso_tz_DST(self):
ts = iTS("2020-06-01T10:00:00Z")
res = ts.iso_tz(pytz.timezone("Europe/Bucharest"))
Expand Down Expand Up @@ -1011,6 +1014,16 @@ def test_str(self):
ts = iTSms(ts=TestTS.INT_BASE_MS_TS)
self.assertEqual(TestTS.STR_MSEC_TS, str(ts))

def test_as_iso_tz_standard(self):
ts = iTSms("2018-03-01T00:00:00Z")
res = ts.iso_tz(pytz.timezone("Europe/Bucharest"))
self.assertEqual(res, "2018-03-01T02:00:00+02:00")
res = ts.iso_tz("Europe/Bucharest")
self.assertEqual(res, "2018-03-01T02:00:00+02:00")

res = iTSns("2018-06-01T00:00:00.123456789Z").iso_tz("Europe/Bucharest")
self.assertEqual(res, "2018-06-01T03:00:00.123457+03:00")

def test_pydantic_validator(self):
class TestModel(BaseModel):
ts: iTSms
Expand Down Expand Up @@ -1064,6 +1077,26 @@ class Test_iTSus(TestCase):
def test_from_iso(self):
run_test_from_iso(self, iTSus)

def test_as_dt_tz_conversion_preserves_instant_regression(self):
ts = iTSus("2018-03-01T00:00:00.123456Z")
dt_utc = ts.as_dt(timezone.utc)
dt_buc = ts.as_dt("Europe/Bucharest")

# Same instant, different timezone representation.
self.assertEqual(dt_utc.timestamp(), dt_buc.timestamp())
self.assertEqual(dt_utc.isoformat(), "2018-03-01T00:00:00.123456+00:00")
self.assertEqual(dt_buc.isoformat(), "2018-03-01T02:00:00.123456+02:00")

res = iTSns("2018-06-01T00:00:00.123456789Z").iso_tz("Europe/Bucharest")
self.assertEqual(res, "2018-06-01T03:00:00.123457+03:00")

def test_as_iso_tz_standard(self):
ts = iTSus("2018-03-01T00:00:00.123456Z")
res = ts.iso_tz(pytz.timezone("Europe/Bucharest"))
self.assertEqual(res, "2018-03-01T02:00:00.123456+02:00")
res = ts.iso_tz("Europe/Bucharest")
self.assertEqual(res, "2018-03-01T02:00:00.123456+02:00")

def test_regression_iso_basic_precision(self):
its = iTSus("2025-10-07T13:11:21.098321Z")
self.assertEqual("2025-10-07T13:11:21.098321Z", its.isoformat())
Expand Down Expand Up @@ -1178,6 +1211,17 @@ class Test_iTSns(TestCase):
def test_from_iso(self):
run_test_from_iso(self, iTSns)

def test_as_iso_tz_standard(self):
ts = iTSns("2018-03-01T00:00:00.123456789Z")
# as_dt() is microsecond-based for iTSns via as_usec(), so TZ conversion rounds to microseconds.
res = ts.iso_tz(pytz.timezone("Europe/Bucharest"))
self.assertEqual(res, "2018-03-01T02:00:00.123457+02:00")
res = ts.iso_tz("Europe/Bucharest")
self.assertEqual(res, "2018-03-01T02:00:00.123457+02:00")

res = iTSns("2018-06-01T00:00:00.123456789Z").iso_tz("Europe/Bucharest")
self.assertEqual(res, "2018-06-01T03:00:00.123457+03:00")

def test_regression_itsns_constructor_vs_from_iso(self):
"""Test that iTSns() and iTSns.from_iso() produce the same result"""
date_str = "2025-11-02T13:45:14.012345678Z"
Expand Down
54 changes: 28 additions & 26 deletions tsx/ts.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ def from_parts_utc(cls, y: int, m: int = 1, d: int = 1, hh: int = 0, mm: int = 0

@classmethod
def from_parts(cls, y: int, m: int = 1, d: int = 1, hh: int = 0, mm: int = 0, ss: int = 0, ms: int = 0, us: int = 0, ns: int = 0,
tzinfo: Union[str, dt_tzinfo] = timezone.utc) -> Self:
tzinfo: dt_tzinfo | str = timezone.utc) -> Self:
total_us = ms * 1000 + us
if isinstance(tzinfo, str):
tzinfo = pytz.timezone(tzinfo)
Expand Down Expand Up @@ -380,30 +380,31 @@ def timestamp(self) -> "TS":
"""
raise NotImplementedError()

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

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.
ts = float(self.timestamp())
try:
if isinstance(tz, str):
tz = pytz.timezone(tz)
naive_dt = datetime.fromtimestamp(int(self))
dt = tz.localize(naive_dt)
else:
dt = datetime.fromtimestamp(ts, tz=tz)
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
if tz != timezone.utc:
dt = dt.astimezone(tz)
return dt
except OSError:
# can't convert due to overflow error, so we need to do it using other method
# return datetime.utcfromtimestamp(float(ts)) also fails
days = ts // SECONDS_PER_DAY
years = int(days / AVG_DAYS_PER_YEAR) # Average considering leap years
year = 1970 + years
year_date = datetime(year, 1, 1, tzinfo=tz)
td_to_year_beginning = year_date - datetime(1970, 1, 1, tzinfo=tz)
year_date = datetime(year, 1, 1, tzinfo=timezone.utc)
td_to_year_beginning = year_date - datetime(1970, 1, 1, tzinfo=timezone.utc)
year_remaining_sec = ts - td_to_year_beginning.total_seconds()
td = timedelta(seconds=year_remaining_sec)
res = year_date + td
if tz != timezone.utc:
res = res.astimezone(tz)
return res

def as_local_dt(self) -> datetime:
Expand Down Expand Up @@ -466,7 +467,7 @@ def iso_date_basic(self, use_zulu: bool = False) -> str:
"""
return self.iso_date(sep="", use_zulu=use_zulu)

def iso_tz(self, tz: Union[str, dt_tzinfo]) -> str:
def iso_tz(self, tz: dt_tzinfo | str) -> str:
"""
Returns ISO date format with TZ info.
Example: 2021-01-01
Comment on lines +470 to 473

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.
Expand Down Expand Up @@ -660,7 +661,7 @@ def as_iso_date_basic(self) -> str:
s = self.as_dt().strftime("%Y%m%d")
return s

def as_iso_tz(self, tz: Union[str, dt_tzinfo]) -> str:
def as_iso_tz(self, tz: dt_tzinfo | str) -> str:
if isinstance(tz, str):
tz = pytz.timezone(tz)
dt = self.as_dt(tz=tz)
Expand Down Expand Up @@ -842,7 +843,7 @@ def __setattr__(self, name: str, value: Any) -> None:
object.__setattr__(self, name, value)

@classmethod
def from_year(cls, year: int, tz: Union[str, dt_tzinfo] = timezone.utc, dtype:Optional[Type[BaseTS]]=None) -> "TSInterval":
def from_year(cls, year: int, tz: dt_tzinfo | str = timezone.utc, dtype:Optional[Type[BaseTS]]=None) -> "TSInterval":
"""
Create an interval covering the full calendar year in the requested timezone (defaults to UTC).
"""
Expand Down Expand Up @@ -1107,7 +1108,7 @@ class iBaseTS(BaseTS, int):
@override
@classmethod
def from_parts(cls, y: int, m: int = 1, d: int = 1, hh: int = 0, mm: int = 0, ss: int = 0, ms: int = 0, us: int = 0, ns: int = 0,
tzinfo: Union[str, dt_tzinfo] = timezone.utc) -> Self:
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)
Expand Down Expand Up @@ -1256,16 +1257,16 @@ def _get_auto_timespec(self) -> str:
"""
return "seconds"

def as_dt(self, tz: Union[str, dt_tzinfo] = timezone.utc) -> datetime:
def as_dt(self, tz: dt_tzinfo | str = timezone.utc) -> datetime:
"""
Returns an "aware" datetime object in UTC by default
"""
Comment on lines +1260 to 1263

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.
if isinstance(tz, str):
tz = pytz.timezone(tz)
naive_dt = datetime.fromtimestamp(int(self))
return tz.localize(naive_dt)
assert isinstance(tz, dt_tzinfo)
utc_dt = datetime.fromtimestamp(int(self), tz=tz)
utc_dt = datetime.fromtimestamp(int(self), tz=timezone.utc)
if tz != timezone.utc:
utc_dt = utc_dt.astimezone(tz)
return utc_dt


Expand Down Expand Up @@ -1306,12 +1307,16 @@ def __new__(cls, ts: Union[int, float, str], utc: bool = True):
def as_msec(self) -> "iTSms":
return self

def as_dt(self, tz: dt_tzinfo = timezone.utc) -> datetime:
def as_dt(self, tz: dt_tzinfo | str = timezone.utc) -> datetime:
"""
Returns an "aware" datetime object in UTC by default
"""
seconds, ms = divmod(self, 1000)
return datetime(1970, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=seconds, milliseconds=ms)
utc_dt = datetime(1970, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=seconds, milliseconds=ms)
if isinstance(tz, str):
tz = pytz.timezone(tz)
assert isinstance(tz, dt_tzinfo)
return utc_dt.astimezone(tz)

def _get_auto_timespec(self) -> str:
"""
Expand Down Expand Up @@ -1376,19 +1381,16 @@ def _get_auto_timespec(self) -> str:
"""
return "microseconds"

def as_dt(self, tz: Union[str, dt_tzinfo] = timezone.utc) -> datetime:
def as_dt(self, tz: dt_tzinfo | str = timezone.utc) -> datetime:
"""
Returns an "aware" datetime object in UTC by default
"""
seconds, us = divmod(self, 1_000_000)
utc_dt = datetime(1970, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=seconds, microseconds=us)
if isinstance(tz, str):
tz = pytz.timezone(tz)
naive_dt = EPOCH_DT + timedelta(seconds=seconds, microseconds=us)
dt = tz.localize(naive_dt)
else:
assert isinstance(tz, dt_tzinfo)
dt = datetime(1970, 1, 1, tzinfo=tz) + timedelta(seconds=seconds, microseconds=us)
return dt
assert isinstance(tz, dt_tzinfo)
return utc_dt.astimezone(tz)

def iso_basic(self, sep="-", use_zulu: bool = True) -> str:
"""
Expand Down Expand Up @@ -1505,7 +1507,7 @@ def as_usec(self) -> "iTSus":
us += 1
return iTSus(us)

def as_dt(self, tz: Union[str, dt_tzinfo] = timezone.utc) -> datetime:
def as_dt(self, tz: dt_tzinfo | str = timezone.utc) -> datetime:
"""
Returns an "aware" datetime object in UTC by default;
Since the datetime object has a microsecond resolution, we'll convert to iTSus and return it
Expand Down