dstamp 2__tar.gz → 3__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/PKG-INFO ADDED
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: dstamp
3
+ Version: 3
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: argcomplete>=3.6.3
14
+ Requires-Dist: platformdirs>=4.10.0
15
+ Requires-Dist: pyperclip>=1.11.0
16
+ Requires-Python: >=3.14
17
+ Project-URL: Issues, https://github.com/mariusz-tang/dstamp/issues
18
+ Project-URL: Repository, https://github.com/mariusz-tang/dstamp
19
+ Description-Content-Type: text/markdown
20
+
21
+ # dstamp
22
+
23
+ CLI app for generating timestamps for use in Discord chats.
24
+
25
+ *Note*: This is a complete rewrite of the original project from the ground up,
26
+ using the standard argparse module instead of cyclopts.
27
+
28
+ ## Installation
29
+
30
+ Install using [pipx] or [uv]:
31
+
32
+ ```bash
33
+ pipx install dstamp
34
+ ```
35
+
36
+ ```bash
37
+ uv tool install dstamp
38
+ ```
39
+
40
+ [pipx]: https://pipx.pypa.io/stable/
41
+ [uv]: https://docs.astral.sh/uv/
42
+
43
+ ## Usage
44
+
45
+ ```bash
46
+ # Get the current time.
47
+ dstamp get
48
+
49
+ # Get the current time, to the nearest 15 minutes.
50
+ dstamp get --precision 15m
51
+
52
+ # Show the config file location.
53
+ dstamp show-config
54
+ ```
55
+
56
+ See the help messages for full usage information:
57
+
58
+ ```bash
59
+ dstamp -h
60
+ dstamp -h get
61
+ ```
62
+
63
+ ### Configuration
64
+
65
+ Dstamp supports configuration by TOML file. See `dstamp -h show-config` for full
66
+ information.
67
+
68
+ ## Shell completion
69
+
70
+ Dstamp supports shell completion using [argcomplete]. See the argcomplete
71
+ documentation for setup instructions.
72
+
73
+ [argcomplete]: https://github.com/kislyuk/argcomplete#activating-global-completion
74
+
75
+ ## Versioning
76
+
77
+ This project uses an `MAJOR.MINOR` versioning scheme. `MAJOR` is incremented for
78
+ significant feature additions or changes. Everything else increments `MINOR`
79
+ only. `MINOR` is omitted if it is zero.
80
+
81
+ ## Contributing
82
+
83
+ If you'd like to see something added to dstamp or if you would like to add something
84
+ yourself, please see [CONTRIBUTING](./CONTRIBUTING.md)!
85
+
86
+ Please also see the above if you have a bug to report.
dstamp-3/README.md ADDED
@@ -0,0 +1,66 @@
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
+ # Show the config file location.
33
+ dstamp show-config
34
+ ```
35
+
36
+ See the help messages for full usage information:
37
+
38
+ ```bash
39
+ dstamp -h
40
+ dstamp -h get
41
+ ```
42
+
43
+ ### Configuration
44
+
45
+ Dstamp supports configuration by TOML file. See `dstamp -h show-config` for full
46
+ information.
47
+
48
+ ## Shell completion
49
+
50
+ Dstamp supports shell completion using [argcomplete]. See the argcomplete
51
+ documentation for setup instructions.
52
+
53
+ [argcomplete]: https://github.com/kislyuk/argcomplete#activating-global-completion
54
+
55
+ ## Versioning
56
+
57
+ This project uses an `MAJOR.MINOR` versioning scheme. `MAJOR` is incremented for
58
+ significant feature additions or changes. Everything else increments `MINOR`
59
+ only. `MINOR` is omitted if it is zero.
60
+
61
+ ## Contributing
62
+
63
+ If you'd like to see something added to dstamp or if you would like to add something
64
+ yourself, please see [CONTRIBUTING](./CONTRIBUTING.md)!
65
+
66
+ 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 = "3"
4
4
  description = "CLI app for generating timestamps for use in Discord chats."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -14,10 +14,9 @@ classifiers = [
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
+ "platformdirs>=4.10.0",
19
19
  "pyperclip>=1.11.0",
20
- "pytest-mock>=3.15.1",
21
20
  ]
22
21
 
23
22
  [project.urls]
@@ -25,13 +24,14 @@ Issues = "https://github.com/mariusz-tang/dstamp/issues"
25
24
  Repository = "https://github.com/mariusz-tang/dstamp"
26
25
 
27
26
  [project.scripts]
28
- dstamp = "dstamp.main:app.meta"
27
+ dstamp = "dstamp.cli:run"
29
28
 
30
29
  [dependency-groups]
31
30
  dev = [
32
31
  "coverage>=7.13.5",
33
32
  "freezegun>=1.5.5",
34
33
  "pytest>=9.0.3",
34
+ "pytest-mock>=3.15.1",
35
35
  "ty>=0.0.34",
36
36
  ]
37
37
 
@@ -43,7 +43,6 @@ build-backend = "uv_build"
43
43
  branch = true
44
44
  command_line = "-m pytest"
45
45
  source = ["src/"]
46
- omit = ["src/dstamp/__main__.py", "src/dstamp/console.py"]
47
46
 
48
47
  [tool.coverage.report]
49
48
  skip_empty = true
@@ -64,6 +63,7 @@ select = [
64
63
  "B", # flake8-bugbear
65
64
  "C4", # flake8-comprehensions
66
65
  "C90", # mccabe
66
+ "D", # pydocstyle
67
67
  "E", # pycodestyle error
68
68
  "F", # pyflakes
69
69
  "FURB", # refurb
@@ -79,5 +79,5 @@ select = [
79
79
  "UP", # pyupgrade
80
80
  "W", # pycodestyle warning
81
81
  ]
82
- ignore = ["TRY003"] # This rule is too strict, especially for ValueError.
83
- flake8-pytest-style.parametrize-names-type = "csv"
82
+ pydocstyle.convention = "pep257"
83
+ per-file-ignores = { "tests/**/*" = ["D"] }
@@ -0,0 +1 @@
1
+ """CLI tool for printing Discord timestamps."""
@@ -0,0 +1,53 @@
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))
@@ -0,0 +1,46 @@
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
@@ -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,129 @@
1
+ """Input-parsing utilities."""
2
+
3
+ import datetime as dt
4
+ import re
5
+ from collections import Counter
6
+
7
+ from dstamp import exceptions, round
8
+
9
+ MONTHS = [
10
+ "jan",
11
+ "feb",
12
+ "mar",
13
+ "apr",
14
+ "may",
15
+ "jun",
16
+ "jul",
17
+ "aug",
18
+ "sep",
19
+ "oct",
20
+ "nov",
21
+ "dec",
22
+ ]
23
+
24
+
25
+ def date(input: str) -> dt.date:
26
+ """Parse `input` as a date."""
27
+ if input == "today":
28
+ return dt.date.today()
29
+ if input in ["tomorrow", "tmrw"]:
30
+ return dt.date.today() + dt.timedelta(1)
31
+ if input == "yesterday":
32
+ return dt.date.today() - dt.timedelta(1)
33
+
34
+ m = re.fullmatch(rf"(\d+)({'|'.join(MONTHS)})(\d+)?", input.lower())
35
+ if not m:
36
+ raise exceptions.ParserFormatError(input, dt.date)
37
+
38
+ day = int(m[1])
39
+ month = MONTHS.index(m[2]) + 1
40
+ year = int(m[3]) if m[3] else dt.date.today().year
41
+
42
+ try:
43
+ return dt.date(year, month, day)
44
+ except ValueError as e:
45
+ raise exceptions.ParserValueError(input, dt.date) from e
46
+
47
+
48
+ def time(input: str) -> dt.time:
49
+ """Parse `input` as a time."""
50
+ if input == "now":
51
+ return dt.datetime.now().time()
52
+ if input == "midnight":
53
+ return dt.time()
54
+ if input == "noon":
55
+ return dt.time(12)
56
+
57
+ m = re.fullmatch(r"(\d{1,2})(\d{2})?(\d{2})?(am|pm)?", input.lower())
58
+ if not m:
59
+ raise exceptions.ParserFormatError(input, dt.time)
60
+
61
+ hour = int(m[1])
62
+ if ampm := m[4]:
63
+ if not 1 <= hour <= 12:
64
+ # Cannot use 24 hour time and ampm simultaneously.
65
+ raise exceptions.ParserFormatError(input, dt.time)
66
+ if ampm == "pm" and hour != 12:
67
+ hour += 12
68
+ elif ampm == "am" and hour == 12:
69
+ # 12am is the 0th hour.
70
+ hour = 0
71
+
72
+ minute = int(m[2]) if m[2] else 0
73
+ second = int(m[3]) if m[3] else 0
74
+
75
+ try:
76
+ return dt.time(hour, minute, second)
77
+ except ValueError as e:
78
+ raise exceptions.ParserValueError(input, dt.time) from e
79
+
80
+
81
+ _OFFSET_UNITS = {
82
+ "d": "days",
83
+ "h": "hours",
84
+ "m": "minutes",
85
+ "s": "seconds",
86
+ }
87
+
88
+
89
+ def offset(input: str) -> dt.timedelta:
90
+ """Parse `input` as a time offset, represented by a `timedelta` object."""
91
+ unit_pattern = rf"(\d+)([{''.join(_OFFSET_UNITS.keys())}])"
92
+
93
+ if not re.fullmatch(rf"b?({unit_pattern})+", input.lower()):
94
+ raise exceptions.ParserFormatError(input, dt.timedelta)
95
+
96
+ kwargs = Counter()
97
+ for quantity, unit_code in re.findall(unit_pattern, input.lower()):
98
+ unit = _OFFSET_UNITS[unit_code]
99
+ kwargs[unit] += int(quantity)
100
+
101
+ result = dt.timedelta(**kwargs)
102
+
103
+ if input.lower().startswith("b"):
104
+ # Return the backwards offset.
105
+ result *= -1
106
+
107
+ return result
108
+
109
+
110
+ _ROUNDING_UNITS = {
111
+ "h": round.Unit.HOUR,
112
+ "m": round.Unit.MINUTE,
113
+ "s": round.Unit.SECOND,
114
+ }
115
+
116
+
117
+ def precision(input: str) -> round.Precision:
118
+ """Parse `input` as a rounding precision.
119
+
120
+ Raises `PrecisionQuantityError` instead of `ParserValueError` if the
121
+ precision quantity is invalid.
122
+ """
123
+ m = re.fullmatch(rf"(\d+)([{''.join(_ROUNDING_UNITS.keys())}])", input.lower())
124
+ if not m:
125
+ raise exceptions.ParserFormatError(input, round.Precision)
126
+
127
+ quantity = int(m[1])
128
+ unit = _ROUNDING_UNITS[m[2]]
129
+ return round.Precision(quantity, unit)
@@ -0,0 +1,88 @@
1
+ """Contains utilities for rounding datetime objects."""
2
+
3
+ import datetime as dt
4
+ from enum import Enum
5
+
6
+ from dstamp import exceptions
7
+
8
+
9
+ class Unit(Enum):
10
+ """Represents a rounding unit."""
11
+
12
+ HOUR = "hour", "h", 24
13
+ MINUTE = "minute", "m", 60
14
+ SECOND = "second", "s", 60
15
+
16
+ def __init__(self, attribute_nanme: str, code: str, max_quantity: int) -> None:
17
+ """Initialize a Unit instance."""
18
+ self.attribute_name = attribute_nanme
19
+ self.code = code
20
+ self.max_quantity = max_quantity
21
+
22
+
23
+ class Precision:
24
+ """Represents a rounding precision."""
25
+
26
+ def __init__(self, quantity: int, unit: Unit) -> None:
27
+ """Intialize a Precision instance.
28
+
29
+ `quantity` should be a factor of `unit.max_quantity` between 1 and
30
+ unit.max_quantity (inclusive).
31
+ """
32
+ if (
33
+ quantity <= 0
34
+ or quantity > unit.max_quantity
35
+ or unit.max_quantity % quantity != 0
36
+ ):
37
+ raise exceptions.PrecisionQuantityError(quantity, unit)
38
+
39
+ self.quantity = quantity
40
+ self.unit = unit
41
+
42
+ def __eq__(self, other: object) -> bool:
43
+ """Compare equality of this Precision object with `other`."""
44
+ if type(other) is Precision:
45
+ return self.quantity == other.quantity and self.unit == other.unit
46
+
47
+ return NotImplemented
48
+
49
+
50
+ def datetime(datetime: dt.datetime, precision: Precision) -> dt.datetime:
51
+ """Round `datetime` to `precision`."""
52
+ datetime_rounded_down = _round_down(datetime, precision)
53
+
54
+ # Find the difference between the original and the rounded-down time,
55
+ # and between the rounded-up time and rounded-down time.
56
+ difference_original_down = datetime - datetime_rounded_down
57
+ attr_name = precision.unit.attribute_name + "s"
58
+ difference_up_down = dt.timedelta(**{attr_name: precision.quantity})
59
+
60
+ # Round down if the original was less than halfway to the rounded-up time.
61
+ if difference_original_down < difference_up_down / 2:
62
+ return datetime_rounded_down
63
+
64
+ # Otherwise, round up.
65
+ return datetime_rounded_down + difference_up_down
66
+
67
+
68
+ def _round_down(datetime: dt.datetime, precision: Precision) -> dt.datetime:
69
+ # Truncate smaller units.
70
+ datetime_trunacted = _truncate(datetime, precision.unit)
71
+
72
+ # Round the `precision.unit` unit down.
73
+ attr_name = precision.unit.attribute_name
74
+ value_original = getattr(datetime, attr_name)
75
+ value_rounded_down = value_original - value_original % precision.quantity
76
+
77
+ return datetime_trunacted.replace(**{attr_name: value_rounded_down})
78
+
79
+
80
+ def _truncate(datetime: dt.datetime, unit: Unit) -> dt.datetime:
81
+ """Truncate `datetime` so that `unit` is the smallest non-zero unit."""
82
+ datetime_trunacted = datetime.replace(microsecond=0)
83
+ if unit != Unit.SECOND:
84
+ datetime_trunacted = datetime_trunacted.replace(second=0)
85
+ if unit != Unit.MINUTE:
86
+ datetime_trunacted = datetime_trunacted.replace(minute=0)
87
+
88
+ return datetime_trunacted
@@ -0,0 +1,14 @@
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"]