software/hoardy-web/./tool/hoardy_web/time.py

Passively capture, archive, and hoard your web browsing history, including the contents of the pages you visit, for later offline viewing, mirroring, and/or indexing. Your own personal private Wayback Machine that can also archive HTTP POST requests and responses, as well as most other HTTP-level data.

Files

Raw Source

Contents

# Copyright (c) 2023 Jan Malakhovski <oxij@oxij.org>
#
# This file is a part of `hoardy-web` project.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

import calendar as _calendar
import dataclasses as _dc
import decimal as _dec
import re as _re
import time as _time
import typing as _t

from kisstdlib.exceptions import *

import hoardy_web.parser as _p

class TimeStamp(_dec.Decimal):
    """Seconds since UNIX epoch, with arbitrary precision."""

    def format(self, fmt : str = "%Y-%m-%d %H:%M:%S", *, precision : int = 0, utc : bool = False) -> str:
        if self.is_infinite():
            if self < 0:
                return "-inf"
            else:
                return "+inf"

        i = int(self)
        r = self - i
        res = _time.strftime(fmt, _time.gmtime(i) if utc else _time.localtime(i))
        if precision > 0:
            x = str(r)[2:precision + 2]
            res += "." + x + "0" * (precision - len(x))
        return res

    def __repr__(self) -> str:
        return f"<TimeStamp {self.format(precision=9, utc=True)}>"

at_timestamp_re = _re.compile(r"@(\d+)(?:\.(\d+))?")
iso_timestamp_re = _re.compile(r"(\d\d\d\d)(?:[_-]?(\d\d)(?:[_-]?(\d\d)(?:[T_-]?\s*(\d\d)(?:[h:_-]?(\d\d)(?:[h:_-]?(\d\d)(?:[sd,.]?(\d+))?)?\s*(?:([_+-])?(\d\d)[:h]?(\d\d)m?)?)?)?)?)?")

def parse_TimeStamp(value : str, *, utc : bool = False) -> tuple[tuple[TimeStamp, TimeStamp], str]:
    """Parse a given string `value` into a pair of `TimeStamp` values which
       represent the start and the end (non-inclusive) of the continiuous time
       interval for which all timestamps can be described as being part of
       given `value`.

       E.g.
       - `parse_TimeStamp("2024") == (timestamp("2024-01-01 00:00:00"), timestamp("2025-01-01 00:00:00")), ""`
       - `parse_TimeStamp("2024-12") == (timestamp("2024-12-01 00:00:00"), timestamp("2025-01-01 00:00:00")), ""`
       - `parse_TimeStamp("2024-12-01 12:00:16") == (timestamp("2024-12-01 12:00:16"), timestamp("2024-12-01 12:00:17")), ""`
       - `parse_TimeStamp("2024-12-01 12:00:16.5") == (timestamp("2024-12-01 12:00:16.5"), timestamp("2024-12-01 12:00:16.6")), ""`

       Also, return leftover part of `value`.

       When the `value` includes no time zone information, it is interpreted as local time, unless `utc` is set.
    """

    ending = True
    m = at_timestamp_re.match(value)
    if m is not None:
        hs_, rs = m.groups()
        hs = es = int(hs_)
    else:
        m = iso_timestamp_re.match(value)
        if m is not None:
            year, month, day, hour, minute, second, rs, sign, tzhour, tzminute = m.groups()
            res = (int(year),
                   int(month) if month is not None else 1,
                   int(day) if day is not None else 1,
                   int(hour) if hour is not None else 0,
                   int(minute) if minute is not None else 0,
                   int(second) if second is not None else 0,
                   0, 1, -1)

            hts = _time.struct_time(res)

            if month is None:
                ets = _time.struct_time((res[0] + 1, *res[1:]))
                ending = False
            elif day is None:
                if res[1] < 12:
                    ets = _time.struct_time((res[0], res[1] + 1, *res[2:]))
                else:
                    ets = _time.struct_time((res[0] + 1, 1, *res[2:]))
                ending = False

            if tzhour is not None:
                offset = (1 if sign == "+" else -1) * (3600 * int(tzhour) + 60 * int(tzminute))
                utc = True
            else:
                offset = 0

            if utc:
                hs = int(_calendar.timegm(hts)) - offset
                es = hs if ending else int(_calendar.timegm(ets)) - offset
            else:
                hs = int(_time.mktime(hts))
                es = hs if ending else int(_time.mktime(ets))

            if ending:
                if hour is None:
                    es += 86400
                    ending = False
                elif minute is None:
                    es += 3600
                    ending = False
                elif second is None:
                    es += 60
                    ending = False
        else:
            raise _p.ParseError("failed to parse `%s` as a timestamp", value)

    if rs is not None:
        r, rp = int(rs), len(rs)
    else:
        r, rp = 0, 0

    hrem = _dec.Decimal(r) / (10 ** rp)
    erem = _dec.Decimal(r + 1) / (10 ** rp) if ending else _dec.Decimal(0)

    return (TimeStamp(_dec.Decimal(hs) + hrem), TimeStamp(_dec.Decimal(es) + erem)), value[m.span()[1]:]

