Skip to content
16 changes: 15 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ jobs:
- uses: psf/black@stable
with:
options: "--check"
pyright:
name: pyright
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
cache: 'pip'
- name: Run pyright
run: |
pip install "pyright>=1.1,<2"
pyright
Comment on lines 15 to 28
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Make Pyright check against Python 3.8 and cache deps.

  • The library supports 3.8–3.12; run Pyright with pythonVersion=3.8 to catch 3.8-incompatible annotations (e.g., PEP 585 generics).
  • Add typing-extensions for 3.8-only typing gaps.
  • Improve pip caching with cache-dependency-path.

Apply:

   pyright:
     name: pyright
     runs-on: ubuntu-latest
     steps:
     - uses: actions/checkout@v4
     - name: Set up Python
       uses: actions/setup-python@v5
       with:
         python-version: '3.x'
-        cache: 'pip'
+        cache: 'pip'
+        cache-dependency-path: |
+          requirements*.txt
+          setup.cfg
+          setup.py
+          pyproject.toml
     - name: Run pyright
       run: |
-        pip install "pyright>=1.1,<2"
-        pyright
+        python -m pip install -U pip
+        pip install "pyright>=1.1,<2" typing-extensions
+        pyright --pythonversion 3.8
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pyright:
name: pyright
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
cache: 'pip'
- name: Run pyright
run: |
pip install "pyright>=1.1,<2"
pyright
pyright:
name: pyright
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
cache: 'pip'
cache-dependency-path: |
requirements*.txt
setup.cfg
setup.py
pyproject.toml
- name: Run pyright
run: |
python -m pip install -U pip
pip install "pyright>=1.1,<2" typing-extensions
pyright --pythonversion 3.8
🤖 Prompt for AI Agents
.github/workflows/build.yml around lines 15 to 28: update the pyright job to
install typing-extensions and run pyright targeting Python 3.8, and improve pip
caching by adding cache-dependency-path; specifically, in the Set up Python step
add cache-dependency-path: requirements.txt (or your lock file if you use
pyproject/poetry), and in the Run pyright step install typing-extensions
alongside pyright (pip install "pyright>=1.1,<2" "typing-extensions") and invoke
pyright with --pythonversion 3.8 (pyright --pythonversion 3.8).

test:
name: run tests
runs-on: ubuntu-latest
Expand Down Expand Up @@ -46,7 +60,7 @@ jobs:
publish:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
needs: [black, build, test]
needs: [black, build, test, pyright]
steps:
- name: checkout code
uses: actions/checkout@v4
Expand Down
89 changes: 47 additions & 42 deletions unit_of_time/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import math
from datetime import date, datetime, timedelta
from typing import Dict, Union
Comment on lines 1 to 2
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Python 3.8 compatibility: annotations will break without future import.

You’re using PEP 585 generics (list[...] / tuple[...]) while supporting 3.8. Add the future import (or switch to typing.List/Tuple). This is a real runtime error on 3.8.

+from __future__ import annotations
 import math
 from datetime import date, datetime, timedelta
-from typing import Dict, Union
+from typing import Dict, Union, ClassVar, Mapping, Type
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import math
from datetime import date, datetime, timedelta
from typing import Dict, Union
from __future__ import annotations
import math
from datetime import date, datetime, timedelta
from typing import Dict, Union, ClassVar, Mapping, Type
🤖 Prompt for AI Agents
In unit_of_time/__init__.py lines 1-3, the module may use PEP 585-style
annotations which break at runtime on Python 3.8; add the future import by
inserting "from __future__ import annotations" as the very first line of the
file (before any other imports) to enable postponed evaluation of annotations,
or alternatively replace PEP 585 types with typing.List, typing.Tuple, etc.,
throughout the file to maintain 3.8 compatibility.



def date_from_int(val, div=1):
def date_from_int(val: int, div=1) -> date:
val //= div
d = val % 100
val //= 100
Expand All @@ -11,26 +12,26 @@ def date_from_int(val, div=1):
return date(val, m, d)


