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.
- epochlens-0.1.0/LICENSE +21 -0
- epochlens-0.1.0/PKG-INFO +134 -0
- epochlens-0.1.0/README.md +112 -0
- epochlens-0.1.0/pyproject.toml +37 -0
- epochlens-0.1.0/setup.cfg +4 -0
- epochlens-0.1.0/src/epochlens/__init__.py +3 -0
- epochlens-0.1.0/src/epochlens/__main__.py +6 -0
- epochlens-0.1.0/src/epochlens/cli.py +180 -0
- epochlens-0.1.0/src/epochlens/core.py +297 -0
- epochlens-0.1.0/src/epochlens.egg-info/PKG-INFO +134 -0
- epochlens-0.1.0/src/epochlens.egg-info/SOURCES.txt +13 -0
- epochlens-0.1.0/src/epochlens.egg-info/dependency_links.txt +1 -0
- epochlens-0.1.0/src/epochlens.egg-info/entry_points.txt +2 -0
- epochlens-0.1.0/src/epochlens.egg-info/top_level.txt +1 -0
- epochlens-0.1.0/tests/test_core.py +148 -0
epochlens-0.1.0/LICENSE
ADDED
|
@@ -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.
|
epochlens-0.1.0/PKG-INFO
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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")
|