dstamp 3__tar.gz → 4__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.
@@ -1,16 +1,17 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dstamp
3
- Version: 3
3
+ Version: 4
4
4
  Summary: CLI app for generating timestamps for use in Discord chats.
5
5
  Keywords: discord,generator,timestamp
6
6
  Author: Mariusz Tang
7
7
  Author-email: Mariusz Tang <dev@mariusztang.com>
8
8
  License-Expression: MIT
9
- Classifier: Development Status :: 4 - Beta
9
+ Classifier: Development Status :: 5 - Production/Stable
10
10
  Classifier: Environment :: Console
11
11
  Classifier: Intended Audience :: End Users/Desktop
12
12
  Classifier: Topic :: Communications :: Chat
13
13
  Requires-Dist: argcomplete>=3.6.3
14
+ Requires-Dist: logassert>=8.6
14
15
  Requires-Dist: platformdirs>=4.10.0
15
16
  Requires-Dist: pyperclip>=1.11.0
16
17
  Requires-Python: >=3.14
@@ -49,21 +50,30 @@ dstamp get
49
50
  # Get the current time, to the nearest 15 minutes.
50
51
  dstamp get --precision 15m
51
52
 
52
- # Show the config file location.
53
+ # Get the time 20 minutes from now.
54
+ dstamp in 20m
55
+
56
+ # Show the default config file location.
53
57
  dstamp show-config
54
58
  ```
55
59
 
56
60
  See the help messages for full usage information:
57
61
 
58
62
  ```bash
59
- dstamp -h
63
+ dstamp --help
60
64
  dstamp -h get
65
+ dstamp -h in
61
66
  ```
62
67
 
63
68
  ### Configuration
64
69
 
65
70
  Dstamp supports configuration by TOML file. See `dstamp -h show-config` for full
66
- information.
71
+ information. The config file location can be overridden using the `--config`
72
+ option.
73
+
74
+ ## Logs
75
+
76
+ Dstamp writes logs to a file. Use `dstamp show-log` to get the file location.
67
77
 
68
78
  ## Shell completion
69
79
 
@@ -29,21 +29,30 @@ dstamp get
29
29
  # Get the current time, to the nearest 15 minutes.
30
30
  dstamp get --precision 15m
31
31
 
32
- # Show the config file location.
32
+ # Get the time 20 minutes from now.
33
+ dstamp in 20m
34
+
35
+ # Show the default config file location.
33
36
  dstamp show-config
34
37
  ```
35
38
 
36
39
  See the help messages for full usage information:
37
40
 
38
41
  ```bash
39
- dstamp -h
42
+ dstamp --help
40
43
  dstamp -h get
44
+ dstamp -h in
41
45
  ```
42
46
 
43
47
  ### Configuration
44
48
 
45
49
  Dstamp supports configuration by TOML file. See `dstamp -h show-config` for full
46
- information.
50
+ information. The config file location can be overridden using the `--config`
51
+ option.
52
+
53
+ ## Logs
54
+
55
+ Dstamp writes logs to a file. Use `dstamp show-log` to get the file location.
47
56
 
48
57
  ## Shell completion
49
58
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dstamp"
3
- version = "3"
3
+ version = "4"
4
4
  description = "CLI app for generating timestamps for use in Discord chats."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -8,13 +8,14 @@ license = "MIT"
8
8
  authors = [{ name = "Mariusz Tang", email = "dev@mariusztang.com" }]
9
9
  keywords = ["discord", "generator", "timestamp"]
10
10
  classifiers = [
11
- "Development Status :: 4 - Beta",
11
+ "Development Status :: 5 - Production/Stable",
12
12
  "Environment :: Console",
13
13
  "Intended Audience :: End Users/Desktop",
14
14
  "Topic :: Communications :: Chat",
15
15
  ]
16
16
  dependencies = [
17
17
  "argcomplete>=3.6.3",
18
+ "logassert>=8.6",
18
19
  "platformdirs>=4.10.0",
19
20
  "pyperclip>=1.11.0",
20
21
  ]
@@ -81,3 +82,4 @@ select = [
81
82
  ]
82
83
  pydocstyle.convention = "pep257"
83
84
  per-file-ignores = { "tests/**/*" = ["D"] }
85
+ flake8-annotations.allow-star-arg-any = true
@@ -1 +1,3 @@
1
1
  """CLI tool for printing Discord timestamps."""
