dstamp 2__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-4/PKG-INFO ADDED
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: dstamp
3
+ Version: 4
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 :: 5 - Production/Stable
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: End Users/Desktop
12
+ Classifier: Topic :: Communications :: Chat
13
+ Requires-Dist: argcomplete>=3.6.3
14
+ Requires-Dist: logassert>=8.6
15
+ Requires-Dist: platformdirs>=4.10.0
16
+ Requires-Dist: pyperclip>=1.11.0
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
+ *Note*: This is a complete rewrite of the original project from the ground up,
27
+ using the standard argparse module instead of cyclopts.
28
+
29
+ ## Installation
30
+
31
+ Install using [pipx] or [uv]:
32
+
33
+ ```bash
34
+ pipx install dstamp
35
+ ```
36
+
37
+ ```bash
38
+ uv tool install dstamp
39
+ ```
40
+
41
+ [pipx]: https://pipx.pypa.io/stable/
42
+ [uv]: https://docs.astral.sh/uv/
43
+
44
+ ## Usage
45
+
46
+ ```bash
47
+ # Get the current time.
48
+ dstamp get
49
+
50
+ # Get the current time, to the nearest 15 minutes.
51
+ dstamp get --precision 15m
52
+
53
+ # Get the time 20 minutes from now.
54
+ dstamp in 20m
55
+
56
+ # Show the default config file location.
57
+ dstamp show-config
58
+ ```
59
+
60
+ See the help messages for full usage information:
61
+
62
+ ```bash
63
+ dstamp --help
64
+ dstamp -h get
65
+ dstamp -h in
66
+ ```
67
+
68
+ ### Configuration
69
+
70
+ Dstamp supports configuration by TOML file. See `dstamp -h show-config` for full
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.
77
+
78
+ ## Shell completion
79
+
80
+ Dstamp supports shell completion using [argcomplete]. See the argcomplete
81
+ documentation for setup instructions.
82
+
83
+ [argcomplete]: https://github.com/kislyuk/argcomplete#activating-global-completion
84
+
85
+ ## Versioning
86
+
87
+ This project uses an `MAJOR.MINOR` versioning scheme. `MAJOR` is incremented for
88
+ significant feature additions or changes. Everything else increments `MINOR`
89
+ only. `MINOR` is omitted if it is zero.
90
+
91
+ ## Contributing
92
+
93
+ If you'd like to see something added to dstamp or if you would like to add something
94
+ yourself, please see [CONTRIBUTING](./CONTRIBUTING.md)!
95
+
96
+ Please also see the above if you have a bug to report.
dstamp-4/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # dstamp
2
+
3
+ CLI app for generating timestamps for use in Discord chats.
4
+
5
+ *Note*: This is a complete rewrite of the original project from the ground up,
6
+ using the standard argparse module instead of cyclopts.
7
+
8
+ ## Installation
9
+
10
+ Install using [pipx] or [uv]:
11
+
12
+ ```bash
13
+ pipx install dstamp
14
+ ```
15
+
16
+ ```bash
17
+ uv tool install dstamp
18
+ ```
19
+
20
+ [pipx]: https://pipx.pypa.io/stable/
21
+ [uv]: https://docs.astral.sh/uv/
22
+
23
+ ## Usage
24
+
25
+ ```bash
26
+ # Get the current time.
27
+ dstamp get
28
+
29
+ # Get the current time, to the nearest 15 minutes.
30
+ dstamp get --precision 15m
31
+
32
+ # Get the time 20 minutes from now.
33
+ dstamp in 20m
34
+
35
+ # Show the default config file location.
36
+ dstamp show-config
37
+ ```
38
+
39
+ See the help messages for full usage information:
40
+
41
+ ```bash
42
+ dstamp --help
43
+ dstamp -h get
44
+ dstamp -h in
45
+ ```
46
+
47
+ ### Configuration
48
+
49
+ Dstamp supports configuration by TOML file. See `dstamp -h show-config` for full
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.
56
+
57
+ ## Shell completion
58
+
59
+ Dstamp supports shell completion using [argcomplete]. See the argcomplete
60
+ documentation for setup instructions.
61
+
62
+ [argcomplete]: https://github.com/kislyuk/argcomplete#activating-global-completion
63
+
64
+ ## Versioning
65
+
66
+ This project uses an `MAJOR.MINOR` versioning scheme. `MAJOR` is incremented for
67
+ significant feature additions or changes. Everything else increments `MINOR`
68
+ only. `MINOR` is omitted if it is zero.
69
+
70
+ ## Contributing
71
+
72
+ If you'd like to see something added to dstamp or if you would like to add something
73
+ yourself, please see [CONTRIBUTING](./CONTRIBUTING.md)!
74
+
75
+ Please also see the above if you have a bug to report.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dstamp"
3
- version = "2"
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,16 +8,16 @@ 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
- "cyclopts>=4.11.0",
18
- "platformdirs>=4.9.6",
17
+ "argcomplete>=3.6.3",
18
+ "logassert>=8.6",
19
+ "platformdirs>=4.10.0",
19
20
  "pyperclip>=1.11.0",