def date_to_int(val, mul=1):
def date_to_int(val: date, mul: int = 1) -> int:
return mul * (val.year * 10000 + val.month * 100 + val.day)


class TimeunitKindMeta(type):
kind_int = None
formatter = None
_pre_registered = []
_registered = None
_multiplier = None
kind_int: int = -1
formatter: str = ""
_pre_registered: list["TimeunitKindMeta"] = []
_registered: Union[None, Dict[int, "TimeunitKindMeta"]] = None
_multiplier: int = -1

def __init__(cls, name, bases, attrs):
def __init__(cls, name, bases, attrs) -> None:
super().__init__(name, bases, attrs)
if cls.kind_int is not None:
TimeunitKindMeta._pre_registered.append(cls)
TimeunitKindMeta._registered = None
TimeunitKindMeta._multiplier = None
TimeunitKindMeta._multiplier = -1

@property
def unit_register(self):
def unit_register(self) -> Dict[int, "TimeunitKindMeta"]:
result = TimeunitKindMeta._registered
if result is None:
result = {
Expand All @@ -42,27 +43,29 @@ def unit_register(self):
return result

@property
def multiplier(cls):
def multiplier(cls) -> int:
result = TimeunitKindMeta._multiplier
if result is None:
result = max(1, *[k.kind_int for k in TimeunitKindMeta._pre_registered])
result = 10 ** math.ceil(math.log10(result))
if result == -1:
result = 1
for k in TimeunitKindMeta._pre_registered:
while k.kind_int >= result:
result *= 10
TimeunitKindMeta._multiplier = result
return result

def __int__(self):
def __int__(self) -> int:
return self.kind_int

def __index__(self):
def __index__(self) -> int:
return int(self)

def __hash__(self):
def __hash__(self) -> int:
"""
Return the hash value of the time unit, based on its integer encoding.
"""
return hash(int(self))

def __eq__(self, other):
def __eq__(self, other: object) -> bool:
"""
Return True if this time unit kind is the same as another kind or matches the kind registered for the given integer.

Expand All @@ -73,10 +76,11 @@ def __eq__(self, other):
bool: True if both refer to the same time unit kind, otherwise False.
"""
if isinstance(other, int):
other = TimeunitKind.unit_register[other]
# if out of range, None will fail
other = TimeunitKind.unit_register.get(other)
return self is other

def __call__(cls, dt):
def __call__(cls, dt: Union["Timeunit", date]) -> "Timeunit":
"""
Creates a `Timeunit` instance of this kind from a given date or `Timeunit`.

Expand All @@ -89,17 +93,17 @@ def __call__(cls, dt):
def __lt__(self, other):
return self.kind_int < other.kind_int

def from_int(cls, val):
def from_int(cls, val: int) -> "Timeunit":
mul = cls.multiplier
return TimeunitKind.unit_register[val % mul](date_from_int(val, mul))

def get_previous(cls, dt):
def get_previous(cls, dt: Union[date, "Timeunit"]) -> "Timeunit":
if isinstance(dt, Timeunit):
dt = dt.dt
dt -= timedelta(days=1)
return cls(dt)

def last_day(cls, dt):
def last_day(cls, dt: date) -> date:
"""
Return the last date of the time unit containing the given date.

Expand All @@ -111,7 +115,7 @@ def last_day(cls, dt):
"""
return cls._next(dt) - timedelta(days=1)

def _next(cls, dt):
def _next(cls, dt: date) -> date:
"""
Return the first day of the next time unit following the given date.

Expand All @@ -123,7 +127,7 @@ def _next(cls, dt):
"""
return cls.last_day(dt) + timedelta(days=1)

def get_next(cls, dt):
def get_next(cls, dt: Union["Timeunit", date]) -> "Timeunit":
"""
Return the next time unit instance of this kind after the given date.

Expand All @@ -133,16 +137,16 @@ def get_next(cls, dt):
dt = dt.dt
return cls(cls._next(cls.truncate(dt)))

def to_str(cls, dt):
def to_str(cls, dt) -> str:
return dt.strftime(cls.formatter)

def truncate(cls, dt):
def truncate(cls, dt: date) -> date:
return datetime.strptime(cls.to_str(dt), cls.formatter).date()

def _inner_shift(cls, cur, dt, amount):
def _inner_shift(cls, cur, dt, amount) -> Union[date, None]:
return None

def _shift(cls, cur, dt, amount):
def _shift(cls, cur: "Timeunit", dt: date, amount: int) -> "Timeunit":
new_dt = cls._inner_shift(cur, dt, amount)
if new_dt is not None:
return cls(new_dt)
Expand All @@ -159,20 +163,20 @@ def _shift(cls, cur, dt, amount):


class TimeunitKind(metaclass=TimeunitKindMeta):
kind_int = None
formatter = None
kind_int = -1
formatter = ""


class Year(TimeunitKind):
kind_int = 1
formatter = "%Y"

@classmethod
def _next(cls, dt):
def _next(cls, dt: date) -> date:
return date(dt.year + 1, 1, 1)

@classmethod
def _inner_shift(cls, cur, dt, amount):
def _inner_shift(cls, cur: "Timeunit", dt: date, amount: int):
return date(dt.year + amount, 1, 1)


Expand All @@ -184,11 +188,11 @@ def to_str(cls, dt):
return f"{dt.year}Q{dt.month//3}"

@classmethod
def truncate(cls, dt):
def truncate(cls, dt: date) -> date:
return date(dt.year, 3 * ((dt.month - 1) // 3) + 1, 1)

@classmethod
def _inner_shift(cls, cur, dt, amount):
def _inner_shift(cls, cur: "Timeunit", dt: date, amount: int) -> date:
q_new = dt.year * 4 + amount + (dt.month - 1) // 3
y = q_new // 4
q = q_new % 4
Expand All @@ -207,7 +211,7 @@ class Month(TimeunitKind):
formatter = "%YM%m"

@classmethod
def _inner_shift(cls, cur, dt, amount):
def _inner_shift(cls, cur: "Timeunit", dt: date, amount: int) -> date:
m_new = dt.year * 12 + amount + dt.month - 1
return date(m_new // 12, m_new % 12 + 1, 1)

Expand All @@ -225,11 +229,11 @@ class Week(TimeunitKind):
formatter = "%YW%W"

@classmethod
def _inner_shift(cls, cur, dt, amount):
def _inner_shift(cls, cur: "Timeunit", dt: date, amount: int) -> date:
return dt + timedelta(days=7 * amount)

@classmethod
def truncate(cls, dt):
def truncate(cls, dt: date) -> date:
if isinstance(dt, datetime):
dt = dt.date()
return dt - timedelta(days=dt.weekday())
Expand All @@ -244,11 +248,11 @@ class Day(TimeunitKind):
formatter = "%Y-%m-%d"

@classmethod
def _inner_shift(cls, cur, dt, amount):
def _inner_shift(cls, cur: "Timeunit", dt: date, amount: int) -> date:
return dt + timedelta(days=amount)

@classmethod
def _next(self, dt):
def _next(cls, dt: date) -> date:
return dt + timedelta(days=1)


Expand Down Expand Up @@ -370,7 +374,7 @@ def __repr__(self):
return f"{self.__class__.__name__}({self.kind.__qualname__}, {self.dt!r})"

@classmethod
def _get_range(cls, item):
def _get_range(cls, item) -> tuple[date, date]:
"""
Extracts a date range tuple from the given item.

Expand All @@ -396,7 +400,8 @@ def _get_range(cls, item):
try:
dt0, dt1 = item
if isinstance(dt0, date) and isinstance(dt1, date):
return item
return dt0, dt1
raise TypeError(f"Item {item!r} is not a date range.") from None
except TypeError:
raise TypeError(f"Item {item!r} has no date range.") from None

Expand Down
Loading