Initial commit. Needs more test coverage.
This commit is contained in:
commit
bd2cc20110
17 changed files with 2903 additions and 0 deletions
163
.gitignore
vendored
Normal file
163
.gitignore
vendored
Normal 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
0
README.md
Normal file
27
contrib/example.py
Normal file
27
contrib/example.py
Normal 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
2
mypy.ini
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[mypy]
|
||||||
|
mypy_path = $MYPY_CONFIG_FILE_DIR/src
|
1044
poetry.lock
generated
Normal file
1044
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
28
pyproject.toml
Normal file
28
pyproject.toml
Normal 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"
|
7
src/dnidatetime/__init__.py
Normal file
7
src/dnidatetime/__init__.py
Normal 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"]
|
120
src/dnidatetime/constants.py
Normal file
120
src/dnidatetime/constants.py
Normal 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]
|
152
src/dnidatetime/conversions.py
Normal file
152
src/dnidatetime/conversions.py
Normal 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
183
src/dnidatetime/dnidate.py
Normal 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)
|
319
src/dnidatetime/dnidatetime.py
Normal file
319
src/dnidatetime/dnidatetime.py
Normal 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
154
src/dnidatetime/dnitime.py
Normal 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)
|
320
src/dnidatetime/dnitimedelta.py
Normal file
320
src/dnidatetime/dnitimedelta.py
Normal 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
137
src/dnidatetime/utils.py
Normal 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
0
tests/__init__.py
Normal file
93
tests/test_conversions.py
Normal file
93
tests/test_conversions.py
Normal 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
154
tests/test_utils.py
Normal 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
|
Loading…
Reference in a new issue