Initial commit. Needs more test coverage.

This commit is contained in:
Josh W 2024-04-03 20:52:01 -04:00
commit bd2cc20110
17 changed files with 2903 additions and 0 deletions

163
.gitignore vendored Normal file
View file

@ -0,0 +1,163 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Dev settings
.vscode/

0
README.md Normal file
View file

27
contrib/example.py Normal file
View file

@ -0,0 +1,27 @@
"""
An example showing off the current D'Ni time and it's conversion back to an
Earth datetime.
"""
from datetime import datetime
import sys
from time import sleep
from dnidatetime import DniDatetime
if __name__ == "__main__":
while True:
try:
earth_now = datetime.now().astimezone()
dni_now = DniDatetime.from_earth(earth_now)
back = dni_now.to_earth().astimezone()
print(
f"{earth_now} --> {dni_now} --> {back}",
end=" \r",
flush=True,
)
sleep(0.01)
except KeyboardInterrupt:
print("\n")
sys.exit()

2
mypy.ini Normal file
View file

@ -0,0 +1,2 @@
[mypy]
mypy_path = $MYPY_CONFIG_FILE_DIR/src

1044
poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

28
pyproject.toml Normal file
View file

@ -0,0 +1,28 @@
[tool.poetry]
name = "dnidatetime"
version = "0.1.0"
description = ""
authors = ["Josh Washburne <josh@jodh.us>"]
readme = "README.md"
packages = [{include = "dnidatetime", from = "src"}]
[tool.poetry.dependencies]
python = "^3.11"
[tool.poetry.group.dev.dependencies]
pylint = "^3.1.0"
mypy = "^1.9.0"
black = "^24.3.0"
pytest = "^8.1.1"
[tool.poetry.group.docs.dependencies]
mkdocs = "^1.5.3"
mkdocstrings = {extras = ["python"], version = "^0.24.1"}
mkdocs-material = "^9.5.14"
[tool.pytest.ini_options]
pythonpath = "src"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

View file

@ -0,0 +1,7 @@
from .dnitimedelta import DniTimedelta
from .dnidate import DniDate
from .dnitime import DniTime
from .dnidatetime import DniDatetime
__all__ = ["DniDate", "DniDatetime", "DniTime", "DniTimedelta"]

View file

@ -0,0 +1,120 @@
"""Constants for all classes and functions with D'Ni datetimes."""
from collections import OrderedDict
from datetime import datetime, timezone
from typing import Tuple, TypedDict
DniDateTimeTuple = Tuple[int, int, int, int, int, int, int]
class DniUnitType(TypedDict):
"""Stores D'Ni units of time. Used for type-checking purposes."""
min: int
max: int
total_pro: int
# All the units of time in the D'Ni culture from largest to smallest.
#
# Hahr - The D'Ni eqivalent of an earth year.
# Vailee - The D'Ni eqivalent of an earth month, 10 total in each hahr.
# Yahr - The D'Ni equivalent of an earth day, 29 total in each vailee.
# Gahrtahvo - 5 total in each yahr.
# Pahrtahvo - The D'Ni equivalent of an earth hour, 5 total in each gahrtahvo
# and 25 total in each yahr. Not used in calculations, but can
# be used to show a different method of D'Ni time.
# Tahvo - 5 total in each pahrtahvo and 25 total in each gahrtahvo.
# Gorahn - 25 total in each tahvo.
# Prorahn - The D'Ni equivalent of an earth second, 25 total in each gorahn.
#
# Limit: Maximum amount before rollover (ie. 60 seconds in 1 minute)
# Total Prorahntee: A "prorahn" is the smallest unit of time and is
# used in the calcuation of the other units.
DNI_UNITS: OrderedDict[str, DniUnitType] = OrderedDict(
[
("hahr", {"min": 7654, "max": 17655, "total_pro": 22656250}),
("vailee", {"min": 1, "max": 10, "total_pro": 2265625}),
("yahr", {"min": 1, "max": 29, "total_pro": 78125}),
("gahrtahvo", {"min": 0, "max": 5, "total_pro": 15625}),
("tahvo", {"min": 0, "max": 25, "total_pro": 625}),
("gorahn", {"min": 0, "max": 25, "total_pro": 25}),
("prorahn", {"min": 0, "max": 25, "total_pro": 1}),
]
)
# A hahr's length is equal to the Mean Solar Tropical Year for 1995 in
# milliseconds.
MS_PER_HAHR = 31556925216
# A prorahn's length in milliseconds.
# Some previous [wrong] calculations:
# JS = 1392.8573857142859
# Python = 1392.8573888441379
MS_PER_PRORAHN = 1392.85737275
# Just as the UNIX Epoch is based on midnight on 1-1-1970, the D'Ni Epoch
# is based on the timestamp of the original MYST Hypercard file:
# April 21, 1991 9:54AM PST (16:54 UTC) --> Leefo 1, 9647 00:00:00:00
EARTH_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
DNI_EPOCH_EDT = datetime(1991, 4, 21, 16, 54, 0, tzinfo=timezone.utc) # Earth
DNI_EPOCH_HAHR = 9647
# Converted leap seconds to ms, but adjusted for the UNIX epoch instead.
# https://data.iana.org/time-zones/data/leap-seconds.list
# delta_ms_1900_to_1970 = -2208988800000
# leap_ms = leap_sec_from_ietf * 1000 + delta_ms_1900_to_1970
LEAP_MS_FROM_EPOCH = [
63072000000, # 1 Jan 1972
78796800000, # 1 Jul 1972
94694400000, # 1 Jan 1973
126230400000, # 1 Jan 1974
157766400000, # 1 Jan 1975
189302400000, # 1 Jan 1976
220924800000, # 1 Jan 1977
252460800000, # 1 Jan 1978
283996800000, # 1 Jan 1979
315532800000, # 1 Jan 1980
362793600000, # 1 Jul 1981
394329600000, # 1 Jul 1982
425865600000, # 1 Jul 1983
489024000000, # 1 Jul 1985
567993600000, # 1 Jan 1988
631152000000, # 1 Jan 1990
662688000000, # 1 Jan 1991
709948800000, # 1 Jul 1992
741484800000, # 1 Jul 1993
773020800000, # 1 Jul 1994
820454400000, # 1 Jan 1996
867715200000, # 1 Jul 1997
915148800000, # 1 Jan 1999
1136073600000, # 1 Jan 2006
1230768000000, # 1 Jan 2009
1341100800000, # 1 Jul 2012
1435708800000, # 1 Jul 2015
1483228800000, # 1 Jan 2017
]
# DniDate.max.to_ordinal()
# (For whatever reason, 5119863 was the former value and I can't figure why.
# Noting it here in case I need it for something.)
MIN_DNI_ORDINAL = 1
MAX_DNI_ORDINAL = 2900494
# The names of the 10 vailee in a hahr.
VAILEE_NAMES = [
"Leefo",
"Leebro",
"Leesahn",
"Leetar",
"Leevot",
"Leevofo",
"Leevobro",
"Leevosahn",
"Leevotar",
"Leenovoo",
]
# The number of yahrtee (days) before each consectutive vailee (month).
YAHRTEE_BEFORE_VAILEE = [-1, 0, 29, 58, 87, 116, 145, 174, 203, 232, 261]

