dstamp 1.0.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.
- dstamp/__init__.py +0 -0
- dstamp/__main__.py +3 -0
- dstamp/clipboard.py +26 -0
- dstamp/config.py +76 -0
- dstamp/console.py +22 -0
- dstamp/format.py +29 -0
- dstamp/main.py +211 -0
- dstamp/parse.py +225 -0
- dstamp/round.py +59 -0
- dstamp-1.0.0.dist-info/LICENSE +21 -0
- dstamp-1.0.0.dist-info/METADATA +79 -0
- dstamp-1.0.0.dist-info/RECORD +14 -0
- dstamp-1.0.0.dist-info/WHEEL +4 -0
- dstamp-1.0.0.dist-info/entry_points.txt +3 -0
dstamp/__init__.py
ADDED
|
File without changes
|
dstamp/__main__.py
ADDED
dstamp/clipboard.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""clipboard.py
|
|
2
|
+
|
|
3
|
+
This module provides functions for interacting with the clipboard.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import clipman
|
|
7
|
+
|
|
8
|
+
from . import console
|
|
9
|
+
|
|
10
|
+
COPY_SUCCESS_TEXT = "Copied to clipboard!"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def copy(text: str):
|
|
14
|
+
"""
|
|
15
|
+
Copy text to clipboard.
|
|
16
|
+
|
|
17
|
+
If the clipboard manager raises an error, inform the user that this is
|
|
18
|
+
the problem area and reraise the error.
|
|
19
|
+
"""
|
|
20
|
+
try:
|
|
21
|
+
clipman.init()
|
|
22
|
+
clipman.set(text)
|
|
23
|
+
console.info(COPY_SUCCESS_TEXT)
|
|
24
|
+
except clipman.exceptions.ClipmanBaseException: # pragma: no cover
|
|
25
|
+
console.error("There was a problem with the clipboard manager:")
|
|
26
|
+
raise
|
dstamp/config.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""config.py
|
|
2
|
+
|
|
3
|
+
This module contains the config file loader and parser.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import tomllib
|
|
7
|
+
from functools import cache
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from . import console, format
|
|
14
|
+
|
|
15
|
+
APP_NAME = "dstamp"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DstampConfig(BaseModel):
|
|
19
|
+
copy_to_clipboard: bool = False
|
|
20
|
+
output_format: format.Format = format.Format.RELATIVE
|
|
21
|
+
round: bool = False
|
|
22
|
+
rounding_precision: str = "10m"
|
|
23
|
+
|
|
24
|
+
def __str__(self):
|
|
25
|
+
def convert_to_line(option):
|
|
26
|
+
if type(option) is str:
|
|
27
|
+
option = (option, getattr(self, option))
|
|
28
|
+
name, value = option
|
|
29
|
+
return f"{name}: {value}"
|
|
30
|
+
|
|
31
|
+
options = (
|
|
32
|
+
"copy_to_clipboard",
|
|
33
|
+
("output_format", self.output_format.value),
|
|
34
|
+
"round",
|
|
35
|
+
"rounding_precision",
|
|
36
|
+
)
|
|
37
|
+
lines = (convert_to_line(option) for option in options)
|
|
38
|
+
return "\n".join(lines)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@cache
|
|
42
|
+
def get(config_path: Path) -> DstampConfig:
|
|
43
|
+
"""Load the config file and return config object."""
|
|
44
|
+
config_path = config_path or get_config_path()
|
|
45
|
+
raw_config = _get_raw_config_from_path(config_path)
|
|
46
|
+
config = DstampConfig(**raw_config)
|
|
47
|
+
return config
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _get_raw_config_from_path(config_path: Path) -> dict:
|
|
51
|
+
raw_config = {}
|
|
52
|
+
|
|
53
|
+
if not config_path.is_file():
|
|
54
|
+
_warn_using_default_because(f"{config_path} is not a file.")
|
|
55
|
+
return raw_config
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
with open(config_path, "rb") as f:
|
|
59
|
+
raw_config = tomllib.load(f)
|
|
60
|
+
except tomllib.TOMLDecodeError:
|
|
61
|
+
_warn_using_default_because(
|
|
62
|
+
f"Config at {config_path} is not a valid TOML file."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return raw_config
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _warn_using_default_because(reason: str) -> None:
|
|
69
|
+
console.warn(reason)
|
|
70
|
+
console.warn("Using default config settings.\n")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_config_path() -> Path: # pragma: no cover
|
|
74
|
+
# This function is patched in all our tests.
|
|
75
|
+
app_dir = typer.get_app_dir(APP_NAME)
|
|
76
|
+
return Path(app_dir) / "config.toml"
|
dstamp/console.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""console.py
|
|
2
|
+
|
|
3
|
+
This module contains functions for printing to the console.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
_console = Console()
|
|
9
|
+
|
|
10
|
+
print = print
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def info(text: str) -> None:
|
|
14
|
+
_console.print(text, style="white")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def warn(text: str) -> None:
|
|
18
|
+
_console.print(text, style="yellow")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def error(text: str) -> None:
|
|
22
|
+
_console.print(text, style="bold red")
|
dstamp/format.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""format.py
|
|
2
|
+
|
|
3
|
+
This module provides tools for formatting datetimes for dstamp output.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from enum import Enum
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Format(str, Enum):
|
|
11
|
+
SHORT_TIME = "shorttime", "t"
|
|
12
|
+
LONG_TIME = "longtime", "T"
|
|
13
|
+
SHORT_DATE = "shortdate", "d"
|
|
14
|
+
LONG_DATE = "longdate", "D"
|
|
15
|
+
SHORT_DATETIME = "shortdatetime", "f"
|
|
16
|
+
LONG_DATETIME = "longdatetime", "F"
|
|
17
|
+
RELATIVE = "relative", "R"
|
|
18
|
+
|
|
19
|
+
def __new__(cls, value, code):
|
|
20
|
+
obj = str.__new__(cls, [value])
|
|
21
|
+
obj._value_ = value
|
|
22
|
+
obj.code = code
|
|
23
|
+
return obj
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def convert_to_discord_format(time: datetime, format: Format) -> str:
|
|
27
|
+
"""Convert a datetime object into a Discord-friendly timestamp."""
|
|
28
|
+
timestamp = int(time.timestamp())
|
|
29
|
+
return f"<t:{timestamp}:{format.code}>"
|
dstamp/main.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""main.py
|
|
2
|
+
|
|
3
|
+
This module contains the commands provided by dstamp.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from typing_extensions import Annotated
|
|
12
|
+
|
|
13
|
+
from . import clipboard, config, console, format, parse, round
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(no_args_is_help=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.callback()
|
|
19
|
+
def callback():
|
|
20
|
+
"""Discord Timestamp Generator."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
FROM_CONFIG = "from config"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command("get")
|
|
27
|
+
def get_timestamp(
|
|
28
|
+
time: Annotated[
|
|
29
|
+
datetime,
|
|
30
|
+
typer.Argument(
|
|
31
|
+
parser=parse.datetime_string,
|
|
32
|
+
show_default=False,
|
|
33
|
+
help="The date and time to which the timestamp should point. "
|
|
34
|
+
"If omitted, the current time is used.",
|
|
35
|
+
),
|
|
36
|
+
] = "",
|
|
37
|
+
offset: Annotated[
|
|
38
|
+
timedelta,
|
|
39
|
+
typer.Option(
|
|
40
|
+
"--offset",
|
|
41
|
+
"-o",
|
|
42
|
+
parser=parse.offset,
|
|
43
|
+
show_default=False,
|
|
44
|
+
help="Optional offset to apply to TIME. Examples: 2d3h1m, +3s, -3d+1d, "
|
|
45
|
+
"3m-3h2h. The only acceptable units are d(ays), h(ours), m(inutes), "
|
|
46
|
+
"and s(econds). Subtraction applies to all times after the - until "
|
|
47
|
+
"the next +. Units can be repeated.",
|
|
48
|
+
),
|
|
49
|
+
] = "",
|
|
50
|
+
output_format: Annotated[
|
|
51
|
+
Optional[format.Format],
|
|
52
|
+
typer.Option(
|
|
53
|
+
"--output-format",
|
|
54
|
+
"-f",
|
|
55
|
+
show_default=FROM_CONFIG,
|
|
56
|
+
help="The format in which the timestamp will be displayed in Discord.",
|
|
57
|
+
),
|
|
58
|
+
] = None,
|
|
59
|
+
copy_to_clipboard: Annotated[
|
|
60
|
+
Optional[bool],
|
|
61
|
+
typer.Option(
|
|
62
|
+
"--copy-to-clipboard/--no-copy",
|
|
63
|
+
"-x",
|
|
64
|
+
show_default=FROM_CONFIG,
|
|
65
|
+
help="If set, copy the timestamp to clipboard. "
|
|
66
|
+
"On Linux, requires that xsel or xclip be installed.",
|
|
67
|
+
),
|
|
68
|
+
] = None,
|
|
69
|
+
config_path: Annotated[
|
|
70
|
+
Optional[Path],
|
|
71
|
+
typer.Option(
|
|
72
|
+
"--config",
|
|
73
|
+
"-c",
|
|
74
|
+
show_default=False,
|
|
75
|
+
exists=True,
|
|
76
|
+
dir_okay=False,
|
|
77
|
+
help="If specified, read config from this file instead of the default "
|
|
78
|
+
"location.",
|
|
79
|
+
),
|
|
80
|
+
] = None,
|
|
81
|
+
do_rounding: Annotated[
|
|
82
|
+
Optional[bool],
|
|
83
|
+
typer.Option(
|
|
84
|
+
"--round/--no-round",
|
|
85
|
+
"-r",
|
|
86
|
+
show_default=FROM_CONFIG,
|
|
87
|
+
help="If specified, round TIME based on --precision.",
|
|
88
|
+
),
|
|
89
|
+
] = None,
|
|
90
|
+
precision: Annotated[
|
|
91
|
+
Optional[str],
|
|
92
|
+
typer.Option(
|
|
93
|
+
"--precision",
|
|
94
|
+
"-p",
|
|
95
|
+
show_default=FROM_CONFIG,
|
|
96
|
+
help="The precision to which TIME will be rounded if --round is specified.",
|
|
97
|
+
),
|
|
98
|
+
] = None,
|
|
99
|
+
):
|
|
100
|
+
"""
|
|
101
|
+
Generate a Discord timestamp.
|
|
102
|
+
|
|
103
|
+
If TIME is omitted, uses the current time.
|
|
104
|
+
|
|
105
|
+
TIME should be of the form <date>,<time>.
|
|
106
|
+
Either <date> or <time> can be omitted, in which case you should omit the
|
|
107
|
+
comma as well.
|
|
108
|
+
|
|
109
|
+
<date> should be of the following form: ddmmmyyyy.
|
|
110
|
+
|
|
111
|
+
dd and yyyy should be numbers.
|
|
112
|
+
|
|
113
|
+
mmm should be at least the first three letters of the month. For instance,
|
|
114
|
+
for August you can specify aug, augu, augus, or august.
|
|
115
|
+
|
|
116
|
+
You can omit the year to specify the current year. You can omit <date>
|
|
117
|
+
entirely to specify the current date.
|
|
118
|
+
|
|
119
|
+
The command will also accept the following special values:
|
|
120
|
+
today,
|
|
121
|
+
tmrw (tomorrow),
|
|
122
|
+
tomorrow,
|
|
123
|
+
yesterday.
|
|
124
|
+
|
|
125
|
+
<time> should be of the following form: hhmmss(am/pm).
|
|
126
|
+
|
|
127
|
+
You can omit seconds to specify 0. If you do, you can omit minutes to specify
|
|
128
|
+
0 as well. If you omit (am/pm), 24-hour time will be used. You can omit <time>
|
|
129
|
+
entirely to specify midnight.
|
|
130
|
+
|
|
131
|
+
If you omit both <date> and <time>, the current date and time will be used.
|
|
132
|
+
|
|
133
|
+
The command will also accept the following special values:
|
|
134
|
+
now (the current time, but not necessarily the current date),
|
|
135
|
+
midnight,
|
|
136
|
+
noon.
|
|
137
|
+
|
|
138
|
+
Examples:
|
|
139
|
+
5jun,1pm (5th June of the current year, 1:00pm),
|
|
140
|
+
8january2025,1530 (8th January 2025, 3:30pm),
|
|
141
|
+
25nov2000,13002pm (25th November 2000, 1:30:02pm),
|
|
142
|
+
1jan (1st January of the current year, midnight),
|
|
143
|
+
7feb2023,15 (7th February 2023, 3:00pm),
|
|
144
|
+
5 (the current date, 5:00am),
|
|
145
|
+
0 (the current date, midnight),
|
|
146
|
+
tmrw,now (tomorrow, the current time, ie. 24 hours from the current time),
|
|
147
|
+
tmrw (tomorrow, midnight).
|
|
148
|
+
"""
|
|
149
|
+
cfg = config.get(config_path)
|
|
150
|
+
output_format = fill_value(output_format, cfg.output_format)
|
|
151
|
+
do_rounding = fill_value(do_rounding, cfg.round)
|
|
152
|
+
precision = fill_value(precision, cfg.rounding_precision)
|
|
153
|
+
|
|
154
|
+
target_time = time + offset
|
|
155
|
+
if do_rounding:
|
|
156
|
+
target_time = try_round(target_time, precision)
|
|
157
|
+
|
|
158
|
+
output = format.convert_to_discord_format(target_time, output_format)
|
|
159
|
+
|
|
160
|
+
console.info(f"Using time: {round.round_time_to_precision(target_time, "1s")}.")
|
|
161
|
+
console.print(output)
|
|
162
|
+
|
|
163
|
+
copy_to_clipboard = fill_value(copy_to_clipboard, cfg.copy_to_clipboard)
|
|
164
|
+
if copy_to_clipboard:
|
|
165
|
+
clipboard.copy(output)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def fill_value(user_provided_value, filler_value):
|
|
169
|
+
"""
|
|
170
|
+
Return filler_value is user_provided value is None.
|
|
171
|
+
|
|
172
|
+
Otherwise, return user_provided_value.
|
|
173
|
+
"""
|
|
174
|
+
if user_provided_value is not None:
|
|
175
|
+
return user_provided_value
|
|
176
|
+
return filler_value
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def try_round(time, precision):
|
|
180
|
+
try:
|
|
181
|
+
return round.round_time_to_precision(time, precision)
|
|
182
|
+
except round.RoundingError as e:
|
|
183
|
+
console.error(f"There was an error in rounding:\n{e.message}")
|
|
184
|
+
raise typer.Exit(code=1)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@app.command()
|
|
188
|
+
def show_config(
|
|
189
|
+
path: Annotated[
|
|
190
|
+
Optional[Path],
|
|
191
|
+
typer.Argument(
|
|
192
|
+
show_default=False,
|
|
193
|
+
exists=True,
|
|
194
|
+
dir_okay=False,
|
|
195
|
+
help="If specified, use this config file instead of the default.",
|
|
196
|
+
),
|
|
197
|
+
] = None,
|
|
198
|
+
):
|
|
199
|
+
"""
|
|
200
|
+
Show the currently-active configuration settings and file location.
|
|
201
|
+
|
|
202
|
+
The config file should be in TOML format.
|
|
203
|
+
"""
|
|
204
|
+
if path is None:
|
|
205
|
+
path = config.get_config_path()
|
|
206
|
+
console.info(f"Using config at {path}\n")
|
|
207
|
+
console.print(config.get(path))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
if __name__ == "__main__": # pragma: no cover
|
|
211
|
+
app()
|
dstamp/parse.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""parse.py
|
|
2
|
+
|
|
3
|
+
This module contains parsers for datetimes and offsets.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from datetime import date, datetime, time, timedelta
|
|
8
|
+
|
|
9
|
+
from . import round
|
|
10
|
+
|
|
11
|
+
units = {
|
|
12
|
+
"d": "days",
|
|
13
|
+
"h": "hours",
|
|
14
|
+
"m": "minutes",
|
|
15
|
+
"s": "seconds",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def offset(raw_offset: str | None) -> timedelta:
|
|
20
|
+
"""
|
|
21
|
+
Parse raw_offset into a timedelta.
|
|
22
|
+
|
|
23
|
+
Accepts sequences of suboffsets consisting of an optional +/- sign,
|
|
24
|
+
a count, and a unit. Units are dhms.
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
3d4m
|
|
28
|
+
3d-2m
|
|
29
|
+
+43h
|
|
30
|
+
-2m4s+4s-19m
|
|
31
|
+
"""
|
|
32
|
+
if raw_offset is None:
|
|
33
|
+
return timedelta()
|
|
34
|
+
|
|
35
|
+
offset = timedelta()
|
|
36
|
+
subtracting = False
|
|
37
|
+
matches = re.findall(r"([+-]?)(\d+)([dhms])", raw_offset)
|
|
38
|
+
for sign, count_str, unit_key in matches:
|
|
39
|
+
subtracting = update_operation(sign, subtracting)
|
|
40
|
+
offset += get_suboffset(unit_key, count_str, subtracting)
|
|
41
|
+
|
|
42
|
+
return offset
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def update_operation(sign: str, subtracting: bool) -> bool:
|
|
46
|
+
if sign == "+":
|
|
47
|
+
return False
|
|
48
|
+
elif sign == "-":
|
|
49
|
+
return True
|
|
50
|
+
# If no sign is provided, persist the current operation.
|
|
51
|
+
return subtracting
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_suboffset(unit_key: str, count_str: str, subtracting: bool) -> timedelta:
|
|
55
|
+
unit = units[unit_key]
|
|
56
|
+
count = int(count_str)
|
|
57
|
+
if subtracting:
|
|
58
|
+
count *= -1
|
|
59
|
+
kwargs = {unit: count}
|
|
60
|
+
return timedelta(**kwargs)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ParserInputError(ValueError):
|
|
64
|
+
"""Raised when there is a problem with the input received by a parser."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, message=None, *args):
|
|
67
|
+
self.message = message
|
|
68
|
+
super().__init__(*args)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class InvalidFormatError(ParserInputError):
|
|
72
|
+
"""Raised when a parser is provided an improperly-formatted value."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class InvalidValueError(ParserInputError):
|
|
76
|
+
"""Raised when a parser is provided a correctly-formatted but invalid value."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def datetime_string(raw_datetime: str) -> datetime:
|
|
80
|
+
"""
|
|
81
|
+
Parse raw_datetime into a datetime.
|
|
82
|
+
|
|
83
|
+
raw_datetime should be of the form "ddmmmyyyy,hhmmss", albeit with some
|
|
84
|
+
flexibility and special values allowed.
|
|
85
|
+
|
|
86
|
+
If the date is omitted, use the current date.
|
|
87
|
+
If the only time is omitted, use midnight.
|
|
88
|
+
If both are omitted, use the current date and time.
|
|
89
|
+
|
|
90
|
+
Valid examples:
|
|
91
|
+
07jun2025,1230
|
|
92
|
+
9august,330pm
|
|
93
|
+
today,now
|
|
94
|
+
tomorrow
|
|
95
|
+
12pm
|
|
96
|
+
73002pm
|
|
97
|
+
noon
|
|
98
|
+
midnight
|
|
99
|
+
"""
|
|
100
|
+
if raw_datetime == "":
|
|
101
|
+
return datetime.now()
|
|
102
|
+
# Ensure there is both a date and time supplied.
|
|
103
|
+
if (x := raw_datetime.count(",")) == 0:
|
|
104
|
+
try:
|
|
105
|
+
return datetime_string("today," + raw_datetime)
|
|
106
|
+
except InvalidFormatError:
|
|
107
|
+
return datetime_string(raw_datetime + ",midnight")
|
|
108
|
+
elif x >= 2:
|
|
109
|
+
raise InvalidFormatError
|
|
110
|
+
|
|
111
|
+
datestr, timestr = raw_datetime.split(",")
|
|
112
|
+
parsed_date = parse_date(datestr)
|
|
113
|
+
parsed_time = parse_time(timestr)
|
|
114
|
+
timezone = datetime.now().tzinfo
|
|
115
|
+
return datetime.combine(parsed_date, parsed_time, timezone)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def parse_date(datestr: str) -> date:
|
|
119
|
+
if datestr == "today":
|
|
120
|
+
return date.today()
|
|
121
|
+
|
|
122
|
+
if datestr in ("tmrw", "tomorrow"):
|
|
123
|
+
return date.today() + timedelta(days=1)
|
|
124
|
+
|
|
125
|
+
if datestr == "yesterday":
|
|
126
|
+
return date.today() - timedelta(days=1)
|
|
127
|
+
|
|
128
|
+
m = re.fullmatch(r"(\d{1,2})([a-zA-Z]{3,})(\d*)", datestr)
|
|
129
|
+
if m is None:
|
|
130
|
+
raise InvalidFormatError
|
|
131
|
+
|
|
132
|
+
day = int(m[1])
|
|
133
|
+
month = get_month_from_shortening(m[2].lower())
|
|
134
|
+
year = int(m[3]) if m[3] else date.today().year
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
return date(year, month, day)
|
|
138
|
+
except ValueError as e:
|
|
139
|
+
raise InvalidValueError from e
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def parse_time(timestr: str) -> time:
|
|
143
|
+
if timestr == "now":
|
|
144
|
+
return datetime.now().time()
|
|
145
|
+
|
|
146
|
+
if timestr == "midnight":
|
|
147
|
+
return time()
|
|
148
|
+
|
|
149
|
+
if timestr == "noon":
|
|
150
|
+
return time(12)
|
|
151
|
+
|
|
152
|
+
m = re.fullmatch(r"(\d{1,2}?)(\d{2})?(\d{2})?([ap]m)?", timestr)
|
|
153
|
+
if m is None:
|
|
154
|
+
raise InvalidFormatError
|
|
155
|
+
|
|
156
|
+
hour = int(m[1])
|
|
157
|
+
minute = int(m[2]) if m[2] else 0
|
|
158
|
+
second = int(m[3]) if m[3] else 0
|
|
159
|
+
noonstr = m[4]
|
|
160
|
+
|
|
161
|
+
if noonstr is not None and hour > 12:
|
|
162
|
+
# Disallow noonstrings with 24h format.
|
|
163
|
+
raise InvalidFormatError
|
|
164
|
+
|
|
165
|
+
if noonstr == "pm":
|
|
166
|
+
hour += 12
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
return time(hour, minute, second)
|
|
170
|
+
except ValueError as e:
|
|
171
|
+
raise InvalidValueError from e
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
months = [
|
|
175
|
+
"january",
|
|
176
|
+
"february",
|
|
177
|
+
"march",
|
|
178
|
+
"april",
|
|
179
|
+
"may",
|
|
180
|
+
"june",
|
|
181
|
+
"july",
|
|
182
|
+
"august",
|
|
183
|
+
"september",
|
|
184
|
+
"october",
|
|
185
|
+
"november",
|
|
186
|
+
"december",
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def get_month_from_shortening(shortening: str) -> int:
|
|
191
|
+
"""Returns the index of the first month which starts with the given string."""
|
|
192
|
+
for ix, name in enumerate(months):
|
|
193
|
+
if name.startswith(shortening):
|
|
194
|
+
return ix + 1
|
|
195
|
+
raise InvalidFormatError
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def rounding_precision(raw_precision: str) -> tuple[int, str]:
|
|
199
|
+
"""
|
|
200
|
+
Accepts precisions in the format of <value><unit>
|
|
201
|
+
"""
|
|
202
|
+
lowercase_input = raw_precision.lower()
|
|
203
|
+
|
|
204
|
+
for unit in round.RoundingUnit:
|
|
205
|
+
m = re.fullmatch(rf"(\d*){unit.code}", lowercase_input)
|
|
206
|
+
if m is None:
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
quantity = int(m[1]) if m[1] else 1
|
|
210
|
+
|
|
211
|
+
quantity_is_in_range = 0 < quantity < unit.max_quantity
|
|
212
|
+
quantity_is_a_factor_of_max = (
|
|
213
|
+
quantity != 0 and unit.max_quantity % quantity == 0
|
|
214
|
+
)
|
|
215
|
+
if not (quantity_is_in_range and quantity_is_a_factor_of_max):
|
|
216
|
+
raise InvalidValueError(
|
|
217
|
+
"Invalid precision quantity: "
|
|
218
|
+
f"[underline cyan]{quantity}[/]{unit.code}. "
|
|
219
|
+
f"Quantity must be between 0 and {unit.max_quantity} (exclusive), "
|
|
220
|
+
f"and must be a factor of {unit.max_quantity}."
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return quantity, unit
|
|
224
|
+
|
|
225
|
+
raise InvalidFormatError(f"Invalid precision: {raw_precision}.")
|
dstamp/round.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""round.py
|
|
2
|
+
|
|
3
|
+
This module contains logic for rounding timestamps to nicer ones.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from enum import Enum
|
|
8
|
+
|
|
9
|
+
from . import parse
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RoundingError(ValueError):
|
|
13
|
+
"""Raised when a rounding attempt fails."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, message=None, *args):
|
|
16
|
+
self.message = message
|
|
17
|
+
super().__init__(*args)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RoundingUnit(Enum):
|
|
21
|
+
HOUR = "hour", "h", 24
|
|
22
|
+
MINUTE = "minute", "m", 60
|
|
23
|
+
SECOND = "second", "s", 60
|
|
24
|
+
|
|
25
|
+
def __init__(self, attribute_name, code, max_quantity):
|
|
26
|
+
self.attribute_name = attribute_name
|
|
27
|
+
self.code = code
|
|
28
|
+
self.max_quantity = max_quantity
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def round_time_to_precision(time: datetime, precision: str):
|
|
32
|
+
try:
|
|
33
|
+
quantity, unit = parse.rounding_precision(precision)
|
|
34
|
+
except parse.ParserInputError as e:
|
|
35
|
+
raise RoundingError(e.message) from e
|
|
36
|
+
|
|
37
|
+
truncated_time = time.replace(microsecond=0)
|
|
38
|
+
if unit is not RoundingUnit.SECOND:
|
|
39
|
+
truncated_time = truncated_time.replace(second=0)
|
|
40
|
+
if unit is not RoundingUnit.MINUTE:
|
|
41
|
+
truncated_time = truncated_time.replace(minute=0)
|
|
42
|
+
|
|
43
|
+
# This is currently very crude as it only checks the top-most unit.
|
|
44
|
+
# This means it may not handle values close to half-way as expected.
|
|
45
|
+
initial_value = getattr(time, unit.attribute_name)
|
|
46
|
+
rounded_value = round_int_to_precision(initial_value, quantity)
|
|
47
|
+
|
|
48
|
+
# Use timedelta to handle the case where the rounded value is equal to the
|
|
49
|
+
# maximum quantity, eg. if we need to round up to the next day.
|
|
50
|
+
return truncated_time.replace(**{unit.attribute_name: 0}) + timedelta(
|
|
51
|
+
**{f"{unit.attribute_name}s": rounded_value}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def round_int_to_precision(value: int, precision: int) -> int:
|
|
56
|
+
scaled_value = float(value) / precision
|
|
57
|
+
rounded_scaled_value = int(round(scaled_value))
|
|
58
|
+
rounded_value = rounded_scaled_value * precision
|
|
59
|
+
return rounded_value
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Mariusz Tang
|
|
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,79 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: dstamp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: CLI app for generating timestamps for use in Discord chats.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: discord,timestamp,generator
|
|
7
|
+
Author: Mariusz Tang
|
|
8
|
+
Author-email: dev@mariusztang.com
|
|
9
|
+
Requires-Python: >=3.13
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Topic :: Communications :: Chat
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Requires-Dist: clipman (>=3.3.1,<4.0.0)
|
|
16
|
+
Requires-Dist: pydantic (>=2.11.7,<3.0.0)
|
|
17
|
+
Requires-Dist: typer (>=0.16.0,<0.17.0)
|
|
18
|
+
Project-URL: Issues, https://github.com/mariusz-tang/dstamp/issues
|
|
19
|
+
Project-URL: Repository, https://github.com/mariusz-tang/dstamp
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# dstamp
|
|
23
|
+
|
|
24
|
+
CLI app for generating timestamps for use in Discord chats.
|
|
25
|
+
|
|
26
|
+
## Example usage
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# For the current time
|
|
30
|
+
dstamp get
|
|
31
|
+
|
|
32
|
+
# Two hours in the future
|
|
33
|
+
dstamp get --offset 2h
|
|
34
|
+
|
|
35
|
+
# Round to the nearest hour
|
|
36
|
+
dstamp get -o 2h --round --precision h
|
|
37
|
+
|
|
38
|
+
# Copy to clipboard
|
|
39
|
+
dstamp --copy-to-clipboard
|
|
40
|
+
|
|
41
|
+
# Show active configuration
|
|
42
|
+
dstamp show-config
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For full documentation see the help messages:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
dstamp --help
|
|
49
|
+
dstamp get --help
|
|
50
|
+
dstamp show-config --help
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Configuration
|
|
54
|
+
|
|
55
|
+
Run `show-config` with no arguments to find the default config location:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
dstamp show-config
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The config file should be in `.toml` format. The defaults are equivalent to the
|
|
62
|
+
following:
|
|
63
|
+
|
|
64
|
+
```toml
|
|
65
|
+
copy_to_clipboard = false
|
|
66
|
+
output_format = "relative"
|
|
67
|
+
round = false
|
|
68
|
+
rounding_precision = "10m"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
These settings will be in effect unless overriden by CLI options.
|
|
72
|
+
|
|
73
|
+
## Contributing
|
|
74
|
+
|
|
75
|
+
If you'd like to see something added to dstamp or if you would like to add something
|
|
76
|
+
yourself, please see [CONTRIBUTING](./CONTRIBUTING.md)!
|
|
77
|
+
|
|
78
|
+
Please also see the above if you have a bug to report.
|
|
79
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
dstamp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
dstamp/__main__.py,sha256=6Hs2PV7EYc5Tid4g4OtcLXhqVHiNYTGzSBdoOnW2HXA,29
|
|
3
|
+
dstamp/clipboard.py,sha256=4zkRfRvYb6L4FJL6l70im_oCXR5ffP13HcAfQLL2LUk,615
|
|
4
|
+
dstamp/config.py,sha256=ECEIeVPRlq4V3IXiOv9Oa_mPgK6xXm_yZbYPZUxgyHY,1993
|
|
5
|
+
dstamp/console.py,sha256=lDwOqmaayRyou9OJvPggBsz-X1bH3GOZP652_v4Elt0,368
|
|
6
|
+
dstamp/format.py,sha256=F-D46RAb6E4CVn-lzfvWGvle83DE6qp7_e72SCLWCc8,786
|
|
7
|
+
dstamp/main.py,sha256=Yjb6Vf-9ofVCgSslDrq7Bs5ag5VzP5SJWYpV2Y83LMM,6180
|
|
8
|
+
dstamp/parse.py,sha256=EQPMDdTL7UUqjcPus_6hIsmRUeh_6_9QMqnkInICZzU,5769
|
|
9
|
+
dstamp/round.py,sha256=5dyK4T7LaoSd9yuNyeYqWSB57SAyzruRDXi-k9YpnuE,1912
|
|
10
|
+
dstamp-1.0.0.dist-info/LICENSE,sha256=K9oI2B5w9TbYBLlh6p35IPz59zRh4gWnY9I5rKFoQb4,1069
|
|
11
|
+
dstamp-1.0.0.dist-info/METADATA,sha256=aMKr22eyHszzEB2F1SAMJZePb25BCDV95iUDh0ukdmw,1840
|
|
12
|
+
dstamp-1.0.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
13
|
+
dstamp-1.0.0.dist-info/entry_points.txt,sha256=XLxBMWrm64JnP_nEcVwl1fXfC0j7tki81W7hhcqrzHI,42
|
|
14
|
+
dstamp-1.0.0.dist-info/RECORD,,
|