2
+
3
+ APP_NAME = "dstamp"
@@ -0,0 +1,172 @@
1
+ # PYTHON_ARGCOMPLETE_OK
2
+ """Dstamp's CLI interface."""
3
+
4
+ import argparse
5
+ import logging
6
+ import logging.config
7
+ import pathlib
8
+ import sys
9
+ import tomllib
10
+ from collections.abc import Iterable
11
+
12
+ import argcomplete
13
+ import pyperclip
14
+
15
+ import dstamp.logging
16
+ from dstamp import config, exceptions, subcommands
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Use a base parser to change the help flag's help message.
21
+ base_parser = argparse.ArgumentParser(add_help=False)
22
+ base_parser.add_argument(
23
+ "-h",
24
+ "--help",
25
+ action="help",
26
+ help="Show this help message and exit.",
27
+ )
28
+
29
+
30
+ def construct_parser(config: dict | None = None) -> argparse.ArgumentParser:
31
+ """Create the dstamp CLI argument parser."""
32
+ # Add the help option manually so we can change the help message.
33
+ parser = argparse.ArgumentParser(
34
+ add_help=False,
35
+ parents=[base_parser],
36
+ description="Discord timestamp generator.",
37
+ epilog="Some options can be set via config file. See %(prog)s -h show-config",
38
+ suggest_on_error=True,
39
+ )
40
+ parser.add_argument(
41
+ "--version",
42
+ action="version",
43
+ version="%(prog)s 4",
44
+ help="Show version number and exit.",
45
+ )
46
+ parser.add_argument(
47
+ "--config",
48
+ metavar="CONFIG_PATH",
49
+ type=pathlib.Path,
50
+ help="Alternative config file to use instead of the default. Should be "
51
+ "a TOML file.",
52
+ )
53
+ parser.add_argument(
54
+ "--quiet",
55
+ action=argparse.BooleanOptionalAction,
56
+ help="Do not print warnings to the console. Disabled by default.",
57
+ )
58
+ parser.add_argument(
59
+ "--verbose",
60
+ action=argparse.BooleanOptionalAction,
61
+ help="Print detailed info messages to the console. Takes precedence over "
62
+ "--quiet. Disabled by default.",
63
+ )
64
+
65
+ subparsers = parser.add_subparsers(title="commands")
66
+ subcommands.register_all(subparsers, config or {})
67
+
68
+ return parser
69
+
70
+
71
+ def run(args: Iterable[str] | None = None) -> None:
72
+ """Parse `args` as CLI arguments and execute the resulting command."""
73
+ parser = construct_parser()
74
+ argcomplete.autocomplete(parser)
75
+
76
+ # Move help flags past subcommands so the most specific help message is shown.
77
+ args = _move_help_to_end(args or sys.argv[1:])
78
+ parsed_args = parser.parse_args(args)
79
+
80
+ # Print help if no arguments are provided.
81
+ if not hasattr(parsed_args, "func"):
82
+ parser.print_help()
83
+ return
84
+
85
+ # Read the config file.
86
+ config_path, config_, unknown_keys, config_warning = _get_config(parsed_args.config)
87
+
88
+ # Re-parse the arguments with defaults from the config.
89
+ # We use this method because the config path needs to be known at parse
90
+ # time, but we cannot determine an overridden config path before parsing.
91
+ parser = construct_parser(config_)
92
+ parsed_args = parser.parse_args(args)
93
+
94
+ # Configure logging only after reading the config.
95
+ verbosity = (
96
+ "verbose" if parsed_args.verbose else "quiet" if parsed_args.quiet else "normal"
97
+ )
98
+ logging.config.dictConfig(dstamp.logging.get_config(verbosity))
99
+
100
+ # Now we can start logging.
101
+ logger.info(f"args: {args or sys.argv[1:]}")
102
+
103
+ logger.info(f"using config in {config_path}")
104
+ if config_warning:
105
+ logger.warning(config_warning)
106
+
107
+ logger.info(f"computed config options: {config_}")
108
+ if unknown_keys:
109
+ logger.warning(f"unknown keys in config file: {', '.join(unknown_keys)}")
110
+
111
+ try:
112
+ parsed_args.func(parsed_args)
113
+ except exceptions.DstampError as e:
114
+ parser.error(str(e))
115
+ except pyperclip.PyperclipException as e:
116
+ parser.error(f"there was a problem with the clipboard manager: {e}")
117
+ except Exception:
118
+ logger.exception("An unexpected error occurred.")
119
+ parser.error("an unexpected error occurred; please report this to dstamp")
120
+
121
+
122
+ def _move_help_to_end(args: Iterable[str]) -> Iterable[str]:
123
+ args = list(args)
124
+ if "-h" not in args and "--help" not in args:
125
+ return args
126
+
127
+ # Remove --version so it doesn't override the help flag if present.
128
+ _remove_arg(args, "--version")
129
+ _remove_arg(args, "--help")
130
+ _remove_arg(args, "-h")
131
+
132
+ args.append("--help")
133
+ return args
134
+
135
+
136
+ def _remove_arg(args: list[str], arg: str) -> None:
137
+ while arg in args:
138
+ args.remove(arg)
139
+
140
+
141
+ def _get_config(
142
+ config_path_arg: pathlib.Path | None,
143
+ ) -> tuple[pathlib.Path, dict, set, str | None]:
144
+ """Get config from `config_path_arg`.
145
+
146
+ Returns the effective config path, cleaned config, unknown keys set, and
147
+ a warning message if applicable.
148
+ """
149
+ log_warning = None
150
+
151
+ # Log if config path is specified at the command line but cannot be used.
152
+ if config_path_arg:
153
+ if not config_path_arg.exists():
154
+ log_warning = "specified config file does not exist"
155
+ elif not config_path_arg.is_file():
156
+ log_warning = "specified config path is not a file"
157
+
158
+ # Default result if we cannot read a file.
159
+ config_cleaned = {}
160
+ unknown_keys = set()
161
+
162
+ config_path = config_path_arg or config.default_path()
163
+ if config_path.is_file():
164
+ try:
165
+ config_raw = tomllib.loads(config_path.read_text())
166
+ except tomllib.TOMLDecodeError:
167
+ log_warning = "config file is not valid TOML"
168
+ else:
169
+ # Override result with file contents.
170
+ config_cleaned, unknown_keys = config.clean(config_raw)
171
+
172
+ return config_path, config_cleaned, unknown_keys, log_warning
@@ -0,0 +1,40 @@
1
+ """Config file utilities.
2
+
3
+ The functions in this module do not create a config file if one does not exist.
4
+ """
5
+
6
+ import pathlib
7
+ from typing import Any
8
+
9
+ import platformdirs
10
+
11
+ import dstamp
12
+
13
+
14
+ def default_path() -> pathlib.Path:
15
+ """Return the default config file path.
16
+
17
+ Note that the file does not necessarily exist.
18
+ """
19
+ config_dir = platformdirs.user_config_path(dstamp.APP_NAME)
20
+ return config_dir / "config.toml"
21
+
22
+
23
+ _VALID_KEYS = {
24
+ "copy",
25
+ "format",
26
+ "precision",
27
+ "quiet",
28
+ "verbose",
29
+ }
30
+
31
+
32
+ def clean(config: dict) -> tuple[dict[str, Any], set[str]]:
33
+ """Clean a config dict so it contains only valid keys.
34
+
35
+ Returns a dictionary containing the cleaned config and a set containing the
36
+ invalid keys that were found in the input.
37
+ """
38
+ cleaned_config = {key: value for key, value in config.items() if key in _VALID_KEYS}
39
+ unknown_keys = config.keys() - _VALID_KEYS
40
+ return cleaned_config, unknown_keys
@@ -0,0 +1,52 @@
1
+ """Defines dstamp's logging configuration."""
2
+
3
+ import platformdirs
4
+
5
+ import dstamp
6
+
7
+ LOG_DIR = platformdirs.user_log_path(dstamp.APP_NAME, ensure_exists=True)
8
+
9
+ CONFIG = {
10
+ "version": 1,
11
+ "formatters": {
12
+ "short": {"format": "%(levelname)s: %(message)s"},
13
+ "full": {"format": "%(asctime)s %(levelname)s: %(name)s: %(message)s"},
14
+ },
15
+ "handlers": {
16
+ "console": {
17
+ "class": "logging.StreamHandler",
18
+ "formatter": "short",
19
+ "level": "WARNING",
20
+ },
21
+ "file": {
22
+ "class": "logging.handlers.RotatingFileHandler",
23
+ "formatter": "full",
24
+ "filename": LOG_DIR / f"{dstamp.APP_NAME}.log",
25
+ "encoding": "utf-8",
26
+ "maxBytes": 50_000,
27
+ "backupCount": 1,
28
+ },
29
+ },
30
+ "root": {
31
+ "level": "INFO",
32
+ "handlers": ["console", "file"],
33
+ },
34
+ "disable_existing_loggers": False,
35
+ }
36
+
37
+
38
+ def get_config(verbosity: str) -> dict:
39
+ """Get an appropriate logging configuration for the given `verbosity`.
40
+
41
+ :param verbosity: recognized values are "quiet" and "verbose". Other values
42
+ are ignored and produce the default config.
43
+ """
44
+ # Use unspecific type annotation to avoid type errors below.
45
+ base: dict = CONFIG
46
+
47
+ if verbosity == "quiet":
48
+ base["root"]["handlers"].remove("console")
49
+ elif verbosity == "verbose":
50
+ base["handlers"]["console"]["level"] = "INFO"
51
+
52
+ return base
@@ -6,7 +6,7 @@ from collections import Counter
6
6
 