View file

@ -0,0 +1,152 @@
"""Conversion functions for Earth/D'Ni datetimes & Ordinal/Julian dates."""
from datetime import datetime, timezone
from math import floor
from typing import Dict, Optional, Tuple
from .constants import (
DniDateTimeTuple,
DNI_EPOCH_EDT,
DNI_EPOCH_HAHR,
DNI_UNITS,
MAX_DNI_ORDINAL,
MIN_DNI_ORDINAL,
MS_PER_HAHR,
MS_PER_PRORAHN,
YAHRTEE_BEFORE_VAILEE
)
from .utils import (
check_dni_date_fields,
get_adj_ms_from_epoch,
get_ms_from_epoch,
is_timezone_aware
)
# Number of yahrtee (days) in a hahr (year) == 290.
_YAHR_IN_HAHR = DNI_UNITS["yahr"]["max"] * DNI_UNITS["vailee"]["max"]
def hvy2ord(hahr: int, vailee: int, yahr: int) -> int:
"""The amount of yahrtee since Leefo 1, 7654 (DniDate.min)."""
ok_hahr, ok_vailee, ok_yahr = check_dni_date_fields(hahr, vailee, yahr)
yahr_before_hahr = (ok_hahr - DNI_UNITS["hahr"]["min"]) * _YAHR_IN_HAHR
return yahr_before_hahr + YAHRTEE_BEFORE_VAILEE[ok_vailee] + ok_yahr
def ord2hvy(ordinal: int) -> Tuple[int, int, int]:
"""Find the exact hahr, vailee, and yahr from an ordinal value of
yahrtee.
"""
if not MIN_DNI_ORDINAL <= ordinal <= MAX_DNI_ORDINAL:
raise ValueError(
f"Ordinal must be in {MIN_DNI_ORDINAL}..{MAX_DNI_ORDINAL}",
ordinal,
)
# TODO: I hate this with a firey passion, but it works. Needs a revamp at
# some point.
hahr, rem = divmod(ordinal, _YAHR_IN_HAHR)
hahr += DNI_UNITS["hahr"]["min"]
if rem == 0:
hahr -= 1
vailee = DNI_UNITS["vailee"]["max"]
yahr = DNI_UNITS["yahr"]["max"]
else:
vailee, yahr = divmod(
rem + DNI_UNITS["yahr"]["max"],
DNI_UNITS["yahr"]["max"]
)
if yahr == 0:
vailee -= 1
yahr = DNI_UNITS["yahr"]["max"]
return hahr, vailee, yahr
def earth_to_dni(
date_time: Optional[datetime] = None
) -> DniDateTimeTuple:
"""Converts an Earth gregorian datetime to a D'Ni datetime."""
if date_time is None:
moment = datetime.now(timezone.utc)
else:
if is_timezone_aware(date_time):
moment = date_time.astimezone(timezone.utc)
else:
raise ValueError("Supplied datetime must be timezone aware.")
dni_dt: Dict[str, int] = {}
stamp = get_adj_ms_from_epoch(moment)
dni_epoch = get_adj_ms_from_epoch(DNI_EPOCH_EDT)
delta = float(stamp - dni_epoch)
for name, details in DNI_UNITS.items():
if name == "hahr":
dni_dt[name] = floor(delta / MS_PER_HAHR)
delta -= dni_dt[name] * MS_PER_HAHR
delta *= details["total_pro"] / MS_PER_HAHR
else:
dni_dt[name] = floor(delta / details["total_pro"])
delta -= dni_dt[name] * details["total_pro"]
# Correct potential value underflow (dates before the reference date)
temp_units = list(DNI_UNITS.items())
rev_index = len(temp_units) - 1
for index, unit in enumerate(reversed(temp_units)):
if dni_dt[unit[0]] < 0:
dni_dt[unit[0]] += unit[1]["max"]
dni_dt[temp_units[rev_index - index][0]] -= 1
# Add reference D'ni hahr
dni_dt["hahr"] += DNI_EPOCH_HAHR
# Vailee and Yahr are not 0-based
dni_dt["vailee"] += 1
dni_dt["yahr"] += 1
return (
dni_dt["hahr"],
dni_dt["vailee"],
dni_dt["yahr"],
dni_dt["gahrtahvo"],
dni_dt["tahvo"],
dni_dt["gorahn"],
dni_dt["prorahn"],
)
def dni_to_earth(dni_date_time: DniDateTimeTuple) -> datetime:
"""Converts a D'Ni datetime to an Earth gregorian datetime."""
if not isinstance(dni_date_time, tuple):
raise ValueError(
(
"D'Ni datetime must be in the form of a timetuple. "
"See DniDateTime.timetuple()."
),
dni_date_time
)
if len(dni_date_time) != 7:
raise ValueError(
"The D'Ni timetuple is malformed. See DniDateTime.timetuple().",
dni_date_time
)
temp_units = list(dni_date_time)
temp_units[0] -= DNI_EPOCH_HAHR
temp_units[1] -= 1
temp_units[2] -= 1
total_prorahn = 0
for index, unit in enumerate(DNI_UNITS.items()):
total_prorahn += temp_units[index] * unit[1]["total_pro"]
timestamp = total_prorahn * MS_PER_PRORAHN
epoch = get_ms_from_epoch(DNI_EPOCH_EDT)
date = timestamp + epoch
return datetime.fromtimestamp(date / 1000.0, timezone.utc)

183
src/dnidatetime/dnidate.py Normal file
View file