def timestamp(value : str, start : bool = True, *, utc : bool = False) -> TimeStamp:
    """A simple wrapper over `parse_TimeStamp`."""
    (sres, eres), left = parse_TimeStamp(value, utc=utc)
    if len(left) > 0:
        raise _p.ParseError("failed to parse `%s` as a timestamp", value)
    return sres if start else eres

def test_parse_TimeStamp() -> None:
    def check(xs : str | list[str], value : TimeStamp, leftover : str = "") -> None:
        if isinstance(xs, str):
            xs = [xs]
        for x in xs:
            (res, _), left = parse_TimeStamp(x, utc=True)
            if (res, left) != (value, leftover):
                raise CatastrophicFailure("while parsing `%s`, expected %s, got %s", x, (value, leftover), (res, left))

    check("@123",                       TimeStamp(123))
    check("@123.456",                   TimeStamp("123.456"))
    check("2024",                       TimeStamp(1704067200))
    check(["2024-12", "202412"],        TimeStamp(1733011200))
    check(["2024-12-31", "20241231"],   TimeStamp(1735603200))

    check(["2024-12-31 12:07",
           "202412311207"],             TimeStamp(1735646820))

    check(["2024-12-31 12:07:16",
           "2024-12-31_12:07:16",
           "20241231120716"],           TimeStamp(1735646836))

    check(["2024-12-31 12:07:16.456",
           "20241231_120716.456",
           "20241231120716.456",
           "20241231120716456"],        TimeStamp("1735646836.456"))

    check(["2024-12-31 12:07:16 -01:00",
           "2024-12-31T12:07:16-01:00",
           "2024-12-31 12:07:16-01:00",
           "2024-12-31_12:07:16-0100",
           "20241231120716-0100"],      timestamp("2024-12-31 13:07:16", utc=True))

    check(["2024-12-31 12:07:16.456 -01:00",
           "2024-12-31T12:07:16.456-01:00",
           "2024-12-31T12:07:16,456-01:00",
           "2024-12-31T12:07:16,456000000-01:00",
           "20241231 120716.456 -0100",
           "20241231120716.456 -0100",
           "20241231120716.456-0100",
           "20241231120716456-0100"],   timestamp("2024-12-31 13:07:16.456", utc=True))

    check("2022-11-20 23:32:16+00:30",  timestamp("2022-11-20 23:02:16", utc=True))
    check("2022-11-20 23:32:16 -00:30", timestamp("2022-11-21 00:02:16", utc=True))

    check("20241231120716456-0100 or so",     TimeStamp("1735650436.456"), " or so")
    check("2024-12-31 12:07:16 -0100 or so",  TimeStamp(1735650436), " or so")

def test_format_TimeStamp() -> None:
    assert timestamp("2024-12-31 12:07:16.456789", utc=True).format(precision=3, utc=True) == "2024-12-31 12:07:16.456"
    assert timestamp("2024-12-31 12:07:16.450", utc=True).format(precision=3, utc=True) == "2024-12-31 12:07:16.450"
    assert timestamp("2024-12-31 12:07:16", utc=True).format(precision=3, utc=True) == "2024-12-31 12:07:16.000"

def test_parse_TimeStamp_end() -> None:
    def check(x : str, value : TimeStamp, leftover : str = "") -> None:
        (_, res), left = parse_TimeStamp(x, utc=True)
        if (res, left) != (value, leftover):
            raise CatastrophicFailure("while parsing `%s`, expected %s, got %s", x, (value, leftover), res)

    check("@123",                       TimeStamp(124))
    check("@123.456",                   TimeStamp("123.457"))
    check("2024",                       timestamp("2025-01-01", utc=True))
    check("2024-11",                    timestamp("2024-12-01", utc=True))
    check("2024-12",                    timestamp("2025-01-01", utc=True))
    check("2024-10-30",                 timestamp("2024-10-31", utc=True))
    check("2024-11-30",                 timestamp("2024-12-01", utc=True))
    check("2024-12-31",                 timestamp("2025-01-01", utc=True))
    check("2024-12-31 12",              timestamp("2024-12-31 13:00", utc=True))
    check("2024-11-30 23",              timestamp("2024-12-01 00:00", utc=True))
    check("2024-12-31 23",              timestamp("2025-01-01 00:00", utc=True))
    check("2024-12-31 23:30",           timestamp("2024-12-31 23:31", utc=True))
    check("2024-12-31 23:59",           timestamp("2025-01-01 00:00", utc=True))
    check("2024-12-31 23:59:30",        timestamp("2024-12-31 23:59:31", utc=True))
    check("2024-12-31 23:59:59",        timestamp("2025-01-01 00:00", utc=True))
    check("2024-12-31 23:59:59.5",      timestamp("2024-12-31 23:59:59.6", utc=True))
    check("2024-12-31 23:59:59.9",      timestamp("2025-01-01 00:00", utc=True))

