diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 395040b..e247664 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e1debd7..a3945b3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 @@ -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: . @@ -41,7 +41,7 @@ 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/ @@ -49,14 +49,14 @@ repos: 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/ @@ -65,7 +65,7 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit # Ruff version. - rev: 'v0.11.7' + rev: 'v0.15.4' hooks: - id: ruff files: ^python/ @@ -73,7 +73,7 @@ repos: "--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/ \ No newline at end of file diff --git a/setup.py b/setup.py index 4d359ca..aab9992 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ __author__ = 'ASU' # Bump up this version -VERSION = '0.2.17' +VERSION = '0.2.18' basedir = path.abspath(path.dirname(__file__)) @@ -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", diff --git a/tests/test_ts.py b/tests/test_ts.py index a5c3307..3e768a0 100644 --- a/tests/test_ts.py +++ b/tests/test_ts.py @@ -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")) @@ -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")) @@ -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 @@ -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()) @@ -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" diff --git a/tsx/ts.py b/tsx/ts.py index 86ee0c0..d0583e2 100644 --- a/tsx/ts.py +++ b/tsx/ts.py @@ -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) @@ -380,7 +380,7 @@ 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 """ @@ -388,10 +388,9 @@ def as_dt(self, tz: Union[dt_tzinfo, str] = timezone.utc) -> datetime: 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 @@ -399,11 +398,13 @@ def as_dt(self, tz: Union[dt_tzinfo, str] = timezone.utc) -> datetime: 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: @@ -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 @@ -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) @@ -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). """ @@ -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) @@ -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 """ 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 @@ -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: """ @@ -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: """ @@ -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