@ -0,0 +1,183 @@
"""The DniDate class."""
from typing import Any, Optional, TYPE_CHECKING, Union
from .constants import DniDateTimeTuple, DNI_UNITS, MAX_DNI_ORDINAL
from .conversions import hvy2ord, ord2hvy
from .dnitimedelta import DniTimedelta
from .utils import check_dni_date_fields, cmp
class DniDate:
"""A D'Ni date."""
__slots__ = "_hahr", "_vailee", "_yahr"
if TYPE_CHECKING:
_hahr: int
_vailee: int
_yahr: int
# Class properties
min: "DniDate"
max: "DniDate"
resolution: DniTimedelta
def __new__(
cls,
hahr: int = DNI_UNITS["hahr"]["min"],
vailee: int = DNI_UNITS["vailee"]["min"],
yahr: int = DNI_UNITS["yahr"]["min"],
) -> "DniDate":
if isinstance(hahr, int):
hahr, vailee, yahr = check_dni_date_fields(hahr, vailee, yahr)
self = object.__new__(cls)
self._hahr = hahr
self._vailee = vailee
self._yahr = yahr
return self
return NotImplemented
@classmethod
def from_dnitimestamp(cls, prorahntee: int) -> "DniDate":
"""Construct a D'Ni date from a D'Ni timestamp."""
yahrtee = prorahntee // DNI_UNITS["yahr"]["total_pro"]
return cls.from_ordinal(yahrtee)
@classmethod
def today(cls) -> "DniDate":
# TODO
return NotImplemented
@classmethod
def from_isoformat(cls, date_str: str) -> "DniDate":
if not isinstance(date_str, str):
raise TypeError("from_isoformat: argument must be str.")
try:
assert 8 <= len(date_str) <= 11
parts = date_str.split("-")
if len(parts) != 3:
raise ValueError(f"Invalid isoformat string: {date_str!r}")
return cls(int(parts[0]), int(parts[1]), int(parts[2]))
except Exception as exc:
raise ValueError(f"Invalid isoformat string: {date_str!r}") from exc
@classmethod
def from_ordinal(cls, yahrtee: int) -> "DniDate":
"""Construct a D'ni date from a D'ni ordinal."""
hahr, vailee, yahr = ord2hvy(yahrtee)
return cls(hahr, vailee, yahr)
def to_ordinal(self) -> int:
"""Return a D'ni ordinal for the hahr, vailee, and yahr."""
return hvy2ord(self._hahr, self._vailee, self._yahr)
def __repr__(self) -> str:
return (
f"{self.__class__.__qualname__}"
f"({self._hahr}, "
f"{self._vailee}, "
f"{self._yahr})"
)
def isoformat(self) -> str:
"""Return the date formatted according to ISO standard."""
return f"{self._hahr}-{self._vailee:02}-{self._yahr:02}"
__str__ = isoformat
# Read-only field accessors
@property
def hahr(self) -> int:
"""hahr"""
return self._hahr
@property
def vailee(self) -> int:
"""vailee"""
return self._vailee
@property
def yahr(self) -> int:
"""yahr"""
return self._yahr
def replace(
self,
hahr: Optional[int] = None,
vailee: Optional[int] = None,
yahr: Optional[int] = None,
) -> "DniDate":
"""Return a new D'ni date with new values for the specified fields."""
if hahr is None:
hahr = self._hahr
if vailee is None:
vailee = self._vailee
if yahr is None:
yahr = self._yahr
return type(self)(hahr, vailee, yahr)
# Arithmetic
def __add__(self, other: Any) -> "DniDate":
if isinstance(other, DniTimedelta):
ordinal = self.to_ordinal() + other._yahrtee
if 0 < ordinal <= MAX_DNI_ORDINAL:
return type(self).from_ordinal(ordinal)
raise OverflowError("Result out of range.")
return NotImplemented
__radd__ = __add__
def __sub__(self, other: Any) -> Union["DniDate", DniTimedelta]:
if isinstance(other, DniTimedelta):
return self + DniTimedelta(-other._yahrtee)
if isinstance(other, DniDate):
yahrtee1 = self.to_ordinal()
yahrtee2 = other.to_ordinal()
return DniTimedelta(yahrtee1 - yahrtee2)
return NotImplemented
# Rich comparisons
def _cmp(self, other: Any) -> int:
"""Helper function for rich comparison of DniDate objects."""
# pylint: disable=protected-access
assert isinstance(other, DniDate)
hahr1, vailee1, yahr1 = self._hahr, self._vailee, self._yahr
hahr2, vailee2, yahr2 = other._hahr, other._vailee, other._yahr
return cmp((hahr1, vailee1, yahr1), (hahr2, vailee2, yahr2))
def __eq__(self, other: Any) -> bool:
if isinstance(other, DniDate):
return self._cmp(other) == 0
return NotImplemented
def __le__(self, other: Any) -> bool:
if isinstance(other, DniDate):
return self._cmp(other) <= 0
return NotImplemented
def __lt__(self, other: Any) -> bool:
if isinstance(other, DniDate):
return self._cmp(other) < 0
return NotImplemented
def __ge__(self, other: Any) -> bool:
if isinstance(other, DniDate):
return self._cmp(other) >= 0
return NotImplemented
def __gt__(self, other: Any) -> bool:
if isinstance(other, DniDate):
return self._cmp(other) > 0
return NotImplemented
def dnitimetuple(self) -> DniDateTimeTuple:
"""Local time tuple compatible with dnitime.localtime()."""
# TODO
return NotImplemented
DniDate.min = DniDate(7654, 1, 1)
DniDate.max = DniDate(17655, 8, 1)
DniDate.resolution = DniTimedelta(yahrtee=1)

View file

