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 +86 -0
- dstamp-3/README.md +66 -0
- {dstamp-2 → dstamp-3}/pyproject.toml +8 -8
- dstamp-3/src/dstamp/__init__.py +1 -0
- dstamp-3/src/dstamp/cli.py +53 -0
- dstamp-3/src/dstamp/config.py +46 -0
- dstamp-3/src/dstamp/discord.py +14 -0
- dstamp-3/src/dstamp/exceptions.py +62 -0
- dstamp-3/src/dstamp/parse.py +129 -0
- dstamp-3/src/dstamp/round.py +88 -0
- dstamp-3/src/dstamp/subcommands/__init__.py +14 -0
- dstamp-3/src/dstamp/subcommands/get.py +124 -0
- dstamp-3/src/dstamp/subcommands/show_config.py +23 -0
- dstamp-2/PKG-INFO +0 -103
- dstamp-2/README.md +0 -82
- dstamp-2/src/dstamp/__init__.py +0 -0
- dstamp-2/src/dstamp/__main__.py +0 -5
- dstamp-2/src/dstamp/config.py +0 -12
- dstamp-2/src/dstamp/console.py +0 -19
- dstamp-2/src/dstamp/format.py +0 -24
- dstamp-2/src/dstamp/main.py +0 -172
- dstamp-2/src/dstamp/parse/__init__.py +0 -15
- dstamp-2/src/dstamp/parse/datetime.py +0 -127
- dstamp-2/src/dstamp/parse/exceptions.py +0 -13
- dstamp-2/src/dstamp/parse/offset.py +0 -62
- dstamp-2/src/dstamp/parse/precision.py +0 -30
- dstamp-2/src/dstamp/round/__init__.py +0 -10
- dstamp-2/src/dstamp/round/precision.py +0 -38
- dstamp-2/src/dstamp/round/round.py +0 -30
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 = "
|
|
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
|
-
"
|
|
18
|
-
"platformdirs>=4.
|
|
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.
|
|
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
|
-
|
|
83
|
-
|
|
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"]
|