dstamp 1.0.0__tar.gz → 2__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-2/PKG-INFO ADDED
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: dstamp
3
+ Version: 2
4
+ Summary: CLI app for generating timestamps for use in Discord chats.
5
+ Keywords: discord,generator,timestamp
6
+ Author: Mariusz Tang
7
+ Author-email: Mariusz Tang <dev@mariusztang.com>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: End Users/Desktop
12
+ Classifier: Topic :: Communications :: Chat
13
+ Requires-Dist: cyclopts>=4.11.0
14
+ Requires-Dist: platformdirs>=4.9.6
15
+ Requires-Dist: pyperclip>=1.11.0
16
+ Requires-Dist: pytest-mock>=3.15.1
17
+ Requires-Python: >=3.14
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
+ ## TODO
27
+
28
+ I intend to rewrite this project to use the standard argparse library instead
29
+ of cyclopts. This makes the whole typer-to-cyclopts migration seem rather silly.
30
+ My reasoning is that cyclopts is a bit too heavy-handed for my tastes, and I
31
+ prefer how argparse forces you to separate parsing from handling functions.
32
+
33
+ ## Installation
34
+
35
+ Install using [pipx] or [uv]:
36
+
37
+ ```bash
38
+ pipx install dstamp
39
+ ```
40
+
41
+ ```bash
42
+ uv tool install dstamp
43
+ ```
44
+
45
+ [pipx]: https://pipx.pypa.io/stable/
46
+ [uv]: https://docs.astral.sh/uv/
47
+
48
+ ## Example usage
49
+
50
+ ```bash
51
+ # For the current time
52
+ dstamp get
53
+
54
+ # Two hours in the future
55
+ dstamp get --offset 2h
56
+
57
+ # Round to the nearest hour
58
+ dstamp get --offset 2h --round --precision h
59
+
60
+ # Copy to clipboard
61
+ dstamp --copy-to-clipboard
62
+ ```
63
+
64
+ For full documentation see the help messages:
65
+
66
+ ```bash
67
+ dstamp --help
68
+ dstamp get --help
69
+ ```
70
+
71
+ ## Configuration
72
+
73
+ dstamp accepts a TOML configuration file which can override any command-line
74
+ argument. Keys should be structured as `command.option`. For example, to enable
75
+ rounding by default and set a custom default rounding precision for the `get`
76
+ command, you could do the following:
77
+
78
+ ```toml
79
+ [get]
80
+ round = true
81
+ precision = "15m"
82
+ ```
83
+
84
+ Use the `show-config` command to find the default config location. Specify the
85
+ `--config` flag to use a different file.
86
+
87
+ ```bash
88
+ dstamp show-config
89
+ dstamp --config ./path-to-a-different-file.toml
90
+ ```
91
+
92
+ ## Versioning
93
+
94
+ This project uses an `X.Y` versioning scheme. Bugfixes will move the version to
95
+ `X.Y+1`, and anything else will move it to `X+1.0`. If `Y` is 0, we omit it,
96
+ so `X+1.0` actually just becomes `X+1`.
97
+
98
+ ## Contributing
99
+
100
+ If you'd like to see something added to dstamp or if you would like to add something
101
+ yourself, please see [CONTRIBUTING](./CONTRIBUTING.md)!
102
+
103
+ Please also see the above if you have a bug to report.
dstamp-2/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # dstamp
2
+
3
+ CLI app for generating timestamps for use in Discord chats.
4
+
5
+ ## TODO
6
+
7
+ I intend to rewrite this project to use the standard argparse library instead
8
+ of cyclopts. This makes the whole typer-to-cyclopts migration seem rather silly.
9
+ My reasoning is that cyclopts is a bit too heavy-handed for my tastes, and I
10
+ prefer how argparse forces you to separate parsing from handling functions.
11
+
12
+ ## Installation
13
+
14
+ Install using [pipx] or [uv]:
15
+
16
+ ```bash
17
+ pipx install dstamp
18
+ ```
19
+
20
+ ```bash
21
+ uv tool install dstamp
22
+ ```
23
+
24
+ [pipx]: https://pipx.pypa.io/stable/
25
+ [uv]: https://docs.astral.sh/uv/
26
+
27
+ ## Example usage
28
+
29
+ ```bash
30
+ # For the current time
31
+ dstamp get
32
+
33
+ # Two hours in the future
34
+ dstamp get --offset 2h
35
+
36
+ # Round to the nearest hour
37
+ dstamp get --offset 2h --round --precision h
38
+
39
+ # Copy to clipboard
40
+ dstamp --copy-to-clipboard
41
+ ```
42
+
43
+ For full documentation see the help messages:
44
+
45
+ ```bash
46
+ dstamp --help
47
+ dstamp get --help
48
+ ```
49
+
50
+ ## Configuration
51
+
52
+ dstamp accepts a TOML configuration file which can override any command-line
53
+ argument. Keys should be structured as `command.option`. For example, to enable
54
+ rounding by default and set a custom default rounding precision for the `get`
55
+ command, you could do the following:
56
+
57
+ ```toml
58
+ [get]
59
+ round = true
60
+ precision = "15m"
61
+ ```
62
+
63
+ Use the `show-config` command to find the default config location. Specify the
64
+ `--config` flag to use a different file.
65
+
66
+ ```bash
67
+ dstamp show-config
68
+ dstamp --config ./path-to-a-different-file.toml
69
+ ```
70
+
71
+ ## Versioning
72
+
73
+ This project uses an `X.Y` versioning scheme. Bugfixes will move the version to
74
+ `X.Y+1`, and anything else will move it to `X+1.0`. If `Y` is 0, we omit it,
75
+ so `X+1.0` actually just becomes `X+1`.
76
+
77
+ ## Contributing
78
+
79
+ If you'd like to see something added to dstamp or if you would like to add something
80
+ yourself, please see [CONTRIBUTING](./CONTRIBUTING.md)!
81
+
82
+ Please also see the above if you have a bug to report.
@@ -0,0 +1,83 @@
1
+ [project]
2
+ name = "dstamp"
3
+ version = "2"
4
+ description = "CLI app for generating timestamps for use in Discord chats."
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ license = "MIT"
8
+ authors = [{ name = "Mariusz Tang", email = "dev@mariusztang.com" }]
9
+ keywords = ["discord", "generator", "timestamp"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Environment :: Console",
13
+ "Intended Audience :: End Users/Desktop",
14
+ "Topic :: Communications :: Chat",
15
+ ]
16
+ dependencies = [
17
+ "cyclopts>=4.11.0",
18
+ "platformdirs>=4.9.6",
19
+ "pyperclip>=1.11.0",
20
+ "pytest-mock>=3.15.1",
21
+ ]
22
+
23
+ [project.urls]
24
+ Issues = "https://github.com/mariusz-tang/dstamp/issues"
25
+ Repository = "https://github.com/mariusz-tang/dstamp"
26
+
27
+ [project.scripts]
28
+ dstamp = "dstamp.main:app.meta"
29
+
30
+ [dependency-groups]
31
+ dev = [
32
+ "coverage>=7.13.5",
33
+ "freezegun>=1.5.5",
34
+ "pytest>=9.0.3",
35
+ "ty>=0.0.34",
36
+ ]
37
+
38
+ [build-system]
39
+ requires = ["uv_build>=0.11.8,<0.12"]
40
+ build-backend = "uv_build"
41
+
42
+ [tool.coverage.run]
43
+ branch = true
44
+ command_line = "-m pytest"
45
+ source = ["src/"]
46
+ omit = ["src/dstamp/__main__.py", "src/dstamp/console.py"]
47
+
48
+ [tool.coverage.report]
49
+ skip_empty = true
50
+ skip_covered = true
51
+
52
+ [tool.coverage.html]
53
+ skip_empty = true
54
+ skip_covered = true
55
+
56
+ [tool.pytest]
57
+ addopts = ["--import-mode=importlib"]
58
+ strict = true
59
+
60
+ [tool.ruff.lint]
61
+ select = [
62
+ "ANN", # flake8-annotations
63
+ "ARG", # flake8-unused-arguments
64
+ "B", # flake8-bugbear
65
+ "C4", # flake8-comprehensions
66
+ "C90", # mccabe
67
+ "E", # pycodestyle error
68
+ "F", # pyflakes
69
+ "FURB", # refurb
70
+ "I", # isort
71
+ "N", # pep8-naming
72
+ "PT", # flake8-pytest-style
73
+ "PTH", # flake8-use-pathlib
74
+ "RET", # flake8-return
75
+ "RSE", # flake8-raise
76
+ "SIM", # flake8-simplify
77
+ "SLF", # flake8-self
78
+ "TRY", # tryceratops
79
+ "UP", # pyupgrade
80
+ "W", # pycodestyle warning
81
+ ]
82
+ ignore = ["TRY003"] # This rule is too strict, especially for ValueError.
83
+ flake8-pytest-style.parametrize-names-type = "csv"
@@ -0,0 +1,5 @@
1
+ """Executes the app."""
2
+
3
+ from dstamp.main import app
4
+
5
+ app.meta()
@@ -0,0 +1,12 @@
1
+ """Config file utilities."""
2
+
3
+ from pathlib import Path
4
+
5
+ import platformdirs
6
+
7
+ APP_NAME = "dstamp"
8
+
9
+
10
+ def get_config_path() -> Path:
11
+ app_dir = platformdirs.user_config_path(APP_NAME, ensure_exists=True)
12
+ return app_dir / "config.toml"
@@ -1,11 +1,8 @@
1
- """console.py
2
-
3
- This module contains functions for printing to the console.
4
- """
1
+ """Console printing utilities."""
5
2
 