@ -0,0 +1,319 @@
"""The DniDatetime class."""
from datetime import datetime, timedelta, timezone
from typing import Any, Optional, TYPE_CHECKING, Union
from .constants import (
DniDateTimeTuple,
DNI_EPOCH_HAHR,
DNI_UNITS,
MAX_DNI_ORDINAL
)
from .conversions import dni_to_earth, earth_to_dni, hvy2ord
from .dnidate import DniDate
from .dnitime import DniTime
from .dnitimedelta import DniTimedelta
from .utils import check_dni_date_fields, check_dni_time_fields, cmp
class DniDatetime:
"""A D'ni timestamp."""
__slots__ = DniDate.__slots__ + DniTime.__slots__
if TYPE_CHECKING:
_hahr: int
_vailee: int
_yahr: int
_gahrtahvo: int
_tahvo: int
_gorahn: int
_prorahn: int
# Class properties
min: "DniDatetime"
max: "DniDatetime"
resolution: DniTimedelta
def __new__(
cls,
hahr: int = DNI_UNITS["hahr"]["min"],
vailee: int = DNI_UNITS["vailee"]["min"],
yahr: int = DNI_UNITS["yahr"]["min"],
gahrtahvo: int = DNI_UNITS["gahrtahvo"]["min"],
tahvo: int = DNI_UNITS["tahvo"]["min"],
gorahn: int = DNI_UNITS["gorahn"]["min"],
prorahn: int = DNI_UNITS["prorahn"]["min"],
) -> "DniDatetime":
if isinstance(hahr, int):
hahr, vailee, yahr = check_dni_date_fields(hahr, vailee, yahr)
gahrtahvo, tahvo, gorahn, prorahn = check_dni_time_fields(
gahrtahvo, tahvo, gorahn, prorahn
)
self = object.__new__(cls)
self._hahr = hahr
self._vailee = vailee
self._yahr = yahr
self._gahrtahvo = gahrtahvo
self._tahvo = tahvo
self._gorahn = gorahn
self._prorahn = prorahn
return self
return NotImplemented
def __repr__(self) -> str:
return (
f"{self.__class__.__qualname__}"
f"({self._hahr}, "
f"{self._vailee}, "
f"{self._yahr}, "
f"{self._gahrtahvo}, "
f"{self._tahvo}, "
f"{self._gorahn}, "
f"{self._prorahn})"
)
def isoformat(self) -> str:
"""Return the datetime formatted according to ISO standard."""
return (
f"{self._hahr}-{self._vailee:02}-{self._yahr:02} "
f"{self._gahrtahvo}:{self._tahvo:02}:"
f"{self._gorahn:02}:{self._prorahn:02}"
)
__str__ = isoformat
# Read-only field accessors
@property
def hahr(self) -> int:
"""hahr"""
return self._hahr
@property
def vailee(self) -> int:
"""vailee"""
return self._vailee
@property
def yahr(self) -> int:
"""yahr"""
return self._yahr
@property
def gahrtahvo(self) -> int:
"""gahrtahvo"""
return self._gahrtahvo
@property
def tahvo(self) -> int:
"""tahvo"""
return self._tahvo
@property
def gorahn(self) -> int:
"""gorahn"""
return self._gorahn
@property
def prorahn(self) -> int:
"""prorahn"""
return self._prorahn
def replace(
self,
hahr: Optional[int] = None,
vailee: Optional[int] = None,
yahr: Optional[int] = None,
gahrtahvo: Optional[int] = None,
tahvo: Optional[int] = None,
gorahn: Optional[int] = None,
prorahn: Optional[int] = None,
) -> "DniDatetime":
"""Return a new D'ni datetime with new values for the specified fields."""
if hahr is None:
hahr = self._hahr
if vailee is None:
vailee = self._vailee
if yahr is None:
yahr = self._yahr
if gahrtahvo is None:
gahrtahvo = self._gahrtahvo
if tahvo is None:
tahvo = self._tahvo
if gorahn is None:
gorahn = self._gorahn
if prorahn is None:
prorahn = self._prorahn
return type(self)(hahr, vailee, yahr, gahrtahvo, tahvo, gorahn, prorahn)
# Arithmetic
def __add__(self, other: Any) -> "DniDatetime":
if not isinstance(other, (timedelta, DniTimedelta)):
return NotImplemented
if isinstance(other, timedelta):
other = DniTimedelta.from_timedelta(other)
delta = DniTimedelta(
self.to_ordinal(),
gahrtahvotee=self._gahrtahvo,
tahvotee=self._tahvo,
gorahntee=self._gorahn,
prorahntee=self._prorahn,
)
delta += other
total_prorahn = delta.pahrtahvotee * 3125 + delta.prorahntee
gahrtahvo, rem = divmod(total_prorahn, 15625)
tahvo, rem = divmod(rem, 625)
gorahn, prorahn = divmod(rem, 25)
if 0 < delta.yahrtee <= MAX_DNI_ORDINAL:
return type(self).combine(
DniDate.from_ordinal(delta.yahrtee),
DniTime(gahrtahvo, tahvo, gorahn, prorahn),
)
raise OverflowError("result out of range")
__radd__ = __add__
def __sub__(self, other: Any) -> Union["DniDatetime", DniTimedelta]:
if not isinstance(other, DniDatetime):
if isinstance(other, timedelta):
return self + -other
else:
yahrtee1 = self.to_ordinal()
yahrtee2 = other.to_ordinal()
prorahntee1 = (
self._gahrtahvo * 15625
+ self._tahvo * 625
+ self._gorahn * 25
+ self._prorahn
)
prorahntee2 = (
other._gahrtahvo * 15625
+ other._tahvo * 625
+ other._gorahn * 25
+ other._prorahn
)
return DniTimedelta(yahrtee1 - yahrtee2, prorahntee1 - prorahntee2)
return NotImplemented
# Rich comparisons
def _cmp(self, other: Any) -> int:
"""Helper function for rich comparison of DniDatetime objects."""
# pylint: disable=protected-access
assert isinstance(other, DniDatetime)
return cmp(
(
self._hahr,
self._vailee,
self._yahr,
self._gahrtahvo,
self._tahvo,
self._gorahn,
self._prorahn,
),
(
other._hahr,
other._vailee,
other._yahr,
other._gahrtahvo,
other._tahvo,
other._gorahn,
other._prorahn,
),
)
def __eq__(self, other: Any) -> bool:
if isinstance(other, DniDatetime):
return self._cmp(other) == 0
if not isinstance(other, DniDate):
return NotImplemented
return False
def __le__(self, other: Any) -> bool:
if isinstance(other, DniDatetime):
return self._cmp(other) <= 0
if not isinstance(other, DniDate):
return NotImplemented
return NotImplemented
def __lt__(self, other: Any) -> bool:
if isinstance(other, DniDatetime):
return self._cmp(other) < 0
if not isinstance(other, DniDate):
return NotImplemented
return NotImplemented
def __ge__(self, other: Any) -> bool:
if isinstance(other, DniDatetime):
return self._cmp(other) >= 0
if not isinstance(other, DniDate):
return NotImplemented
return NotImplemented
def __gt__(self, other: Any) -> bool:
if isinstance(other, DniDatetime):
return self._cmp(other) > 0
if not isinstance(other, DniDate):
return NotImplemented
return NotImplemented
def date(self) -> DniDate:
"""Return the date part of the DniDatetime."""
return DniDate(self._hahr, self._vailee, self._yahr)
def time(self) -> DniTime:
"""Return the time part of the DniDatetime."""
return DniTime(self._gahrtahvo, self._tahvo, self._gorahn, self._prorahn)
def timestamp(self) -> int:
"""Return total number of prorahntee since the D'ni epoch."""
return (self - DNI_EPOCH_DDT).total_prorahntee() #type: ignore
def timetuple(self) -> DniDateTimeTuple:
"""Return the DniDateTime in the form of a timetuple."""
return (
self._hahr,
self._vailee,
self._yahr,
self._gahrtahvo,
self._tahvo,
self._gorahn,
self._prorahn
)
def to_ordinal(self) -> int:
"""Return a D'ni ordinal for the hahr, vailee, and yahr."""
return hvy2ord(self._hahr, self._vailee, self._yahr)
@classmethod
def combine(cls, date_obj: DniDate, time_obj: DniTime) -> "DniDatetime":
"""Construct a D'ni datetime from a given DniDate and DniTime."""
# pylint: disable=protected-access
if not isinstance(date_obj, DniDate):
raise TypeError("date_obj argument must be a DniDate instance.")
if not isinstance(time_obj, DniTime):
raise TypeError("time_obj argument must be a DniTime instance.")
return cls(
date_obj._hahr,
date_obj._vailee,
date_obj._yahr,
time_obj._gahrtahvo,
time_obj._tahvo,
time_obj._gorahn,
time_obj._prorahn,
)
def to_earth(self, tz: Optional[timezone] = timezone.utc) -> datetime:
"""Create an earth datetime from a DniDatetime."""
return dni_to_earth(self.timetuple()).astimezone(tz)
@classmethod
def from_earth(cls, date_time: datetime) -> "DniDatetime":
"""Create a DniDatetime from an earth datetime."""
ddt = earth_to_dni(date_time)
return cls(*ddt)
DniDatetime.min = DniDatetime(7654, 1, 1)
DniDatetime.max = DniDatetime(17655, 8, 1, 4, 24, 24, 24)
DniDatetime.resolution = DniTimedelta(prorahntee=1)
DNI_EPOCH_DDT = DniDatetime(DNI_EPOCH_HAHR, 1, 1, 0, 0, 0, 0)

154
src/dnidatetime/dnitime.py Normal file
View file

