defl/defl/_time_.py
2025-04-28 14:44:03 -04:00

639 lines
22 KiB
Python

#!/usr/bin/env python
import math
import re
from dataclasses import KW_ONLY, dataclass
from datetime import datetime, timedelta, timezone, tzinfo
from typing import ClassVar
from zoneinfo import ZoneInfo
from subprocess import Popen, PIPE
from ._ansii_ import cl
from ._obj_ import Obj
from ._typing_ import *
from ._basic_ import Enumer
"""
strftime
https://manpages.debian.org/bookworm/manpages-dev/strftime.3.en.html
| fmt | Meaning | Example |
| --- | ------------------------------------------------------------------- | ------------------------------------ |
| %a | Weekday as locale's abbreviated name. | Sun, Mon, ..., Sat |
| %A | Weekday as locale's full name. | Sunday, Monday, ..., |
| %w | Weekday as a decimal number. 0=Sun and 6=sat | 0, 1, ..., 6 |
| %d | Day of the month as a zero-padded decimal number. | 01, 02, ..., 31 |
| %b | Month as locale's abbreviated name. | Jan, Feb, ..., Dec |
| %B | Month as locale's full name. | January, February, ..., |
| %m | Month as a zero-padded decimal number. | 01, 02, ..., 12 |
| %y | Year without century as a zero- padded decimal number. | 00, 01, ..., 99 |
| %Y | Year with century as a decimal number. | 0001, 0002, ..., 2013, |
| %H | Hour (24-hour clock) as a zero- padded decimal number. | 00, 01, ..., 23 |
| %I | Hour (12-hour clock) as a zero- padded decimal number. | 01, 02, ..., 12 |
| %p | Locale's equivalent of either AM or PM. | AM, PM (en_US); am, pm |
| %M | Minute as a zero-padded decimal number. | 00, 01, ..., 59 |
| %S | Second as a zero-padded decimal number. | 00, 01, ..., 59 |
| %f | Microsecond as a decimal number, zero-padded to 6 digits. | 000000, 000001, ..., |
| %z | UTC offset in the form "±HHMM[SS[.ffffff]]" | (empty), +0000, -0400, |
| %Z | Time zone name (empty string if the object is naive). | (empty), UTC, GMT |
| %j | Day of the year as a zero-padded decimal number. | 001, 002, ..., 366 |
| %U | Week # of the year (first day is Sunday ). zero-padd decimal number | 00, 01, ..., 53 |
| %W | Week # of the year (first day is Monday ). zero-padd decimal number | 00, 01, ..., 53 |
| %c | Locale's appropriate date and time representation. | Tue Aug 16 21:30:00 1988 |
| %x | Locale's appropriate date representation. | 08/16/88 (None); 08/16/1988 (en_US); |
| %X | Locale's appropriate time representation. | 21:30:00 (en_US); |
| %% | A literal "'%'" character. | % |
| %G | ISO 8601 year with century with year contain the > ISO week ("%V"). | 0001, 0002, ..., 2013, |
| %u | ISO 8601 weekday as a decimal number where 1 is Monday. | 1, 2, ..., 7 |
| %V | ISO 8601 week as a decimal number | 01, 02, ..., 53 |
"""
regex_timestamp = re.compile(r'^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})')
# | because `datetime(1970, 1, 1, tzinfo=timezone.utc).strftime("%s") != 0`
epochNormalize = datetime(1970, 1, 1, tzinfo=timezone.utc)
daysInYear = 365.242086145121
@dataclass(slots=T, kw_only=T, frozen=F)
class TimeUnit:
ms: float | int = 0
s: float | int = 0
m: float | int = 0
h: float | int = 0
d: float | int = 0
# TODO if it's a percent or a while number i.e. is .999999 ms == 99.9999s or 1000/999999 like a percent of 1 second?
__add__ = Obj.__add__
__sub__ = Obj.__sub__
__mul__ = Obj.__mul__
__lt__ = Obj.__lt__
__le__ = Obj.__le__
__eq__ = Obj.__eq__
__ne__ = Obj.__ne__
__gt__ = Obj.__gt__
__ge__ = Obj.__ge__
toJson = Obj.toJson
def __math__(_, oper, func, other) -> Self:
return func(_.toSec(), other.toSec())
@classmethod
def FromString(cls, timeStr: str) -> Self:
tu = cls()
parse = timeStr[::-1]
parse = re.compile(r'[\:\-]|[0-9\.]+|[a-zA-Z]+|\s+|.*').findall(parse)
parse = [x[::-1].lower() for x in parse]
unit = None
i = 0
# print('========', parse)
while i < len(parse):
x = parse[i]
# print('==', x.encode(), res)
if re.compile(r'^\s*$').search(x) or x in [':', '-']:
# print('skip')
...
elif re.compile(r'^[a-zA-Z]+$').search(x):
# print('set unit', x)
unit = x
elif re.compile(r'^[0-9\.]+$').search(x):
x = float(x)
# print('set scaler', x)
if not unit:
for u in ['s', 'm', 'h', 'd']:
if not unit and not getattr(tu, u):
unit = u
# print('set unit', u)
if unit:
if getattr(tu, unit):
raise ValueError(f'{unit} already in {tu}')
setattr(tu, unit, x)
else:
raise ValueError(f'{parse}[{i}] == {x} | No unit for')
unit = None
else:
raise ValueError(f'{parse}[{i}] == {x.encode()} | could not parse')
i = i + 1
return tu
def toSec(_) -> float:
return (_.ms + _.s * 1000 + _.m * 1000 * 60 + _.h * 1000 * 60 * 60 + _.d * 1000 * 60 * 60 * 24) / 1000
@dataclass(slots=True, kw_only=True, frozen=False, repr=F)
class TimeDelta:
seconds: int | float = field(kw_only=F, repr=T)
__add__ = Obj.__add__
__sub__ = Obj.__sub__
__mul__ = Obj.__mul__
__lt__ = Obj.__lt__
__le__ = Obj.__le__
__eq__ = Obj.__eq__
__ne__ = Obj.__ne__
__gt__ = Obj.__gt__
__ge__ = Obj.__ge__
toJson = Obj.toJson
@property
def td(_) -> None:
return timedelta(seconds=_.seconds)
def __post_init__(_) -> None:
if isinstance(_.seconds, TimeDelta): # TODO typeMap
_.seconds = _.seconds.seconds
elif not isinstance(_.seconds, int | float):
_.seconds = _.seconds.total_seconds()
@classmethod
def FromUnits(cls, **kargs) -> Self:
return cls(timedelta(**kargs))
@classmethod
def FromString(cls, timeStr: str) -> Self:
return cls(TimeUnit.FromString(timeStr).toSec())
def toStr(_) -> str:
return str(FormatSeconds(_.seconds))
def __repr__(_) -> str:
return str(FormatSeconds(_.seconds).toStr(color=F))
__str__ = toStr
def __math__(_, oper, func, other) -> Self:
if isinstance(other, TimeDelta):
other = other.seconds
elif isinstance(other, timedelta):
other = other.total_seconds()
res = func(_.seconds, other)
if isinstance(res, bool): # TODO typeMap
return res
elif isinstance(res, timedelta | float | int):
return TimeDelta(res)
return res
def wait(_):
from ._timeUtils_ import countDownTimer
countDownTimer(_)
def asMilliSec(_) -> float:
return _.seconds * 1000
def asSecs(_) -> float:
return _.seconds
def asMins(_) -> float:
return _.seconds / 60
def asHours(_) -> float:
return _.seconds / 60 / 60
def asDays(_) -> float:
return _.seconds / 60 / 60 / 24
def asWeeks(_) -> float:
return _.seconds / 60 / 60 / 24 / 7
def asMonths(_) -> float:
return _.seconds / 60 / 60 / 24 / 7 / (365 / 13)
def asYears(_) -> float:
return _.seconds / 60 / 60 / 24 / 7 / 365
@property
def secs(_):
return _.asSecs()
@property
def mins(_):
return _.asMins()
@property
def hours(_):
return _.asHours()
@property
def days(_):
return _.asDays()
@property
def weeks(_):
return _.asWeeks()
@property
def months(_):
return _.asMonths()
@property
def years(_):
return _.asYears()
# zoneinfo.available_timezones()
@dataclass(slots=True, kw_only=False, frozen=False)
class TimeZone(tzinfo, Obj):
offset: timedelta
__: KW_ONLY
name: str | None = None
__add__ = Obj.__add__
__sub__ = Obj.__sub__
__mul__ = Obj.__mul__
__lt__ = Obj.__lt__
__le__ = Obj.__le__
__eq__ = Obj.__eq__
__ne__ = Obj.__ne__
__gt__ = Obj.__gt__
__ge__ = Obj.__ge__
toJson = Obj.toJson
def __post_init__(_):
if not isinstance(_.offset, None | timedelta): # TODO typeMap
_.offset = _.offset.td
# print(repr(_.offset))
# _.offset = typeMap(_.offset, {
# None | timedelta: lambda: _.offset.td,
# }, default=lambda: _.offset)()
def utcoffset(_, dt):
return _.offset
def dst(_, dt):
return _.offset
def tzname(_, dt):
return _.name if _.name else str(_.offset)
def __math__(_, other, func, oper) -> bool:
if isinstance(other, TimeZone):
return func(_.offset, other.offset)
elif isinstance(other, timedelta):
return func(_.offset, other)
else:
raise NotImplementedError(str(type(other)))
@staticmethod
def offsetFromDateTime(dt: datetime) -> TimeDelta:
return TimeZone.offsetFromString(dt.strftime('%z'))
@staticmethod
def offsetFromString(offset: str) -> TimeDelta:
neg = -1 if offset.startswith('-') else 1
return TimeDelta(timedelta(hours=neg * int(offset[1:3]), minutes=int(offset[3:5])))
@staticmethod
def localOffset() -> TimeDelta:
return TimeZone.Offset(zone=None)
@staticmethod
def Offset(zone: tzinfo) -> TimeDelta:
a = datetime.now(timezone.utc)
b = a.replace(tzinfo=zone)
c = a.astimezone(zone).replace(tzinfo=zone)
# print((b - c).total_seconds() / 60 / 60)
return TimeDelta(-1 * (b - c))
def toStr(_):
return f'{_.name}+{_.offset}' if _.name else f'{_.offset}'
__str__ = toStr
class FuzzyTimeParseError(Exception):
__slot__ = ()
Time = TypeVar('Time')
class TimeFormat(Enumer):
ymdhms = '%Y-%m-%d_%H-%M-%S'
ISO8601NoSep = '%Y%m%dT%H%M%S'
ymdhmsNoSpace = '%y%m%d%H%M%S'
date = '%Y-%m-%d'
time = '%H:%M:%S'
pretty = '%a %d %b %y %H:%M:%S'
prettyDay = '%a %d %b %y'
ymdhmsfDash = '%Y-%m-%d-%H-%M-%S-%f'
@dataclass(slots=True, kw_only=False, frozen=False)
class Time:
# TODO Time().epoch() - time.now()
dt: datetime = None
__: KW_ONLY
fmt: str = 'default'
LocalTz: ClassVar = TimeZone(TimeZone.localOffset())
tz: TimeZone = Undefined
autoAddTzStr: bool = True # only effects __str__
TZ: ClassVar[Type] = TimeZone
TD: ClassVar[Type] = TimeDelta
TU: ClassVar[Type] = TimeUnit
# TODO when getting dt the timezone is TimeZone and not timezone.utc type date = date.replace(tzinfo=timezone.utc)
# TODO so dataframes fail on TimeZone type
Fmt = TimeFormat
__add__ = Obj.__add__
__sub__ = Obj.__sub__
__mul__ = Obj.__mul__
__lt__ = Obj.__lt__
__le__ = Obj.__le__
__eq__ = Obj.__eq__
__ne__ = Obj.__ne__
__gt__ = Obj.__gt__
__ge__ = Obj.__ge__
def __hash__(_):
return hash(_.dt)
def __post_init__(_):
# TODO auto convert chars to %char
if _.fmt == 'default' or _.fmt == 'ymdhms':
_.fmt = TimeFormat.ymdhms
elif _.fmt == 'pretty':
_.fmt = TimeFormat.pretty
if _.dt is None:
_.dt = datetime.now(Time.LocalTz)
_._normalizeTz()
def _normalizeTz(_):
isDst = False if _.dt.tzinfo else True
if isinstance(_.tz, str) and _.tz.lower() == 'utc':
_.tz = timezone.utc
if _.tz is None:
_.dt = Time.ToTimeZone(_, Time.LocalTz, isDst=isDst)
elif _.tz is Undefined:
_.dt = Time.ToTimeZone(_, _.dt.tzinfo if _.dt.tzinfo else Time.LocalTz, isDst=isDst)
elif _.tz:
_.dt = Time.ToTimeZone(_, _.tz, isDst=isDst)
else:
raise NotImplementedError('')
_.tz = _.dt.tzinfo
assert isinstance(_.dt.tzinfo, TimeZone), (type(_.dt.tzinfo), _.dt.tzinfo) # TODO typeMap
assert isinstance(_.tz, TimeZone), (type(_.tz), _.tz)
def toTz(_, dst: timedelta | TimeZone | str, isDst: bool) -> Time:
return Time(Time.ToTimeZone(_, dst=dst, isDst=isDst))
@staticmethod
def ToTimeZone(
src: datetime | Time,
dst: timedelta | TimeZone | str,
isDst: bool,
) -> datetime:
src = src.dt if isinstance(src, Time) else src # TODO typeMap
dst = ZoneInfo(dst) if isinstance(dst, str) else dst
dst = TimeZone(dst) if isinstance(dst, TimeDelta) else dst
assert isinstance(src, datetime)
srcTz = src.tzinfo
srcNoTz = src.replace(tzinfo=None)
srcOffset = TimeZone.Offset(srcTz)
if not isinstance(dst, timedelta): # TODO typeMap
dstOffset = TimeZone.Offset(dst)
tzinfo = TimeZone(offset=dstOffset, name=str(dst))
if isDst:
return srcNoTz.replace(tzinfo=tzinfo)
diff = dstOffset - srcOffset
return (src + diff.td).replace(tzinfo=tzinfo)
def toStr(_, fmt: str = None):
fmt = fmt if fmt else _.fmt
if _.autoAddTzStr and _.tz != Time.LocalTz and '%Z' not in _.fmt and '%Z' not in _.fmt:
fmt = _.fmt + '%z'
return _.dt.strftime(fmt)
__str__ = toStr
toJson = toStr
@property
def s(_) -> str:
return _.toStr()
def child(_, frm: datetime | int | float) -> Time:
if isinstance(frm, int | float):
frm = datetime.fromtimestamp(frm)
assert isinstance(frm, datetime)
res = Obj.getObjAsDict(_) | dict(dt=frm)
return Time(**res)
def __math__(_, oper: str, func: Callable, other: int | float | TimeDelta | timedelta) -> Any:
if oper in ['add', 'sub']:
# TODO typeMap
other = other.td if isinstance(other, TimeDelta) else other
other = timedelta(seconds=other) if isinstance(other, int | float) else other
other = timedelta(**other) if isinstance(other, dict) else other
other = other.dt if isinstance(other, Time) else other
res = func(_.dt, other)
else:
# TODO typeMap
other = other.epoch() if isinstance(other, Time) else other
other = Time.epochFromDatetime(other) if isinstance(other, datetime) else other
res = func(_.epoch(), other)
if isinstance(res, datetime):
return _.child(frm=res)
elif isinstance(res, timedelta):
return TimeDelta(res)
return res
@staticmethod
def epochFromDatetime(dt: datetime):
return (dt.astimezone(timezone.utc) - epochNormalize).total_seconds()
def epoch(_):
return Time.epochFromDatetime(_.dt)
def startOfDay(_) -> Time:
dd = _.dt
dd = dd - timedelta(hours=dd.hour, minutes=dd.minute, seconds=dd.second, microseconds=dd.microsecond)
return _.child(frm=dd)
def waitUntil(_):
from ._timeUtils_ import countDownTimer
countDownTimer(_ - Time())
def add(
_, d: float = 0, h: float = 0, m: float = 0, s: float = 0, ms: float = 0, mls: float = 0, w: float = 0
) -> Time:
td = timedelta(days=d, seconds=s, microseconds=ms, milliseconds=mls, minutes=m, hours=h, weeks=w)
return _.child(frm=_.dt + td)
def replace(_, y=None, M=None, d=None, h=None, m=None, s=None, ms=None, tz=None):
return _.child(
frm=datetime(
year=y if y is not None else _.dt.year,
month=M if M is not None else _.dt.month,
day=d if d is not None else _.dt.day,
hour=h if h is not None else _.dt.hour,
minute=m if m is not None else _.dt.minute,
second=s if s is not None else _.dt.second,
microsecond=ms if ms is not None else _.dt.microsecond,
tzinfo=tz if tz is not None else _.dt.tzinfo,
)
)
def yearMonthDayStr(_, sep='-') -> str:
"{year}-{month}-{day}"
return _.toStr(f'%Y{sep}%m{sep}%d')
def hourMinSecStr(_, sep='-') -> str:
"{hours}-{min}-{sec}"
return _.toStr(f'%H{sep}%M{sep}%S')
def yearMonthDayHourMinSecStr(_, join='_', sep='-') -> str:
"{year}-{month}-{day}_{hours}-{min}-{sec}"
return _.toStr(f'%Y{sep}%m{sep}%d{join}%H{sep}%M{sep}%S')
@property
def ymd(_):
return _.yearMonthDayStr()
@property
def hms(_):
return _.hourMinSecStr()
@property
def ymdhms(_):
return _.yearMonthDayHourMinSecStr()
@staticmethod
def __sheet_fmt__(column: '_Column'):
pass
return column.sheet.sheetFormat.datetime(column)
__sheet_align__: ClassVar = '>'
@classmethod
def FromFuzzy(cls, date: str, **kargs) -> Time:
return cls(cls.FuzzyToDatetime(date=date), **kargs)
@staticmethod
def FuzzyToDatetime(date: str) -> datetime:
# TODO next/prev using timedelta(). if next and before now then + timedelta()
date = date.replace('@', ' ')
popen = Popen(['date', '--date', date, '+%y%m%d%H%M%S%z'], stdout=PIPE, stderr=PIPE, env={})
out, err = popen.communicate()
if popen.returncode != 0:
raise FuzzyTimeParseError(err.split(b'\n'))
out = out.decode('utf8').strip('\n')
# , tz=datetime.now().astimezone().tzinfo
return datetime.strptime(out, '%y%m%d%H%M%S%z')
def fuzzyTimeDelta(_, fuzzy: str):
curStr = _.toStr(fmt='%c')
return _.child(frm=_.__class__.FuzzyToDatetime(f'{curStr} {fuzzy}'))
@classmethod
def FromStr(cls, date: str, fmt: str, **kargs) -> Time:
return cls(datetime.strptime(date, fmt), **kargs)
@classmethod
def FromUnits(cls, *arg, tz, **kargs) -> Time:
arg = (int(x) for x in arg)
if tz is T:
tz = cls.LocalTz
elif tz is F:
tz = timezone.utc
return cls(datetime(*arg, tzinfo=tz), **kargs)
@classmethod
def FromEpoch(cls, epoch: int | float, **kargs) -> str:
tz = datetime.now().astimezone().tzinfo
dt = datetime.fromtimestamp(epoch, tz=tz)
return cls(dt, **kargs)
@property
def year(_):
return _.dt.year
@property
def month(_):
return _.dt.month
@property
def day(_):
return _.dt.day
@property
def hour(_):
return _.dt.hour
@property
def minute(_):
return _.dt.minute
@property
def second(_):
return _.dt.second
# https://docs.python.org/3/library/datetime.html#datetime.timedelta.total_seconds
# For interval units other than seconds, use the division form directly (e.g. td / timedelta(microseconds=1)).
@dataclass(slots=True, kw_only=False, frozen=False)
class FormatSeconds:
totalSec: float | int = 0
outputFmt: str = 'YDHMS' # TODO
__: KW_ONLY = KW_ONLY
units: ClassVar = ['s', 'm', 'h', 'd', 'y']
times: ClassVar = [60, 60, 24, 365, 9999]
timesCompound: ClassVar = [1, 60, 60 * 60, 60 * 60 * 24, 60 * 60 * 24 * 365]
lens: ClassVar = [2, 2, 2, 1, 1]
@staticmethod
def listToSec(a):
return sum([x * y for x, y in zip(a, FormatSeconds.timesCompound, strict=false)])
def __post_init__(_):
if isinstance(_.totalSec, timedelta): # TODO typeMap
_.totalSec = _.totalSec.total_seconds()
elif isinstance(_.totalSec, TimeDelta):
_.totalSec = _.totalSec.td.seconds
elif isinstance(_.totalSec, Iterable):
_.totalSec = FormatSeconds.listToSec(_.totalSec)
assert isinstance(_.totalSec, int | float)
def inc(_, amt: int | float | Iterable = 0, /):
if isinstance(amt, Iterable):
amt = FormatSeconds.listToSec(_.totalSec)
_.totalSec += amt
return _
def toStr(_, color: bool = T, units: bool = T):
neg = f'{cl.wht if color else ""}-{cl.r if color else ""}' if _.totalSec < 0 else ''
totalSec = _.totalSec * -1 if neg else _.totalSec
unitCast = []
for i in FormatSeconds.times:
add, totalSec = totalSec % i, math.floor(totalSec / i)
unitCast.append(add)
zeroIndex = len(unitCast) # TODO optomize
while zeroIndex > 0 and unitCast[zeroIndex - 1] == 0:
zeroIndex -= 1
unitCast = unitCast[:zeroIndex]
if not unitCast:
return f'{cl.yel if color else ""}00{cl.res if color else ""}{cl.grn if color else ""}{FormatSeconds.units[0].upper()}{cl.res if color else ""}'
elem = [
f'{cl.yel if color else ""}{int(unitCast[x]):0{FormatSeconds.lens[x]}}{cl.res if color else ""}'
+ (f'{cl.grn if color else ""}{FormatSeconds.units[x].upper()}{cl.res if color else ""}' if units else '')
for x in range(len(unitCast))
]
return neg + ':'.join(elem[::-1])
__str__ = toStr