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.
- {dstamp-3 → dstamp-4}/PKG-INFO +15 -5
- {dstamp-3 → dstamp-4}/README.md +12 -3
- {dstamp-3 → dstamp-4}/pyproject.toml +4 -2
- {dstamp-3 → dstamp-4}/src/dstamp/__init__.py +2 -0
- dstamp-4/src/dstamp/cli.py +172 -0
- dstamp-4/src/dstamp/config.py +40 -0
- dstamp-4/src/dstamp/logging.py +52 -0
- {dstamp-3 → dstamp-4}/src/dstamp/parse.py +3 -3
- dstamp-4/src/dstamp/subcommands/__init__.py +15 -0
- dstamp-4/src/dstamp/subcommands/get.py +182 -0
- dstamp-4/src/dstamp/subcommands/show_config.py +25 -0
- dstamp-4/src/dstamp/subcommands/show_log.py +21 -0
- dstamp-3/src/dstamp/cli.py +0 -53
- dstamp-3/src/dstamp/config.py +0 -46
- dstamp-3/src/dstamp/subcommands/__init__.py +0 -14
- dstamp-3/src/dstamp/subcommands/get.py +0 -124
- dstamp-3/src/dstamp/subcommands/show_config.py +0 -23
- {dstamp-3 → dstamp-4}/src/dstamp/discord.py +0 -0
- {dstamp-3 → dstamp-4}/src/dstamp/exceptions.py +0 -0
- {dstamp-3 → dstamp-4}/src/dstamp/round.py +0 -0
{dstamp-3 → dstamp-4}/PKG-INFO
RENAMED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dstamp
|
|
3
|
-
Version:
|
|
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 ::
|
|
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
|
-
#
|
|
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
|
|
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
|
|
{dstamp-3 → dstamp-4}/README.md
RENAMED
|
@@ -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
|
-
#
|
|
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
|
|
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
|
+
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 ::
|
|
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
|
|
@@ -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
|
-
|
|
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(
|
|
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 =
|
|
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)
|
dstamp-3/src/dstamp/cli.py
DELETED
|
@@ -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))
|
dstamp-3/src/dstamp/config.py
DELETED
|
@@ -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
|