7
7
  from dstamp import exceptions, round
8
8
 
9
- MONTHS = [
9
+ _MONTHS = [
10
10
  "jan",
11
11
  "feb",
12
12
  "mar",
@@ -31,12 +31,12 @@ def date(input: str) -> dt.date:
31
31
  if input == "yesterday":
32
32
  return dt.date.today() - dt.timedelta(1)
33
33
 
34
- m = re.fullmatch(rf"(\d+)({'|'.join(MONTHS)})(\d+)?", input.lower())
34
+ m = re.fullmatch(rf"(\d+)({'|'.join(_MONTHS)})(\d+)?", input.lower())
35
35
  if not m:
36
36
  raise exceptions.ParserFormatError(input, dt.date)
37
37
 
38
38
  day = int(m[1])
39
- month = MONTHS.index(m[2]) + 1
39
+ month = _MONTHS.index(m[2]) + 1
40
40
  year = int(m[3]) if m[3] else dt.date.today().year
41
41
 
42
42
  try:
@@ -0,0 +1,15 @@
1
+ """Dstamp subcommands."""
2
+
3
+ import argparse
4
+
5
+ from . import get, show_config, show_log
6
+
7
+
8
+ def register_all(subparsers: argparse._SubParsersAction, config: dict) -> None:
9
+ """Register all subcommands as subparsers."""
10
+ get.register(subparsers, config)
11
+ show_config.register(subparsers)
12
+ show_log.register(subparsers)
13
+
14
+
15
+ __all__ = ["register_all"]
@@ -0,0 +1,182 @@
1
+ """The get timestamp command, and the related in command."""
2
+
3
+ import argparse
4
+ import datetime as dt
5
+ import logging
6
+ from typing import Any
7
+
8
+ import pyperclip
9
+
10
+ import dstamp
11
+ from dstamp import cli, discord, exceptions, parse, round
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def register(subparsers: argparse._SubParsersAction, config: dict) -> None:
17
+ """Register the get and in commands as subparsers."""
18
+ base = argparse.ArgumentParser(add_help=False)
19
+ base.add_argument(
20
+ "-c",
21
+ "--copy",
22
+ action=argparse.BooleanOptionalAction,
23
+ default=True,
24
+ help="If set, copy the result to clipboard. Enabled by default.",
25
+ )
26
+ base.add_argument(
27
+ "-p",
28
+ "--precision",
29
+ default="1s",
30
+ help="The precision to which the date and time should be rounded. "
31
+ "Examples: 1s, 30m, 2h, 24h, 60s, 60m. The only valid units are h "
32
+ "(hours), m (minutes), and s (seconds). The quantity must be a factor "
33
+ "of 60 (minutes or seconds) or 24 (hours). The default is 1s.",
34
+ )
35
+
36
+ get: argparse.ArgumentParser = subparsers.add_parser(
37
+ "get",
38
+ help="Generate a timestamp.",
39
+ description="Generate a Discord-compatible timestamp.",
40
+ epilog=f"Some options can be set via config file. See {dstamp.APP_NAME} "
41
+ "-h show-config",
42
+ add_help=False,
43
+ parents=[cli.base_parser, base],
44
+ )
45
+ get.add_argument(
46
+ "date",
47
+ nargs="?",
48
+ help="Examples: 1jan2025, 30may2020, 6jun. If the year is omitted, the "
49
+ "current year is used. If the entire date is omitted, the current date "
50
+ "is used. Accepts special keywords: today, tomorrow, tmrw, yesterday.",
51
+ )
52
+ get.add_argument(
53
+ "time",
54
+ nargs="?",
55
+ help="Examples: 1, 15, 1730, 35004pm. If omitted, midnight is used. If "
56
+ "the date is also omitted, the current time is used instead. Can be "
57
+ "provided even if the date is omitted. Accepts special keywords: now "
58
+ "(current time), midnight, noon.",
59
+ )
60
+ get.add_argument(
61
+ "-o",
62
+ "--offset",
63
+ help="An offset to apply to the date and time. Examples: 1d (one day), "
64
+ "3h5m (three hours and five minutes), 5s (five seconds). The only valid "
65
+ "units are d (days), h (hours), m (minutes), and s (seconds). Units can "
66
+ "be repeated, for example 5s3d2s would be three days and seven (5+2) "
67
+ "seconds. Units can be specified in any order. To specify a backwards "
68
+ "offset, prefix the offset with b, for example b1d would be backwards "
69
+ "one day.",
70
+ )
71
+ get.add_argument(
72
+ "-f",
73
+ "--format",
74
+ choices=_output_formats.keys(),
75
+ default="long-datetime",
76
+ help="The output format to use. The default is long-datetime.",
77
+ ).completer = _output_format_completer # ty: ignore[unresolved-attribute]
78
+ get.set_defaults(func=_get, **config)
79
+
80
+ in_: argparse.ArgumentParser = subparsers.add_parser(
81
+ "in",
82
+ help="Generate a timestamp relative to now.",
83
+ description="Generate a Discord-compatible timestamp relative to the current "
84
+ "time.",
85
+ epilog=f"Some options can be set via config file. See {dstamp.APP_NAME} "
86
+ "-h show-config",
87
+ add_help=False,
88
+ parents=[cli.base_parser, base],
89
+ )
90
+ in_.add_argument(
91
+ "offset",
92
+ help="An offset to apply to the current time. Examples: 1d (one day), "
93
+ "3h5m (three hours and five minutes), 5s (five seconds). The only valid "
94
+ "units are d (days), h (hours), m (minutes), and s (seconds). Units can "
95
+ "be repeated, for example 5s3d2s would be three days and seven (5+2) "
96
+ "seconds. Units can be specified in any order. To specify a backwards "
97
+ "offset, prefix the offset with b, for example b1d would be backwards "
98
+ "one day.",
99
+ )
100
+ # We duplicate the format argument because the defaults are linked if we
101
+ # put it in the parent parser.
102
+ in_.add_argument(
103
+ "-f",
104
+ "--format",
105
+ choices=_output_formats.keys(),
106
+ default="relative",
107
+ help="The output format to use. The default is relative.",
108
+ ).completer = _output_format_completer # ty: ignore[unresolved-attribute]
109
+ in_.set_defaults(func=_in, **config)
110
+
111
+
112
+ _output_formats = {
113
+ "short-time": ("t", "18:01"),
114
+ "long-time": ("T", "18:01:06"),
115
+ "short-date": ("d", "29/05/2026"),
116
+ "long-date": ("D", "29 May 2026"),
117
+ "short-datetime": ("f", "29 May 2026 at 18:01"),
118
+ "long-datetime": ("F", "Friday 29 May 2026 at 18:01"),
119
+ "relative": ("R", "3 minutes ago"),
120
+ }
121
+
122
+
123
+ def _output_format_completer(**_: Any) -> dict: # pragma: nocover
124
+ return {key: "e.g. " + _output_formats[key][1] for key in _output_formats}
125
+
126
+
127
+ def _get(args: argparse.Namespace) -> None:
128
+ datetime = _get_datetime(args.date, args.time)
129
+ logger.info(f"using datetime: {datetime}")
130
+
131
+ if args.offset:
132
+ datetime += parse.offset(args.offset)
133
+ logger.info(f"datetime after offset: {datetime}")
134
+
135
+ _show_timestamp(datetime, args)
136
+
137
+
138
+ def _in(args: argparse.Namespace) -> None:
139
+ datetime = dt.datetime.now()
140
+ logger.info(f"using datetime: {datetime}")
141
+
142
+ datetime += parse.offset(args.offset)
143
+ logger.info(f"datetime after offset: {datetime}")
144
+
145
+ _show_timestamp(datetime, args)
146
+
147
+
148
+ def _show_timestamp(datetime: dt.datetime, args: argparse.Namespace) -> None:
149
+ precision = parse.precision(args.precision)
150
+ datetime_rounded = round.datetime(datetime, precision)
151
+ logger.info(f"datetime after rounding: {datetime_rounded}")
152
+
153
+ format_code = _output_formats[args.format][0]
154
+ timestamp = discord.timestamp(datetime_rounded, format_code)
155
+ print(timestamp)
156
+
157
+ if args.copy:
158
+ pyperclip.copy(timestamp)
159
+ print("Copied to clipboard!")
160
+
161
+
162
+ def _get_datetime(date: str | None, time: str | None) -> dt.datetime:
163
+ if not date:
164
+ # Use current time if neither date nor time are provided.
165
+ return dt.datetime.now()
166
+ if not time:
167
+ date_or_time = _parse_date_or_time(date)
168
+ if isinstance(date_or_time, dt.date):
169
+ # Use midnight if only date is provided.
170
+ return dt.datetime.combine(date_or_time, dt.time())
171
+ # Use current date if only time is provided.
172
+ return dt.datetime.combine(dt.date.today(), date_or_time)
173
+ # Note that it's not possible for time to be defined but not date because
174
+ # they are both optional positional arguments.
175
+ return dt.datetime.combine(parse.date(date), parse.time(time))
176
+
177
+
178
+ def _parse_date_or_time(input: str) -> dt.date | dt.time:
179
+ try:
180
+ return parse.date(input)
181
+ except exceptions.ParserFormatError:
182
+ return parse.time(input)
@@ -0,0 +1,25 @@
1
+ """The show-config command."""
2
+
3
+ import argparse
4
+
5
+ from dstamp import cli, config
6
+
7
+
8
+ def register(subparsers: argparse._SubParsersAction) -> None:
9
+ """Register the show-config command as a subparser."""
10
+ show_config: argparse.ArgumentParser = subparsers.add_parser(
11
+ "show-config",
12
+ help="Show the default config location and exit.",
13
+ description="Show the default configuration file location and exit.",
14
+ epilog="The config file should contain valid TOML. Valid keys are copy "
15
+ "(bool), format (string), precision (string), quiet (bool), and verbose "
16
+ "(bool), which override the default values for the corresponding options "
17
+ "for other commands.",
18
+ add_help=False,
19
+ parents=[cli.base_parser],
20
+ )
21
+ show_config.set_defaults(func=_show_config)
22
+
23
+
24
+ def _show_config(_: argparse.Namespace) -> None:
25
+ print(config.default_path())
@@ -0,0 +1,21 @@
1
+ """The show-log command."""
2
+
3
+ import argparse
4
+
5
+ from dstamp import cli, logging
6
+
7
+
8
+ def register(subparsers: argparse._SubParsersAction) -> None:
9
+ """Register the show-log command as a subparser."""
10
+ show_config: argparse.ArgumentParser = subparsers.add_parser(
11
+ "show-log",
12
+ help="Show the log location and exit.",
13
+ description="Show the default logfile location and exit.",
14
+ add_help=False,
15
+ parents=[cli.base_parser],
16
+ )
17
+ show_config.set_defaults(func=_show_log)
18
+
19
+
20
+ def _show_log(_: argparse.Namespace) -> None:
21
+ print(logging.LOG_DIR)
@@ -1,53 +0,0 @@
1
- # PYTHON_ARGCOMPLETE_OK
2
- """Dstamp's CLI interface."""
3
-
4
- import argparse
5
- from collections.abc import Iterable
6
-
7
- import argcomplete
8
-
9
- from dstamp import exceptions, subcommands
10
-
11
-
12
- def construct_parser() -> argparse.ArgumentParser:
13
- """Create the dstamp CLI argument parser."""
14
- # Add the help option manually so we can change the help message.
15
- parser = argparse.ArgumentParser(
16
- add_help=False,
17
- description="Discord timestamp generator",
18
- )
19
- parser.add_argument(
20
- "-h",
21
- "--help",
22
- action="store_true",
23
- help="Show this help message and exit. For command-specific help, use "
24
- "%(prog)s -h COMMAND",
25
- )
26
- parser.set_defaults(print_help=parser.print_help)
27
- parser.add_argument(
28
- "--version",
29
- action="version",
30
- version="%(prog)s 3",
31
- help="Show version number and exit",
32
- )
33
-
34
- subparsers = parser.add_subparsers(title="commands")
35
- subcommands.register_all(subparsers)
36
-
37
- return parser
38
-
39
-
40
- def run(args: Iterable[str] | None = None) -> None:
41
- """Parse `args` as CLI arguments and execute the resulting command."""
42
- parser = construct_parser()
43
- argcomplete.autocomplete(parser)
44
- parsed_args = parser.parse_args(args)
45
-
46
- if not hasattr(parsed_args, "func") or parsed_args.help:
47
- parsed_args.print_help()
48
- return
49
-
50
- try:
51
- parsed_args.func(parsed_args)
52
- except exceptions.DstampError as e:
53
- parser.error(str(e))
@@ -1,46 +0,0 @@
1
- """Config file utilities.
2
-
3
- The functions in this module do not create a config file if one does not exist.
4
- """
5
-
6
- import pathlib
7
- import tomllib
8
- import warnings
9
-
10
- import platformdirs
11
-
12
- _APP_NAME = "dstamp"
13
-
14
-
15
- def default_path() -> pathlib.Path:
16
- """Return the default config file path.
17
-
18
- Note that the file does not necessarily exist.
19
- """
20
- config_dir = platformdirs.user_config_path(_APP_NAME)
21
- return config_dir / "config.toml"
22
-
23
-
24
- _VALID_KEYS = {
25
- "copy",
26
- "format",
27
- "precision",
28
- }
29
-
30
-
31
- def parse(path: pathlib.Path) -> dict:
32
- """Parse `path` as a config file and return the corresponding defaults dict.
33
-
34
- If `path` does not exist, it is treated as if it were an empty file.
35
- """
36
- if not path.exists():
37
- return {}
38
-
39
- config = tomllib.loads(path.read_text())
40
-
41
- if unknown_keys := config.keys() - _VALID_KEYS:
42
- warnings.warn(
43
- f"unknown keys in config file: {', '.join(unknown_keys)}", stacklevel=2
44
- )
45
-
46
- return config
@@ -1,14 +0,0 @@
1
- """Dstamp subcommands."""
2
-
3
- import argparse
4
-
5
- from . import get, show_config
6
-
7
-
8
- def register_all(subparsers: argparse._SubParsersAction) -> None:
9
- """Register all subcommands as subparsers."""
10
- get.register(subparsers)
11
- show_config.register(subparsers)
12
-
13
-
14
- __all__ = ["register_all"]
@@ -1,124 +0,0 @@
1
- """The get timestamp command."""
2
-
3
- import argparse
4
- import datetime as dt
5
-
6
- import pyperclip
7
-
8
- from dstamp import config, discord, exceptions, parse, round
9
-
10
-
11
- def register(subparsers: argparse._SubParsersAction) -> None:
12
- """Register the get command as a subparser."""
13
- get: argparse.ArgumentParser = subparsers.add_parser(
14
- "get",
15
- help="Generate a timestamp",
16
- description="Generate a Discord-compatible timestamp",
17
- epilog="Some options can be set via config file. See %(prog)s -h show-config",
18
- add_help=False,
19
- )
20
- get.add_argument(
21
- "date",
22
- nargs="?",
23
- help="Examples: 1jan2025, 30may2020, 6jun. If the year is omitted, the "
24
- "current year is used. If the entire date is omitted, the current date "
25
- "is used. Accepts special keywords: today, tomorrow, tmrw, yesterday",
26
- )
27
- get.add_argument(
28
- "time",
29
- nargs="?",
30
- help="Examples: 1, 15, 1730, 35004pm. If omitted, midnight is used. If "
31
- "the date is also omitted, the current time is used instead. Can be "
32
- "provided even if the date is omitted. Accepts special keywords: now "
33
- "(current time), midnight, noon",
34
- )
35
- get.add_argument(
36
- "-c",
37
- "--copy",
38
- action=argparse.BooleanOptionalAction,
39
- default=True,
40
- help="If set, copy the result to clipboard. Enabled by default",
41
- )
42
- get.add_argument(
43
- "-f",
44
- "--format",
45
- choices=_format_codes.keys(),
46
- default="long-datetime",
47
- help="The output format to use. The default is long-datetime",
48
- )
49
- get.add_argument(
50
- "-o",
51
- "--offset",
52
- help="An offset to apply to the date and time. Examples: 1d (one day), "
53
- "3h5m (three hours and five minutes), 5s (five seconds). The only valid "
54
- "units are d (days), h (hours), m (minutes), and s (seconds). Units can "
55
- "be repeated, for example 5s3d2s would be three days and seven (5+2) "
56
- "seconds. Units can be specified in any order. To specify a backwards "
57
- "offset, prefix the offset with b, for example b1d would be backwards "
58
- "one day",
59
- )
60
- get.add_argument(
61
- "-p",
62
- "--precision",
63
- default="1s",
64
- help="The precision to which the date and time should be rounded. "
65
- "Examples: 1s, 30m, 2h, 24h, 60s, 60m. The only valid units are h "
66
- "(hours), m (minutes), and s (seconds). The quantity must be a factor "
67
- "of 60 (minutes or seconds) or 24 (hours). The default is 1s",
68
- )
69
- get.set_defaults(
70
- print_help=get.print_help,
71
- func=_get,
72
- **config.parse(config.default_path()),
73
- )
74
-
75
-
76
- _format_codes = {
77
- "short-time": "t",
78
- "long-time": "T",
79
- "short-date": "d",
80
- "long-date": "D",
81
- "short-datetime": "f",
82
- "long-datetime": "F",
83
- "relative": "R",
84
- }
85
-
86
-
87
- def _get(args: argparse.Namespace) -> None:
88
- datetime = _get_datetime(args.date, args.time)
89
-
90
- if args.offset:
91
- datetime += parse.offset(args.offset)
92
-
93
- precision = parse.precision(args.precision)
94
- datetime_rounded = round.datetime(datetime, precision)
95
-
96
- timestamp = discord.timestamp(datetime_rounded, _format_codes[args.format])
97
- print(timestamp)
98
-
99
- if args.copy:
100
- pyperclip.copy(timestamp)
101
- print("Copied to clipboard!")
102
-
103
-
104
- def _get_datetime(date: str | None, time: str | None) -> dt.datetime:
105
- if not date:
106
- # Use current time if neither date nor time are provided.
107
- return dt.datetime.now()
108
- if not time:
109
- date_or_time = _parse_date_or_time(date)
110
- if isinstance(date_or_time, dt.date):
111
- # Use midnight if only date is provided.
112
- return dt.datetime.combine(date_or_time, dt.time())
113
- # Use current date if only time is provided.
114
- return dt.datetime.combine(dt.date.today(), date_or_time)
115
- # Note that it's not possible for time to be defined but not date because
116
- # they are both optional positional arguments.
117
- return dt.datetime.combine(parse.date(date), parse.time(time))
118
-
119
-
120
- def _parse_date_or_time(input: str) -> dt.date | dt.time:
121
- try:
122
- return parse.date(input)
123
- except exceptions.ParserFormatError:
124
- return parse.time(input)
@@ -1,23 +0,0 @@
1
- """The get timestamp command."""
2
-
3
- import argparse
4
-
5
- from dstamp import config
6
-
7
-
8
- def register(subparsers: argparse._SubParsersAction) -> None:
9
- """Register the get command as a subparser."""
10
- show_config: argparse.ArgumentParser = subparsers.add_parser(
11
- "show-config",
12
- help="Show the default config location and exit",
13
- description="Show the default configuration file location and exit",
14
- epilog="The config file should contain valid TOML. Valid keys are copy "
15
- "(bool), format (string), and precision (string), which override the "
16
- "default values for the corresponding options for other commands.",
17
- add_help=False,
18
- )
19
- show_config.set_defaults(print_help=show_config.print_help, func=_show_config)
20
-
21
-
22
- def _show_config(_: argparse.Namespace) -> None:
23
- print(config.default_path())
File without changes
File without changes
File without changes