@ -0,0 +1,154 @@
"""The DniTime class."""
from typing import Any, Optional, TYPE_CHECKING
from .constants import DNI_UNITS
from .dnitimedelta import DniTimedelta
from .utils import check_dni_time_fields, cmp
class DniTime:
"""A D'ni representation of a timestamp."""
__slots__ = "_gahrtahvo", "_tahvo", "_gorahn", "_prorahn"
if TYPE_CHECKING:
_gahrtahvo: int
_tahvo: int
_gorahn: int
_prorahn: int
# Class properties
min: "DniTime"
max: "DniTime"
resolution: DniTimedelta
def __new__(
cls,
gahrtahvo: int = DNI_UNITS["gahrtahvo"]["min"],
tahvo: int = DNI_UNITS["tahvo"]["min"],
gorahn: int = DNI_UNITS["gorahn"]["min"],
prorahn: int = DNI_UNITS["prorahn"]["min"],
) -> "DniTime":
gahrtahvo, tahvo, gorahn, prorahn = check_dni_time_fields(
gahrtahvo, tahvo, gorahn, prorahn
)
self = object.__new__(cls)
self._gahrtahvo = gahrtahvo
self._tahvo = tahvo
self._gorahn = gorahn
self._prorahn = prorahn
return self
@classmethod
def from_isoformat(cls, time_str: str) -> "DniTime":
if not isinstance(time_str, str):
raise TypeError("from_isoformat: argument must be str.")
try:
assert 7 <= len(time_str) <= 11
parts = time_str.split(":")
if len(parts) != 4:
raise ValueError(f"Invalid isoformat string: {time_str!r}")
return cls(int(parts[0]), int(parts[1]), int(parts[2]), int(parts[3]))
except Exception as exc:
raise ValueError(f"Invalid isoformat string: {time_str!r}") from exc
def __repr__(self) -> str:
return (
f"{self.__class__.__qualname__}"
f"({self._gahrtahvo}, "
f"{self._tahvo}, "
f"{self._gorahn}, "
f"{self._prorahn})"
)
def isoformat(self) -> str:
"""Return the date formatted according to ISO standard."""
return (
f"{self._gahrtahvo}:{self._tahvo:02}:"
f"{self._gorahn:02}:{self._prorahn:02}"
)
__str__ = isoformat
# Read-only field accessors
@property
def gahrtahvo(self) -> int:
"""gahrtahvo"""
return self._gahrtahvo
@property
def tahvo(self) -> int:
"""tahvo"""
return self._tahvo
@property
def gorahn(self) -> int:
"""gorahn"""
return self._gorahn
@property
def prorahn(self) -> int:
"""prorahn"""
return self._prorahn
def replace(
self,
gahrtahvo: Optional[int] = None,
tahvo: Optional[int] = None,
gorahn: Optional[int] = None,
prorahn: Optional[int] = None,
) -> "DniTime":
"""Return a new D'ni time with new values for the specified fields."""
if gahrtahvo is None:
gahrtahvo = self._gahrtahvo
if tahvo is None:
tahvo = self._tahvo
if gorahn is None:
gorahn = self._gorahn
if prorahn is None:
prorahn = self._prorahn
return type(self)(gahrtahvo, tahvo, gorahn, prorahn)
# Rich comparisons
def _cmp(self, other: Any) -> int:
"""Helper function for rich comparison of dnidate objects."""
# pylint: disable=protected-access
assert isinstance(other, DniTime)
mygopro = self._gorahn * 25 + self._prorahn
othergopro = self._gorahn * 25 + self._prorahn
return cmp(
(self._gahrtahvo, self._tahvo, mygopro),
(other._gahrtahvo, other._tahvo, othergopro),
)
def __eq__(self, other: Any) -> bool:
if isinstance(other, DniTime):
return self._cmp(other) == 0
return NotImplemented
def __le__(self, other: Any) -> bool:
if isinstance(other, DniTime):
return self._cmp(other) <= 0
return NotImplemented
def __lt__(self, other: Any) -> bool:
if isinstance(other, DniTime):
return self._cmp(other) < 0
return NotImplemented
def __ge__(self, other: Any) -> bool:
if isinstance(other, DniTime):
return self._cmp(other) >= 0
return NotImplemented
def __gt__(self, other: Any) -> bool:
if isinstance(other, DniTime):
return self._cmp(other) > 0
return NotImplemented
DniTime.min = DniTime(0, 0, 0, 0)
DniTime.max = DniTime(4, 24, 24, 24)
DniTime.resolution = DniTimedelta(prorahntee=1)

View file