20
- "pytest-mock>=3.15.1",
21
21
  ]
22
22
 
23
23
  [project.urls]
@@ -25,13 +25,14 @@ Issues = "https://github.com/mariusz-tang/dstamp/issues"
25
25
  Repository = "https://github.com/mariusz-tang/dstamp"
26
26
 
27
27
  [project.scripts]
28
- dstamp = "dstamp.main:app.meta"
28
+ dstamp = "dstamp.cli:run"
29
29
 
30
30
  [dependency-groups]
31
31
  dev = [
32
32
  "coverage>=7.13.5",
33
33
  "freezegun>=1.5.5",
34
34
  "pytest>=9.0.3",
35
+ "pytest-mock>=3.15.1",
35
36
  "ty>=0.0.34",
36
37
  ]
37
38
 
@@ -43,7 +44,6 @@ build-backend = "uv_build"
43
44
  branch = true
44
45
  command_line = "-m pytest"
45
46
  source = ["src/"]
46
- omit = ["src/dstamp/__main__.py", "src/dstamp/console.py"]
47
47
 
48
48
  [tool.coverage.report]
49
49
  skip_empty = true
@@ -64,6 +64,7 @@ select = [
64
64
  "B", # flake8-bugbear
65
65
  "C4", # flake8-comprehensions
66
66
  "C90", # mccabe
67
+ "D", # pydocstyle
67
68
  "E", # pycodestyle error
68
69
  "F", # pyflakes
69
70
  "FURB", # refurb
@@ -79,5 +80,6 @@ select = [
79
80
  "UP", # pyupgrade
80
81
  "W", # pycodestyle warning
81
82
  ]
82
- ignore = ["TRY003"] # This rule is too strict, especially for ValueError.
83
- flake8-pytest-style.parametrize-names-type = "csv"
83
+ pydocstyle.convention = "pep257"
84
+ per-file-ignores = { "tests/**/*" = ["D"] }
85
+ flake8-annotations.allow-star-arg-any = true
@@ -0,0 +1,3 @@
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,14 @@
1
+ """Discord-related formatting utilities."""
2
+
3
+ from datetime import datetime
4
+
5
+
6
+ def timestamp(time: datetime, format_code: str) -> str:
7
+ """Return a Discord timestamp representing `time`.
8
+
9
+ :param time: The time to respresent. If it has a fractional timestamp, it
10
+ is truncated towards zero.
11
+ :param format_code: The format code to use. Tells Discord how to format the
12
+ timestamp.
13
+ """
14
+ return f"<t:{int(time.timestamp())}:{format_code}>"
@@ -0,0 +1,62 @@
1
+ """Exception classes for errors raised by dstamp."""
2
+
3
+ from dstamp import round
4
+
5
+
6
+ class DstampError(Exception):
7
+ """Base class for all exceptions raised by dstamp."""
8
+
9
+
10
+ class ParserInputError(DstampError):
11
+ """Base class for parser input errors."""
12
+
13
+ def __init__(self, input: str, output_type: type) -> None:
14
+ """Initialize an error instance with a formatted error message.
15
+
16
+ :param input: The input string which caused the parser to fail.
17
+ :param output_type: The expected output type.
18
+ """
19
+ self.input = input
20
+ self.output_type = output_type
21
+ super().__init__(self._format_message())
22
+
23
+ def _format_message(self) -> str:
24
+ raise NotImplementedError # pragma: no cover
25
+
26
+
27
+ class ParserFormatError(ParserInputError):
28
+ """Raised when the input to a parser was not in the right format."""
29
+
30
+ def _format_message(self) -> str:
31
+ return f"invalid {self.output_type.__name__.lower()} format: {repr(self.input)}"
32
+
33
+
34
+ class ParserValueError(ParserInputError):
35
+ """Raised when an invalid time object would be created."""
36
+
37
+ def _format_message(self) -> str:
38
+ return (
39
+ f"input represents an invalid {self.output_type.__name__.lower()}: "
40
+ f"{repr(self.input)}"
41
+ )
42
+
43
+
44
+ class PrecisionQuantityError(DstampError):
45
+ """Raised when the `Precision` constructor receives an invalid quantity."""
46
+
47
+ def __init__(self, quantity: int, unit: round.Unit) -> None:
48
+ """Initialize an error instance with a formatted error message.
49
+
50
+ :param quantity: The quantity passed to the `Precision` constructor.
51
+ :param unit: The unit object passed to the `Precision` constructor.
52
+ """
53
+ self.quantity = quantity
54
+ self.unit = unit
55
+ super().__init__(self._format_message())
56
+
57
+ def _format_message(self) -> str:
58
+ return (
59
+ f"quantity for {self.unit.name.lower()} must be between 1 and "
60
+ f"{self.unit.max_quantity} (inclusive), and must be a factor of "
61
+ f"{self.unit.max_quantity} (actual quantity was {self.quantity})."
62
+ )
@@ -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