timerange_pre_re = _re.compile(r"[([{<]?")
timerange_post_re = _re.compile(r"[)\]}>]?")
timerange_delimiter_re = _re.compile("--?")

@_dc.dataclass
class TimeRange:
    """Continious time interval between two `TimeStamp` timestamps."""

    start : TimeStamp
    end : TimeStamp
    includes_start : bool = _dc.field(default = True)
    includes_end : bool = _dc.field(default = False)

    def __contains__(self, value : TimeStamp) -> bool:
        if self.includes_start and value == self.start or \
           self.includes_end and value == self.end:
            return True
        return self.start < value < self.end

    @property
    def middle(self) -> TimeStamp:
        return TimeStamp((self.start + self.end) / 2)

    @property
    def delta(self) -> _dec.Decimal:
        return self.end - self.start

    def format(self, fmt : str = "%Y-%m-%d %H:%M:%S", *, precision : int = 0, utc : bool = False) -> str:
        return self.start.format(fmt, precision=precision, utc=utc) + "--" + self.end.format(fmt, precision=precision, utc=utc)

    def __repr__(self) -> str:
        return f"<TimeRange {'[' if self.includes_start else '('}{self.format(precision=9, utc=True)}{']' if self.includes_end else ')'}>"

    def format_org_delta(self, precision : int = 0) -> str:
        r = self.delta
        hours = r // 3600
        r = r % 3600
        minutes = r // 60
        r = r % 60
        seconds = int(r)
        r = r - seconds
        res = str(hours) + ":" + format(minutes, "02") + ":" + format(seconds, "02")
        if precision > 0:
            x = str(r)[2:precision + 2]
            res += "." + x + "0" * (precision - len(x))
        return res

    def format_org(self, *, precision : int = 0, utc : bool = False) -> str:
        return f"[{self.start.format(precision=precision, utc=utc)}]--[{self.end.format(precision=precision, utc=utc)}] => {self.format_org_delta(precision=precision)}"

anytime = TimeRange(TimeStamp("-inf"), TimeStamp("+inf"), True, True)

def parse_TimeRange(value : str, *, utc : bool = False) -> tuple[TimeRange, str]:
    """Parse a given string `value` into `TimeRange`."""
    p = _p.Parser(value)
    if p.opt_string("*"):
        return anytime, p.leftovers
    try:
        p.opt_regex(timerange_pre_re)
        start, end = p.chomp(parse_TimeStamp, utc=utc)
        stop = p.opt_string("*")
        p.opt_regex(timerange_post_re)
        if not stop:
            try:
                p.regex(timerange_delimiter_re)
            except _p.ParseError:
                pass
            else:
                p.opt_regex(timerange_pre_re)
                _, end = p.chomp(parse_TimeStamp, utc=utc)
                p.opt_regex(timerange_post_re)
        return TimeRange(start, end), p.leftovers
    except _p.ParseError:
        raise _p.ParseError("failed to parse `%s` as a time interval", value)

def timerange(value : str, utc : bool = False) -> TimeRange:
    """A simple wrapper over `parse_TimeRange`."""
    res, left = parse_TimeRange(value, utc=utc)
    if len(left) > 0:
        raise _p.ParseError("failed to parse `%s` as a time interval", value)
    return res

def test_parse_TimeRange() -> None:
    def check(xs : str | list[str], value : TimeRange, leftover : str = "") -> None:
        if isinstance(xs, str):
            xs = [xs]
        for x in xs:
            res = parse_TimeRange(x, utc=True)
            if res != (value, leftover):
                raise CatastrophicFailure("while parsing `%s`, expected %s, got %s", x, (value, leftover), res)

    check("*",                          anytime)
    check(["@123--@125",
           "<@123>--<@125>"],           TimeRange(TimeStamp(123), TimeStamp(126)))
    check(["2024-12-31",
           "2024-12-31*",
           "[2024-12-31]"],             TimeRange(timestamp("2024-12-31 00:00", utc=True),
                                                  timestamp("2025-01-01 00:00", utc=True)))
    check("2024-12-31 12",              TimeRange(timestamp("2024-12-31 12:00", utc=True),
                                                  timestamp("2024-12-31 13:00", utc=True)))
    check("2024-12-31 12:00",           TimeRange(timestamp("2024-12-31 12:00", utc=True),
                                                  timestamp("2024-12-31 12:01", utc=True)))
    check("2024-12-31 23:59",           TimeRange(timestamp("2024-12-31 23:59", utc=True),
                                                  timestamp("2025-01-01 00:00", utc=True)))
    check("[2024-12-31 23:59]--[2025-01-02]", TimeRange(timestamp("2024-12-31 23:59", utc=True),
                                                        timestamp("2025-01-03 00:00", utc=True)))