6
3
  from rich.console import Console
7
4
 
8
- _console = Console()
5
+ _console = Console(highlight=False)
9
6
 
10
7
  print = print
11
8
 
@@ -0,0 +1,24 @@
1
+ """Datetime formatting utilities."""
2
+
3
+ from datetime import datetime
4
+ from enum import StrEnum
5
+
6
+
7
+ class Format(StrEnum):
8
+ SHORT_TIME = "t"
9
+ LONG_TIME = "T"
10
+ SHORT_DATE = "d"
11
+ LONG_DATE = "D"
12
+ SHORT_DATETIME = "f"
13
+ LONG_DATETIME = "F"
14
+ RELATIVE = "R"
15
+
16
+
17
+ def human_readable(time: datetime) -> str:
18
+ return time.strftime("%I:%M:%S on %B %d, %Y")
19
+
20
+
21
+ def discord(time: datetime, format: Format) -> str:
22
+ """Convert a datetime object into a Discord-friendly timestamp."""
23
+ timestamp = int(time.timestamp())
24
+ return f"<t:{timestamp}:{format.value}>"
@@ -0,0 +1,172 @@
1
+ """CLI commands."""
2
+
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ import cyclopts.config
8
+ import pyperclip
9
+ from cyclopts import App, Parameter
10
+
11
+ from dstamp import config, console, format, parse
12
+ from dstamp.round import Precision, time_to_precision
13
+
14
+ app = App(help="Discord timestamp generator")
15
+ app.register_install_completion_command(add_to_startup=False)
16
+
17
+ COPY_SUCCESS_TEXT = "Copied to clipboard!"
18
+
19
+
20
+ @app.meta.default
21
+ def launch_with_config_file(
22
+ *tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)],
23
+ config_path: Annotated[Path | None, Parameter("config")] = None,
24
+ ) -> int:
25
+ """
26
+ Launch the application with a config file.
27
+
28
+ :param config_path: Alternate configuration file path.
29
+ """
30
+ if config_path is None:
31
+ config_path = config.get_config_path()
32
+ app.config = cyclopts.config.Toml(config_path)
33
+ return app(tokens)
34
+
35
+
36
+ @app.command(name="get")
37
+ def get_timestamp(
38
+ time: str | None = None,
39
+ /,
40
+ *,
41
+ offset: Annotated[str | None, Parameter(alias="-o")] = None,
42
+ output_format: Annotated[
43
+ format.Format, Parameter(alias="-f")
44
+ ] = format.Format.RELATIVE,
45
+ copy_to_clipboard: Annotated[bool, Parameter(alias="-x")] = False,
46
+ round: Annotated[bool, Parameter(alias="-r")] = False,
47
+ precision: Annotated[str, Parameter(alias="-p")] = "10m",
48
+ ) -> int:
49
+ r"""
50
+ Generate a Discord timestamp.
51
+
52
+ If TIME is omitted, uses the current time.
53
+
54
+ TIME should be of the form \<date>,\<time>.
55
+ Either \<date> or \<time> can be omitted, in which case you should omit the
56
+ comma as well.
57
+
58
+ \<date> should be of the following form: ddmmmyyyy.
59
+
60
+ dd and yyyy should be numbers.
61
+
62
+ mmm should be at least the first three letters of the month. For instance,
63
+ for August you can specify aug, augu, augus, or august.
64
+
65
+ You can omit the year to specify the current year. You can omit \<date>
66
+ entirely to specify the current date.
67
+
68
+ The command will also accept the following special values:
69
+ - today
70
+ - tmrw (tomorrow)
71
+ - tomorrow
72
+ - yesterday
73
+
74
+ \<time> should be of the following form: hhmmss(am/pm).
75
+
76
+ You can omit seconds to specify 0. If you do, you can omit minutes to specify
77
+ 0 as well. If you omit (am/pm), 24-hour time will be used. You can omit \<time>
78
+ entirely to specify midnight.
79
+
80
+ If you omit both \<date> and \<time>, the current date and time will be used.
81
+
82
+ The command will also accept the following special values:
83
+ now (the current time, but not necessarily the current date),
84
+ midnight,
85
+ noon.
86
+
87
+ Examples:
88
+ - 5jun,1pm (5th June of the current year, 1:00pm)
89
+ - 8january2025,1530 (8th January 2025, 3:30pm)
90
+ - 25nov2000,13002pm (25th November 2000, 1:30:02pm)
91
+ - 1jan (1st January of the current year, midnight)
92
+ - 7feb2023,15 (7th February 2023, 3:00pm)
93
+ - 5 (the current date, 5:00am)
94
+ - 0 (the current date, midnight)
95
+ - tmrw,now (tomorrow, the current time, ie. 24 hours from the current time)
96
+ - tmrw (tomorrow, midnight)
97
+
98
+ :param time: The date and time to which the timestamp should point. If
99
+ omitted, the current time is used.
100
+ :param offset: Optional offset to apply to TIME. Optional offset to apply
101
+ to TIME. Examples: 2d3h1m, +3s, -3d+1d, 3m-3h2h. The only acceptable units
102
+ are d(ays), h(ours), m(inutes), and s(econds). Subtraction applies to all
103
+ times after the - until the next +. Units can be repeated.
104
+ :param output_format: The format in which the timestamp will be displayed
105
+ in Discord.
106
+ :param copy_to_clipboard: If set, copy the timestamp to clipboard. On Linux,
107
+ requires that xsel or xclip be installed.
108
+ :param do_rounding: If specified, round TIME based on PRECISION.
109
+ :param precision: The precision to which TIME will be rounded if ROUND is
110
+ specified.
111
+ """
112
+ time_obj = try_parse(parse.datetime, time, "datetime")
113
+ time_delta = try_parse(parse.offset, offset, "offset")
114
+
115
+ if time_obj is None or time_delta is None:
116
+ return 1
117
+
118
+ time_obj = time_delta + time_obj
119
+
120
+ console.info(f"Using time: {format.human_readable(time_obj)}.")
121
+
122
+ if round:
123
+ try:
124
+ precision: Precision = parse.rounding_precision(precision)
125
+ except parse.ParserInputError as e:
126
+ console.error(str(e))
127
+ return 1
128
+ time_obj = time_to_precision(time_obj, precision)
129
+ console.info(f"After rounding: {format.human_readable(time_obj)}.")
130
+
131
+ output = format.discord(time_obj, output_format)
132
+
133
+ console.print(output)
134
+
135
+ if copy_to_clipboard:
136
+ try:
137
+ pyperclip.copy(output)
138
+ console.info(COPY_SUCCESS_TEXT)
139
+ except pyperclip.PyperclipException as e:
140
+ console.error(f"There was a problem with the clipboard manager:{e}")
141
+ return 1
142
+
143
+ return 0
144
+
145
+
146
+ @app.command
147
+ def show_config() -> int:
148
+ """Show the default config location."""
149
+ print(config.get_config_path())
150
+ return 0
151
+
152
+
153
+ def try_parse[T](
154
+ parser: Callable[[str | None], T], raw_input: str | None, quantity_type: str
155
+ ) -> T | None:
156
+ """Attempt to parse a quantity, printing an error message on failure.
157
+
158
+ :param parser: Parsing function to use.
159
+ :param raw_input: Input string to parse.
160
+ :param quantity_type: Type of quantity being parsed. Used in the error
161
+ message.
162
+
163
+ :returns: The parsed quantity, or None on failure.
164
+ """
165
+ try:
166
+ return parser(raw_input)
167
+ except parse.InvalidFormatError:
168
+ console.error(f"{raw_input} could not be parsed as a {quantity_type}.")
169
+ except parse.InvalidValueError:
170
+ console.error(f"{raw_input} represents an invalid {quantity_type}.")
171
+
172
+ return None
@@ -0,0 +1,15 @@
1
+ """Provides input parsers for various types."""
2
+
3
+ from .datetime import datetime
4
+ from .exceptions import InvalidFormatError, InvalidValueError, ParserInputError
5
+ from .offset import offset
6
+ from .precision import rounding_precision
7
+
8
+ __all__ = [
9
+ "datetime",
10
+ "InvalidFormatError",
11
+ "InvalidValueError",
12
+ "ParserInputError",
13
+ "offset",
14
+ "rounding_precision",
15
+ ]
@@ -0,0 +1,127 @@
1
+ """Provides input parsers for dates and times."""
2
+
3
+ import datetime as dt
4
+ import re
5
+
6
+ from .exceptions import InvalidFormatError, InvalidValueError
7
+
8
+
9
+ def datetime(raw_datetime: str | None) -> dt.datetime:
10
+ """
11
+ Parse raw_datetime into a datetime.
12
+
13
+ raw_datetime should be of the form "ddmmmyyyy,hhmmss", albeit with some
14
+ flexibility and special values allowed.
15
+
16
+ If the date is omitted, use the current date.
17
+ If the only time is omitted, use midnight.
18
+ If both are omitted, use the current date and time.
19
+
20
+ Valid examples:
21
+ 07jun2025,1230
22
+ 9august,330pm
23
+ today,now
24
+ tomorrow
25
+ 12pm
26
+ 73002pm
27
+ noon
28
+ midnight
29
+ """
30
+ if raw_datetime is None or raw_datetime == "":
31
+ return dt.datetime.now().replace(microsecond=0)
32
+ # Ensure there is both a date and time supplied.
33
+ if (x := raw_datetime.count(",")) == 0:
34
+ try:
35
+ return datetime("today," + raw_datetime)
36
+ except InvalidFormatError:
37
+ return datetime(raw_datetime + ",midnight")
38
+ elif x >= 2:
39
+ raise InvalidFormatError
40
+
41
+ datestr, timestr = raw_datetime.split(",")
42
+ parsed_date = _date(datestr)
43
+ parsed_time = _time(timestr).replace(microsecond=0)
44
+ timezone = dt.datetime.now().tzinfo
45
+ return dt.datetime.combine(parsed_date, parsed_time, timezone)
46
+
47
+
48
+ def _date(datestr: str) -> dt.date:
49
+ if datestr == "today":
50
+ return dt.date.today()
51
+
52
+ if datestr in ("tmrw", "tomorrow"):
53
+ return dt.date.today() + dt.timedelta(days=1)
54
+
55
+ if datestr == "yesterday":
56
+ return dt.date.today() - dt.timedelta(days=1)
57
+
58
+ m = re.fullmatch(r"(\d{1,2})([a-zA-Z]{3,})(\d*)", datestr)
59
+ if m is None:
60
+ raise InvalidFormatError
61
+
62
+ day = int(m[1])
63
+ month = _get_month_from_shortening(m[2].lower())
64
+ year = int(m[3]) if m[3] else dt.date.today().year
65
+
66
+ try:
67
+ return dt.date(year, month, day)
68
+ except ValueError as e:
69
+ raise InvalidValueError from e
70
+
71
+
72
+ def _time(timestr: str) -> dt.time:
73
+ if timestr == "now":
74
+ return dt.datetime.now().time()
75
+
76
+ if timestr == "midnight":
77
+ return dt.time()
78
+
79
+ if timestr == "noon":
80
+ return dt.time(12)
81
+
82
+ m = re.fullmatch(r"(\d{1,2}?)(\d{2})?(\d{2})?([ap]m)?", timestr)
83
+ if m is None:
84
+ raise InvalidFormatError
85
+
86
+ hour = int(m[1])
87
+ minute = int(m[2]) if m[2] else 0
88
+ second = int(m[3]) if m[3] else 0
89
+ noonstr = m[4]
90
+
91
+ if noonstr is not None and (hour == 0 or hour > 12):
92
+ # Disallow noonstrings with 24h format.
93
+ raise InvalidFormatError
94
+
95
+ if noonstr == "pm" and hour != 12:
96
+ hour += 12
97
+ elif noonstr == "am" and hour == 12:
98
+ hour = 0
99
+
100
+ try:
101
+ return dt.time(hour, minute, second)
102
+ except ValueError as e:
103
+ raise InvalidValueError from e
104
+
105
+
106
+ _months = [
107
+ "january",
108
+ "february",
109
+ "march",
110
+ "april",
111
+ "may",
112
+ "june",
113
+ "july",
114
+ "august",
115
+ "september",
116
+ "october",
117
+ "november",
118
+ "december",
119
+ ]
120
+
121
+
122
+ def _get_month_from_shortening(shortening: str) -> int:
123
+ """Returns the index of the first month which starts with the given string."""
124
+ for ix, name in enumerate(_months):
125
+ if name.startswith(shortening):
126
+ return ix + 1
127
+ raise InvalidFormatError
@@ -0,0 +1,13 @@
1
+ """Parsing-related exceptions."""
2
+
3
+
4
+ class ParserInputError(ValueError):
5
+ """Raised when there is a problem with the input received by a parser."""
6
+
7
+
8
+ class InvalidFormatError(ParserInputError):
9
+ """Raised when a parser is provided an improperly-formatted value."""
10
+
11
+
12
+ class InvalidValueError(ParserInputError):
13
+ """Raised when a parser is provided a correctly-formatted but invalid value."""