epochlens 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 epochlens contributors
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.
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.4
2
+ Name: epochlens
3
+ Version: 0.1.0
4
+ Summary: A smart bidirectional timestamp inspector: paste any unix timestamp (s/ms/us/ns auto-detected) or date, see every representation. Zero dependencies.
5
+ Author: yyfjj
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jjdoor/epochlens-py
8
+ Project-URL: Repository, https://github.com/jjdoor/epochlens-py
9
+ Project-URL: Issues, https://github.com/jjdoor/epochlens-py/issues
10
+ Keywords: timestamp,epoch,unix-time,datetime,iso8601,converter,cli,date,devtools
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Utilities
18
+ Requires-Python: >=3.8
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # epochlens
24
+
25
+ **A smart, bidirectional timestamp inspector.** Paste any unix timestamp — in
26
+ seconds, milliseconds, microseconds *or* nanoseconds, auto-detected — or a date
27
+ string, and see every representation at once. No flags to remember, no `date -r`
28
+ vs `date -d @` platform roulette.
29
+
30
+ ```bash
31
+ pipx run epochlens 1718750000
32
+ # input 1718750000 (unix seconds)
33
+ #
34
+ # unix s 1718750000
35
+ # unix ms 1718750000000
36
+ # unix µs 1718750000000000
37
+ # unix ns 1718750000000000000
38
+ # iso utc 2024-06-18T22:33:20Z
39
+ # iso local 2024-06-19T06:33:20+08:00
40
+ # relative 2 minutes ago
41
+ # rfc 2822 Tue, 18 Jun 2024 22:33:20 +0000
42
+ ```
43
+
44
+ Zero dependencies, pure standard library. Also on npm (`npx epochlens`) — the two
45
+ builds produce **byte-for-byte identical** output.
46
+
47
+ ## Why
48
+
49
+ `date` can do this, but `date -r 1718750000` (BSD/macOS) and `date -d @1718750000`
50
+ (GNU/Linux) are *different commands*, neither auto-detects whether you pasted
51
+ seconds or milliseconds, and neither shows you all the forms at once. Online
52
+ epoch converters do, but you have to leave the terminal and paste your data into
53
+ someone else's website.
54
+
55
+ `epochlens` is the one command that just works on every platform, guesses the
56
+ unit the way you'd expect, and goes **both directions**:
57
+
58
+ ```bash
59
+ epochlens 1718750000123 # millis? micros? it figures it out
60
+ epochlens 2024-06-18T22:33:20Z # date string -> every epoch precision
61
+ epochlens now # the current moment, all forms
62
+ echo 1718750000 | epochlens # reads stdin too
63
+ ```
64
+
65
+ ## Usage
66
+
67
+ ```bash
68
+ epochlens <timestamp|date>
69
+ epochlens --unit ms 1718750000 # force the unit if the guess is wrong
70
+ epochlens --offset +05:30 1718750000 # add a row in a fixed UTC offset
71
+ epochlens --json 1718750000 # machine-readable
72
+ ```
73
+
74
+ **Auto-detection.** A bare number is classified by magnitude: ~10 digits →
75
+ seconds, ~13 → milliseconds, ~16 → microseconds, ~19 → nanoseconds. The detected
76
+ unit is always echoed back so you can catch a wrong guess and re-run with
77
+ `--unit`. A value with a decimal point is read as seconds.
78
+
79
+ **Accepted input.** Unix timestamps (any precision), ISO 8601
80
+ (`2024-06-18T22:33:20Z`, `2024-06-18 22:33:20`, `2024-06-18`, with or without a
81
+ `Z`/`±HH:MM` offset), and RFC 2822 (`Tue, 18 Jun 2024 22:33:20 +0000`). A date
82
+ string **without** an offset is read as UTC.
83
+
84
+ **`--json`** emits the unix values as **strings** so nanosecond precision
85
+ survives a 19-digit integer:
86
+
87
+ ```json
88
+ {
89
+ "input": "1718750000123",
90
+ "detected": "unix milliseconds",
91
+ "unix": { "s": "1718750000", "ms": "1718750000123", "us": "1718750000123000", "ns": "1718750000123000000" },
92
+ "iso_utc": "2024-06-18T22:33:20.123Z",
93
+ "iso_local": "2024-06-19T06:33:20.123+08:00",
94
+ "iso_offset": null,
95
+ "relative": "2 minutes ago",
96
+ "rfc2822": "Tue, 18 Jun 2024 22:33:20 +0000"
97
+ }
98
+ ```
99
+
100
+ Exit codes: `0` ok · `2` error (unparseable input, out-of-range date).
101
+
102
+ ## How it works
103
+
104
+ Date/time handling is a minefield of cross-platform and cross-language
105
+ disagreements, so epochlens does **none** of its conversion through the
106
+ runtime's date library. `datetime.fromisoformat`, `isoformat`, `strftime` (and
107
+ Node's `Date.parse`/`toISOString`) all differ between (and within) the two
108
+ runtimes. Instead every conversion is plain integer arithmetic over a
109
+ proleptic-Gregorian
110
+ [civil-day algorithm](https://howardhinnant.github.io/date_algorithms.html), and
111
+ all formatting is hand-rolled. That's what lets the Node and Python builds stay
112
+ byte-identical, and it's why nanosecond values stay exact (native ints in
113
+ Python, BigInt in Node).
114
+
115
+ ## Scope
116
+
117
+ The MVP shows UTC, your machine's local time, and any fixed `--offset`. Named
118
+ IANA zones (`--tz America/New_York`) are intentionally left out for now: Python's
119
+ `zoneinfo` needs an OS tz database that Windows lacks, which would break the
120
+ zero-dependency promise. Natural-language *input* (`"3 days ago"`) is also out of
121
+ scope — relative time is shown as output only.
122
+
123
+ ## Install
124
+
125
+ ```bash
126
+ pip install epochlens # or pipx run epochlens
127
+ npm i -g epochlens # Node build, identical behaviour
128
+ ```
129
+
130
+ Python ≥ 3.8 or Node ≥ 18. No dependencies.
131
+
132
+ ## License
133
+
134
+ MIT
@@ -0,0 +1,112 @@
1
+ # epochlens
2
+
3
+ **A smart, bidirectional timestamp inspector.** Paste any unix timestamp — in
4
+ seconds, milliseconds, microseconds *or* nanoseconds, auto-detected — or a date
5
+ string, and see every representation at once. No flags to remember, no `date -r`
6
+ vs `date -d @` platform roulette.
7
+
8
+ ```bash
9
+ pipx run epochlens 1718750000
10
+ # input 1718750000 (unix seconds)
11
+ #
12
+ # unix s 1718750000
13
+ # unix ms 1718750000000
14
+ # unix µs 1718750000000000
15
+ # unix ns 1718750000000000000
16
+ # iso utc 2024-06-18T22:33:20Z
17
+ # iso local 2024-06-19T06:33:20+08:00
18
+ # relative 2 minutes ago
19
+ # rfc 2822 Tue, 18 Jun 2024 22:33:20 +0000
20
+ ```
21
+
22
+ Zero dependencies, pure standard library. Also on npm (`npx epochlens`) — the two
23
+ builds produce **byte-for-byte identical** output.
24
+
25
+ ## Why
26
+
27
+ `date` can do this, but `date -r 1718750000` (BSD/macOS) and `date -d @1718750000`
28
+ (GNU/Linux) are *different commands*, neither auto-detects whether you pasted
29
+ seconds or milliseconds, and neither shows you all the forms at once. Online
30
+ epoch converters do, but you have to leave the terminal and paste your data into
31
+ someone else's website.
32
+
33
+ `epochlens` is the one command that just works on every platform, guesses the
34
+ unit the way you'd expect, and goes **both directions**:
35
+
36
+ ```bash
37
+ epochlens 1718750000123 # millis? micros? it figures it out
38
+ epochlens 2024-06-18T22:33:20Z # date string -> every epoch precision
39
+ epochlens now # the current moment, all forms
40
+ echo 1718750000 | epochlens # reads stdin too
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ```bash
46
+ epochlens <timestamp|date>
47
+ epochlens --unit ms 1718750000 # force the unit if the guess is wrong
48
+ epochlens --offset +05:30 1718750000 # add a row in a fixed UTC offset
49
+ epochlens --json 1718750000 # machine-readable
50
+ ```
51
+
52
+ **Auto-detection.** A bare number is classified by magnitude: ~10 digits →
53
+ seconds, ~13 → milliseconds, ~16 → microseconds, ~19 → nanoseconds. The detected
54
+ unit is always echoed back so you can catch a wrong guess and re-run with
55
+ `--unit`. A value with a decimal point is read as seconds.
56
+
57
+ **Accepted input.** Unix timestamps (any precision), ISO 8601
58
+ (`2024-06-18T22:33:20Z`, `2024-06-18 22:33:20`, `2024-06-18`, with or without a
59
+ `Z`/`±HH:MM` offset), and RFC 2822 (`Tue, 18 Jun 2024 22:33:20 +0000`). A date
60
+ string **without** an offset is read as UTC.
61
+
62
+ **`--json`** emits the unix values as **strings** so nanosecond precision
63
+ survives a 19-digit integer:
64
+
65
+ ```json
66
+ {
67
+ "input": "1718750000123",
68
+ "detected": "unix milliseconds",
69
+ "unix": { "s": "1718750000", "ms": "1718750000123", "us": "1718750000123000", "ns": "1718750000123000000" },
70
+ "iso_utc": "2024-06-18T22:33:20.123Z",
71
+ "iso_local": "2024-06-19T06:33:20.123+08:00",
72
+ "iso_offset": null,
73
+ "relative": "2 minutes ago",
74
+ "rfc2822": "Tue, 18 Jun 2024 22:33:20 +0000"
75
+ }
76
+ ```
77
+
78
+ Exit codes: `0` ok · `2` error (unparseable input, out-of-range date).
79
+
80
+ ## How it works
81
+
82
+ Date/time handling is a minefield of cross-platform and cross-language
83
+ disagreements, so epochlens does **none** of its conversion through the
84
+ runtime's date library. `datetime.fromisoformat`, `isoformat`, `strftime` (and
85
+ Node's `Date.parse`/`toISOString`) all differ between (and within) the two
86
+ runtimes. Instead every conversion is plain integer arithmetic over a
87
+ proleptic-Gregorian
88
+ [civil-day algorithm](https://howardhinnant.github.io/date_algorithms.html), and
89
+ all formatting is hand-rolled. That's what lets the Node and Python builds stay
90
+ byte-identical, and it's why nanosecond values stay exact (native ints in
91
+ Python, BigInt in Node).
92
+
93
+ ## Scope
94
+
95
+ The MVP shows UTC, your machine's local time, and any fixed `--offset`. Named
96
+ IANA zones (`--tz America/New_York`) are intentionally left out for now: Python's
97
+ `zoneinfo` needs an OS tz database that Windows lacks, which would break the
98
+ zero-dependency promise. Natural-language *input* (`"3 days ago"`) is also out of
99
+ scope — relative time is shown as output only.
100
+
101
+ ## Install
102
+
103
+ ```bash
104
+ pip install epochlens # or pipx run epochlens
105
+ npm i -g epochlens # Node build, identical behaviour
106
+ ```
107
+
108
+ Python ≥ 3.8 or Node ≥ 18. No dependencies.
109
+
110
+ ## License
111
+
112
+ MIT
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "epochlens"
7
+ version = "0.1.0"
8
+ description = "A smart bidirectional timestamp inspector: paste any unix timestamp (s/ms/us/ns auto-detected) or date, see every representation. Zero dependencies."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "yyfjj" }]
13
+ keywords = ["timestamp", "epoch", "unix-time", "datetime", "iso8601", "converter", "cli", "date", "devtools"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Utilities",
22
+ ]
23
+ dependencies = []
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/jjdoor/epochlens-py"
27
+ Repository = "https://github.com/jjdoor/epochlens-py"
28
+ Issues = "https://github.com/jjdoor/epochlens-py/issues"
29
+
30
+ [project.scripts]
31
+ epochlens = "epochlens.cli:main"
32
+
33
+ [tool.setuptools]
34
+ package-dir = { "" = "src" }
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """epochlens — a smart bidirectional timestamp inspector. Zero dependencies."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,180 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import signal
5
+ import sys
6
+ import time
7
+
8
+ from . import __version__
9
+ from . import core
10
+
11
+
12
+ def _paint():
13
+ use_color = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
14
+
15
+ def col(c, s):
16
+ return f"\x1b[{c}m{s}\x1b[0m" if use_color else s
17
+
18
+ return {
19
+ "red": lambda s: col("31", s), "green": lambda s: col("32", s),
20
+ "yellow": lambda s: col("33", s), "dim": lambda s: col("2", s),
21
+ "bold": lambda s: col("1", s),
22
+ }
23
+
24
+
25
+ def _help(p):
26
+ b = p["bold"]
27
+ return (
28
+ f"{b('epochlens')} — a smart, bidirectional timestamp inspector. Zero dependencies.\n"
29
+ "\n"
30
+ "Paste any unix timestamp (seconds, millis, micros or nanos — auto-detected) OR a\n"
31
+ "date string (ISO 8601 / RFC 2822), and see every representation at once: all four\n"
32
+ "precisions, ISO 8601 UTC + your local time, a human relative time, and RFC 2822.\n"
33
+ "\n"
34
+ f"{b('Usage')}\n"
35
+ " epochlens <timestamp|date> e.g. epochlens 1718750000\n"
36
+ " epochlens now Inspect the current moment\n"
37
+ " echo 1718750000 | epochlens Reads stdin when no argument is given\n"
38
+ "\n"
39
+ f"{b('Options')}\n"
40
+ " --unit s|ms|us|ns Force the unit instead of auto-detecting\n"
41
+ " --offset ±HH:MM Add a row in a fixed UTC offset (e.g. --offset +05:30)\n"
42
+ " --json Machine-readable output (unix values are strings, for precision)\n"
43
+ " --help | --version\n"
44
+ "\n"
45
+ f"{b('Notes')}\n"
46
+ " Date strings without an offset are read as UTC. Numbers are unambiguous.\n"
47
+ " Exit 0 ok · 2 error\n"
48
+ )
49
+
50
+
51
+ def _parse_offset_arg(tok, die):
52
+ if not tok:
53
+ return die("--offset needs a value like +05:30")
54
+ m = re.fullmatch(r"([+-])([0-9]{2}):?([0-9]{2})", tok)
55
+ if not m:
56
+ return die(f"invalid --offset: {tok} (use ±HH:MM)")
57
+ hh, mm = int(m.group(2)), int(m.group(3))
58
+ if hh > 23 or mm > 59:
59
+ return die(f"--offset out of range: {tok}")
60
+ sign = -1 if m.group(1) == "-" else 1
61
+ return sign * (hh * 3600 + mm * 60)
62
+
63
+
64
+ def _local_offset_sec(ms):
65
+ try:
66
+ off = time.localtime(ms // 1000).tm_gmtoff
67
+ return off if off is not None else None
68
+ except (OverflowError, OSError, ValueError):
69
+ return None
70
+
71
+
72
+ class _Exit(Exception):
73
+ def __init__(self, code):
74
+ self.code = code
75
+
76
+
77
+ def main(argv=None):
78
+ try:
79
+ signal.signal(signal.SIGPIPE, signal.SIG_DFL)
80
+ except (AttributeError, ValueError):
81
+ pass
82
+
83
+ argv = list(sys.argv[1:] if argv is None else argv)
84
+ p = _paint()
85
+
86
+ def die(msg):
87
+ sys.stderr.write(p["red"](f"epochlens: {msg}\n"))
88
+ raise _Exit(2)
89
+
90
+ try:
91
+ # Only honour -h/-v BEFORE a `--` terminator (after it they're data).
92
+ dd_idx = argv.index("--") if "--" in argv else len(argv)
93
+ pre = argv[:dd_idx]
94
+ if "-h" in pre or "--help" in pre:
95
+ sys.stdout.write(_help(p))
96
+ return 0
97
+ if "-v" in pre or "--version" in pre:
98
+ sys.stdout.write(__version__ + "\n")
99
+ return 0
100
+
101
+ as_json = False
102
+ unit = None
103
+ offset_sec = None
104
+ positional = []
105
+ dd_seen = False
106
+ i = 0
107
+ while i < len(argv):
108
+ a = argv[i]
109
+ if dd_seen:
110
+ positional.append(a)
111
+ elif a == "--":
112
+ dd_seen = True
113
+ elif a == "--json":
114
+ as_json = True
115
+ elif a == "--unit":
116
+ i += 1
117
+ unit = argv[i] if i < len(argv) else None
118
+ if unit not in ("s", "ms", "us", "ns"):
119
+ die("--unit must be one of s, ms, us, ns")
120
+ elif a.startswith("--unit="):
121
+ unit = a[7:]
122
+ if unit not in ("s", "ms", "us", "ns"):
123
+ die("--unit must be one of s, ms, us, ns")
124
+ elif a == "--offset":
125
+ i += 1
126
+ offset_sec = _parse_offset_arg(argv[i] if i < len(argv) else None, die)
127
+ elif a.startswith("--offset="):
128
+ offset_sec = _parse_offset_arg(a[9:], die)
129
+ elif a in ("-h", "--help", "-v", "--version"):
130
+ pass
131
+ elif a.startswith("-") and a != "-" and not re.match(r"-\d", a):
132
+ die(f"unknown option: {a} (use -- to end options)")
133
+ else:
134
+ positional.append(a)
135
+ i += 1
136
+
137
+ raw = core.trim_ascii(" ".join(positional))
138
+ if raw == "":
139
+ try:
140
+ raw = core.trim_ascii(sys.stdin.read())
141
+ except Exception:
142
+ raw = ""
143
+ if raw == "":
144
+ sys.stdout.write(_help(p))
145
+ return 0
146
+
147
+ env_now = os.environ.get("EPOCHLENS_NOW")
148
+ if env_now:
149
+ if not re.fullmatch(r"-?[0-9]+", env_now):
150
+ die("invalid EPOCHLENS_NOW (must be integer milliseconds)")
151
+ now_ms = int(env_now)
152
+ else:
153
+ now_ms = int(time.time() * 1000)
154
+
155
+ try:
156
+ ns, detected = core.parse_input(raw, {"unit": unit, "nowMs": now_ms})
157
+ except ValueError as e:
158
+ die(str(e))
159
+
160
+ ms = ns // 1_000_000
161
+ ctx = {
162
+ "rawInput": raw,
163
+ "detected": detected,
164
+ "nowMs": now_ms,
165
+ "localOffsetSec": _local_offset_sec(ms),
166
+ "explicitOffset": {"sec": offset_sec} if offset_sec is not None else None,
167
+ }
168
+
169
+ try:
170
+ result = core.compute(ns, ctx)
171
+ except ValueError as e:
172
+ die(str(e))
173
+
174
+ if as_json:
175
+ sys.stdout.write(json.dumps(result, indent=2, ensure_ascii=False) + "\n")
176
+ else:
177
+ sys.stdout.write(core.format_human(result, p) + "\n")
178
+ return 0
179
+ except _Exit as ex:
180
+ return ex.code
@@ -0,0 +1,297 @@
1
+ """epochlens core — pure timestamp parsing, conversion & rendering.
2
+
3
+ No datetime.fromisoformat, no isoformat(), no clock, no IO — all of those diverge
4
+ from the Node build. Every conversion is done with our own integer math (a
5
+ proleptic-Gregorian civil-day algorithm) and hand-rolled formatting, so the Node
6
+ and Python builds emit byte-for-byte identical output.
7
+
8
+ "now" and the machine's local UTC offset are the only runtime-dependent inputs;
9
+ they are passed in from cli.py so this module stays pure & testable.
10
+ """
11
+
12
+ import re
13
+
14
+ # nanoseconds per unit
15
+ NS_PER = {"s": 1_000_000_000, "ms": 1_000_000, "us": 1_000, "ns": 1}
16
+ UNIT_NAME = {"s": "unix seconds", "ms": "unix milliseconds",
17
+ "us": "unix microseconds", "ns": "unix nanoseconds"}
18
+ MON = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
19
+ DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
20
+
21
+ # The ASCII whitespace set, stripped identically in both builds. Native
22
+ # trim()/strip() disagree on exotic chars (NEL/C1/BOM), so we never use them.
23
+ _WS = " \t\n\r\f\v"
24
+
25
+
26
+ def trim_ascii(s):
27
+ return str(s).strip(_WS)
28
+
29
+
30
+ # ---- integer helpers (parity-critical) -------------------------------------
31
+
32
+ # Howard Hinnant's civil <-> days-from-1970 algorithm (proleptic Gregorian).
33
+ # Python // floors, matching Node's Math.floor, so both languages agree exactly.
34
+ def days_from_civil(y, m, d):
35
+ yy = y - (1 if m <= 2 else 0)
36
+ era = yy // 400
37
+ yoe = yy - era * 400
38
+ doy = (153 * (m + (-3 if m > 2 else 9)) + 2) // 5 + d - 1
39
+ doe = yoe * 365 + yoe // 4 - yoe // 100 + doy
40
+ return era * 146097 + doe - 719468
41
+
42
+
43
+ def civil_from_days(z0):
44
+ z = z0 + 719468
45
+ era = z // 146097
46
+ doe = z - era * 146097
47
+ yoe = (doe - doe // 1460 + doe // 36524 - doe // 146096) // 365
48
+ y = yoe + era * 400
49
+ doy = doe - (365 * yoe + yoe // 4 - yoe // 100)
50
+ mp = (5 * doy + 2) // 153
51
+ d = doy - (153 * mp + 2) // 5 + 1
52
+ m = mp + (3 if mp < 10 else -9)
53
+ return (y + (1 if m <= 2 else 0), m, d)
54
+
55
+
56
+ def components_from_ms(ms):
57
+ """Break an epoch-millisecond int into UTC calendar components (floor-based)."""
58
+ days = ms // 86400000
59
+ rem = ms - days * 86400000 # 0 .. 86399999
60
+ y, mo, d = civil_from_days(days)
61
+ h = rem // 3600000
62
+ rem -= h * 3600000
63
+ mi = rem // 60000
64
+ rem -= mi * 60000
65
+ s = rem // 1000
66
+ ms_c = rem - s * 1000
67
+ return {"y": y, "mo": mo, "d": d, "h": h, "mi": mi, "s": s, "ms": ms_c, "days": days}
68
+
69
+
70
+ def _dow(days):
71
+ return (days % 7 + 4) % 7 # 1970-01-01 (days=0) was Thursday (=4, Sunday=0)
72
+
73
+
74
+ _DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
75
+
76
+
77
+ def _is_leap(y):
78
+ return (y % 4 == 0 and y % 100 != 0) or y % 400 == 0
79
+
80
+
81
+ def _days_in_month(y, m):
82
+ return 29 if (m == 2 and _is_leap(y)) else _DAYS_IN_MONTH[m - 1]
83
+
84
+
85
+ def _validate_calendar(y, mo, d, h, mi, s):
86
+ if mo < 1 or mo > 12:
87
+ raise ValueError(f"invalid month: {mo}")
88
+ if d < 1 or d > _days_in_month(y, mo):
89
+ raise ValueError(f"invalid day: {d}")
90
+ if h > 23:
91
+ raise ValueError(f"invalid hour: {h}")
92
+ if mi > 59:
93
+ raise ValueError(f"invalid minute: {mi}")
94
+ if s == 60:
95
+ raise ValueError("leap seconds (:60) are not representable")
96
+ if s > 59:
97
+ raise ValueError(f"invalid second: {s}")
98
+
99
+
100
+ # ---- detection & parsing ---------------------------------------------------
101
+
102
+ def detect_unit(abs_int):
103
+ if abs_int < 100000000000: # < 1e11
104
+ return "s"
105
+ if abs_int < 100000000000000: # < 1e14
106
+ return "ms"
107
+ if abs_int < 100000000000000000: # < 1e17
108
+ return "us"
109
+ return "ns"
110
+
111
+
112
+ # All patterns use [0-9]/[ \t] (never \d/\s) so they match the Node build
113
+ # exactly: Python re \d also matches Unicode Nd digits and \s differs from JS
114
+ # \s on NEL/C1/BOM. Explicit classes erase that.
115
+ def _parse_offset_token(tok):
116
+ m = re.fullmatch(r"([+-])([0-9]{2}):?([0-9]{2})", tok)
117
+ if not m:
118
+ raise ValueError(f"invalid offset: {tok}")
119
+ hh, mm = int(m.group(2)), int(m.group(3))
120
+ if hh > 23 or mm > 59:
121
+ raise ValueError(f"offset out of range: {tok}")
122
+ sign = -1 if m.group(1) == "-" else 1
123
+ return sign * (hh * 3600 + mm * 60)
124
+
125
+
126
+ _ISO_RE = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})(?:[ T]([0-9]{2}):([0-9]{2})(?::([0-9]{2})(?:\.([0-9]+))?)?(Z|[+-][0-9]{2}:?[0-9]{2})?)?")
127
+ _RFC_RE = re.compile(r"(?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun),[ \t]*)?([0-9]{1,2})[ \t]+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[ \t]+([0-9]{4})[ \t]+([0-9]{2}):([0-9]{2})(?::([0-9]{2}))?[ \t]+([+-][0-9]{4}|GMT|UTC|UT)")
128
+ _NUM_RE = re.compile(r"([+-]?)([0-9]+)(?:\.([0-9]+))?")
129
+
130
+
131
+ def _frac_to_ns(frac):
132
+ if not frac:
133
+ return 0
134
+ return (int(frac) * 1_000_000_000) // (10 ** len(frac))
135
+
136
+
137
+ def _date_components_to_ns(y, mo, d, h, mi, s, frac, offset_sec):
138
+ _validate_calendar(y, mo, d, h, mi, s)
139
+ days = days_from_civil(y, mo, d)
140
+ total_sec = days * 86400 + h * 3600 + mi * 60 + s - offset_sec
141
+ return total_sec * 1_000_000_000 + _frac_to_ns(frac)
142
+
143
+
144
+ def parse_input(raw, opts=None):
145
+ """Parse a raw input string into (ns:int, detected:str). Handles "now", a
146
+ bare unix timestamp (auto-detected s/ms/us/ns, overridable via opts['unit']),
147
+ ISO 8601 and RFC 2822. Raises ValueError on unrecognized input."""
148
+ opts = opts or {}
149
+ s = trim_ascii(raw)
150
+ if s == "":
151
+ raise ValueError("no input given")
152
+
153
+ if s.lower() == "now":
154
+ if opts.get("nowMs") is None:
155
+ raise ValueError('"now" is unavailable here')
156
+ return (int(opts["nowMs"]) * 1_000_000, "current time")
157
+
158
+ num_candidate = s.replace(",", "").replace("_", "")
159
+ nm = _NUM_RE.fullmatch(num_candidate)
160
+ if nm:
161
+ neg = nm.group(1) == "-"
162
+ int_digits = nm.group(2)
163
+ frac = nm.group(3) or ""
164
+ unit = opts.get("unit") or ("s" if frac else detect_unit(int(int_digits)))
165
+ per = NS_PER[unit]
166
+ ns = int(int_digits) * per
167
+ if frac:
168
+ ns += (int(frac) * per) // (10 ** len(frac))
169
+ if neg:
170
+ ns = -ns
171
+ return (ns, UNIT_NAME[unit] + (" (forced)" if opts.get("unit") else ""))
172
+
173
+ iso = _ISO_RE.fullmatch(s)
174
+ if iso:
175
+ y, mo, d = int(iso.group(1)), int(iso.group(2)), int(iso.group(3))
176
+ has_time = iso.group(4) is not None
177
+ h = int(iso.group(4)) if has_time else 0
178
+ mi = int(iso.group(5)) if iso.group(5) is not None else 0
179
+ sec = int(iso.group(6)) if iso.group(6) is not None else 0
180
+ frac = iso.group(7) or ""
181
+ off = iso.group(8)
182
+ offset_sec = _parse_offset_token(off) if (off and off != "Z") else 0
183
+ ns = _date_components_to_ns(y, mo, d, h, mi, sec, frac, offset_sec)
184
+ return (ns, "ISO 8601 datetime" if has_time else "ISO 8601 date")
185
+
186
+ rfc = _RFC_RE.fullmatch(s)
187
+ if rfc:
188
+ d = int(rfc.group(1))
189
+ mo = MON.index(rfc.group(2)) + 1
190
+ y = int(rfc.group(3))
191
+ h, mi = int(rfc.group(4)), int(rfc.group(5))
192
+ sec = int(rfc.group(6)) if rfc.group(6) is not None else 0
193
+ z = rfc.group(7)
194
+ offset_sec = _parse_offset_token(z[:3] + ":" + z[3:]) if re.fullmatch(r"[+-][0-9]{4}", z) else 0
195
+ ns = _date_components_to_ns(y, mo, d, h, mi, sec, "", offset_sec)
196
+ return (ns, "RFC 2822")
197
+
198
+ raise ValueError(
199
+ f'unrecognized input: "{s}"\n'
200
+ "accepted: a unix timestamp (s/ms/us/ns), ISO 8601 (e.g. 2024-06-18T22:33:20Z), or RFC 2822")
201
+
202
+
203
+ # ---- formatting ------------------------------------------------------------
204
+
205
+ def _iso_from_components(c):
206
+ s = f"{c['y']:04d}-{c['mo']:02d}-{c['d']:02d}T{c['h']:02d}:{c['mi']:02d}:{c['s']:02d}"
207
+ if c["ms"]:
208
+ s += f".{c['ms']:03d}"
209
+ return s
210
+
211
+
212
+ def _offset_label(sec):
213
+ sign = "-" if sec < 0 else "+"
214
+ a = abs(sec)
215
+ return f"{sign}{a // 3600:02d}:{(a % 3600) // 60:02d}"
216
+
217
+
218
+ def _iso_with_offset(ms, offset_sec):
219
+ # Quantize to whole minutes: the label only shows ±HH:MM, and the OS APIs
220
+ # disagree on sub-minute precision (Node getTimezoneOffset is minute-resolution,
221
+ # Python tm_gmtoff is seconds — they differ for pre-1900 LMT zones). Dropping
222
+ # the sub-minute part in both keeps the wall-clock byte-identical.
223
+ q = int(offset_sec / 60) * 60
224
+ return _iso_from_components(components_from_ms(ms + q * 1000)) + _offset_label(q)
225
+
226
+
227
+ def _rfc2822(c):
228
+ return (f"{DOW[_dow(c['days'])]}, {c['d']:02d} {MON[c['mo'] - 1]} {c['y']:04d} "
229
+ f"{c['h']:02d}:{c['mi']:02d}:{c['s']:02d} +0000")
230
+
231
+
232
+ _REL_UNITS = [("year", 31536000), ("month", 2592000), ("day", 86400),
233
+ ("hour", 3600), ("minute", 60), ("second", 1)]
234
+
235
+
236
+ def relative(delta_ms):
237
+ """delta_ms = now - instant; positive => instant is in the past."""
238
+ past = delta_ms >= 0
239
+ secs = abs(delta_ms) // 1000
240
+ if secs < 1:
241
+ return "just now"
242
+ for name, size in _REL_UNITS:
243
+ if secs >= size:
244
+ v = secs // size
245
+ label = f"{v} {name}{'s' if v != 1 else ''}"
246
+ return f"{label} ago" if past else f"in {label}"
247
+ return "just now"
248
+
249
+
250
+ def compute(ns, ctx):
251
+ """Turn a nanosecond instant (int) into the full structured result. Pure."""
252
+ ms = ns // 1_000_000
253
+ c = components_from_ms(ms)
254
+ if c["y"] < 1 or c["y"] > 9999:
255
+ raise ValueError("out of displayable range (year must be between 0001 and 9999)")
256
+ local = ctx.get("localOffsetSec")
257
+ explicit = ctx.get("explicitOffset")
258
+ return {
259
+ "input": ctx["rawInput"],
260
+ "detected": ctx["detected"],
261
+ "unix": {
262
+ "s": str(ns // 1_000_000_000),
263
+ "ms": str(ns // 1_000_000),
264
+ "us": str(ns // 1_000),
265
+ "ns": str(ns),
266
+ },
267
+ "iso_utc": _iso_from_components(c) + "Z",
268
+ "iso_local": _iso_with_offset(ms, local) if local is not None else None,
269
+ "iso_offset": _iso_with_offset(ms, explicit["sec"]) if explicit else None,
270
+ "relative": relative(ctx["nowMs"] - ms),
271
+ "rfc2822": _rfc2822(c),
272
+ }
273
+
274
+
275
+ PLAIN = {"red": lambda s: s, "green": lambda s: s, "yellow": lambda s: s,
276
+ "dim": lambda s: s, "bold": lambda s: s}
277
+
278
+
279
+ def format_human(r, paint=None):
280
+ p = paint or PLAIN
281
+
282
+ def row(label, value):
283
+ return f" {p['dim'](label.ljust(9))} {value}"
284
+
285
+ lines = [f" {p['dim']('input'.ljust(9))} {p['bold'](r['input'])} {p['dim']('(' + r['detected'] + ')')}", ""]
286
+ lines.append(row("unix s", p["green"](r["unix"]["s"])))
287
+ lines.append(row("unix ms", r["unix"]["ms"]))
288
+ lines.append(row("unix µs", r["unix"]["us"]))
289
+ lines.append(row("unix ns", r["unix"]["ns"]))
290
+ lines.append(row("iso utc", p["green"](r["iso_utc"])))
291
+ if r["iso_local"]:
292
+ lines.append(row("iso local", r["iso_local"]))
293
+ if r["iso_offset"]:
294
+ lines.append(row("iso off", r["iso_offset"]))
295
+ lines.append(row("relative", r["relative"]))
296
+ lines.append(row("rfc 2822", r["rfc2822"]))
297
+ return "\n".join(lines)
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.4
2
+ Name: epochlens
3
+ Version: 0.1.0
4
+ Summary: A smart bidirectional timestamp inspector: paste any unix timestamp (s/ms/us/ns auto-detected) or date, see every representation. Zero dependencies.
5
+ Author: yyfjj
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jjdoor/epochlens-py
8
+ Project-URL: Repository, https://github.com/jjdoor/epochlens-py
9
+ Project-URL: Issues, https://github.com/jjdoor/epochlens-py/issues
10
+ Keywords: timestamp,epoch,unix-time,datetime,iso8601,converter,cli,date,devtools
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Utilities
18
+ Requires-Python: >=3.8
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # epochlens
24
+
25
+ **A smart, bidirectional timestamp inspector.** Paste any unix timestamp — in
26
+ seconds, milliseconds, microseconds *or* nanoseconds, auto-detected — or a date
27
+ string, and see every representation at once. No flags to remember, no `date -r`
28
+ vs `date -d @` platform roulette.
29
+
30
+ ```bash
31
+ pipx run epochlens 1718750000
32
+ # input 1718750000 (unix seconds)
33
+ #
34
+ # unix s 1718750000
35
+ # unix ms 1718750000000
36
+ # unix µs 1718750000000000
37
+ # unix ns 1718750000000000000
38
+ # iso utc 2024-06-18T22:33:20Z
39
+ # iso local 2024-06-19T06:33:20+08:00
40
+ # relative 2 minutes ago
41
+ # rfc 2822 Tue, 18 Jun 2024 22:33:20 +0000
42
+ ```
43
+
44
+ Zero dependencies, pure standard library. Also on npm (`npx epochlens`) — the two
45
+ builds produce **byte-for-byte identical** output.
46
+
47
+ ## Why
48
+
49
+ `date` can do this, but `date -r 1718750000` (BSD/macOS) and `date -d @1718750000`
50
+ (GNU/Linux) are *different commands*, neither auto-detects whether you pasted
51
+ seconds or milliseconds, and neither shows you all the forms at once. Online
52
+ epoch converters do, but you have to leave the terminal and paste your data into
53
+ someone else's website.
54
+
55
+ `epochlens` is the one command that just works on every platform, guesses the
56
+ unit the way you'd expect, and goes **both directions**:
57
+
58
+ ```bash
59
+ epochlens 1718750000123 # millis? micros? it figures it out
60
+ epochlens 2024-06-18T22:33:20Z # date string -> every epoch precision
61
+ epochlens now # the current moment, all forms
62
+ echo 1718750000 | epochlens # reads stdin too
63
+ ```
64
+
65
+ ## Usage
66
+
67
+ ```bash
68
+ epochlens <timestamp|date>
69
+ epochlens --unit ms 1718750000 # force the unit if the guess is wrong
70
+ epochlens --offset +05:30 1718750000 # add a row in a fixed UTC offset
71
+ epochlens --json 1718750000 # machine-readable
72
+ ```
73
+
74
+ **Auto-detection.** A bare number is classified by magnitude: ~10 digits →
75
+ seconds, ~13 → milliseconds, ~16 → microseconds, ~19 → nanoseconds. The detected
76
+ unit is always echoed back so you can catch a wrong guess and re-run with
77
+ `--unit`. A value with a decimal point is read as seconds.
78
+
79
+ **Accepted input.** Unix timestamps (any precision), ISO 8601
80
+ (`2024-06-18T22:33:20Z`, `2024-06-18 22:33:20`, `2024-06-18`, with or without a
81
+ `Z`/`±HH:MM` offset), and RFC 2822 (`Tue, 18 Jun 2024 22:33:20 +0000`). A date
82
+ string **without** an offset is read as UTC.
83
+
84
+ **`--json`** emits the unix values as **strings** so nanosecond precision
85
+ survives a 19-digit integer:
86
+
87
+ ```json
88
+ {
89
+ "input": "1718750000123",
90
+ "detected": "unix milliseconds",
91
+ "unix": { "s": "1718750000", "ms": "1718750000123", "us": "1718750000123000", "ns": "1718750000123000000" },
92
+ "iso_utc": "2024-06-18T22:33:20.123Z",
93
+ "iso_local": "2024-06-19T06:33:20.123+08:00",
94
+ "iso_offset": null,
95
+ "relative": "2 minutes ago",
96
+ "rfc2822": "Tue, 18 Jun 2024 22:33:20 +0000"
97
+ }
98
+ ```
99
+
100
+ Exit codes: `0` ok · `2` error (unparseable input, out-of-range date).
101
+
102
+ ## How it works
103
+
104
+ Date/time handling is a minefield of cross-platform and cross-language
105
+ disagreements, so epochlens does **none** of its conversion through the
106
+ runtime's date library. `datetime.fromisoformat`, `isoformat`, `strftime` (and
107
+ Node's `Date.parse`/`toISOString`) all differ between (and within) the two
108
+ runtimes. Instead every conversion is plain integer arithmetic over a
109
+ proleptic-Gregorian
110
+ [civil-day algorithm](https://howardhinnant.github.io/date_algorithms.html), and
111
+ all formatting is hand-rolled. That's what lets the Node and Python builds stay
112
+ byte-identical, and it's why nanosecond values stay exact (native ints in
113
+ Python, BigInt in Node).
114
+
115
+ ## Scope
116
+
117
+ The MVP shows UTC, your machine's local time, and any fixed `--offset`. Named
118
+ IANA zones (`--tz America/New_York`) are intentionally left out for now: Python's
119
+ `zoneinfo` needs an OS tz database that Windows lacks, which would break the
120
+ zero-dependency promise. Natural-language *input* (`"3 days ago"`) is also out of
121
+ scope — relative time is shown as output only.
122
+
123
+ ## Install
124
+
125
+ ```bash
126
+ pip install epochlens # or pipx run epochlens
127
+ npm i -g epochlens # Node build, identical behaviour
128
+ ```
129
+
130
+ Python ≥ 3.8 or Node ≥ 18. No dependencies.
131
+
132
+ ## License
133
+
134
+ MIT
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/epochlens/__init__.py
5
+ src/epochlens/__main__.py
6
+ src/epochlens/cli.py
7
+ src/epochlens/core.py
8
+ src/epochlens.egg-info/PKG-INFO
9
+ src/epochlens.egg-info/SOURCES.txt
10
+ src/epochlens.egg-info/dependency_links.txt
11
+ src/epochlens.egg-info/entry_points.txt
12
+ src/epochlens.egg-info/top_level.txt
13
+ tests/test_core.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ epochlens = epochlens.cli:main
@@ -0,0 +1 @@
1
+ epochlens
@@ -0,0 +1,148 @@
1
+ import pytest
2
+
3
+ from epochlens import core
4
+
5
+ NOW = 1718750123000 # 2024-06-18T22:35:23Z
6
+
7
+
8
+ def ctx(**extra):
9
+ base = {"rawInput": "x", "detected": "d", "nowMs": NOW, "localOffsetSec": None, "explicitOffset": None}
10
+ base.update(extra)
11
+ return base
12
+
13
+
14
+ def test_detect_unit_bands():
15
+ assert core.detect_unit(1718750000) == "s"
16
+ assert core.detect_unit(99999999999) == "s"
17
+ assert core.detect_unit(100000000000) == "ms"
18
+ assert core.detect_unit(1718750000123) == "ms"
19
+ assert core.detect_unit(100000000000000) == "us"
20
+ assert core.detect_unit(100000000000000000) == "ns"
21
+
22
+
23
+ def test_civil_roundtrip():
24
+ for days in [-719162, -100000, -1, 0, 1, 19892, 2932896]:
25
+ y, mo, d = core.civil_from_days(days)
26
+ assert core.days_from_civil(y, mo, d) == days
27
+ assert core.days_from_civil(1970, 1, 1) == 0
28
+ assert core.civil_from_days(0) == (1970, 1, 1)
29
+
30
+
31
+ def test_parse_numeric_exact():
32
+ assert core.parse_input("1718750000")[0] == 1718750000000000000
33
+ assert core.parse_input("1718750000123")[0] == 1718750000123000000
34
+ assert core.parse_input("1718750000123456789")[0] == 1718750000123456789
35
+ assert "unix seconds" in core.parse_input("1718750000")[1]
36
+ assert "unix milliseconds" in core.parse_input("1718750000123")[1]
37
+
38
+
39
+ def test_parse_fraction_separators_sign_forced():
40
+ assert core.parse_input("1718750000.123")[0] == 1718750000123000000
41
+ assert core.parse_input("1_718_750_000")[0] == 1718750000000000000
42
+ assert core.parse_input("1,718,750,000")[0] == 1718750000000000000
43
+ assert core.parse_input("-100000000")[0] == -100000000000000000
44
+ assert core.parse_input("1718750000", {"unit": "ms"})[0] == 1718750000000000
45
+ assert "forced" in core.parse_input("1718750000", {"unit": "ms"})[1]
46
+
47
+
48
+ def test_parse_iso_variants():
49
+ target = 1718750000000000000
50
+ assert core.parse_input("2024-06-18T22:33:20Z")[0] == target
51
+ assert core.parse_input("2024-06-18T22:33:20")[0] == target
52
+ assert core.parse_input("2024-06-18 22:33:20")[0] == target
53
+ assert core.parse_input("2024-06-19T04:03:20+05:30")[0] == target
54
+ assert core.parse_input("2024-06-18")[0] == 1718668800000000000
55
+ assert core.parse_input("2024-06-18T22:33:20.123Z")[0] == 1718750000123000000
56
+
57
+
58
+ def test_parse_rfc2822():
59
+ assert core.parse_input("Tue, 18 Jun 2024 22:33:20 +0000")[0] == 1718750000000000000
60
+ assert core.parse_input("18 Jun 2024 22:33:20 GMT")[0] == 1718750000000000000
61
+
62
+
63
+ def test_parse_now():
64
+ assert core.parse_input("now", {"nowMs": NOW})[0] == NOW * 1_000_000
65
+
66
+
67
+ def test_parse_rejects():
68
+ with pytest.raises(ValueError, match="unrecognized"):
69
+ core.parse_input("hello")
70
+ with pytest.raises(ValueError, match="leap second"):
71
+ core.parse_input("2016-12-31T23:59:60Z")
72
+ with pytest.raises(ValueError, match="invalid day"):
73
+ core.parse_input("2023-02-29T00:00:00Z")
74
+ with pytest.raises(ValueError, match="invalid month"):
75
+ core.parse_input("2024-13-01")
76
+ with pytest.raises(ValueError, match="no input"):
77
+ core.parse_input("")
78
+
79
+
80
+ def test_compute_structure_and_fraction():
81
+ r = core.compute(1718750000000000000, ctx())
82
+ assert r["iso_utc"] == "2024-06-18T22:33:20Z"
83
+ assert r["unix"]["s"] == "1718750000"
84
+ assert r["unix"]["ns"] == "1718750000000000000"
85
+ assert r["iso_local"] is None
86
+ r2 = core.compute(1718750000123000000, ctx())
87
+ assert r2["iso_utc"] == "2024-06-18T22:33:20.123Z"
88
+
89
+
90
+ def test_compute_offset_rows():
91
+ r = core.compute(1718750000000000000, ctx(localOffsetSec=8 * 3600, explicitOffset={"sec": 5 * 3600 + 30 * 60}))
92
+ assert r["iso_local"] == "2024-06-19T06:33:20+08:00"
93
+ assert r["iso_offset"] == "2024-06-19T04:03:20+05:30"
94
+
95
+
96
+ def test_compute_epoch0_and_negative():
97
+ assert core.compute(0, ctx())["iso_utc"] == "1970-01-01T00:00:00Z"
98
+ assert core.compute(0, ctx())["rfc2822"] == "Thu, 01 Jan 1970 00:00:00 +0000"
99
+ neg = core.compute(-100000000000000000, ctx())
100
+ assert neg["iso_utc"] == "1966-10-31T14:13:20Z"
101
+ assert neg["rfc2822"] == "Mon, 31 Oct 1966 14:13:20 +0000"
102
+
103
+
104
+ def test_compute_year_range():
105
+ with pytest.raises(ValueError, match="out of displayable range"):
106
+ core.compute(999999999999 * 1_000_000_000, ctx())
107
+
108
+
109
+ def test_relative_ladder():
110
+ r = lambda sec: core.relative(sec * 1000)
111
+ assert r(0) == "just now"
112
+ assert r(1) == "1 second ago"
113
+ assert r(2) == "2 seconds ago"
114
+ assert r(60) == "1 minute ago"
115
+ assert r(3600) == "1 hour ago"
116
+ assert r(86400) == "1 day ago"
117
+ assert r(2592000) == "1 month ago"
118
+ assert r(31536000) == "1 year ago"
119
+ assert r(-1) == "in 1 second"
120
+ assert r(-7200) == "in 2 hours"
121
+
122
+
123
+ def test_iso_offset_formatting():
124
+ assert core._iso_from_components({"y": 1, "mo": 1, "d": 1, "h": 0, "mi": 0, "s": 0, "ms": 0}) == "0001-01-01T00:00:00"
125
+ assert core._offset_label(0) == "+00:00"
126
+ assert core._offset_label(-4 * 3600) == "-04:00"
127
+ assert core._offset_label(5 * 3600 + 30 * 60) == "+05:30"
128
+
129
+
130
+ def test_ascii_only_parsing_rejects_unicode_and_exotic_ws():
131
+ # Node parity: Python re \d / int() must NOT accept Unicode digits, and strip
132
+ # must NOT remove NEL/C1/BOM (which JS trim() leaves / removes differently).
133
+ for bad in ["٤٢", "123", "2024-06-18T22:33:20Z", "1718750000\x85", "1718750000"]:
134
+ with pytest.raises(ValueError, match="unrecognized"):
135
+ core.parse_input(bad)
136
+
137
+
138
+ def test_trim_ascii_set():
139
+ assert core.trim_ascii(" 1718750000\t\n") == "1718750000"
140
+ assert core.trim_ascii("1718750000\x85") == "1718750000\x85" # NEL kept
141
+ assert core.trim_ascii("1718750000") == "1718750000" # BOM kept
142
+
143
+
144
+ def test_offset_range_validated():
145
+ with pytest.raises(ValueError, match="out of range"):
146
+ core.parse_input("2024-06-18T00:00:00+25:00")
147
+ with pytest.raises(ValueError, match="out of range"):
148
+ core.parse_input("2024-06-18T00:00:00+05:99")