dstamp 1.0.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.
dstamp-1.0.0/LICENSE ADDED
@@ -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.
dstamp-1.0.0/PKG-INFO ADDED
@@ -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
+
dstamp-1.0.0/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # dstamp
2
+
3
+ CLI app for generating timestamps for use in Discord chats.
4
+
5
+ ## Example usage
6
+
7
+ ```bash
8
+ # For the current time
9
+ dstamp get
10
+
11
+ # Two hours in the future
12
+ dstamp get --offset 2h
13
+
14
+ # Round to the nearest hour
15
+ dstamp get -o 2h --round --precision h
16
+
17
+ # Copy to clipboard
18
+ dstamp --copy-to-clipboard
19
+
20
+ # Show active configuration
21
+ dstamp show-config
22
+ ```
23
+
24
+ For full documentation see the help messages:
25
+
26
+ ```bash
27
+ dstamp --help
28
+ dstamp get --help
29
+ dstamp show-config --help
30
+ ```
31
+
32
+ ## Configuration
33
+
34
+ Run `show-config` with no arguments to find the default config location:
35
+
36
+ ```bash
37
+ dstamp show-config
38
+ ```
39
+
40
+ The config file should be in `.toml` format. The defaults are equivalent to the
41
+ following:
42
+
43
+ ```toml
44
+ copy_to_clipboard = false
45
+ output_format = "relative"
46
+ round = false
47
+ rounding_precision = "10m"
48
+ ```
49
+
50
+ These settings will be in effect unless overriden by CLI options.
51
+
52
+ ## Contributing
53
+
54
+ If you'd like to see something added to dstamp or if you would like to add something
55
+ yourself, please see [CONTRIBUTING](./CONTRIBUTING.md)!
56
+
57
+ Please also see the above if you have a bug to report.
File without changes
@@ -0,0 +1,3 @@
1
+ from .main import app
2
+
3
+ app()
@@ -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
@@ -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"
@@ -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")
@@ -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}>"
@@ -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()
@@ -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}.")
@@ -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,56 @@
1
+ [project]
2
+ name = "dstamp"
3
+ version = "1.0.0"
4
+ description = "CLI app for generating timestamps for use in Discord chats."
5
+ authors = [{ name = "Mariusz Tang", email = "dev@mariusztang.com" }]
6
+ license = { text = "MIT" }
7
+ readme = "README.md"
8
+ requires-python = ">=3.13"
9
+ dependencies = [
10
+ "typer (>=0.16.0,<0.17.0)",
11
+ "clipman (>=3.3.1,<4.0.0)",
12
+ "pydantic (>=2.11.7,<3.0.0)",
13
+ ]
14
+ keywords = ["discord", "timestamp", "generator"]
15
+ classifiers = [
16
+ "License :: OSI Approved :: MIT License",
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: End Users/Desktop",
19
+ "Topic :: Communications :: Chat",
20
+ "Environment :: Console",
21
+ ]
22
+
23
+ [project.scripts]
24
+ dstamp = "dstamp.main:app"
25
+
26
+ [project.urls]
27
+ Repository = "https://github.com/mariusz-tang/dstamp"
28
+ Issues = "https://github.com/mariusz-tang/dstamp/issues"
29
+
30
+ [tool.poetry]
31
+
32
+ [tool.poetry.group.dev.dependencies]
33
+ pytest = "^8.4.0"
34
+ coverage = "^7.8.2"
35
+ freezegun = "^1.5.2"
36
+
37
+ [tool.pytest.ini_options]
38
+ addopts = ["--import-mode=importlib"]
39
+
40
+ [tool.coverage.run]
41
+ branch = true
42
+ command_line = "-m pytest"
43
+ source = ["dstamp"]
44
+ omit = ["dstamp/__main__.py", "dstamp/console.py"]
45
+
46
+ [tool.coverage.report]
47
+ skip_empty = true
48
+ skip_covered = true
49
+
50
+ [tool.coverage.html]
51
+ skip_empty = true
52
+ skip_covered = true
53
+
54
+ [build-system]
55
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
56
+ build-backend = "poetry.core.masonry.api"