@ -0,0 +1,320 @@
"""The DniTimedelta class."""
from datetime import timedelta
from math import modf
from typing import Any, Tuple, TYPE_CHECKING, Union
from .constants import DNI_UNITS, MS_PER_PRORAHN
from .utils import cmp, divide_and_round
class DniTimedelta:
"""A D'ni timedelta, modelled after the Python datetime.timedelta class."""
__slots__ = "_yahrtee", "_pahrtahvotee", "_prorahntee"
if TYPE_CHECKING:
_yahrtee: int
_pahrtahvotee: int
_prorahntee: int
# Class properties
min: "DniTimedelta"
max: "DniTimedelta"
resolution: "DniTimedelta"
def __new__(
cls,
yahrtee: Union[float, int] = 0,
pahrtahvotee: Union[float, int] = 0,
prorahntee: Union[float, int] = 0,
gorahntee: Union[float, int] = 0,
tahvotee: Union[float, int] = 0,
gahrtahvotee: Union[float, int] = 0,
vaileetee: Union[float, int] = 0,
) -> "DniTimedelta":
# Final values are integer.
yahr: int = 0
pahr: int = 0
pro: int = 0
# Normalize
yahrtee += vaileetee * DNI_UNITS["yahr"]["max"]
pahrtahvotee += gahrtahvotee * 5
prorahntee += (gorahntee * DNI_UNITS["prorahn"]["max"]) + (
tahvotee * DNI_UNITS["gorahn"]["max"] * DNI_UNITS["prorahn"]["max"]
)
# Remove fractions: yahrtee
if isinstance(yahrtee, float):
yahr_frac, yahrtee = modf(yahrtee)
yahr_pahr_frac, yahr_pahr_whole = modf(yahr_frac * 25.0)
assert yahr_pahr_whole == int(yahr_pahr_whole)
pahr = int(yahr_pahr_whole)
assert yahrtee == int(yahrtee)
yahr = int(yahrtee)
else:
yahr_pahr_frac = 0.0
yahr = yahrtee
assert isinstance(yahr_pahr_frac, float)
assert abs(yahr_pahr_frac) <= 1.0
assert isinstance(yahr, int)
assert abs(pahr) <= 25
# Remove fractions: pahrtahvotee
if isinstance(pahrtahvotee, float):
pahr_frac, pahrtahvotee = modf(pahrtahvotee)
assert pahrtahvotee == int(pahrtahvotee)
pahrtahvotee = int(pahrtahvotee)
pahr_frac += yahr_pahr_frac
assert abs(pahr_frac) <= 2.0
else:
pahr_frac = yahr_pahr_frac
assert isinstance(pahr_frac, float)
assert abs(pahr_frac) <= 2.0
assert isinstance(pahrtahvotee, int)
yahrtee, pahrtahvotee = divmod(pahrtahvotee, 25)
yahr += yahrtee
pahr += int(pahrtahvotee)
assert isinstance(pahr, int)
assert abs(pahr) <= 2 * 25
# Remove fractions: prorahntee
pro_double = pahr_frac * 3125.0
assert abs(pro_double) < 2 * 3125.0
if isinstance(prorahntee, float):
prorahntee = round(prorahntee + pro_double)
pahrtahvotee, prorahntee = divmod(prorahntee, 3125)
yahrtee, pahrtahvotee = divmod(pahrtahvotee, 25)
yahr += yahrtee
pahr += pahrtahvotee
else:
prorahntee = int(prorahntee)
pahrtahvotee, prorahntee = divmod(prorahntee, 3125)
yahrtee, pahrtahvotee = divmod(pahrtahvotee, 25)
yahr += yahrtee
pahr += pahrtahvotee
prorahntee = round(prorahntee + pro_double)
assert isinstance(pahr, int)
assert isinstance(prorahntee, int)
assert abs(pahr) <= 3 * 25
assert abs(prorahntee) < 3 * 3125.0
# Last bit of normalization
pahrtahvotee, pro = divmod(prorahntee, 3125)
pahr += pahrtahvotee
yahrtee, pahr = divmod(pahr, 25)
yahr += yahrtee
assert isinstance(yahr, int)
assert isinstance(pahr, int) and 0 <= pahr < 25
assert isinstance(pro, int) and 0 <= pro < 3125
if abs(yahr) > 999999999:
raise OverflowError(f"DniTimedelta # of yahrtee is too large: {yahr}")
self = object.__new__(cls)
self._yahrtee = yahr
self._pahrtahvotee = pahr
self._prorahntee = pro
return self
def __repr__(self) -> str:
args = []
if self._yahrtee:
args.append(f"yahrtee={self._yahrtee}")
if self._pahrtahvotee:
args.append(f"pahrtahvotee={self._pahrtahvotee}")
if self._prorahntee:
args.append(f"prorahntee={self._prorahntee}")
if not args:
args.append("0")
return f"{self.__class__.__qualname__}" f"({', '.join(args)})"
def __str__(self) -> str:
prorahn = self._pahrtahvotee * 3125 + self._prorahntee
go, pro = divmod(prorahn, 25)
tahvo, go = divmod(go, 25)
pahr, tahvo = divmod(tahvo, 5)
output = f"{pahr}:{tahvo:02}:{go:02}:{pro:02}"
if self._yahrtee:
plural = "tee" if self._yahrtee != 1 else ""
output = f"{self._yahrtee} yahr{plural}, " + output
return output
def total_prorahntee(self) -> int:
"""Total number of prorahntee in the duration."""
return (self._yahrtee * 78125) + (self._pahrtahvotee * 3125) + self._prorahntee
# Read-only field accessors
@property
def yahrtee(self) -> int:
"""yahrtee"""
return self._yahrtee
@property
def pahrtahvotee(self) -> int:
"""pahrtahvotee"""
return self._pahrtahvotee
@property
def prorahntee(self) -> int:
"""prorahntee"""
return self._prorahntee
# Arithmetic functions
def __add__(self, other: Any) -> "DniTimedelta":
if isinstance(other, (timedelta, DniTimedelta)):
if isinstance(other, timedelta):
other = self.from_timedelta(other)
return DniTimedelta(
self._yahrtee + other._yahrtee,
self._pahrtahvotee + other._pahrtahvotee,
self._prorahntee + other._prorahntee,
)
return NotImplemented
__radd__ = __add__
def __sub__(self, other: Any) -> "DniTimedelta":
if isinstance(other, (timedelta, DniTimedelta)):
if isinstance(other, timedelta):
other = self.from_timedelta(other)
return DniTimedelta(
self._yahrtee - other._yahrtee,
self._pahrtahvotee - other._pahrtahvotee,
self._prorahntee - other._prorahntee,
)
return NotImplemented
def __rsub__(self, other: Any) -> "DniTimedelta":
if isinstance(other, (timedelta, DniTimedelta)):
if isinstance(other, timedelta):
other = self.from_timedelta(other)
return -self + other
return NotImplemented
def __neg__(self) -> "DniTimedelta":
return DniTimedelta(-self._yahrtee, -self._pahrtahvotee, -self._prorahntee)
def __pos__(self) -> "DniTimedelta":
return self
def __abs__(self) -> "DniTimedelta":
if self._yahrtee < 0:
return -self
return self
def __mul__(self, other: Any) -> "DniTimedelta":
if isinstance(other, int):
return DniTimedelta(
self._yahrtee * other,
self._pahrtahvotee * other,
self._prorahntee * other,
)
if isinstance(other, float):
pro = self.total_prorahntee()
numer, denom = other.as_integer_ratio()
return DniTimedelta(0, 0, divide_and_round(pro * numer, denom))
return NotImplemented
__rmul__ = __mul__
def __floordiv__(self, other: Any) -> Union["DniTimedelta", int]:
if not isinstance(other, (int, DniTimedelta)):
return NotImplemented
pro = self.total_prorahntee()
if isinstance(other, DniTimedelta):
return pro // other.total_prorahntee()
if isinstance(other, int):
return DniTimedelta(0, 0, pro // other)
def __truediv__(self, other: Any) -> Union["DniTimedelta", float]:
if not isinstance(other, (float, int, DniTimedelta)):
return NotImplemented
pro = self.total_prorahntee()
if isinstance(other, DniTimedelta):
return pro / other.total_prorahntee()
if isinstance(other, float):
numer, denom = other.as_integer_ratio()
return DniTimedelta(0, 0, divide_and_round(denom * pro, numer))
if isinstance(other, int):
return DniTimedelta(0, 0, divide_and_round(pro, other))
def __mod__(self, other: Any) -> "DniTimedelta":
if isinstance(other, DniTimedelta):
rem = self.total_prorahntee() % other.total_prorahntee()
return DniTimedelta(0, 0, rem)
return NotImplemented
def __divmod__(self, other: Any) -> Tuple[int, "DniTimedelta"]:
if isinstance(other, DniTimedelta):
quot, rem = divmod(self.total_prorahntee(), other.total_prorahntee())
return quot, DniTimedelta(0, 0, rem)
return NotImplemented
# Rich comparisons
def _cmp(self, other: Any) -> int:
"""Helper function for rich comparison of DniTimedelta objects."""
assert isinstance(other, DniTimedelta)
return cmp(self.total_prorahntee(), other.total_prorahntee())
def __eq__(self, other: Any) -> bool:
if isinstance(other, DniTimedelta):
return self._cmp(other) == 0
return NotImplemented
def __le__(self, other: Any) -> bool:
if isinstance(other, DniTimedelta):
return self._cmp(other) <= 0
return NotImplemented
def __lt__(self, other: Any) -> bool:
if isinstance(other, DniTimedelta):
return self._cmp(other) < 0
return NotImplemented
def __ge__(self, other: Any) -> bool:
if isinstance(other, DniTimedelta):
return self._cmp(other) >= 0
return NotImplemented
def __gt__(self, other: Any) -> bool:
if isinstance(other, DniTimedelta):
return self._cmp(other) > 0
return NotImplemented
def __bool__(self) -> bool:
return self._yahrtee != 0 or self._pahrtahvotee != 0 or self._prorahntee != 0
@classmethod
def from_timedelta(cls, delta: timedelta) -> "DniTimedelta":
"""Converts a normal timedelta to a D'Ni timedelta."""
prorahntee = delta.total_seconds() * 1000 / MS_PER_PRORAHN
return cls(prorahntee=prorahntee)
def to_timedelta(self) -> timedelta:
"""Converts the D'Ni timedelta to a normal timedelta."""
milliseconds = self.total_prorahntee() * MS_PER_PRORAHN
try:
delta = timedelta(milliseconds=milliseconds)
return delta
except OverflowError as exc:
# This is needed for precision issues in conversion.
if self >= self.max:
return timedelta.max
if self <= self.min:
return timedelta.min
raise exc
# Converted from timedelta.min
DniTimedelta.min = DniTimedelta(yahrtee=-793993715, pahrtahvotee=17, prorahntee=768)
# Converted from timedelta.max
DniTimedelta.max = DniTimedelta(yahrtee=793993715, pahrtahvotee=2, prorahntee=1887)
# Smallest measurement of change.
DniTimedelta.resolution = DniTimedelta(prorahntee=1)

137
src/dnidatetime/utils.py Normal file
View file

@ -0,0 +1,137 @@
"""Helper functions for all classes/functions of the module."""
# pylint: disable=line-too-long
from datetime import datetime, timedelta, timezone
from typing import Any, Optional, Tuple, Union
from .constants import DNI_UNITS, EARTH_EPOCH, LEAP_MS_FROM_EPOCH
def cmp(x: Any, y: Any) -> int:
"""Compare objects and return:
x < y -> -1
x == y -> 0
x > y -> 1
"""
return 0 if x == y else 1 if x > y else -1
def divide_and_round(a: Union[float, int], b: Union[float, int]) -> int:
"""Divide a by b and round result to the nearest integer. When the ratio
is exactly half-way between two integers, the even integer is returned.
"""
quot, rem = divmod(a, b)
rem *= 2
greater_than_half = rem > b if b > 0 else rem < b
if greater_than_half or rem == b and quot % 2 == 1:
quot += 1
return int(quot)
def check_dni_date_fields(
hahr: int = DNI_UNITS["hahr"]["min"],
vailee: int = DNI_UNITS["vailee"]["min"],
yahr: int = DNI_UNITS["yahr"]["min"],
) -> Tuple[int, int, int]:
"""Verifies the D'Ni date fields are within proper boundaries."""
if not DNI_UNITS["hahr"]["min"] <= hahr <= DNI_UNITS["hahr"]["max"]:
raise ValueError(
f"Hahr must be in {DNI_UNITS['hahr']['min']}..{DNI_UNITS['hahr']['max']}",
hahr,
)
if not DNI_UNITS["vailee"]["min"] <= vailee <= DNI_UNITS["vailee"]["max"]:
raise ValueError(
f"Vailee must be in {DNI_UNITS['vailee']['min']}..{DNI_UNITS['vailee']['max']}",
vailee,
)
if not DNI_UNITS["yahr"]["min"] <= yahr <= DNI_UNITS["yahr"]["max"]:
raise ValueError(
f"Yahr must be in {DNI_UNITS['yahr']['min']}..{DNI_UNITS['yahr']['max']}",
yahr,
)
return hahr, vailee, yahr
def check_dni_time_fields(
gahrtahvo: int = DNI_UNITS["gahrtahvo"]["min"],
tahvo: int = DNI_UNITS["tahvo"]["min"],
gorahn: int = DNI_UNITS["gorahn"]["min"],
prorahn: int = DNI_UNITS["prorahn"]["min"],
) -> Tuple[int, int, int, int]:
"""Verifies the D'Ni time fields are within proper boundaries."""
if (
not DNI_UNITS["gahrtahvo"]["min"]
<= gahrtahvo
<= DNI_UNITS["gahrtahvo"]["max"] - 1
):
raise ValueError(
f"Gahrtahvo must be in {DNI_UNITS['gahrtahvo']['min']}..{DNI_UNITS['gahrtahvo']['max'] - 1}",
gahrtahvo,
)
if not DNI_UNITS["tahvo"]["min"] <= tahvo <= DNI_UNITS["tahvo"]["max"] - 1:
raise ValueError(
f"Tahvo must be in {DNI_UNITS['tahvo']['min']}..{DNI_UNITS['tahvo']['max'] - 1}",
tahvo,
)
if (
not DNI_UNITS["gorahn"]["min"]
<= gorahn
<= DNI_UNITS["gorahn"]["max"] - 1
):
raise ValueError(
f"Gorahn must be in {DNI_UNITS['gorahn']['min']}..{DNI_UNITS['gorahn']['max'] - 1}",
gorahn,
)
if (
not DNI_UNITS["prorahn"]["min"]
<= prorahn
<= DNI_UNITS["prorahn"]["max"] - 1
):
raise ValueError(
f"Prorahn must be in {DNI_UNITS['prorahn']['min']}..{DNI_UNITS['prorahn']['max'] - 1}",
prorahn,
)
return gahrtahvo, tahvo, gorahn, prorahn
def add_leap_seconds(timestamp: int) -> int:
"""Adds leap seconds to a timestamp."""
leap_seconds = 0
for leap in LEAP_MS_FROM_EPOCH:
if timestamp >= leap:
leap_seconds += 1
# Adjust for the 10 leap seconds that started in 1972.
if leap_seconds > 0:
leap_seconds += 9
return timestamp + (leap_seconds * 1000)
def get_ms_from_epoch(date_time: Optional[datetime] = None) -> int:
"""
Returns the amount of milliseconds since the UNIX epoch (1-1-1970).
"""
# Thanks to the examples from here:
# https://stackoverflow.com/questions/5395872/
if not date_time:
date = datetime.now(timezone.utc)
else:
date = date_time.replace(tzinfo=timezone.utc)
return ((date - EARTH_EPOCH) // timedelta(microseconds=1)) // 1000
def get_adj_ms_from_epoch(date_time: Optional[datetime] = None) -> int:
"""
Returns the amount of milliseconds since the UNIX epoch (1-1-1970),
but also accounting for leap seconds.
"""
return add_leap_seconds(get_ms_from_epoch(date_time))
def is_timezone_aware(dt: datetime) -> bool:
"""Returns whether a datetime object has timezone info or not."""
return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None

0
tests/__init__.py Normal file
View file

93
tests/test_conversions.py Normal file
View file

@ -0,0 +1,93 @@
"""Tests for the helper functions in conversions.py"""
from datetime import datetime, timezone
import pytest
from dnidatetime.conversions import (
hvy2ord,
ord2hvy,
earth_to_dni,
dni_to_earth
)
def test_hvy2ord_8765_4_29():
assert hvy2ord(8765, 4, 29) == 322306
def test_hvy2ord_8765_5_1():
assert hvy2ord(8765, 5, 1) == hvy2ord(8765, 4, 29) + 1
def test_hvy2ord_8765_10_29():
assert hvy2ord(8765, 10, 29) == 322480
def test_hvy2ord_8766_1_1():
assert hvy2ord(8766, 1, 1) == hvy2ord(8765, 10, 29) + 1
def test_ord2hvy_too_low():
with pytest.raises(ValueError, match="Ordinal"):
ord2hvy(-1)
def test_ord2hvy_too_high():
with pytest.raises(ValueError, match="Ordinal"):
ord2hvy(2900500)
def test_ord2hvy_322306():
assert ord2hvy(322306) == (8765, 4, 29)
def test_ord2hvy_322307():
assert ord2hvy(322307) == (8765, 5, 1)
def test_ord2hvy_322480():
assert ord2hvy(322480) == (8765, 10, 29)
def test_ord2hvy_322481():
assert ord2hvy(322481) == (8766, 1, 1)
def test_earth_to_dni_no_timezone():
with pytest.raises(ValueError, match="timezone"):
earth_to_dni(datetime.now())
def test_earth_to_dni_dniepoch():
assert earth_to_dni(
datetime(1991, 4, 21, 16, 54, 0, tzinfo=timezone.utc)
) == (9647, 1, 1, 0, 0, 0, 0)
def test_earth_to_dni_2020():
assert earth_to_dni(
datetime(2020, 8, 27, 10, 51, 0, tzinfo=timezone.utc)
) == (9676, 4, 16, 1, 0, 20, 5)
def test_dni_to_earth_not_tuple():
with pytest.raises(ValueError, match="timetuple"):
dni_to_earth([9647, 1, 1, 0, 0, 0, 0])
def test_dni_to_earth_bad_tuple():
with pytest.raises(ValueError, match="malformed"):
dni_to_earth((9647, 1, 1))
def test_dni_to_earth_dniepoch():
assert dni_to_earth(
(9647, 1, 1, 0, 0, 0, 0)
) == datetime(1991, 4, 21, 16, 54, tzinfo=timezone.utc)
def test_dni_to_earth_2020():
assert dni_to_earth(
(9676, 4, 16, 1, 0, 20, 5)
) == datetime(2020, 8, 27, 10, 50, 59, 668172, tzinfo=timezone.utc)

154
tests/test_utils.py Normal file
View file

@ -0,0 +1,154 @@
"""Tests for the helper functions in util.py"""
from datetime import datetime
import pytest
from dnidatetime.utils import (
cmp,
divide_and_round,
check_dni_date_fields,
check_dni_time_fields,
add_leap_seconds,
get_ms_from_epoch,
get_adj_ms_from_epoch,
is_timezone_aware
)
def test_cmp_lessthan():
assert cmp(1, 10) == -1
def test_cmp_equal():
assert cmp(5, 5) == 0
def test_cmp_greaterthan():
assert cmp(10, 1) == 1
def test_dar_down():
assert divide_and_round(5, 2) == 2
def test_dar_up():
assert divide_and_round(7, 2) == 4
def test_dar_no_rem():
assert divide_and_round(6, 2) == 3
def test_check_dnidate_hahr_low():
with pytest.raises(ValueError, match="Hahr"):
check_dni_date_fields(7000, 1, 1)
def test_check_dnidate_hahr_high():
with pytest.raises(ValueError, match="Hahr"):
check_dni_date_fields(20000, 1, 1)
def test_check_dnidate_vailee_low():
with pytest.raises(ValueError, match="Vailee"):
check_dni_date_fields(7654, -1, 1)
def test_check_dnidate_vailee_high():
with pytest.raises(ValueError, match="Vailee"):
check_dni_date_fields(7654, 12, 1)
def test_check_dnidate_yahr_low():
with pytest.raises(ValueError, match="Yahr"):
check_dni_date_fields(7654, 1, -1)
def test_check_dnidate_yahr_high():
with pytest.raises(ValueError, match="Yahr"):
check_dni_date_fields(7654, 1, 31)
def test_check_dnitime_gahrtahvo_low():
with pytest.raises(ValueError, match="Gahrtahvo"):
check_dni_time_fields(-1, 0, 0, 0)
def test_check_dnitime_gahrtahvo_high():
with pytest.raises(ValueError, match="Gahrtahvo"):
check_dni_time_fields(10, 0, 0, 0)
def test_check_dnitime_tahvo_low():
with pytest.raises(ValueError, match="Tahvo"):
check_dni_time_fields(0, -1, 0, 0)
def test_check_dnitime_tahvo_high():
with pytest.raises(ValueError, match="Tahvo"):
check_dni_time_fields(0, 30, 0, 0)
def test_check_dnitime_gorahn_low():
with pytest.raises(ValueError, match="Gorahn"):
check_dni_time_fields(0, 0, -1, 0)
def test_check_dnitime_gorahn_high():
with pytest.raises(ValueError, match="Gorahn"):
check_dni_time_fields(0, 0, 30, 0)
def test_check_dnitime_prorahn_low():
with pytest.raises(ValueError, match="Prorahn"):
check_dni_time_fields(0, 0, 0, -1)
def test_check_dnitime_prorahn_high():
with pytest.raises(ValueError, match="Prorahn"):
check_dni_time_fields(0, 0, 0, 30)
def test_add_leap_seconds_epoch():
assert add_leap_seconds(0) == 0
def test_add_leap_seconds_1984():
assert add_leap_seconds(455328000000) == 455328022000
def test_add_leap_seconds_2020():
assert add_leap_seconds(1598486400000) == 1598486437000
def test_get_ms_from_epoch_base():
assert get_ms_from_epoch(datetime(1970, 1, 1)) == 0
def test_get_ms_from_epoch_1984():
assert get_ms_from_epoch(datetime(1984, 6, 6)) == 455328000000
def test_get_ms_from_epoch_2020():
assert get_ms_from_epoch(datetime(2020, 8, 27)) == 1598486400000
def test_get_adj_ms_from_epoch_1959():
assert get_adj_ms_from_epoch(datetime(1959, 1, 17)) == -345772800000
def test_get_adj_ms_from_epoch_1981():
assert get_adj_ms_from_epoch(datetime(1981, 9, 14)) == 369273620000
def test_get_adj_ms_from_epoch_2023():
assert get_adj_ms_from_epoch(datetime(2023, 12, 8)) == 1701993637000
def test_is_timezone_aware_true():
assert is_timezone_aware(datetime.now().astimezone()) is True
def test_is_timezone_aware_false():
assert is_timezone_aware(datetime.now()) is False