From 0e3e16b68c4320a846c04b475df90d3a3c7288f7 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sun, 5 Oct 2025 12:25:54 +0200 Subject: [PATCH 01/49] indexing --- timetest.py | 4 ++++ unit_of_time/__init__.py | 28 +++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/timetest.py b/timetest.py index 4c7dd43..ce0838b 100644 --- a/timetest.py +++ b/timetest.py @@ -99,6 +99,9 @@ def test_to_int(self): self.assertLess(tu, tu.next) self.assertLessEqual(tu.previous, tu) self.assertLessEqual(tu, tu.next) + self.assertEqual(kind.get_index_for_date(dt), kind.get_index_for_date(tu.next.dt) - 1) + for dt2 in tu: + self.assertEqual(kind.get_index_for_date(dt), kind.get_index_for_date(dt2)) self.assertGreater(tu, tu.previous) self.assertGreater(tu.next, tu) self.assertGreaterEqual(tu, tu.previous) @@ -197,6 +200,7 @@ def test_kinds(self): self.assertEqual(kind, kind.kind_int) self.assertEqual(kind.kind_int, kind) self.assertEqual(d[kind], kind in seen) + self.assertEqual(kind.get_index_for_date(date.min), 0) d[kind] = True self.assertNotIn(kind, seen) seen.add(kind) diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index 39c8afa..43057a5 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -136,6 +136,9 @@ def get_next(cls, dt): def to_str(cls, dt): return dt.strftime(cls.formatter) + def get_index_for_date(cls, dt): + return None + def truncate(cls, dt): return datetime.strptime(cls.to_str(dt), cls.formatter).date() @@ -171,6 +174,10 @@ class Year(TimeunitKind): def _next(cls, dt): return date(dt.year + 1, 1, 1) + @classmethod + def get_index_for_date(cls, dt): + return dt.year - date.min.year + @classmethod def _inner_shift(cls, cur, dt, amount): return date(dt.year + amount, 1, 1) @@ -181,7 +188,12 @@ class Quarter(TimeunitKind): @classmethod def to_str(cls, dt): - return f"{dt.year}Q{dt.month//3}" + return f"{dt.year}Q{(dt.month+2)//3}" + + @classmethod + def get_index_for_date(cls, dt): + return 4 * (dt.year - date.min.year) + max((dt.month - 1) // 3, 0) + @classmethod def truncate(cls, dt): @@ -211,6 +223,10 @@ def _inner_shift(cls, cur, dt, amount): m_new = dt.year * 12 + amount + dt.month - 1 return date(m_new // 12, m_new % 12 + 1, 1) + @classmethod + def get_index_for_date(cls, dt): + return 12 * (dt.year - date.min.year) + dt.month - 1 + @classmethod def _next(cls, dt): m2 = dt.month + 1 @@ -228,6 +244,11 @@ class Week(TimeunitKind): def _inner_shift(cls, cur, dt, amount): return dt + timedelta(days=7 * amount) + @classmethod + def get_index_for_date(cls, dt): + # date.min has weekday() == 0 + return (dt - date.min).days // 7 + @classmethod def truncate(cls, dt): if isinstance(dt, datetime): @@ -243,6 +264,11 @@ class Day(TimeunitKind): kind_int = 9 formatter = "%Y-%m-%d" + @classmethod + def get_index_for_date(cls, dt): + return (dt - date.min).days + + @classmethod def _inner_shift(cls, cur, dt, amount): return dt + timedelta(days=amount) From 3d68d1e03740ed65a329f12675d420584497497c Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sun, 5 Oct 2025 12:26:53 +0200 Subject: [PATCH 02/49] black --- timetest.py | 9 +++++++-- unit_of_time/__init__.py | 2 -- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/timetest.py b/timetest.py index ce0838b..3e9927a 100644 --- a/timetest.py +++ b/timetest.py @@ -99,9 +99,14 @@ def test_to_int(self): self.assertLess(tu, tu.next) self.assertLessEqual(tu.previous, tu) self.assertLessEqual(tu, tu.next) - self.assertEqual(kind.get_index_for_date(dt), kind.get_index_for_date(tu.next.dt) - 1) + self.assertEqual( + kind.get_index_for_date(dt), + kind.get_index_for_date(tu.next.dt) - 1, + ) for dt2 in tu: - self.assertEqual(kind.get_index_for_date(dt), kind.get_index_for_date(dt2)) + self.assertEqual( + kind.get_index_for_date(dt), kind.get_index_for_date(dt2) + ) self.assertGreater(tu, tu.previous) self.assertGreater(tu.next, tu) self.assertGreaterEqual(tu, tu.previous) diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index 43057a5..bf1debc 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -194,7 +194,6 @@ def to_str(cls, dt): def get_index_for_date(cls, dt): return 4 * (dt.year - date.min.year) + max((dt.month - 1) // 3, 0) - @classmethod def truncate(cls, dt): return date(dt.year, 3 * ((dt.month - 1) // 3) + 1, 1) @@ -268,7 +267,6 @@ class Day(TimeunitKind): def get_index_for_date(cls, dt): return (dt - date.min).days - @classmethod def _inner_shift(cls, cur, dt, amount): return dt + timedelta(days=amount) From 8a6b78464b4c486a02b24d25dd888968a4a286ba Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sun, 5 Oct 2025 13:12:48 +0200 Subject: [PATCH 03/49] year prev --- unit_of_time/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index bf1debc..80dc66e 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -170,6 +170,10 @@ class Year(TimeunitKind): kind_int = 1 formatter = "%Y" + @classmethod + def truncate(cls, dt): + return date(dt.year, 1, 1) + @classmethod def _next(cls, dt): return date(dt.year + 1, 1, 1) From 6c1fa4c15b8da90a9bcff1338a44696c0c0b14da Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sun, 5 Oct 2025 17:36:05 +0200 Subject: [PATCH 04/49] fix test --- timetest.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/timetest.py b/timetest.py index 3e9927a..8e1fdbd 100644 --- a/timetest.py +++ b/timetest.py @@ -20,6 +20,10 @@ def truncate(cls, dt): date: The first day (January 1) of the decade in which `dt` falls. """ return date(10 * (dt.year // 10), 1, 1) + + @classmethod + def get_index_for_date(cls, dt): + return dt.year // 10 @classmethod def last_day(cls, dt): From 7ccdf3855feaf982a40ca7570ff2e1963f07f11c Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sun, 5 Oct 2025 17:37:13 +0200 Subject: [PATCH 05/49] black reformatting --- timetest.py | 2 +- unit_of_time/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/timetest.py b/timetest.py index 8e1fdbd..770e0d9 100644 --- a/timetest.py +++ b/timetest.py @@ -20,7 +20,7 @@ def truncate(cls, dt): date: The first day (January 1) of the decade in which `dt` falls. """ return date(10 * (dt.year // 10), 1, 1) - + @classmethod def get_index_for_date(cls, dt): return dt.year // 10 diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index 80dc66e..b1e2a15 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -276,7 +276,7 @@ def _inner_shift(cls, cur, dt, amount): return dt + timedelta(days=amount) @classmethod - def _next(self, dt): + def _next(cls, dt): return dt + timedelta(days=1) From 07b0a64d03d9856693e903a24b9210ef6d2ac8c2 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Mon, 6 Oct 2025 21:18:22 +0200 Subject: [PATCH 06/49] indexes --- timetest.py | 13 +++++++++++-- unit_of_time/__init__.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/timetest.py b/timetest.py index 770e0d9..f907706 100644 --- a/timetest.py +++ b/timetest.py @@ -103,13 +103,22 @@ def test_to_int(self): self.assertLess(tu, tu.next) self.assertLessEqual(tu.previous, tu) self.assertLessEqual(tu, tu.next) + idx = kind.get_index_for_date(tu.dt) self.assertEqual( - kind.get_index_for_date(dt), + idx, kind.get_index_for_date(tu.next.dt) - 1, ) + self.assertEqual( + tu.dt, + kind.get_date_from_index(idx) + ) + self.assertEqual( + tu, + kind[idx] + ) for dt2 in tu: self.assertEqual( - kind.get_index_for_date(dt), kind.get_index_for_date(dt2) + idx, kind.get_index_for_date(dt2) ) self.assertGreater(tu, tu.previous) self.assertGreater(tu.next, tu) diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index b1e2a15..84fca76 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -139,6 +139,14 @@ def to_str(cls, dt): def get_index_for_date(cls, dt): return None + def get_date_from_index(cls, dt): + return None + + def __getitem__(cls, item): + if isinstance(item, int): + return cls(cls.get_date_from_index(item)) + raise TypeError(f'Can not lookup an index for {item}') + def truncate(cls, dt): return datetime.strptime(cls.to_str(dt), cls.formatter).date() @@ -182,6 +190,10 @@ def _next(cls, dt): def get_index_for_date(cls, dt): return dt.year - date.min.year + @classmethod + def get_date_from_index(cls, idx): + return date(idx+date.min.year, 1, 1) + @classmethod def _inner_shift(cls, cur, dt, amount): return date(dt.year + amount, 1, 1) @@ -198,6 +210,12 @@ def to_str(cls, dt): def get_index_for_date(cls, dt): return 4 * (dt.year - date.min.year) + max((dt.month - 1) // 3, 0) + @classmethod + def get_date_from_index(cls, idx): + yy = (idx - 1) // 4 + qq = idx - 4 * yy + return date(yy, 3*qq+1, 1) + @classmethod def truncate(cls, dt): return date(dt.year, 3 * ((dt.month - 1) // 3) + 1, 1) @@ -230,6 +248,12 @@ def _inner_shift(cls, cur, dt, amount): def get_index_for_date(cls, dt): return 12 * (dt.year - date.min.year) + dt.month - 1 + @classmethod + def get_date_from_index(cls, idx): + yy = (idx - 1) // 12 + mm = ((idx - 1) % 12) + 1 + return date(yy, mm, 1) + @classmethod def _next(cls, dt): m2 = dt.month + 1 @@ -252,6 +276,10 @@ def get_index_for_date(cls, dt): # date.min has weekday() == 0 return (dt - date.min).days // 7 + @classmethod + def get_date_from_index(cls, idx): + return date.min + timedelta(days=7*idx) + @classmethod def truncate(cls, dt): if isinstance(dt, datetime): @@ -271,6 +299,10 @@ class Day(TimeunitKind): def get_index_for_date(cls, dt): return (dt - date.min).days + @classmethod + def get_date_from_index(cls, idx): + return date.min + timedelta(days=idx) + @classmethod def _inner_shift(cls, cur, dt, amount): return dt + timedelta(days=amount) From b128213b0b587b5e38f9c33c187ded8a4da94462 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Mon, 6 Oct 2025 21:18:29 +0200 Subject: [PATCH 07/49] =?UTF-8?q?=E2=84=AC=F0=9D=93=81=F0=9D=92=B6?= =?UTF-8?q?=F0=9D=92=B8=F0=9D=93=80=20reformatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- timetest.py | 14 +++----------- unit_of_time/__init__.py | 8 ++++---- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/timetest.py b/timetest.py index f907706..1c41c2c 100644 --- a/timetest.py +++ b/timetest.py @@ -108,18 +108,10 @@ def test_to_int(self): idx, kind.get_index_for_date(tu.next.dt) - 1, ) - self.assertEqual( - tu.dt, - kind.get_date_from_index(idx) - ) - self.assertEqual( - tu, - kind[idx] - ) + self.assertEqual(tu.dt, kind.get_date_from_index(idx)) + self.assertEqual(tu, kind[idx]) for dt2 in tu: - self.assertEqual( - idx, kind.get_index_for_date(dt2) - ) + self.assertEqual(idx, kind.get_index_for_date(dt2)) self.assertGreater(tu, tu.previous) self.assertGreater(tu.next, tu) self.assertGreaterEqual(tu, tu.previous) diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index 84fca76..f9cb5f0 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -145,7 +145,7 @@ def get_date_from_index(cls, dt): def __getitem__(cls, item): if isinstance(item, int): return cls(cls.get_date_from_index(item)) - raise TypeError(f'Can not lookup an index for {item}') + raise TypeError(f"Can not lookup an index for {item}") def truncate(cls, dt): return datetime.strptime(cls.to_str(dt), cls.formatter).date() @@ -192,7 +192,7 @@ def get_index_for_date(cls, dt): @classmethod def get_date_from_index(cls, idx): - return date(idx+date.min.year, 1, 1) + return date(idx + date.min.year, 1, 1) @classmethod def _inner_shift(cls, cur, dt, amount): @@ -214,7 +214,7 @@ def get_index_for_date(cls, dt): def get_date_from_index(cls, idx): yy = (idx - 1) // 4 qq = idx - 4 * yy - return date(yy, 3*qq+1, 1) + return date(yy, 3 * qq + 1, 1) @classmethod def truncate(cls, dt): @@ -278,7 +278,7 @@ def get_index_for_date(cls, dt): @classmethod def get_date_from_index(cls, idx): - return date.min + timedelta(days=7*idx) + return date.min + timedelta(days=7 * idx) @classmethod def truncate(cls, dt): From 9ea0b3ba10670081bc4d8dbfda7f8160097e4a73 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Mon, 6 Oct 2025 21:20:28 +0200 Subject: [PATCH 08/49] =?UTF-8?q?=E2=84=AC=F0=9D=93=81=F0=9D=92=B6?= =?UTF-8?q?=F0=9D=92=B8=F0=9D=93=80=20reformatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- timetest.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/timetest.py b/timetest.py index 1c41c2c..321105c 100644 --- a/timetest.py +++ b/timetest.py @@ -25,6 +25,10 @@ def truncate(cls, dt): def get_index_for_date(cls, dt): return dt.year // 10 + @classmethod + def get_date_from_index(cls, idx): + return date(10 * idx, 1, 1) + @classmethod def last_day(cls, dt): """ From 26320bb5deab9d18c3ea304f8971040cc227dea4 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Mon, 6 Oct 2025 21:42:18 +0200 Subject: [PATCH 09/49] add readme --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index ed89f5d..86b31b1 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,21 @@ specials_unit_of_times[Day(date(1958, 3, 25))] = True we can even use this to slice, although it probably is not very useful. +More useful are the `.get_index_for_date(..)` and `.get_date_from_index(..)` methods, which allow to determine how many days, weeks, months, quarters and years are between `date.min` and the date given, and convert this back to a date. For example: + +``` +Week.get_index_for_date(date(1958, 3, 25)) # 102123 +Week.get_date_from_index(102123) # date(1958, 3, 24) +``` + +so 1958-03-25 is the 102'123 week since 0001-01-01, and that week starts the 24th of March, 1958. + +We can also use the index to get a `TimUnit` with: + +``` +Week[102123] # Week(date(1958, 3, 24)) +``` + ## Registering a new time unit We can register a new time unit. For example, a decade with: From e39101838e23fa4d1abc8ae0a6536bc590f9c2b4 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Mon, 6 Oct 2025 21:56:50 +0200 Subject: [PATCH 10/49] =?UTF-8?q?=E2=84=AC=F0=9D=93=81=F0=9D=92=B6?= =?UTF-8?q?=F0=9D=92=B8=F0=9D=93=80=20reformatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- unit_of_time/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index f9cb5f0..34789ab 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -250,9 +250,9 @@ def get_index_for_date(cls, dt): @classmethod def get_date_from_index(cls, idx): - yy = (idx - 1) // 12 - mm = ((idx - 1) % 12) + 1 - return date(yy, mm, 1) + yy = idx // 12 + mm = (idx % 12) + 1 + return date(yy + 1, mm, 1) @classmethod def _next(cls, dt): From d77e04b3c64a64504df958cc4a6b39642db6fd6a Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Tue, 7 Oct 2025 00:24:10 +0200 Subject: [PATCH 11/49] bugfix --- unit_of_time/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index 34789ab..9112b0e 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -214,7 +214,7 @@ def get_index_for_date(cls, dt): def get_date_from_index(cls, idx): yy = (idx - 1) // 4 qq = idx - 4 * yy - return date(yy, 3 * qq + 1, 1) + return date(yy+1, 3 * qq + 1, 1) @classmethod def truncate(cls, dt): From f292d4c124e828e5698c6db6e109413c92e22a19 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Tue, 7 Oct 2025 20:36:06 +0200 Subject: [PATCH 12/49] quarter --- unit_of_time/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index 9112b0e..3374c36 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -214,7 +214,7 @@ def get_index_for_date(cls, dt): def get_date_from_index(cls, idx): yy = (idx - 1) // 4 qq = idx - 4 * yy - return date(yy+1, 3 * qq + 1, 1) + return date(yy+1, 3 * qq - 2, 1) @classmethod def truncate(cls, dt): From cbfe24d4580e28f062f07948a3f5ee821f702684 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Tue, 7 Oct 2025 20:36:15 +0200 Subject: [PATCH 13/49] =?UTF-8?q?=E2=84=AC=F0=9D=93=81=F0=9D=92=B6?= =?UTF-8?q?=F0=9D=92=B8=F0=9D=93=80=20reformatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- unit_of_time/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index 3374c36..df1bb12 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -214,7 +214,7 @@ def get_index_for_date(cls, dt): def get_date_from_index(cls, idx): yy = (idx - 1) // 4 qq = idx - 4 * yy - return date(yy+1, 3 * qq - 2, 1) + return date(yy + 1, 3 * qq - 2, 1) @classmethod def truncate(cls, dt): From e206aaf8e1a78bf22d06a4e9ae943da781278084 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Tue, 7 Oct 2025 20:44:34 +0200 Subject: [PATCH 14/49] quarter --- unit_of_time/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index df1bb12..f002402 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -212,9 +212,9 @@ def get_index_for_date(cls, dt): @classmethod def get_date_from_index(cls, idx): - yy = (idx - 1) // 4 + yy = idx // 4 qq = idx - 4 * yy - return date(yy + 1, 3 * qq - 2, 1) + return date(yy + 1, 3 * qq + 1, 1) @classmethod def truncate(cls, dt): From 6dc94677bc0ed3cc9520cd6414a2bb171e2ae438 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Wed, 8 Oct 2025 19:39:46 +0200 Subject: [PATCH 15/49] timeunit and timeunit subscriptable and sliceable --- unit_of_time/__init__.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index f002402..499526c 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -15,7 +15,18 @@ def date_to_int(val, mul=1): return mul * (val.year * 10000 + val.month * 100 + val.day) -class TimeunitKindMeta(type): +class IndexableMixin: + def __getitem__(self, key): + idc = range(len(self))[key] + if isinstance(idc, int): + return self._from_index(idc) + def generator(): + for idx in idc: + yield self._from_index(idx) + return generator() + + +class TimeunitKindMeta(IndexableMixin, type): kind_int = None formatter = None _pre_registered = [] @@ -29,6 +40,9 @@ def __init__(cls, name, bases, attrs): TimeunitKindMeta._registered = None TimeunitKindMeta._multiplier = None + def _from_index(cls, idx): + return cls(cls.get_date_from_index(idx)) + @property def unit_register(self): result = TimeunitKindMeta._registered @@ -50,6 +64,9 @@ def multiplier(cls): TimeunitKindMeta._multiplier = result return result + def __len__(cls): + return cls.get_index_for_date(date.max) + 1 + def __int__(self): return self.kind_int @@ -142,11 +159,6 @@ def get_index_for_date(cls, dt): def get_date_from_index(cls, dt): return None - def __getitem__(cls, item): - if isinstance(item, int): - return cls(cls.get_date_from_index(item)) - raise TypeError(f"Can not lookup an index for {item}") - def truncate(cls, dt): return datetime.strptime(cls.to_str(dt), cls.formatter).date() @@ -312,7 +324,7 @@ def _next(cls, dt): return dt + timedelta(days=1) -class Timeunit: +class Timeunit(IndexableMixin): def __init__(self, kind, dt): if isinstance(kind, int): kind = TimeunitKind.unit_register[kind] @@ -365,6 +377,9 @@ def __len__(self): """ return (self.next.dt - self.dt).days + def _from_index(self, idx): + return self.dt + timedelta(days=idx) + def __iter__(self): dt = self.dt end = self.next.dt From ab3c583d36d154707043a56db82418bb64825ef0 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Wed, 8 Oct 2025 19:55:16 +0200 Subject: [PATCH 16/49] iterable --- unit_of_time/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index 499526c..a37b2a3 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -159,6 +159,10 @@ def get_index_for_date(cls, dt): def get_date_from_index(cls, dt): return None + def __iter__(cls): + for i in range(len(cls)): + yield cls._from_index(i) + def truncate(cls, dt): return datetime.strptime(cls.to_str(dt), cls.formatter).date() From 360fd159b4d54a47feac5487f83edff7e2cd216d Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Wed, 8 Oct 2025 19:55:31 +0200 Subject: [PATCH 17/49] =?UTF-8?q?=E2=84=AC=F0=9D=93=81=F0=9D=92=B6?= =?UTF-8?q?=F0=9D=92=B8=F0=9D=93=80=20reformatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- unit_of_time/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index a37b2a3..5ecc6f6 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -20,9 +20,11 @@ def __getitem__(self, key): idc = range(len(self))[key] if isinstance(idc, int): return self._from_index(idc) + def generator(): for idx in idc: yield self._from_index(idx) + return generator() From 6407573a850f5617cd9dbdf09a19ead406315e0a Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 8 Oct 2025 17:56:27 +0000 Subject: [PATCH 18/49] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`f?= =?UTF-8?q?eature/get-index`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @KommuSoft. * https://github.com/hapytex/unit_of_time/pull/15#issuecomment-3382627334 The following files were modified: * `timetest.py` * `unit_of_time/__init__.py` --- timetest.py | 30 +++- unit_of_time/__init__.py | 340 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 362 insertions(+), 8 deletions(-) diff --git a/timetest.py b/timetest.py index 321105c..2d4b87f 100644 --- a/timetest.py +++ b/timetest.py @@ -23,22 +23,40 @@ def truncate(cls, dt): @classmethod def get_index_for_date(cls, dt): + """ + Return the zero-based decade index for the given date. + + Parameters: + dt (date or datetime): The date for which to compute the decade index. + + Returns: + int: The decade index equal to the calendar year divided by 10 using integer division (year // 10). + """ return dt.year // 10 @classmethod def get_date_from_index(cls, idx): + """ + Return the start date (January 1) of the decade represented by the given index. + + Parameters: + idx (int): Decade index; the corresponding year is 10 * idx. + + Returns: + datetime.date: January 1 of the year 10 * idx. + """ return date(10 * idx, 1, 1) @classmethod def last_day(cls, dt): """ - Return the last day of the decade containing the given date. - + Return the last date of the decade that contains the given date. + Parameters: - dt (date or datetime): The date for which to determine the last day of its decade. - + dt (date | datetime): Date or datetime within the target decade. + Returns: - date: The last day of the decade as a date object. + date: The last day of that decade. """ dt = cls.truncate(dt) return date(dt.year + 10, 1, 1) - timedelta(days=1) @@ -223,4 +241,4 @@ def test_kinds(self): if __name__ == "__main__": - unittest.main() + unittest.main() \ No newline at end of file diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index 5ecc6f6..b18c061 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -12,16 +12,41 @@ def date_from_int(val, div=1): def date_to_int(val, mul=1): + """ + Encode a date as an integer in YYYYMMDD form, optionally scaled by a multiplier. + + Parameters: + val (date): The date to encode. + mul (int): Multiplier applied to the encoded integer (default 1). + + Returns: + int: The value (YYYYMMDD) multiplied by `mul`. + """ return mul * (val.year * 10000 + val.month * 100 + val.day) class IndexableMixin: def __getitem__(self, key): + """ + Map an integer index or slice to the corresponding item(s) produced by _from_index. + + Parameters: + key (int | slice): An index or slice as used on sequences; negative indices and slice semantics follow Python's range indexing behavior. + + Returns: + The single value produced by `self._from_index(index)` when `key` selects one index, or a generator that yields `self._from_index(index)` for each index in the selected range. + """ idc = range(len(self))[key] if isinstance(idc, int): return self._from_index(idc) def generator(): + """ + Yield elements corresponding to each index in the captured index iterable. + + Returns: + generator: Yields the result of self._from_index(idx) for each index in the iterable. + """ for idx in idc: yield self._from_index(idx) @@ -36,6 +61,11 @@ class TimeunitKindMeta(IndexableMixin, type): _multiplier = None def __init__(cls, name, bases, attrs): + """ + Initialize the metaclass for a TimeunitKind subclass and register the class if it defines a kind identifier. + + If the created class has a non-None `kind_int`, the class is appended to TimeunitKindMeta._pre_registered and the cached registry and multiplier on TimeunitKindMeta are cleared (set to None) so they will be rebuilt on next access. This method has the side effect of mutating TimeunitKindMeta's class-level registration caches. + """ super().__init__(name, bases, attrs) if cls.kind_int is not None: TimeunitKindMeta._pre_registered.append(cls) @@ -43,10 +73,27 @@ def __init__(cls, name, bases, attrs): TimeunitKindMeta._multiplier = None def _from_index(cls, idx): + """ + Create a time-unit instance corresponding to the numeric index. + + Parameters: + idx (int): Numeric index into this kind's sequence (0 corresponds to the unit containing date.min). + + Returns: + Timeunit: An instance of this kind representing the unit at the given index. + """ return cls(cls.get_date_from_index(idx)) @property def unit_register(self): + """ + Lazily construct and return the registry that maps each time-unit kind integer to its corresponding TimeunitKind class. + + Only classes with a defined kind_int are included; the mapping is cached on first access and reused thereafter. + + Returns: + dict[int, type]: Mapping from a kind's integer identifier to the TimeunitKind subclass. + """ result = TimeunitKindMeta._registered if result is None: result = { @@ -59,6 +106,16 @@ def unit_register(self): @property def multiplier(cls): + """ + Compute and return a power-of-ten multiplier sized to encode registered kind integers. + + The returned integer is the smallest power of ten that is greater than or equal to the largest + `kind_int` among pre-registered Timeunit kinds (with a minimum of 1). The computed value is cached + on TimeunitKindMeta._multiplier for subsequent accesses. + + Returns: + int: Power-of-ten multiplier (>= 1) suitable for composing integer encodings with `kind_int`. + """ result = TimeunitKindMeta._multiplier if result is None: result = max(1, *[k.kind_int for k in TimeunitKindMeta._pre_registered]) @@ -67,9 +124,21 @@ def multiplier(cls): return result def __len__(cls): + """ + Total number of units of this kind representable within the supported date range. + + Returns: + int: The count of discrete units (computed as highest index for date.max plus one). + """ return cls.get_index_for_date(date.max) + 1 def __int__(self): + """ + Provide the integer identifier for this time unit kind. + + Returns: + int: The kind's integer identifier. + """ return self.kind_int def __index__(self): @@ -153,19 +222,63 @@ def get_next(cls, dt): return cls(cls._next(cls.truncate(dt))) def to_str(cls, dt): + """ + Format a date using the class's formatter. + + Parameters: + dt (date | datetime): The date or datetime to format. + + Returns: + str: String representation of `dt` formatted with `cls.formatter`. + """ return dt.strftime(cls.formatter) def get_index_for_date(cls, dt): + """ + Compute the unit-specific ordinal index for the given date. + + This base implementation returns `None`; concrete TimeunitKind subclasses override this to map a date to an integer index counting units from date.min. + + Parameters: + dt (datetime.date | datetime.datetime): Date to convert to an index for this time unit kind. + + Returns: + int | None: The zero-based index of the unit containing `dt` relative to `date.min`, or `None` if the kind does not implement indexing. + """ return None def get_date_from_index(cls, dt): + """ + Map an index value for this time unit kind to its corresponding start date. + + Parameters: + dt (int): Integer index representing the offset of the unit (e.g., number of days/weeks/months/years since date.min). + + Returns: + date_or_none (datetime.date | None): The start date corresponding to `dt`, or `None` when the kind does not provide a mapping. + """ return None def __iter__(cls): + """ + Iterate over every time unit of this kind in chronological order. + + Yields: + Timeunit: A Timeunit instance for each valid index, from the earliest to the latest. + """ for i in range(len(cls)): yield cls._from_index(i) def truncate(cls, dt): + """ + Return the date obtained by formatting and parsing `dt` with the kind's formatter, effectively truncating `dt` to the unit's boundary. + + Parameters: + dt (datetime.date | datetime.datetime): The input date or datetime to truncate. + + Returns: + datetime.date: The truncated date representing the unit's start as determined by `cls.formatter`. + """ return datetime.strptime(cls.to_str(dt), cls.formatter).date() def _inner_shift(cls, cur, dt, amount): @@ -198,22 +311,69 @@ class Year(TimeunitKind): @classmethod def truncate(cls, dt): + """ + Return the first day of the year containing the given date. + + Parameters: + dt (date or datetime): A date or datetime whose year will be used. + + Returns: + date: A date representing January 1 of dt's year. + """ return date(dt.year, 1, 1) @classmethod def _next(cls, dt): + """ + Return the first day of the year following the given date. + + Parameters: + dt (date): A date within the current year. + + Returns: + date: January 1 of the year after `dt.year`. + """ return date(dt.year + 1, 1, 1) @classmethod def get_index_for_date(cls, dt): + """ + Compute the year index of a date relative to date.min. + + Parameters: + dt (date): The date whose year will be indexed. + + Returns: + index (int): Number of years between dt.year and date.min.year. + """ return dt.year - date.min.year @classmethod def get_date_from_index(cls, idx): + """ + Map a year index to the corresponding first day of that year. + + Parameters: + idx (int): Number of years since date.min.year (0 maps to January 1 of date.min.year). + + Returns: + datetime.date: January 1 of the year at index `idx`. + """ return date(idx + date.min.year, 1, 1) @classmethod def _inner_shift(cls, cur, dt, amount): + """ + Shift the provided date by a number of years and return the first day of the resulting year. + + Parameters: + cur: The current Timeunit or index (not used by this implementation). + dt (datetime.date): The date to shift. + amount (int): Number of years to shift; may be negative. + + Returns: + datetime.date: January 1 of the year `dt.year + amount`. + """ return date(dt.year + amount, 1, 1) @@ -222,20 +382,57 @@ class Quarter(TimeunitKind): @classmethod def to_str(cls, dt): + """ + Return a compact quarter identifier for the given date. + + Parameters: + dt (date | datetime): The date to format. + + Returns: + quarter_str (str): A string in the form `YYYYQn` where `n` is the quarter number (1–4). + """ return f"{dt.year}Q{(dt.month+2)//3}" @classmethod def get_index_for_date(cls, dt): + """ + Compute the 0-based quarter index for a given date relative to date.min. + + Parameters: + cls: The Quarter class (ignored). + dt (date): The date to convert into a quarter index. + + Returns: + int: Quarter index since date.min where each year contributes 4 and quarters are 0..3 based on the month. + """ return 4 * (dt.year - date.min.year) + max((dt.month - 1) // 3, 0) @classmethod def get_date_from_index(cls, idx): + """ + Convert a quarter index into the first day of that quarter. + + Parameters: + idx (int): Quarter index where 0 corresponds to year 1, quarter 1; indices increase by one per quarter. + + Returns: + datetime.date: The date for the first day of the quarter (month = 1, 4, 7, or 10) for the computed year. + """ yy = idx // 4 qq = idx - 4 * yy return date(yy + 1, 3 * qq + 1, 1) @classmethod def truncate(cls, dt): + """ + Get the first day of the quarter containing the given date. + + Parameters: + dt (datetime.date | datetime.datetime): The date to truncate. + + Returns: + datetime.date: The date representing the first day of dt's quarter (month 1, 4, 7, or 10). + """ return date(dt.year, 3 * ((dt.month - 1) // 3) + 1, 1) @classmethod @@ -259,21 +456,58 @@ class Month(TimeunitKind): @classmethod def _inner_shift(cls, cur, dt, amount): + """ + Shift the given date by a number of months and return the first day of the resulting month. + + Parameters: + dt (date): The base date to shift. + amount (int): Number of months to shift `dt` by; may be negative. + + Returns: + result (date): The first day of the month that is `amount` months from `dt`. + """ m_new = dt.year * 12 + amount + dt.month - 1 return date(m_new // 12, m_new % 12 + 1, 1) @classmethod def get_index_for_date(cls, dt): + """ + Compute the zero-based month index for a given date measured from date.min. + + Parameters: + dt (date | datetime): The date to convert into a month index. + + Returns: + int: Number of months since January of date.min.year (January of date.min.year == 0). + """ return 12 * (dt.year - date.min.year) + dt.month - 1 @classmethod def get_date_from_index(cls, idx): + """ + Map a month index to the date of its first day. + + Parameters: + idx (int): Month index where 0 corresponds to 0001-01-01; each increment advances one month. + + Returns: + date: The first day of the month represented by `idx`. + """ yy = idx // 12 mm = (idx % 12) + 1 return date(yy + 1, mm, 1) @classmethod def _next(cls, dt): + """ + Return the first day of the month immediately following the given date. + + Parameters: + dt (datetime.date): A date whose next-month boundary is requested. + + Returns: + datetime.date: Date representing the first day of the month after `dt`. + """ m2 = dt.month + 1 if m2 > 12: return date(dt.year + 1, 1, 1) @@ -287,19 +521,57 @@ class Week(TimeunitKind): @classmethod def _inner_shift(cls, cur, dt, amount): + """ + Shift a date by a number of whole weeks and return the resulting date. + + Parameters: + cur: The current Timeunit instance or kind context (unused by this implementation). + dt (datetime.date | datetime.datetime): The date to shift; time component, if any, is preserved. + amount (int): Number of weeks to shift; may be negative to shift backward. + + Returns: + datetime.date | datetime.datetime: The date obtained by adding `amount * 7` days to `dt`. + """ return dt + timedelta(days=7 * amount) @classmethod def get_index_for_date(cls, dt): # date.min has weekday() == 0 + """ + Compute the zero-based week index of a given date relative to date.min (weeks start on Monday). + + Parameters: + dt (datetime.date | datetime.datetime): The date to index; when a datetime is provided, its date component is used. + + Returns: + int: Number of whole weeks between date.min (which is a Monday) and `dt`. + """ return (dt - date.min).days // 7 @classmethod def get_date_from_index(cls, idx): + """ + Map a week index to the starting date of that week. + + Parameters: + idx (int): Week index where 0 corresponds to date.min and each increment advances by one week. + + Returns: + datetime.date: The date equal to date.min plus 7 * idx days (the start date of the indexed week). + """ return date.min + timedelta(days=7 * idx) @classmethod def truncate(cls, dt): + """ + Return the Monday (start) of the week containing the given date. + + Parameters: + dt (datetime.date | datetime.datetime): Date or datetime to truncate to the week's start. If a datetime is provided, its date portion is used. + + Returns: + datetime.date: Date representing the Monday of the week that contains `dt`. + """ if isinstance(dt, datetime): dt = dt.date() return dt - timedelta(days=dt.weekday()) @@ -315,23 +587,69 @@ class Day(TimeunitKind): @classmethod def get_index_for_date(cls, dt): + """ + Compute the day-based index of a date relative to date.min. + + Parameters: + dt (datetime.date | datetime.datetime): The date to convert into an index. + + Returns: + int: Number of days between `date.min` and `dt`. + """ return (dt - date.min).days @classmethod def get_date_from_index(cls, idx): + """ + Convert a day index to the corresponding calendar date. + + Parameters: + idx (int): Number of days since date.min (0 maps to date.min). + + Returns: + datetime.date: The date that is `idx` days after `date.min`. + """ return date.min + timedelta(days=idx) @classmethod def _inner_shift(cls, cur, dt, amount): + """ + Shift the given date by a number of days. + + Parameters: + cls: The Timeunit kind class invoking this method (unused by this implementation). + cur: The current Timeunit instance that provides context for the shift (unused by this implementation). + dt (date or datetime): The date to shift. + amount (int): Number of days to shift `dt` by; may be negative. + + Returns: + date or datetime: `dt` offset by `amount` days. + """ return dt + timedelta(days=amount) @classmethod def _next(cls, dt): + """ + Return the start date of the day immediately after the given date. + + Parameters: + dt (date | datetime): The date or datetime to advance by one day. + + Returns: + date or datetime: The input advanced by one calendar day. + """ return dt + timedelta(days=1) class Timeunit(IndexableMixin): def __init__(self, kind, dt): + """ + Initialize the Timeunit by resolving the given kind and storing the kind and the unit's start date. + + Parameters: + kind (int | TimeunitKind): Either an integer key for a registered TimeunitKind or a TimeunitKind class; if an integer is provided it is resolved via the kind registry. + dt (date | datetime): A date or datetime that will be truncated to the unit's boundary using the kind's truncate method. + """ if isinstance(kind, int): kind = TimeunitKind.unit_register[kind] self.kind = kind @@ -379,14 +697,32 @@ def successors(self): def __len__(self): """ - Return the number of days in the time unit. + Number of days spanned by this time unit. + + Returns: + days (int): Number of calendar days from this unit's start date up to (but not including) the start date of the next unit. """ return (self.next.dt - self.dt).days def _from_index(self, idx): + """ + Return a date offset from this unit's start by the given number of days. + + Parameters: + idx (int): Number of days to add to the unit's start date; may be negative. + + Returns: + datetime.date: Date equal to the unit's start date shifted by `idx` days. + """ return self.dt + timedelta(days=idx) def __iter__(self): + """ + Iterate each calendar day in this time unit from its start up to (but not including) the next unit's start. + + Returns: + Iterator[date]: Yields each day (as a `date` or `datetime.date`) within the unit's date range. + """ dt = self.dt end = self.next.dt ONE_DAY = timedelta(days=1) @@ -501,4 +837,4 @@ def __contains__(self, item): return frm <= frm0 and to0 <= to def __str__(self): - return self.kind.to_str(self.dt) + return self.kind.to_str(self.dt) \ No newline at end of file From 6f8224eca74cdd90adb30ba5ddd701a8df2e085a Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Wed, 8 Oct 2025 20:04:13 +0200 Subject: [PATCH 19/49] =?UTF-8?q?=E2=84=AC=F0=9D=93=81=F0=9D=92=B6?= =?UTF-8?q?=F0=9D=92=B8=F0=9D=93=80=20reformatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- timetest.py | 14 ++-- unit_of_time/__init__.py | 170 +++++++++++++++++++-------------------- 2 files changed, 92 insertions(+), 92 deletions(-) diff --git a/timetest.py b/timetest.py index 2d4b87f..c58cdcf 100644 --- a/timetest.py +++ b/timetest.py @@ -25,10 +25,10 @@ def truncate(cls, dt): def get_index_for_date(cls, dt): """ Return the zero-based decade index for the given date. - + Parameters: dt (date or datetime): The date for which to compute the decade index. - + Returns: int: The decade index equal to the calendar year divided by 10 using integer division (year // 10). """ @@ -38,10 +38,10 @@ def get_index_for_date(cls, dt): def get_date_from_index(cls, idx): """ Return the start date (January 1) of the decade represented by the given index. - + Parameters: idx (int): Decade index; the corresponding year is 10 * idx. - + Returns: datetime.date: January 1 of the year 10 * idx. """ @@ -51,10 +51,10 @@ def get_date_from_index(cls, idx): def last_day(cls, dt): """ Return the last date of the decade that contains the given date. - + Parameters: dt (date | datetime): Date or datetime within the target decade. - + Returns: date: The last day of that decade. """ @@ -241,4 +241,4 @@ def test_kinds(self): if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index b18c061..9f33ed9 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -14,11 +14,11 @@ def date_from_int(val, div=1): def date_to_int(val, mul=1): """ Encode a date as an integer in YYYYMMDD form, optionally scaled by a multiplier. - + Parameters: val (date): The date to encode. mul (int): Multiplier applied to the encoded integer (default 1). - + Returns: int: The value (YYYYMMDD) multiplied by `mul`. """ @@ -29,10 +29,10 @@ class IndexableMixin: def __getitem__(self, key): """ Map an integer index or slice to the corresponding item(s) produced by _from_index. - + Parameters: key (int | slice): An index or slice as used on sequences; negative indices and slice semantics follow Python's range indexing behavior. - + Returns: The single value produced by `self._from_index(index)` when `key` selects one index, or a generator that yields `self._from_index(index)` for each index in the selected range. """ @@ -43,7 +43,7 @@ def __getitem__(self, key): def generator(): """ Yield elements corresponding to each index in the captured index iterable. - + Returns: generator: Yields the result of self._from_index(idx) for each index in the iterable. """ @@ -63,7 +63,7 @@ class TimeunitKindMeta(IndexableMixin, type): def __init__(cls, name, bases, attrs): """ Initialize the metaclass for a TimeunitKind subclass and register the class if it defines a kind identifier. - + If the created class has a non-None `kind_int`, the class is appended to TimeunitKindMeta._pre_registered and the cached registry and multiplier on TimeunitKindMeta are cleared (set to None) so they will be rebuilt on next access. This method has the side effect of mutating TimeunitKindMeta's class-level registration caches. """ super().__init__(name, bases, attrs) @@ -75,10 +75,10 @@ def __init__(cls, name, bases, attrs): def _from_index(cls, idx): """ Create a time-unit instance corresponding to the numeric index. - + Parameters: idx (int): Numeric index into this kind's sequence (0 corresponds to the unit containing date.min). - + Returns: Timeunit: An instance of this kind representing the unit at the given index. """ @@ -88,9 +88,9 @@ def _from_index(cls, idx): def unit_register(self): """ Lazily construct and return the registry that maps each time-unit kind integer to its corresponding TimeunitKind class. - + Only classes with a defined kind_int are included; the mapping is cached on first access and reused thereafter. - + Returns: dict[int, type]: Mapping from a kind's integer identifier to the TimeunitKind subclass. """ @@ -108,11 +108,11 @@ def unit_register(self): def multiplier(cls): """ Compute and return a power-of-ten multiplier sized to encode registered kind integers. - + The returned integer is the smallest power of ten that is greater than or equal to the largest `kind_int` among pre-registered Timeunit kinds (with a minimum of 1). The computed value is cached on TimeunitKindMeta._multiplier for subsequent accesses. - + Returns: int: Power-of-ten multiplier (>= 1) suitable for composing integer encodings with `kind_int`. """ @@ -126,7 +126,7 @@ def multiplier(cls): def __len__(cls): """ Total number of units of this kind representable within the supported date range. - + Returns: int: The count of discrete units (computed as highest index for date.max plus one). """ @@ -135,7 +135,7 @@ def __len__(cls): def __int__(self): """ Provide the integer identifier for this time unit kind. - + Returns: int: The kind's integer identifier. """ @@ -224,10 +224,10 @@ def get_next(cls, dt): def to_str(cls, dt): """ Format a date using the class's formatter. - + Parameters: dt (date | datetime): The date or datetime to format. - + Returns: str: String representation of `dt` formatted with `cls.formatter`. """ @@ -236,12 +236,12 @@ def to_str(cls, dt): def get_index_for_date(cls, dt): """ Compute the unit-specific ordinal index for the given date. - + This base implementation returns `None`; concrete TimeunitKind subclasses override this to map a date to an integer index counting units from date.min. - + Parameters: dt (datetime.date | datetime.datetime): Date to convert to an index for this time unit kind. - + Returns: int | None: The zero-based index of the unit containing `dt` relative to `date.min`, or `None` if the kind does not implement indexing. """ @@ -250,19 +250,19 @@ def get_index_for_date(cls, dt): def get_date_from_index(cls, dt): """ Map an index value for this time unit kind to its corresponding start date. - + Parameters: - dt (int): Integer index representing the offset of the unit (e.g., number of days/weeks/months/years since date.min). - + dt (int): Integer index representing the offset of the unit (e.g., number of days/weeks/months/years since date.min). + Returns: - date_or_none (datetime.date | None): The start date corresponding to `dt`, or `None` when the kind does not provide a mapping. + date_or_none (datetime.date | None): The start date corresponding to `dt`, or `None` when the kind does not provide a mapping. """ return None def __iter__(cls): """ Iterate over every time unit of this kind in chronological order. - + Yields: Timeunit: A Timeunit instance for each valid index, from the earliest to the latest. """ @@ -272,12 +272,12 @@ def __iter__(cls): def truncate(cls, dt): """ Return the date obtained by formatting and parsing `dt` with the kind's formatter, effectively truncating `dt` to the unit's boundary. - + Parameters: - dt (datetime.date | datetime.datetime): The input date or datetime to truncate. - + dt (datetime.date | datetime.datetime): The input date or datetime to truncate. + Returns: - datetime.date: The truncated date representing the unit's start as determined by `cls.formatter`. + datetime.date: The truncated date representing the unit's start as determined by `cls.formatter`. """ return datetime.strptime(cls.to_str(dt), cls.formatter).date() @@ -313,10 +313,10 @@ class Year(TimeunitKind): def truncate(cls, dt): """ Return the first day of the year containing the given date. - + Parameters: dt (date or datetime): A date or datetime whose year will be used. - + Returns: date: A date representing January 1 of dt's year. """ @@ -326,12 +326,12 @@ def truncate(cls, dt): def _next(cls, dt): """ Return the first day of the year following the given date. - + Parameters: - dt (date): A date within the current year. - + dt (date): A date within the current year. + Returns: - date: January 1 of the year after `dt.year`. + date: January 1 of the year after `dt.year`. """ return date(dt.year + 1, 1, 1) @@ -339,12 +339,12 @@ def _next(cls, dt): def get_index_for_date(cls, dt): """ Compute the year index of a date relative to date.min. - + Parameters: - dt (date): The date whose year will be indexed. - + dt (date): The date whose year will be indexed. + Returns: - index (int): Number of years between dt.year and date.min.year. + index (int): Number of years between dt.year and date.min.year. """ return dt.year - date.min.year @@ -352,10 +352,10 @@ def get_index_for_date(cls, dt): def get_date_from_index(cls, idx): """ Map a year index to the corresponding first day of that year. - + Parameters: idx (int): Number of years since date.min.year (0 maps to January 1 of date.min.year). - + Returns: datetime.date: January 1 of the year at index `idx`. """ @@ -365,12 +365,12 @@ def get_date_from_index(cls, idx): def _inner_shift(cls, cur, dt, amount): """ Shift the provided date by a number of years and return the first day of the resulting year. - + Parameters: cur: The current Timeunit or index (not used by this implementation). dt (datetime.date): The date to shift. amount (int): Number of years to shift; may be negative. - + Returns: datetime.date: January 1 of the year `dt.year + amount`. """ @@ -384,12 +384,12 @@ class Quarter(TimeunitKind): def to_str(cls, dt): """ Return a compact quarter identifier for the given date. - + Parameters: - dt (date | datetime): The date to format. - + dt (date | datetime): The date to format. + Returns: - quarter_str (str): A string in the form `YYYYQn` where `n` is the quarter number (1–4). + quarter_str (str): A string in the form `YYYYQn` where `n` is the quarter number (1–4). """ return f"{dt.year}Q{(dt.month+2)//3}" @@ -397,11 +397,11 @@ def to_str(cls, dt): def get_index_for_date(cls, dt): """ Compute the 0-based quarter index for a given date relative to date.min. - + Parameters: cls: The Quarter class (ignored). dt (date): The date to convert into a quarter index. - + Returns: int: Quarter index since date.min where each year contributes 4 and quarters are 0..3 based on the month. """ @@ -411,10 +411,10 @@ def get_index_for_date(cls, dt): def get_date_from_index(cls, idx): """ Convert a quarter index into the first day of that quarter. - + Parameters: idx (int): Quarter index where 0 corresponds to year 1, quarter 1; indices increase by one per quarter. - + Returns: datetime.date: The date for the first day of the quarter (month = 1, 4, 7, or 10) for the computed year. """ @@ -426,10 +426,10 @@ def get_date_from_index(cls, idx): def truncate(cls, dt): """ Get the first day of the quarter containing the given date. - + Parameters: dt (datetime.date | datetime.datetime): The date to truncate. - + Returns: datetime.date: The date representing the first day of dt's quarter (month 1, 4, 7, or 10). """ @@ -458,13 +458,13 @@ class Month(TimeunitKind): def _inner_shift(cls, cur, dt, amount): """ Shift the given date by a number of months and return the first day of the resulting month. - + Parameters: - dt (date): The base date to shift. - amount (int): Number of months to shift `dt` by; may be negative. - + dt (date): The base date to shift. + amount (int): Number of months to shift `dt` by; may be negative. + Returns: - result (date): The first day of the month that is `amount` months from `dt`. + result (date): The first day of the month that is `amount` months from `dt`. """ m_new = dt.year * 12 + amount + dt.month - 1 return date(m_new // 12, m_new % 12 + 1, 1) @@ -473,10 +473,10 @@ def _inner_shift(cls, cur, dt, amount): def get_index_for_date(cls, dt): """ Compute the zero-based month index for a given date measured from date.min. - + Parameters: dt (date | datetime): The date to convert into a month index. - + Returns: int: Number of months since January of date.min.year (January of date.min.year == 0). """ @@ -486,10 +486,10 @@ def get_index_for_date(cls, dt): def get_date_from_index(cls, idx): """ Map a month index to the date of its first day. - + Parameters: idx (int): Month index where 0 corresponds to 0001-01-01; each increment advances one month. - + Returns: date: The first day of the month represented by `idx`. """ @@ -501,10 +501,10 @@ def get_date_from_index(cls, idx): def _next(cls, dt): """ Return the first day of the month immediately following the given date. - + Parameters: dt (datetime.date): A date whose next-month boundary is requested. - + Returns: datetime.date: Date representing the first day of the month after `dt`. """ @@ -523,12 +523,12 @@ class Week(TimeunitKind): def _inner_shift(cls, cur, dt, amount): """ Shift a date by a number of whole weeks and return the resulting date. - + Parameters: cur: The current Timeunit instance or kind context (unused by this implementation). dt (datetime.date | datetime.datetime): The date to shift; time component, if any, is preserved. amount (int): Number of weeks to shift; may be negative to shift backward. - + Returns: datetime.date | datetime.datetime: The date obtained by adding `amount * 7` days to `dt`. """ @@ -539,10 +539,10 @@ def get_index_for_date(cls, dt): # date.min has weekday() == 0 """ Compute the zero-based week index of a given date relative to date.min (weeks start on Monday). - + Parameters: dt (datetime.date | datetime.datetime): The date to index; when a datetime is provided, its date component is used. - + Returns: int: Number of whole weeks between date.min (which is a Monday) and `dt`. """ @@ -552,10 +552,10 @@ def get_index_for_date(cls, dt): def get_date_from_index(cls, idx): """ Map a week index to the starting date of that week. - + Parameters: idx (int): Week index where 0 corresponds to date.min and each increment advances by one week. - + Returns: datetime.date: The date equal to date.min plus 7 * idx days (the start date of the indexed week). """ @@ -565,10 +565,10 @@ def get_date_from_index(cls, idx): def truncate(cls, dt): """ Return the Monday (start) of the week containing the given date. - + Parameters: dt (datetime.date | datetime.datetime): Date or datetime to truncate to the week's start. If a datetime is provided, its date portion is used. - + Returns: datetime.date: Date representing the Monday of the week that contains `dt`. """ @@ -589,10 +589,10 @@ class Day(TimeunitKind): def get_index_for_date(cls, dt): """ Compute the day-based index of a date relative to date.min. - + Parameters: dt (datetime.date | datetime.datetime): The date to convert into an index. - + Returns: int: Number of days between `date.min` and `dt`. """ @@ -602,10 +602,10 @@ def get_index_for_date(cls, dt): def get_date_from_index(cls, idx): """ Convert a day index to the corresponding calendar date. - + Parameters: idx (int): Number of days since date.min (0 maps to date.min). - + Returns: datetime.date: The date that is `idx` days after `date.min`. """ @@ -615,13 +615,13 @@ def get_date_from_index(cls, idx): def _inner_shift(cls, cur, dt, amount): """ Shift the given date by a number of days. - + Parameters: cls: The Timeunit kind class invoking this method (unused by this implementation). cur: The current Timeunit instance that provides context for the shift (unused by this implementation). dt (date or datetime): The date to shift. amount (int): Number of days to shift `dt` by; may be negative. - + Returns: date or datetime: `dt` offset by `amount` days. """ @@ -631,10 +631,10 @@ def _inner_shift(cls, cur, dt, amount): def _next(cls, dt): """ Return the start date of the day immediately after the given date. - + Parameters: dt (date | datetime): The date or datetime to advance by one day. - + Returns: date or datetime: The input advanced by one calendar day. """ @@ -645,7 +645,7 @@ class Timeunit(IndexableMixin): def __init__(self, kind, dt): """ Initialize the Timeunit by resolving the given kind and storing the kind and the unit's start date. - + Parameters: kind (int | TimeunitKind): Either an integer key for a registered TimeunitKind or a TimeunitKind class; if an integer is provided it is resolved via the kind registry. dt (date | datetime): A date or datetime that will be truncated to the unit's boundary using the kind's truncate method. @@ -698,7 +698,7 @@ def successors(self): def __len__(self): """ Number of days spanned by this time unit. - + Returns: days (int): Number of calendar days from this unit's start date up to (but not including) the start date of the next unit. """ @@ -707,10 +707,10 @@ def __len__(self): def _from_index(self, idx): """ Return a date offset from this unit's start by the given number of days. - + Parameters: idx (int): Number of days to add to the unit's start date; may be negative. - + Returns: datetime.date: Date equal to the unit's start date shifted by `idx` days. """ @@ -719,7 +719,7 @@ def _from_index(self, idx): def __iter__(self): """ Iterate each calendar day in this time unit from its start up to (but not including) the next unit's start. - + Returns: Iterator[date]: Yields each day (as a `date` or `datetime.date`) within the unit's date range. """ @@ -837,4 +837,4 @@ def __contains__(self, item): return frm <= frm0 and to0 <= to def __str__(self): - return self.kind.to_str(self.dt) \ No newline at end of file + return self.kind.to_str(self.dt) From 0c3170409bc5482ef0a319b43b6cdb0f7f3e833d Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Wed, 8 Oct 2025 21:23:41 +0200 Subject: [PATCH 20/49] update readme --- README.md | 49 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 86b31b1..8d8dd7d 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,40 @@ for dt in Quarter(date(1958, 3, 25)): we can also convert such collection to a list. +### Subscripting + +The `Day`, `Week`, `Month, etc. classes have `.get_index_for_date(..)` and `.get_date_from_index(..)` methods, which allow to determine how many days, weeks, months, quarters and years are between `date.min` and the date given, and convert this back to a date. For example: + +``` +Week.get_index_for_date(date(1958, 3, 25)) # 102123 +Week.get_date_from_index(102123) # date(1958, 3, 24) +``` + +so 1958-03-25 is the 102'123 week since 0001-01-01, and that week starts the 24th of March, 1958. + +We can also use the index to get a `TimUnit` with: + +``` +Week[102123] # Week(date(1958, 3, 24)) +``` + +moreover a week itself can be subscripted, for example: + +``` +Week(date(1958, 3, 24))[2] # date(1958, 3, 26) +``` + +one can also slice to get an *generator* that generates `Week`s or `date`s in the week respectively. + +The `Week` class itself is also iterable, for example: + +``` +for week in Week: + print(week) +``` + +will start enumerating over all weeks since 0001-01-01. + ### Shifting units of time The units of time can also be shifted, for example: @@ -142,21 +176,6 @@ specials_unit_of_times[Day(date(1958, 3, 25))] = True we can even use this to slice, although it probably is not very useful. -More useful are the `.get_index_for_date(..)` and `.get_date_from_index(..)` methods, which allow to determine how many days, weeks, months, quarters and years are between `date.min` and the date given, and convert this back to a date. For example: - -``` -Week.get_index_for_date(date(1958, 3, 25)) # 102123 -Week.get_date_from_index(102123) # date(1958, 3, 24) -``` - -so 1958-03-25 is the 102'123 week since 0001-01-01, and that week starts the 24th of March, 1958. - -We can also use the index to get a `TimUnit` with: - -``` -Week[102123] # Week(date(1958, 3, 24)) -``` - ## Registering a new time unit We can register a new time unit. For example, a decade with: From 0174990652c3bca527d61c5be65f8c2d704413b3 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Wed, 8 Oct 2025 21:43:33 +0200 Subject: [PATCH 21/49] limit range --- unit_of_time/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index 9f33ed9..e40aa3d 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -59,6 +59,8 @@ class TimeunitKindMeta(IndexableMixin, type): _pre_registered = [] _registered = None _multiplier = None + first_date = date.min + last_date = date.max def __init__(cls, name, bases, attrs): """ @@ -130,7 +132,7 @@ def __len__(cls): Returns: int: The count of discrete units (computed as highest index for date.max plus one). """ - return cls.get_index_for_date(date.max) + 1 + return cls.get_index_for_date(cls.last_date) + 1 def __int__(self): """ @@ -518,6 +520,7 @@ def _next(cls, dt): class Week(TimeunitKind): kind_int = 7 formatter = "%YW%W" + last_date = date(9999, 12, 26) @classmethod def _inner_shift(cls, cur, dt, amount): From b11c55a95128d330e7f07aa50503f86ba1be9f62 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Thu, 9 Oct 2025 20:42:49 +0200 Subject: [PATCH 22/49] some rewrites --- unit_of_time/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index e40aa3d..5b7a090 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -2,6 +2,8 @@ from datetime import date, datetime, timedelta +ONE_DAY = timedelta(days=1) + def date_from_int(val, div=1): val //= div d = val % 100 @@ -728,7 +730,6 @@ def __iter__(self): """ dt = self.dt end = self.next.dt - ONE_DAY = timedelta(days=1) while dt < end: yield dt dt += ONE_DAY @@ -810,7 +811,7 @@ def _get_range(cls, item): """ if isinstance(item, date): return item, item - elif isinstance(item, Timeunit): + if isinstance(item, Timeunit): return item.date_range # try to make a range try: @@ -818,7 +819,8 @@ def _get_range(cls, item): if isinstance(dt0, date) and isinstance(dt1, date): return item except TypeError: - raise TypeError(f"Item {item!r} has no date range.") from None + pass + raise TypeError(f"Item {item!r} has no date range.") def overlaps_with(self, item): """ From fed70bca6d02d64e562770f01ac5f1691d1c4d83 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Thu, 9 Oct 2025 20:44:14 +0200 Subject: [PATCH 23/49] update readme --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 8d8dd7d..384dc3a 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,14 @@ for week in Week: will start enumerating over all weeks since 0001-01-01. +A time unit also has a length: the number of time units that can be represented, so: + +``` +len(Week) # 521722 +`` + +means the software can represent 521'722 weeks from 0001-01-01 to 9999-12-26. + ### Shifting units of time The units of time can also be shifted, for example: From c842d6cbfe76f979e861ddd47211f2c7bbe3ad4b Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Fri, 10 Oct 2025 18:21:13 +0200 Subject: [PATCH 24/49] recursive slicing --- unit_of_time/__init__.py | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index 5b7a090..79dc76d 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -41,18 +41,35 @@ def __getitem__(self, key): idc = range(len(self))[key] if isinstance(idc, int): return self._from_index(idc) + else: + return SlicedProxy(self, key) + + +class SlicedProxy(IndexableMixin): + def __init__(self, parent, _slice: slice): + self.parent = parent + self._slice = _slice + + @property + def range_object(self): + return range(len(self.parent))[self._slice] - def generator(): - """ - Yield elements corresponding to each index in the captured index iterable. + def __iter__(self): + for idx in self.range_object: + yield self.parent[idx] - Returns: - generator: Yields the result of self._from_index(idx) for each index in the iterable. - """ - for idx in idc: - yield self._from_index(idx) + def _from_index(self, idx): + return self.parent[self.range_object[idx]] - return generator() + def __len__(self): + return len(self.range_object) + + def __repr__(self): + s = self._slice + s = ':'.join( + str(si) if si is not None else '' for si in (s.start, s.stop, s.step) + ) + return f'{self.parent!r}[{s}]' class TimeunitKindMeta(IndexableMixin, type): From 2a29deb206f30166aeca8915453b7a0b867ec54c Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Fri, 10 Oct 2025 19:44:13 +0200 Subject: [PATCH 25/49] =?UTF-8?q?=E2=84=AC=F0=9D=93=81=F0=9D=92=B6?= =?UTF-8?q?=F0=9D=92=B8=F0=9D=93=80=20reformatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- unit_of_time/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index 79dc76d..3871c2d 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -4,6 +4,7 @@ ONE_DAY = timedelta(days=1) + def date_from_int(val, div=1): val //= div d = val % 100 @@ -66,10 +67,10 @@ def __len__(self): def __repr__(self): s = self._slice - s = ':'.join( - str(si) if si is not None else '' for si in (s.start, s.stop, s.step) + s = ":".join( + str(si) if si is not None else "" for si in (s.start, s.stop, s.step) ) - return f'{self.parent!r}[{s}]' + return f"{self.parent!r}[{s}]" class TimeunitKindMeta(IndexableMixin, type): From 80d8f9cdc91a224b84fd709fb58e0e10cba9edd9 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sat, 11 Oct 2025 07:30:30 +0200 Subject: [PATCH 26/49] test slicing --- timetest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/timetest.py b/timetest.py index c58cdcf..10f4cb0 100644 --- a/timetest.py +++ b/timetest.py @@ -63,7 +63,7 @@ def last_day(cls, dt): TIME_UNITS = [Decade, Year, Quarter, Month, Week, Day] -START_DATE = date(1302, 7, 11) +START_DATE = date(1902, 7, 11) END_DATE = date(2019, 11, 25) @@ -234,6 +234,7 @@ def test_kinds(self): self.assertEqual(d[kind], kind in seen) self.assertEqual(kind.get_index_for_date(date.min), 0) d[kind] = True + self.assertEqual(list(kind[10:110:10][5:9:2]), list(kind[60:100:20])) self.assertNotIn(kind, seen) seen.add(kind) for kind2 in TIME_UNITS[i:]: From 744f35f9624a36a34ac0aace6956eb7d8508b7d6 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sat, 11 Oct 2025 07:52:00 +0200 Subject: [PATCH 27/49] fix year problem? --- timetest.py | 4 ++-- unit_of_time/__init__.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/timetest.py b/timetest.py index 10f4cb0..b0fb970 100644 --- a/timetest.py +++ b/timetest.py @@ -63,8 +63,8 @@ def last_day(cls, dt): TIME_UNITS = [Decade, Year, Quarter, Month, Week, Day] -START_DATE = date(1902, 7, 11) -END_DATE = date(2019, 11, 25) +START_DATE = date(902, 7, 11) +END_DATE = date(1019, 11, 25) class TimeUnitTest(unittest.TestCase): diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index 3871c2d..a33c2b7 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -253,7 +253,7 @@ def to_str(cls, dt): Returns: str: String representation of `dt` formatted with `cls.formatter`. """ - return dt.strftime(cls.formatter) + return dt.strftime(cls.formatter.replace('%Y', f'{dt.year:04d}')) def get_index_for_date(cls, dt): """ @@ -413,7 +413,7 @@ def to_str(cls, dt): Returns: quarter_str (str): A string in the form `YYYYQn` where `n` is the quarter number (1–4). """ - return f"{dt.year}Q{(dt.month+2)//3}" + return f"{dt.year:04d}Q{(dt.month+2)//3}" @classmethod def get_index_for_date(cls, dt): From 04ba81bcf39c1cb68e32a8bd8ccad6c7e907c2f8 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sat, 11 Oct 2025 07:52:10 +0200 Subject: [PATCH 28/49] =?UTF-8?q?=E2=84=AC=F0=9D=93=81=F0=9D=92=B6?= =?UTF-8?q?=F0=9D=92=B8=F0=9D=93=80=20reformatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- unit_of_time/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index a33c2b7..8d31c3b 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -253,7 +253,7 @@ def to_str(cls, dt): Returns: str: String representation of `dt` formatted with `cls.formatter`. """ - return dt.strftime(cls.formatter.replace('%Y', f'{dt.year:04d}')) + return dt.strftime(cls.formatter.replace("%Y", f"{dt.year:04d}")) def get_index_for_date(cls, dt): """ From e1d30f3e61e505a303eb1c3e3eb3008a5b7e6c4b Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sat, 11 Oct 2025 08:01:14 +0200 Subject: [PATCH 29/49] update README --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 384dc3a..151566c 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,14 @@ moreover a week itself can be subscripted, for example: Week(date(1958, 3, 24))[2] # date(1958, 3, 26) ``` -one can also slice to get an *generator* that generates `Week`s or `date`s in the week respectively. +one can also slice to created an object that is a sliced "view" that generates `Week`s or `date`s in the week respectively. This view can then be sliced or indexed further. For example: + +``` +Week[102123:105341:2] +``` + +is a collection of `Week` objects between `1958-03-24` and `2019-11-25` each time with one week in between. + The `Week` class itself is also iterable, for example: From dc5caa22684045c953d8e8cc601ae14985aea0f8 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sat, 11 Oct 2025 08:20:45 +0200 Subject: [PATCH 30/49] =?UTF-8?q?=E2=84=AC=F0=9D=93=81=F0=9D=92=B6?= =?UTF-8?q?=F0=9D=92=B8=F0=9D=93=80=20reformatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- timetest.py | 4 ++++ unit_of_time/__init__.py | 3 +++ 2 files changed, 7 insertions(+) diff --git a/timetest.py b/timetest.py index b0fb970..4d82e51 100644 --- a/timetest.py +++ b/timetest.py @@ -193,6 +193,10 @@ def test_to_int(self): (tu.first_date - tu.previous.last_date), timedelta(days=1) ) + def test_repr(self): + self.assertEqual("Week", repr(Week)) + self.assertEqual("Week[102123:105341:]", repr(Week[102123:105341:])) + def test_hierarchy(self): """ Test hierarchical relationships between time unit kinds for correct ordering, duration, and overlap. diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index 8d31c3b..2762492 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -106,6 +106,9 @@ def _from_index(cls, idx): """ return cls(cls.get_date_from_index(idx)) + def __repr__(cls): + return cls.__qualname__ + @property def unit_register(self): """ From 196cd37dadf796f95ad8be787e5c2f80e0e0bba6 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sat, 11 Oct 2025 08:24:30 +0200 Subject: [PATCH 31/49] islice --- timetest.py | 3 ++- unit_of_time/__init__.py | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/timetest.py b/timetest.py index 4d82e51..f5f8a14 100644 --- a/timetest.py +++ b/timetest.py @@ -2,7 +2,7 @@ from datetime import date, datetime, time, timedelta from unit_of_time import Year, Quarter, Month, Week, Day, TimeunitKind, Timeunit - +from itertools import islice class Decade(TimeunitKind): kind_int = 0 @@ -239,6 +239,7 @@ def test_kinds(self): self.assertEqual(kind.get_index_for_date(date.min), 0) d[kind] = True self.assertEqual(list(kind[10:110:10][5:9:2]), list(kind[60:100:20])) + self.assertEqual(list(islice(kind, 3)), list(kind[:3])) self.assertNotIn(kind, seen) seen.add(kind) for kind2 in TIME_UNITS[i:]: diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index 2762492..19f55af 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -1,3 +1,4 @@ +from abc import abstractmethod import math from datetime import date, datetime, timedelta @@ -258,6 +259,7 @@ def to_str(cls, dt): """ return dt.strftime(cls.formatter.replace("%Y", f"{dt.year:04d}")) + @abstractmethod def get_index_for_date(cls, dt): """ Compute the unit-specific ordinal index for the given date. @@ -272,6 +274,7 @@ def get_index_for_date(cls, dt): """ return None + @abstractmethod def get_date_from_index(cls, dt): """ Map an index value for this time unit kind to its corresponding start date. From 7b8c4864ca2c772f5c21148969de6e7e6e535217 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sat, 11 Oct 2025 08:26:08 +0200 Subject: [PATCH 32/49] indexing Timeunit --- timetest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/timetest.py b/timetest.py index f5f8a14..e237e57 100644 --- a/timetest.py +++ b/timetest.py @@ -132,8 +132,9 @@ def test_to_int(self): ) self.assertEqual(tu.dt, kind.get_date_from_index(idx)) self.assertEqual(tu, kind[idx]) - for dt2 in tu: + for idx2, dt2 in enumerate(tu): self.assertEqual(idx, kind.get_index_for_date(dt2)) + self.assertEqual(dt2, tu[idx2]) self.assertGreater(tu, tu.previous) self.assertGreater(tu.next, tu) self.assertGreaterEqual(tu, tu.previous) From 3d94a1901095be3cba11cd65325178fd84ab1505 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sat, 11 Oct 2025 08:26:35 +0200 Subject: [PATCH 33/49] =?UTF-8?q?=E2=84=AC=F0=9D=93=81=F0=9D=92=B6?= =?UTF-8?q?=F0=9D=92=B8=F0=9D=93=80=20reformatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- timetest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/timetest.py b/timetest.py index e237e57..06b645a 100644 --- a/timetest.py +++ b/timetest.py @@ -4,6 +4,7 @@ from unit_of_time import Year, Quarter, Month, Week, Day, TimeunitKind, Timeunit from itertools import islice + class Decade(TimeunitKind): kind_int = 0 formatter = "%Ys" From 47a1ea8041d134b585b363018f472191ae85c861 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sat, 11 Oct 2025 08:28:19 +0200 Subject: [PATCH 34/49] matrix --- .github/workflows/build.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 79e311e..bae26ba 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,13 +14,16 @@ jobs: options: "--check" test: name: run tests + strategy: + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.x' + python-version: ${{ matrix.python-version }} - name: Run test run: | pip install pytest-cov From 9aae40a852bc85050930911cdbc69f0e915ed6ef Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sat, 11 Oct 2025 09:24:53 +0200 Subject: [PATCH 35/49] matrix --- timetest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timetest.py b/timetest.py index 06b645a..6c7b3b8 100644 --- a/timetest.py +++ b/timetest.py @@ -46,7 +46,7 @@ def get_date_from_index(cls, idx): Returns: datetime.date: January 1 of the year 10 * idx. """ - return date(10 * idx, 1, 1) + return date(max(10 * idx, 1), 1, 1) @classmethod def last_day(cls, dt): From b36e7e0c9325867f6f2876a16112156a1c389bd3 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sat, 11 Oct 2025 09:31:56 +0200 Subject: [PATCH 36/49] random order --- .github/workflows/build.yml | 4 ++-- timetest.py | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bae26ba..52783ab 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,8 +26,8 @@ jobs: python-version: ${{ matrix.python-version }} - name: Run test run: | - pip install pytest-cov - pytest --junitxml=pytest.xml --cov-report=term-missing:skip-covered --cov=unit_of_time timetest.py + pip install pytest-cov pytest-random-order + pytest --random-order --junitxml=pytest.xml --cov-report=term-missing:skip-covered --cov=unit_of_time timetest.py - name: Coveralls uses: coverallsapp/github-action@v2 build: diff --git a/timetest.py b/timetest.py index 6c7b3b8..8ebd952 100644 --- a/timetest.py +++ b/timetest.py @@ -133,9 +133,10 @@ def test_to_int(self): ) self.assertEqual(tu.dt, kind.get_date_from_index(idx)) self.assertEqual(tu, kind[idx]) - for idx2, dt2 in enumerate(tu): - self.assertEqual(idx, kind.get_index_for_date(dt2)) - self.assertEqual(dt2, tu[idx2]) + if dt == tu.first_date: + for idx2, dt2 in enumerate(tu): + self.assertEqual(idx, kind.get_index_for_date(dt2)) + self.assertEqual(dt2, tu[idx2]) self.assertGreater(tu, tu.previous) self.assertGreater(tu.next, tu) self.assertGreaterEqual(tu, tu.previous) From 9592bb737e68064f651765a3e7ffbc914e1d216f Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sat, 11 Oct 2025 09:53:20 +0200 Subject: [PATCH 37/49] truncate --- timetest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timetest.py b/timetest.py index 8ebd952..c7fe47a 100644 --- a/timetest.py +++ b/timetest.py @@ -20,7 +20,7 @@ def truncate(cls, dt): Returns: date: The first day (January 1) of the decade in which `dt` falls. """ - return date(10 * (dt.year // 10), 1, 1) + return date(max(10 * (dt.year // 10), 1), 1, 1) @classmethod def get_index_for_date(cls, dt): From 6df07aa4ee059a318f39a85d5d92e37d990e4108 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sat, 11 Oct 2025 09:55:35 +0200 Subject: [PATCH 38/49] fix test error --- timetest.py | 1 + unit_of_time/__init__.py | 6 ++---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/timetest.py b/timetest.py index c7fe47a..a7b6e0c 100644 --- a/timetest.py +++ b/timetest.py @@ -212,6 +212,7 @@ def test_hierarchy(self): """ for i, superkind in enumerate(TIME_UNITS, 1): for kind in TIME_UNITS[i:]: + self.assertLess(superkind, kind) for dt in self.date_range_yield(): with self.subTest(superkind=superkind, kind=kind, dt=dt): stu = superkind(dt) diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index 19f55af..d5c8588 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -270,9 +270,8 @@ def get_index_for_date(cls, dt): dt (datetime.date | datetime.datetime): Date to convert to an index for this time unit kind. Returns: - int | None: The zero-based index of the unit containing `dt` relative to `date.min`, or `None` if the kind does not implement indexing. + int: The zero-based index of the unit containing `dt` relative to `date.min`. """ - return None @abstractmethod def get_date_from_index(cls, dt): @@ -283,9 +282,8 @@ def get_date_from_index(cls, dt): dt (int): Integer index representing the offset of the unit (e.g., number of days/weeks/months/years since date.min). Returns: - date_or_none (datetime.date | None): The start date corresponding to `dt`, or `None` when the kind does not provide a mapping. + date (datetime.date): The start date corresponding to `dt`. """ - return None def __iter__(cls): """ From 627e11c3033df2a56ec6ceba40c7151948a35b74 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sat, 11 Oct 2025 10:40:29 +0200 Subject: [PATCH 39/49] fix remarks --- unit_of_time/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index d5c8588..19dd598 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -446,7 +446,7 @@ def get_date_from_index(cls, idx): """ yy = idx // 4 qq = idx - 4 * yy - return date(yy + 1, 3 * qq + 1, 1) + return date(yy + date.min.year, 3 * qq + 1, 1) @classmethod def truncate(cls, dt): @@ -521,7 +521,7 @@ def get_date_from_index(cls, idx): """ yy = idx // 12 mm = (idx % 12) + 1 - return date(yy + 1, mm, 1) + return date(yy + date.min.year, mm, 1) @classmethod def _next(cls, dt): From c24b639c0f2fcaa4da898f56afc8fbd70ee627a5 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sat, 11 Oct 2025 10:41:55 +0200 Subject: [PATCH 40/49] blackcp --- unit_of_time/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index 0b8db5b..6e30c00 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -1,5 +1,5 @@ -from abc import abstractmethod import math +from abc import abstractmethod from datetime import date, datetime, timedelta ONE_DAY = timedelta(days=1) From e8c7efeb27b2c9e25773bb8d3b0683aff1c2920b Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sat, 11 Oct 2025 10:42:21 +0200 Subject: [PATCH 41/49] =?UTF-8?q?=E2=84=AC=F0=9D=93=81=F0=9D=92=B6?= =?UTF-8?q?=F0=9D=92=B8=F0=9D=93=80=20reformatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- timetest.py | 8 ++++++-- unit_of_time/__init__.py | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/timetest.py b/timetest.py index ecadb2a..a7b6e0c 100644 --- a/timetest.py +++ b/timetest.py @@ -189,8 +189,12 @@ def test_to_int(self): self.assertEqual(tu.previous.previous.previous, 3 << tu) self.assertLess(tu.last_date, tu.next.first_date) self.assertLess(tu.previous.last_date, tu.first_date) - self.assertEqual((tu.next.first_date - tu.last_date), timedelta(days=1)) - self.assertEqual((tu.first_date - tu.previous.last_date), timedelta(days=1)) + self.assertEqual( + (tu.next.first_date - tu.last_date), timedelta(days=1) + ) + self.assertEqual( + (tu.first_date - tu.previous.last_date), timedelta(days=1) + ) def test_repr(self): self.assertEqual("Week", repr(Week)) diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index 6e30c00..00a4848 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -125,7 +125,9 @@ def unit_register(self): result = TimeunitKindMeta._registered if result is None: result = { - k.kind_int: k for k in TimeunitKindMeta._pre_registered if k.kind_int is not None + k.kind_int: k + for k in TimeunitKindMeta._pre_registered + if k.kind_int is not None } TimeunitKindMeta._registered = result return result From 63d66b46052cdc5146b3990a23edf411d1fa179b Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sat, 11 Oct 2025 10:44:03 +0200 Subject: [PATCH 42/49] fix pipeline --- .github/workflows/build.yml | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0fdce0a..bd5ee04 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,14 +19,6 @@ jobs: matrix: python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] runs-on: ubuntu-latest - strategy: - matrix: - python-version: - - '3.9' - - '3.10' - - '3.11' - - '3.12' - - '3.13' steps: - uses: actions/checkout@v4 - name: Set up Python @@ -35,13 +27,8 @@ jobs: python-version: ${{ matrix.python-version }} - name: Run test run: | -<<<<<<< HEAD pip install pytest-cov pytest-random-order - pytest --random-order --junitxml=pytest.xml --cov-report=term-missing:skip-covered --cov=unit_of_time timetest.py -======= - pip install pytest-cov - pytest --junitxml=pytest.xml --cov-fail-under=100 --cov-report=term-missing:skip-covered --cov=unit_of_time timetest.py ->>>>>>> origin/master + pytest --random-order --junitxml=pytest.xml --cov-fail-under=100 --cov-report=term-missing:skip-covered --cov=unit_of_time timetest.py - name: Coveralls uses: coverallsapp/github-action@v2 build: From cce7bff9e62f71f1b656f756084680710ea31608 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sat, 11 Oct 2025 10:48:13 +0200 Subject: [PATCH 43/49] aargh --- .github/workflows/build.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bd5ee04..5b51203 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,6 @@ jobs: - uses: psf/black@stable with: options: "--check" - pylint unit_of_time test: name: run tests strategy: From e348f65be7f23620b94708c18a0e804605168d28 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sat, 11 Oct 2025 10:57:22 +0200 Subject: [PATCH 44/49] one day --- unit_of_time/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index 00a4848..58e4256 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -5,9 +5,6 @@ ONE_DAY = timedelta(days=1) -ONE_DAY = timedelta(days=1) - - def date_from_int(val, div=1): val //= div d = val % 100 From d2c10e0a18a33374f8c8712185e74616005531b5 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sat, 11 Oct 2025 10:58:30 +0200 Subject: [PATCH 45/49] rm __int__ --- unit_of_time/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/unit_of_time/__init__.py b/unit_of_time/__init__.py index 58e4256..395573d 100644 --- a/unit_of_time/__init__.py +++ b/unit_of_time/__init__.py @@ -166,9 +166,6 @@ def __int__(self): """ return self.kind_int - def __int__(cls): - return cls.kind_int - def __index__(cls): return int(cls) From 4e097a09fa046746e5ce6e3a8b4cf8ba68225ed5 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sat, 11 Oct 2025 11:00:03 +0200 Subject: [PATCH 46/49] README formatting --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 151566c..461970c 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ we can also convert such collection to a list. ### Subscripting -The `Day`, `Week`, `Month, etc. classes have `.get_index_for_date(..)` and `.get_date_from_index(..)` methods, which allow to determine how many days, weeks, months, quarters and years are between `date.min` and the date given, and convert this back to a date. For example: +The `Day`, `Week`, `Month, etc. classes have `.get_index_for_date(…)` and `.get_date_from_index(…)` methods, which allow to determine how many days, weeks, months, quarters and years are between `date.min` and the date given, and convert this back to a date. For example: ``` Week.get_index_for_date(date(1958, 3, 25)) # 102123 @@ -230,7 +230,7 @@ class Decade(TimeunitKind): this might be useful if the formatting is more advanced than what Python's date formatter can handle. -Furthermore, one implements the `.truncate(..)` class method to convert a date to the start of the date range, and the `_next(..)` which returns the first date for the next decade. +Furthermore, one implements the `.truncate(…)` class method to convert a date to the start of the date range, and the `_next(…)` which returns the first date for the next decade. With these functions, we have registered a new time unit. From e969811a712a4dd4fb0aedc44d11e9bbc5b4d7f1 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sat, 11 Oct 2025 11:01:02 +0200 Subject: [PATCH 47/49] README code blocks --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 461970c..37087f3 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ we can also convert such collection to a list. The `Day`, `Week`, `Month, etc. classes have `.get_index_for_date(…)` and `.get_date_from_index(…)` methods, which allow to determine how many days, weeks, months, quarters and years are between `date.min` and the date given, and convert this back to a date. For example: -``` +```python3 Week.get_index_for_date(date(1958, 3, 25)) # 102123 Week.get_date_from_index(102123) # date(1958, 3, 24) ``` @@ -118,19 +118,19 @@ so 1958-03-25 is the 102'123 week since 0001-01-01, and that week starts the 24< We can also use the index to get a `TimUnit` with: -``` +```python3 Week[102123] # Week(date(1958, 3, 24)) ``` moreover a week itself can be subscripted, for example: -``` +```python3 Week(date(1958, 3, 24))[2] # date(1958, 3, 26) ``` one can also slice to created an object that is a sliced "view" that generates `Week`s or `date`s in the week respectively. This view can then be sliced or indexed further. For example: -``` +```python3 Week[102123:105341:2] ``` @@ -139,7 +139,7 @@ is a collection of `Week` objects between `1958-03-24` and `2019-11-25` each tim The `Week` class itself is also iterable, for example: -``` +```python3 for week in Week: print(week) ``` @@ -148,9 +148,9 @@ will start enumerating over all weeks since 0001-01-01. A time unit also has a length: the number of time units that can be represented, so: -``` +```python3 len(Week) # 521722 -`` +``` means the software can represent 521'722 weeks from 0001-01-01 to 9999-12-26. From 0dc930fa5e865fcb1a667a72fdd2db66dd807c02 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sat, 11 Oct 2025 11:17:17 +0200 Subject: [PATCH 48/49] test typeerror --- timetest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/timetest.py b/timetest.py index a7b6e0c..7b6194f 100644 --- a/timetest.py +++ b/timetest.py @@ -147,6 +147,8 @@ def test_to_int(self): self.assertEqual(TimeunitKind.from_int(int(tu)), tu) self.assertIn(dt, tu) self.assertIn((dt, dt), tu) + with self.assertRaises(TypeError): + (dt, None) in tu self.assertIn((tu.first_date, tu.last_date), tu) with self.assertRaises(TypeError): self.assertIn(1425, tu) From 37dfafb2d2fb7480c38172736da6b353ebdc3737 Mon Sep 17 00:00:00 2001 From: Willem Van Onsem Date: Sat, 11 Oct 2025 11:18:03 +0200 Subject: [PATCH 49/49] more Py versions --- .github/workflows/build.yml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5b51203..92ec35b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,7 +16,7 @@ jobs: name: run tests strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -34,12 +34,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: - - '3.9' - - '3.10' - - '3.11' - - '3.12' - - '3.13' + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] steps: - name: checkout code uses: actions/checkout@v4