lvtime 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lvtime/__init__.py +49 -0
- lvtime/__main__.py +10 -0
- lvtime/_cli.py +97 -0
- lvtime/_codec.py +61 -0
- lvtime/_convert.py +98 -0
- lvtime/_exact.py +66 -0
- lvtime/_timestamp.py +120 -0
- lvtime/py.typed +0 -0
- lvtime-0.3.0.dist-info/METADATA +206 -0
- lvtime-0.3.0.dist-info/RECORD +13 -0
- lvtime-0.3.0.dist-info/WHEEL +4 -0
- lvtime-0.3.0.dist-info/entry_points.txt +2 -0
- lvtime-0.3.0.dist-info/licenses/LICENSE +21 -0
lvtime/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Lossless LabVIEW 128-bit timestamp to and from datetime, UTC, sub-microsecond preserved."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._codec import ByteOrder
|
|
6
|
+
from ._convert import (
|
|
7
|
+
LABVIEW_UNIX_EPOCH_OFFSET,
|
|
8
|
+
from_datetime,
|
|
9
|
+
from_float,
|
|
10
|
+
from_unix,
|
|
11
|
+
to_datetime,
|
|
12
|
+
to_float,
|
|
13
|
+
to_unix,
|
|
14
|
+
)
|
|
15
|
+
from ._exact import (
|
|
16
|
+
from_decimal,
|
|
17
|
+
from_unix_decimal,
|
|
18
|
+
to_decimal,
|
|
19
|
+
to_unix_decimal,
|
|
20
|
+
)
|
|
21
|
+
from ._timestamp import (
|
|
22
|
+
LVTimestamp,
|
|
23
|
+
read_timestamp,
|
|
24
|
+
read_timestamps,
|
|
25
|
+
write_timestamp,
|
|
26
|
+
write_timestamps,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__version__ = "0.3.0"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"LABVIEW_UNIX_EPOCH_OFFSET",
|
|
33
|
+
"ByteOrder",
|
|
34
|
+
"LVTimestamp",
|
|
35
|
+
"from_datetime",
|
|
36
|
+
"from_decimal",
|
|
37
|
+
"from_float",
|
|
38
|
+
"from_unix",
|
|
39
|
+
"from_unix_decimal",
|
|
40
|
+
"read_timestamp",
|
|
41
|
+
"read_timestamps",
|
|
42
|
+
"to_datetime",
|
|
43
|
+
"to_decimal",
|
|
44
|
+
"to_float",
|
|
45
|
+
"to_unix",
|
|
46
|
+
"to_unix_decimal",
|
|
47
|
+
"write_timestamp",
|
|
48
|
+
"write_timestamps",
|
|
49
|
+
]
|
lvtime/__main__.py
ADDED
lvtime/_cli.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""The ``lvtime`` command line: decode and encode the 16-byte on-disk form."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from ._codec import ByteOrder
|
|
12
|
+
from ._timestamp import LVTimestamp, read_timestamp, write_timestamp
|
|
13
|
+
|
|
14
|
+
_DESCRIPTION = "Decode and encode LabVIEW 128-bit timestamps (1904 epoch)."
|
|
15
|
+
_EPILOG = """\
|
|
16
|
+
examples:
|
|
17
|
+
lvtime decode 00000000e32589408000000000000000
|
|
18
|
+
echo 00000000e32589408000000000000000 | lvtime decode
|
|
19
|
+
lvtime decode --format fields 00000000e32589408000000000000000
|
|
20
|
+
lvtime decode --format decimal 00000000e32589408000000000000000
|
|
21
|
+
lvtime encode 2024-10-04T12:00:00.5+00:00
|
|
22
|
+
lvtime encode --byteorder little 2024-10-04T12:00:00Z
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _read_arg(value: str | None) -> str:
|
|
27
|
+
text = sys.stdin.read() if value is None else value
|
|
28
|
+
text = text.strip()
|
|
29
|
+
if not text:
|
|
30
|
+
raise ValueError("no input given (pass an argument or pipe it on stdin)")
|
|
31
|
+
return text
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_iso(text: str) -> datetime:
|
|
35
|
+
dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
36
|
+
if dt.tzinfo is None:
|
|
37
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
38
|
+
return dt
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _decode(hex_text: str, byteorder: ByteOrder, fmt: str) -> str:
|
|
42
|
+
ts = read_timestamp(bytes.fromhex(hex_text), byteorder)
|
|
43
|
+
if fmt == "iso":
|
|
44
|
+
return ts.to_datetime().isoformat()
|
|
45
|
+
if fmt == "fields":
|
|
46
|
+
dt = ts.to_datetime()
|
|
47
|
+
return f"seconds={ts.seconds} frac={ts.frac} iso={dt.isoformat()} unix={ts.to_unix()!r}"
|
|
48
|
+
if fmt == "decimal":
|
|
49
|
+
return f"epoch1904={ts.to_decimal()} unix={ts.to_unix_decimal()}"
|
|
50
|
+
payload = {
|
|
51
|
+
"seconds": ts.seconds,
|
|
52
|
+
"frac": ts.frac,
|
|
53
|
+
"iso": ts.to_datetime().isoformat(),
|
|
54
|
+
"unix": ts.to_unix(),
|
|
55
|
+
}
|
|
56
|
+
return json.dumps(payload)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _encode(iso_text: str, byteorder: ByteOrder) -> str:
|
|
60
|
+
return write_timestamp(LVTimestamp.from_datetime(_parse_iso(iso_text)), byteorder).hex()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
64
|
+
parser = argparse.ArgumentParser(
|
|
65
|
+
prog="lvtime",
|
|
66
|
+
description=_DESCRIPTION,
|
|
67
|
+
epilog=_EPILOG,
|
|
68
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
69
|
+
)
|
|
70
|
+
parser.add_argument("--version", action="version", version=f"lvtime {__version__}")
|
|
71
|
+
subs = parser.add_subparsers(dest="command", required=True)
|
|
72
|
+
|
|
73
|
+
decode = subs.add_parser("decode", help="16-byte hex timestamp to an ISO datetime")
|
|
74
|
+
decode.add_argument("hex", nargs="?", help="32 hex chars; read from stdin if omitted")
|
|
75
|
+
decode.add_argument("--byteorder", choices=["big", "little"], default="big")
|
|
76
|
+
decode.add_argument("--format", choices=["iso", "fields", "json", "decimal"], default="iso")
|
|
77
|
+
|
|
78
|
+
encode = subs.add_parser("encode", help="ISO datetime to a 16-byte hex timestamp")
|
|
79
|
+
encode.add_argument("iso", nargs="?", help="ISO datetime (UTC if naive); stdin if omitted")
|
|
80
|
+
encode.add_argument("--byteorder", choices=["big", "little"], default="big")
|
|
81
|
+
return parser
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main(argv: list[str] | None = None) -> int:
|
|
85
|
+
"""Entry point for the ``lvtime`` console script. Returns a process exit code."""
|
|
86
|
+
parser = _build_parser()
|
|
87
|
+
args = parser.parse_args(argv)
|
|
88
|
+
try:
|
|
89
|
+
if args.command == "decode":
|
|
90
|
+
line = _decode(_read_arg(args.hex), args.byteorder, args.format)
|
|
91
|
+
else:
|
|
92
|
+
line = _encode(_read_arg(args.iso), args.byteorder)
|
|
93
|
+
except ValueError as error:
|
|
94
|
+
print(f"lvtime: {error}", file=sys.stderr)
|
|
95
|
+
return 1
|
|
96
|
+
print(line)
|
|
97
|
+
return 0
|
lvtime/_codec.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""The 16-byte on-disk form of a LabVIEW timestamp, big- or little-endian.
|
|
2
|
+
|
|
3
|
+
The default layout is the one LabVIEW "Flatten To String" and the timestamp
|
|
4
|
+
channels in TDMS write: eight bytes of signed whole seconds followed by eight
|
|
5
|
+
bytes of unsigned fraction, each most significant byte first. Pass ``"little"``
|
|
6
|
+
for the byte-reversed form some payloads use.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import struct
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
ByteOrder = Literal["big", "little"]
|
|
15
|
+
|
|
16
|
+
_LAYOUTS: dict[str, struct.Struct] = {
|
|
17
|
+
"big": struct.Struct(">qQ"),
|
|
18
|
+
"little": struct.Struct("<qQ"),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
TIMESTAMP_SIZE = _LAYOUTS["big"].size
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _layout(byteorder: ByteOrder) -> struct.Struct:
|
|
25
|
+
try:
|
|
26
|
+
return _LAYOUTS[byteorder]
|
|
27
|
+
except KeyError:
|
|
28
|
+
raise ValueError(f"byteorder must be 'big' or 'little', got {byteorder!r}") from None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def pack(seconds: int, frac: int, byteorder: ByteOrder = "big") -> bytes:
|
|
32
|
+
"""Encode a ``(seconds, frac)`` pair as 16 bytes in the given byte order."""
|
|
33
|
+
return _layout(byteorder).pack(seconds, frac)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def unpack(data: bytes, byteorder: ByteOrder = "big") -> tuple[int, int]:
|
|
37
|
+
"""Decode 16 bytes into a ``(seconds, frac)`` pair.
|
|
38
|
+
|
|
39
|
+
Raises :class:`ValueError` if the buffer is not exactly 16 bytes long.
|
|
40
|
+
"""
|
|
41
|
+
if len(data) != TIMESTAMP_SIZE:
|
|
42
|
+
raise ValueError(f"expected {TIMESTAMP_SIZE} bytes, got {len(data)}")
|
|
43
|
+
seconds, frac = _layout(byteorder).unpack(data)
|
|
44
|
+
return seconds, frac
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def pack_many(pairs: list[tuple[int, int]], byteorder: ByteOrder = "big") -> bytes:
|
|
48
|
+
"""Encode a list of ``(seconds, frac)`` pairs into one contiguous buffer."""
|
|
49
|
+
layout = _layout(byteorder)
|
|
50
|
+
return b"".join(layout.pack(seconds, frac) for seconds, frac in pairs)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def unpack_many(data: bytes, byteorder: ByteOrder = "big") -> list[tuple[int, int]]:
|
|
54
|
+
"""Decode a buffer of back-to-back 16-byte records into pairs.
|
|
55
|
+
|
|
56
|
+
Raises :class:`ValueError` if the length is not a multiple of 16 bytes.
|
|
57
|
+
"""
|
|
58
|
+
if len(data) % TIMESTAMP_SIZE != 0:
|
|
59
|
+
raise ValueError(f"length must be a multiple of {TIMESTAMP_SIZE} bytes, got {len(data)}")
|
|
60
|
+
layout = _layout(byteorder)
|
|
61
|
+
return [layout.unpack_from(data, offset) for offset in range(0, len(data), TIMESTAMP_SIZE)]
|
lvtime/_convert.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Convert LabVIEW absolute time to and from datetime, Unix time and float seconds.
|
|
2
|
+
|
|
3
|
+
LabVIEW absolute time counts from 1904-01-01 00:00:00 UTC. A value is a signed
|
|
4
|
+
64-bit count of whole seconds plus an unsigned 64-bit fraction in units of
|
|
5
|
+
2**-64 seconds. :mod:`datetime` resolves only to microseconds and a float holds
|
|
6
|
+
about 52 bits of mantissa, so those two views round; the lossless path is the
|
|
7
|
+
integer pair itself.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
from datetime import datetime, timedelta, timezone
|
|
14
|
+
|
|
15
|
+
# Seconds between the LabVIEW epoch (1904-01-01) and the Unix epoch (1970-01-01).
|
|
16
|
+
LABVIEW_UNIX_EPOCH_OFFSET = 2082844800
|
|
17
|
+
|
|
18
|
+
I64_MIN = -(2**63)
|
|
19
|
+
I64_MAX = 2**63 - 1
|
|
20
|
+
U64_MAX = 2**64 - 1
|
|
21
|
+
|
|
22
|
+
_EPOCH = datetime(1904, 1, 1, tzinfo=timezone.utc)
|
|
23
|
+
_MICROS_PER_SECOND = 1_000_000
|
|
24
|
+
_FRAC_UNIT = 1 << 64
|
|
25
|
+
_HALF_FRAC_UNIT = 1 << 63
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def check_seconds(seconds: int) -> None:
|
|
29
|
+
"""Reject whole-second counts that do not fit a signed 64-bit integer."""
|
|
30
|
+
if not I64_MIN <= seconds <= I64_MAX:
|
|
31
|
+
raise ValueError(f"seconds must fit a signed 64-bit integer, got {seconds}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def check_frac(frac: int) -> None:
|
|
35
|
+
"""Reject fractions outside the unsigned 64-bit range."""
|
|
36
|
+
if not 0 <= frac <= U64_MAX:
|
|
37
|
+
raise ValueError(f"frac must be in [0, 2**64), got {frac}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def to_datetime(seconds: int, frac: int = 0) -> datetime:
|
|
41
|
+
"""Convert a LabVIEW timestamp to a UTC-aware :class:`datetime.datetime`.
|
|
42
|
+
|
|
43
|
+
``seconds`` is whole seconds since 1904-01-01 UTC and ``frac`` the 2**-64 s
|
|
44
|
+
fraction. The fraction is rounded to the nearest microsecond. Raises
|
|
45
|
+
:class:`ValueError` if ``frac`` is out of range and :class:`OverflowError`
|
|
46
|
+
if the instant falls outside the range of :class:`datetime.datetime`.
|
|
47
|
+
"""
|
|
48
|
+
check_frac(frac)
|
|
49
|
+
micros = (frac * _MICROS_PER_SECOND + _HALF_FRAC_UNIT) >> 64
|
|
50
|
+
return _EPOCH + timedelta(microseconds=seconds * _MICROS_PER_SECOND + micros)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def from_datetime(dt: datetime) -> tuple[int, int]:
|
|
54
|
+
"""Convert a timezone-aware :class:`datetime.datetime` to ``(seconds, frac)``.
|
|
55
|
+
|
|
56
|
+
The input must carry a timezone so the instant is unambiguous; a naive
|
|
57
|
+
datetime raises :class:`ValueError`. The microsecond field maps back to the
|
|
58
|
+
nearest 2**-64 s fraction, so a round trip is exact to the microsecond.
|
|
59
|
+
"""
|
|
60
|
+
if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
|
|
61
|
+
raise ValueError("dt must be timezone-aware")
|
|
62
|
+
delta = dt - _EPOCH
|
|
63
|
+
total_micros = (delta.days * 86400 + delta.seconds) * _MICROS_PER_SECOND + delta.microseconds
|
|
64
|
+
seconds, micro_remainder = divmod(total_micros, _MICROS_PER_SECOND)
|
|
65
|
+
frac = (micro_remainder * _FRAC_UNIT + _MICROS_PER_SECOND // 2) // _MICROS_PER_SECOND
|
|
66
|
+
check_seconds(seconds)
|
|
67
|
+
return seconds, frac
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def to_float(seconds: int, frac: int = 0) -> float:
|
|
71
|
+
"""Return seconds since the 1904 epoch as a float (LabVIEW DBL coercion).
|
|
72
|
+
|
|
73
|
+
This matches wiring a LabVIEW timestamp into a double. A float keeps only
|
|
74
|
+
about 52 bits of mantissa, so sub-microsecond detail is lost.
|
|
75
|
+
"""
|
|
76
|
+
check_frac(frac)
|
|
77
|
+
return seconds + frac / _FRAC_UNIT
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def from_float(value: float) -> tuple[int, int]:
|
|
81
|
+
"""Convert seconds since the 1904 epoch (as a float) to ``(seconds, frac)``."""
|
|
82
|
+
seconds = math.floor(value)
|
|
83
|
+
frac = round((value - seconds) * _FRAC_UNIT)
|
|
84
|
+
if frac >= _FRAC_UNIT:
|
|
85
|
+
seconds += 1
|
|
86
|
+
frac -= _FRAC_UNIT
|
|
87
|
+
check_seconds(seconds)
|
|
88
|
+
return seconds, frac
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def to_unix(seconds: int, frac: int = 0) -> float:
|
|
92
|
+
"""Return Unix time (float seconds since 1970-01-01 UTC), like :func:`time.time`."""
|
|
93
|
+
return to_float(seconds, frac) - LABVIEW_UNIX_EPOCH_OFFSET
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def from_unix(value: float) -> tuple[int, int]:
|
|
97
|
+
"""Convert Unix time (float seconds since 1970-01-01 UTC) to ``(seconds, frac)``."""
|
|
98
|
+
return from_float(value + LABVIEW_UNIX_EPOCH_OFFSET)
|
lvtime/_exact.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Exact conversions through integers and :class:`~decimal.Decimal`, no float.
|
|
2
|
+
|
|
3
|
+
A float keeps only about 52 bits of mantissa, so 0.8 s lands on
|
|
4
|
+
``0xCCCCCCCCCCCCC000`` in 2**-64 units instead of its true ``0xCCCCCCCCCCCCCCCD``.
|
|
5
|
+
These helpers never touch a float, so they reproduce the exact fixed-point value
|
|
6
|
+
NI documents and keep the full 2**-64 s resolution end to end.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from decimal import ROUND_FLOOR, ROUND_HALF_EVEN, Context, Decimal, localcontext
|
|
12
|
+
|
|
13
|
+
from ._convert import LABVIEW_UNIX_EPOCH_OFFSET, check_frac, check_seconds
|
|
14
|
+
|
|
15
|
+
# 2**-64 = 5**64 * 10**-64 exactly, so a fraction scaled this way is exact.
|
|
16
|
+
_FRAC_UNIT = 1 << 64
|
|
17
|
+
_POW5_64 = 5**64
|
|
18
|
+
# Enough precision for a 19-digit second count plus 64 fractional digits.
|
|
19
|
+
_CONTEXT = Context(prec=96)
|
|
20
|
+
|
|
21
|
+
ExactInput = Decimal | int | str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _as_decimal(value: ExactInput) -> Decimal:
|
|
25
|
+
if isinstance(value, float): # pragma: no cover - guarded by typing too
|
|
26
|
+
raise TypeError("pass a Decimal, int or str, not a float, for an exact value")
|
|
27
|
+
return Decimal(value)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def to_decimal(seconds: int, frac: int = 0) -> Decimal:
|
|
31
|
+
"""Return seconds since the 1904 epoch as an exact :class:`~decimal.Decimal`."""
|
|
32
|
+
check_frac(frac)
|
|
33
|
+
with localcontext(_CONTEXT):
|
|
34
|
+
return Decimal(seconds) + Decimal(frac * _POW5_64).scaleb(-64)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def from_decimal(value: ExactInput) -> tuple[int, int]:
|
|
38
|
+
"""Convert exact seconds since the 1904 epoch to a ``(seconds, frac)`` pair.
|
|
39
|
+
|
|
40
|
+
Accepts a :class:`~decimal.Decimal`, :class:`int` or :class:`str`. A
|
|
41
|
+
:class:`float` is refused so a lossy value cannot slip in; use
|
|
42
|
+
:func:`lvtime.from_float` for the float path.
|
|
43
|
+
"""
|
|
44
|
+
with localcontext(_CONTEXT):
|
|
45
|
+
total = _as_decimal(value)
|
|
46
|
+
seconds = int(total.to_integral_value(rounding=ROUND_FLOOR))
|
|
47
|
+
remainder = total - seconds
|
|
48
|
+
frac = int((remainder * _FRAC_UNIT).to_integral_value(rounding=ROUND_HALF_EVEN))
|
|
49
|
+
if frac == _FRAC_UNIT:
|
|
50
|
+
seconds += 1
|
|
51
|
+
frac = 0
|
|
52
|
+
check_seconds(seconds)
|
|
53
|
+
check_frac(frac)
|
|
54
|
+
return seconds, frac
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def to_unix_decimal(seconds: int, frac: int = 0) -> Decimal:
|
|
58
|
+
"""Return exact Unix time (seconds since 1970-01-01 UTC) as a :class:`~decimal.Decimal`."""
|
|
59
|
+
with localcontext(_CONTEXT):
|
|
60
|
+
return to_decimal(seconds, frac) - LABVIEW_UNIX_EPOCH_OFFSET
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def from_unix_decimal(value: ExactInput) -> tuple[int, int]:
|
|
64
|
+
"""Convert exact Unix time (seconds since 1970-01-01 UTC) to a ``(seconds, frac)`` pair."""
|
|
65
|
+
with localcontext(_CONTEXT):
|
|
66
|
+
return from_decimal(_as_decimal(value) + LABVIEW_UNIX_EPOCH_OFFSET)
|
lvtime/_timestamp.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""The lossless :class:`LVTimestamp` integer pair and its byte and stream helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
|
|
9
|
+
from . import _codec, _convert, _exact
|
|
10
|
+
from ._codec import ByteOrder
|
|
11
|
+
from ._exact import ExactInput
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class LVTimestamp:
|
|
16
|
+
"""A LabVIEW absolute time held without loss as two integers.
|
|
17
|
+
|
|
18
|
+
``seconds`` is the signed 64-bit count of whole seconds since 1904-01-01
|
|
19
|
+
00:00:00 UTC; ``frac`` is the unsigned 64-bit fraction in units of 2**-64 s.
|
|
20
|
+
Both fields are validated on construction.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
seconds: int
|
|
24
|
+
frac: int = 0
|
|
25
|
+
|
|
26
|
+
def __post_init__(self) -> None:
|
|
27
|
+
_convert.check_seconds(self.seconds)
|
|
28
|
+
_convert.check_frac(self.frac)
|
|
29
|
+
|
|
30
|
+
def to_datetime(self) -> datetime:
|
|
31
|
+
"""Return this instant as a UTC-aware datetime, rounded to the microsecond."""
|
|
32
|
+
return _convert.to_datetime(self.seconds, self.frac)
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_datetime(cls, dt: datetime) -> LVTimestamp:
|
|
36
|
+
"""Build a timestamp from a timezone-aware datetime."""
|
|
37
|
+
seconds, frac = _convert.from_datetime(dt)
|
|
38
|
+
return cls(seconds, frac)
|
|
39
|
+
|
|
40
|
+
def to_unix(self) -> float:
|
|
41
|
+
"""Return this instant as Unix time (float seconds since 1970-01-01 UTC)."""
|
|
42
|
+
return _convert.to_unix(self.seconds, self.frac)
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_unix(cls, value: float) -> LVTimestamp:
|
|
46
|
+
"""Build a timestamp from Unix time (float seconds since 1970-01-01 UTC)."""
|
|
47
|
+
seconds, frac = _convert.from_unix(value)
|
|
48
|
+
return cls(seconds, frac)
|
|
49
|
+
|
|
50
|
+
def to_float(self) -> float:
|
|
51
|
+
"""Return seconds since the 1904 epoch as a float (LabVIEW DBL coercion)."""
|
|
52
|
+
return _convert.to_float(self.seconds, self.frac)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_float(cls, value: float) -> LVTimestamp:
|
|
56
|
+
"""Build a timestamp from float seconds since the 1904 epoch."""
|
|
57
|
+
seconds, frac = _convert.from_float(value)
|
|
58
|
+
return cls(seconds, frac)
|
|
59
|
+
|
|
60
|
+
def to_decimal(self) -> Decimal:
|
|
61
|
+
"""Return seconds since the 1904 epoch as an exact Decimal (no float)."""
|
|
62
|
+
return _exact.to_decimal(self.seconds, self.frac)
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_decimal(cls, value: ExactInput) -> LVTimestamp:
|
|
66
|
+
"""Build a timestamp from exact seconds since the epoch (Decimal, int or str)."""
|
|
67
|
+
seconds, frac = _exact.from_decimal(value)
|
|
68
|
+
return cls(seconds, frac)
|
|
69
|
+
|
|
70
|
+
def to_unix_decimal(self) -> Decimal:
|
|
71
|
+
"""Return this instant as exact Unix time (Decimal seconds since 1970-01-01 UTC)."""
|
|
72
|
+
return _exact.to_unix_decimal(self.seconds, self.frac)
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def from_unix_decimal(cls, value: ExactInput) -> LVTimestamp:
|
|
76
|
+
"""Build a timestamp from exact Unix time (Decimal, int or str)."""
|
|
77
|
+
seconds, frac = _exact.from_unix_decimal(value)
|
|
78
|
+
return cls(seconds, frac)
|
|
79
|
+
|
|
80
|
+
def to_bytes(self, byteorder: ByteOrder = "big") -> bytes:
|
|
81
|
+
"""Return the 16-byte on-disk form in the given byte order (default big)."""
|
|
82
|
+
return _codec.pack(self.seconds, self.frac, byteorder)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_bytes(cls, data: bytes, byteorder: ByteOrder = "big") -> LVTimestamp:
|
|
86
|
+
"""Build a timestamp from its 16-byte on-disk form (default big-endian)."""
|
|
87
|
+
seconds, frac = _codec.unpack(data, byteorder)
|
|
88
|
+
return cls(seconds, frac)
|
|
89
|
+
|
|
90
|
+
def hex(self, byteorder: ByteOrder = "big") -> str:
|
|
91
|
+
"""Return the 16-byte on-disk form as a 32-character hex string (default big)."""
|
|
92
|
+
return self.to_bytes(byteorder).hex()
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def from_hex(cls, text: str, byteorder: ByteOrder = "big") -> LVTimestamp:
|
|
96
|
+
"""Build a timestamp from a 32-character hex string (default big-endian)."""
|
|
97
|
+
return cls.from_bytes(bytes.fromhex(text), byteorder)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def read_timestamp(data: bytes, byteorder: ByteOrder = "big") -> LVTimestamp:
|
|
101
|
+
"""Read a :class:`LVTimestamp` from its 16-byte on-disk form (default big-endian)."""
|
|
102
|
+
return LVTimestamp.from_bytes(data, byteorder)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def write_timestamp(timestamp: LVTimestamp, byteorder: ByteOrder = "big") -> bytes:
|
|
106
|
+
"""Write a :class:`LVTimestamp` as its 16-byte on-disk form (default big-endian)."""
|
|
107
|
+
return timestamp.to_bytes(byteorder)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def read_timestamps(data: bytes, byteorder: ByteOrder = "big") -> list[LVTimestamp]:
|
|
111
|
+
"""Read back-to-back 16-byte records into a list of :class:`LVTimestamp`.
|
|
112
|
+
|
|
113
|
+
Raises :class:`ValueError` if the length is not a multiple of 16 bytes.
|
|
114
|
+
"""
|
|
115
|
+
return [LVTimestamp(seconds, frac) for seconds, frac in _codec.unpack_many(data, byteorder)]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def write_timestamps(timestamps: list[LVTimestamp], byteorder: ByteOrder = "big") -> bytes:
|
|
119
|
+
"""Write a list of :class:`LVTimestamp` as one contiguous buffer of 16-byte records."""
|
|
120
|
+
return _codec.pack_many([(ts.seconds, ts.frac) for ts in timestamps], byteorder)
|
lvtime/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lvtime
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Lossless LabVIEW 128-bit timestamp (1904 epoch) to and from datetime, UTC, sub-microsecond preserved.
|
|
5
|
+
Project-URL: Homepage, https://lianes8studio.com
|
|
6
|
+
Project-URL: Repository, https://github.com/JaviLianes8/lvtime
|
|
7
|
+
Project-URL: Issues, https://github.com/JaviLianes8/lvtime/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/JaviLianes8/lvtime/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
|
|
10
|
+
Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
|
|
11
|
+
Author-email: Javier Lianes <javier@lianes8studio.com>
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: 1904,datetime,epoch,labview,ni,tdms,timestamp
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: build; extra == 'dev'
|
|
22
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
25
|
+
Requires-Dist: twine; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# lvtime
|
|
29
|
+
|
|
30
|
+
Lossless LabVIEW 128-bit timestamp (1904 epoch) to and from datetime, UTC, sub-microsecond preserved.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
pip install lvtime
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from datetime import datetime, timezone
|
|
42
|
+
from lvtime import LVTimestamp, to_datetime, from_datetime, read_timestamp, write_timestamp
|
|
43
|
+
|
|
44
|
+
# A LabVIEW absolute time is a signed 64-bit second count since 1904-01-01 UTC
|
|
45
|
+
# plus an unsigned 64-bit fraction in units of 2**-64 s.
|
|
46
|
+
ts = LVTimestamp(seconds=3810888000, frac=1 << 63) # 2024-10-04 12:00:00.5 UTC
|
|
47
|
+
ts.to_datetime() # datetime(2024, 10, 4, 12, 0, 0, 500000, tzinfo=UTC)
|
|
48
|
+
|
|
49
|
+
# Build one from an aware datetime (naive input is rejected).
|
|
50
|
+
LVTimestamp.from_datetime(datetime(2024, 10, 4, tzinfo=timezone.utc))
|
|
51
|
+
|
|
52
|
+
# Read and write the 16-byte big-endian on-disk form (TDMS / "Flatten To String").
|
|
53
|
+
raw = write_timestamp(ts) # 16 bytes: 8 signed seconds + 8 unsigned fraction
|
|
54
|
+
read_timestamp(raw) == ts # True, every bit preserved
|
|
55
|
+
|
|
56
|
+
# The plain functions take and return the integer pair directly.
|
|
57
|
+
to_datetime(3810888000, 1 << 63)
|
|
58
|
+
seconds, frac = from_datetime(datetime(2024, 10, 4, tzinfo=timezone.utc))
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`LVTimestamp` is the lossless carrier: it keeps the full 2**-64 s fraction, so two
|
|
62
|
+
instants a single fraction unit apart stay distinct even though they collapse to the
|
|
63
|
+
same microsecond when you ask for a `datetime`.
|
|
64
|
+
|
|
65
|
+
### Unix time and float seconds
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
ts.to_unix() # 1728043200.5, float seconds since 1970-01-01 UTC
|
|
69
|
+
LVTimestamp.from_unix(1728043200.5)
|
|
70
|
+
ts.to_float() # 3810888000.5, seconds since 1904 (LabVIEW coerce-to-double)
|
|
71
|
+
LVTimestamp.from_float(3810888000.5)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Floats hold about 52 bits of mantissa, so these views are convenient but lossy below
|
|
75
|
+
the microsecond. The integer pair stays exact.
|
|
76
|
+
|
|
77
|
+
### Exact values with Decimal
|
|
78
|
+
|
|
79
|
+
When you need the real value and not a float, convert through `Decimal` (or plain `int`
|
|
80
|
+
or a `str`). This is the only way to reproduce NI's own documented bytes for a value
|
|
81
|
+
like -5.2 s, which a float cannot represent:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from decimal import Decimal
|
|
85
|
+
from lvtime import LVTimestamp, to_decimal, from_decimal
|
|
86
|
+
|
|
87
|
+
from_decimal("-5.2") # (-6, 14757395258967641293) == {0xFFFFFFFFFFFFFFFA, 0xCCCCCCCCCCCCCCCD}
|
|
88
|
+
LVTimestamp.from_decimal("-5.2").hex() # 'fffffffffffffffacccccccccccccccd', the exact NI bytes
|
|
89
|
+
|
|
90
|
+
LVTimestamp(10, 1 << 63).to_decimal() # Decimal('10.5'), exact seconds since 1904
|
|
91
|
+
to_decimal(0, 1) # Decimal('5.42...E-20'), one 2**-64 fraction unit, held exactly
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`from_decimal` refuses a `float` on purpose, so a lossy value cannot slip in unnoticed;
|
|
95
|
+
reach for `from_float` when a float is really what you have. There are `to_unix_decimal`
|
|
96
|
+
and `from_unix_decimal` for the Unix-epoch view, and `LVTimestamp.hex()` /
|
|
97
|
+
`LVTimestamp.from_hex()` for the hex string you usually hold the flattened form as.
|
|
98
|
+
|
|
99
|
+
### Endianness and batches
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
ts.to_bytes("little") # byte-reversed 16-byte form
|
|
103
|
+
LVTimestamp.from_bytes(raw, "little")
|
|
104
|
+
|
|
105
|
+
from lvtime import read_timestamps, write_timestamps
|
|
106
|
+
|
|
107
|
+
blob = write_timestamps([ts, LVTimestamp(0, 0)]) # back-to-back 16-byte records
|
|
108
|
+
read_timestamps(blob) # [LVTimestamp(...), LVTimestamp(0, 0)]
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Command line
|
|
112
|
+
|
|
113
|
+
Installing the package puts an `lvtime` command on the path (also runnable as
|
|
114
|
+
`python -m lvtime`):
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
$ lvtime decode 00000000e32589408000000000000000
|
|
118
|
+
2024-10-04T12:00:00.500000+00:00
|
|
119
|
+
|
|
120
|
+
$ lvtime decode --format fields 00000000e32589408000000000000000
|
|
121
|
+
seconds=3810888000 frac=9223372036854775808 iso=2024-10-04T12:00:00.500000+00:00 unix=1728043200.5
|
|
122
|
+
|
|
123
|
+
$ lvtime encode 2024-10-04T12:00:00.5+00:00
|
|
124
|
+
00000000e32589408000000000000000
|
|
125
|
+
|
|
126
|
+
$ lvtime decode --format decimal 00000000e32589408000000000000000
|
|
127
|
+
epoch1904=3810888000.5000... unix=1728043200.5000...
|
|
128
|
+
|
|
129
|
+
$ echo 00000000e32589408000000000000000 | lvtime decode
|
|
130
|
+
2024-10-04T12:00:00.500000+00:00
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`decode` also takes `--format json`, `--format decimal` (exact seconds, no float) and
|
|
134
|
+
`--byteorder little`; `encode` reads the ISO datetime from an argument or stdin and treats
|
|
135
|
+
a naive datetime as UTC. `lvtime --version` prints the version, and a bad input goes to
|
|
136
|
+
stderr with exit code 1. See `lvtime --help`.
|
|
137
|
+
|
|
138
|
+
## How it works
|
|
139
|
+
|
|
140
|
+
A LabVIEW absolute time is 128 bits of fixed point: a **signed** 64-bit count of whole
|
|
141
|
+
seconds since **1904-01-01 00:00:00 UTC** followed by an **unsigned** 64-bit fraction in
|
|
142
|
+
units of 2**-64 s. On disk ("Flatten To String", TDMS timestamp channels) the two fields
|
|
143
|
+
are stored big-endian, most significant byte first, for 16 bytes total. Converting to a
|
|
144
|
+
`datetime` means scaling the fraction to microseconds and adding to the 1904 epoch;
|
|
145
|
+
converting back maps the microsecond field to the nearest fraction unit. The full integer
|
|
146
|
+
pair is the only representation that keeps every bit.
|
|
147
|
+
|
|
148
|
+
## Two caveats worth knowing
|
|
149
|
+
|
|
150
|
+
The [NI timestamp overview](https://www.ni.com/en/support/documentation/supplemental/08/labview-timestamp-overview.html)
|
|
151
|
+
lists example values. Two of them trip people up, and `lvtime` is built around the byte
|
|
152
|
+
values rather than the labels:
|
|
153
|
+
|
|
154
|
+
- **The 0.8 fraction only survives in fixed point.** 0.8 s is exactly
|
|
155
|
+
`0xCCCCCCCCCCCCCCCD` in 2**-64 units (so -5.2 s is `{0xFFFFFFFFFFFFFFFA, 0xCCCCCCCCCCCCCCCD}`).
|
|
156
|
+
Build that same fraction from a C `double` and it collapses to `0xCCCCCCCCCCCCC000`, losing
|
|
157
|
+
the low bits, which NI's own page notes. `from_float(-5.2)` reproduces the lossy
|
|
158
|
+
`0xCCCCCCCCCCCCC000`; `from_decimal("-5.2")` reproduces the exact `0xCCCCCCCCCCCCCCCD`. Use
|
|
159
|
+
the Decimal path when the bytes have to match.
|
|
160
|
+
- **NI's page mislabels the 2002 row by a day.** It labels `{3092601600, 0}` (`0xB8555B00`)
|
|
161
|
+
as "01/01/2002 00:00:00 UTC", but 3092601600 seconds after the 1904 epoch is
|
|
162
|
+
2001-12-31 00:00:00 UTC, one day earlier. `lvtime` decodes the byte value, so it returns
|
|
163
|
+
the correct 2001-12-31.
|
|
164
|
+
|
|
165
|
+
## Why
|
|
166
|
+
|
|
167
|
+
LabVIEW stores absolute time as a 128-bit fixed-point value: a **signed** 64-bit count
|
|
168
|
+
of whole seconds since **1904-01-01 00:00:00 UTC** and an **unsigned** 64-bit fraction in
|
|
169
|
+
units of 2**-64 s ([NI, LabVIEW Timestamp Overview](https://www.ni.com/en/support/documentation/supplemental/08/labview-timestamp-overview.html)).
|
|
170
|
+
Two things trip people up every time:
|
|
171
|
+
|
|
172
|
+
- **The 1904 epoch.** The Unix offset is exactly `2082844800` seconds. Hand-rolled code
|
|
173
|
+
that re-derives or mistypes this constant lands "off by 300 years" or by a timezone, a
|
|
174
|
+
recurring complaint on the NI and LAVA forums.
|
|
175
|
+
- **The precision.** The format resolves far below a nanosecond. Round-tripping through a
|
|
176
|
+
C `double` or a `datetime` throws most of that away (about a 0.5 microsecond step through
|
|
177
|
+
a double). npTDMS's default read path surfaces timestamps as NumPy `datetime64` truncated
|
|
178
|
+
to microseconds, which loses the fraction; it does expose the lossless pair with
|
|
179
|
+
`raw_timestamps=True`, but only for timestamps read from inside a TDMS file.
|
|
180
|
+
|
|
181
|
+
Inside a TDMS file, then, npTDMS already covers the lossless case. What is missing is a
|
|
182
|
+
standalone codec for the raw 16-byte form that is **not** part of a TDMS file: Flatten To
|
|
183
|
+
String blobs, protocol payloads, anything where you hold the bytes directly. The LabVIEW-side
|
|
184
|
+
toolkits (OpenG, VIPM/JKI) run inside LabVIEW and ship no Python reader. `lvtime` fills that
|
|
185
|
+
gap: a tiny, typed, zero-dependency (stdlib only) library that keeps the exact integer pair
|
|
186
|
+
with the correct signedness, reads and writes the 16-byte on-disk form (big- or little-endian)
|
|
187
|
+
independent of any file container, and converts to a UTC-aware `datetime` only when you ask
|
|
188
|
+
(rounded to the microsecond, which is all `datetime` can hold).
|
|
189
|
+
|
|
190
|
+
## Scope
|
|
191
|
+
|
|
192
|
+
`lvtime` handles the absolute-time value itself: the integer pair, the 16-byte record, the
|
|
193
|
+
`datetime` conversion, and the Unix and float-seconds views. It does not parse whole TDMS or
|
|
194
|
+
flattened-data files; it is the piece you reach for once you have the 16 raw bytes in hand, or
|
|
195
|
+
the seconds-and-fraction pair you need to turn into a real instant. LabVIEW follows the
|
|
196
|
+
Gregorian calendar and ignores leap seconds, and so does this library.
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
MIT. See [LICENSE](LICENSE).
|
|
201
|
+
|
|
202
|
+
## Links
|
|
203
|
+
|
|
204
|
+
- Website: https://lianes8studio.com
|
|
205
|
+
- GitHub: https://github.com/JaviLianes8
|
|
206
|
+
- LinkedIn: https://www.linkedin.com/in/jlianes/
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
lvtime/__init__.py,sha256=Vehc5E1RveI-BoMy3hKl7rGxfkiiQLwtMDyHpSVvAFk,935
|
|
2
|
+
lvtime/__main__.py,sha256=GrBOs3F9Jhl8hc0WZuuZas1n2nc-S7ln-iNHvWSpg_w,175
|
|
3
|
+
lvtime/_cli.py,sha256=AYVCrMvUoYRXSyT6Sx71odl9bDlcEA3paP6RDjnbFZ0,3528
|
|
4
|
+
lvtime/_codec.py,sha256=i1K3ciulqh_ZMIGxo113AJvDoUdt3I-f9J1u3udsHdw,2232
|
|
5
|
+
lvtime/_convert.py,sha256=gpqNefQ5ORC_XJzB1fct7Mem-Jc5AoeENmFlzAzAOJo,3820
|
|
6
|
+
lvtime/_exact.py,sha256=liZRB92qdDi3YwZcpkYnuD_uVo08xXeycCJ-HfW0ojM,2594
|
|
7
|
+
lvtime/_timestamp.py,sha256=XeDUoYm1uZL2vGLZbiOuDd0iWtMgGMPdDGAQSWHMAlU,4892
|
|
8
|
+
lvtime/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
lvtime-0.3.0.dist-info/METADATA,sha256=IvEXQd8cIuzSWuypPI6sc-jfrBI5suK3wIcVhb7piho,9237
|
|
10
|
+
lvtime-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
lvtime-0.3.0.dist-info/entry_points.txt,sha256=9ZbJqIXEQRtw_P78u_g4HOkW300hRCZb8uP73fvrZ44,44
|
|
12
|
+
lvtime-0.3.0.dist-info/licenses/LICENSE,sha256=wirD5nCA2aQa5UusK_1YHLBogI3CGcw23xAnulp3L_U,1091
|
|
13
|
+
lvtime-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Javier Lianes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|