dstamp 1.0.0__tar.gz → 2__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-2/PKG-INFO +103 -0
- dstamp-2/README.md +82 -0
- dstamp-2/pyproject.toml +83 -0
- dstamp-2/src/dstamp/__main__.py +5 -0
- dstamp-2/src/dstamp/config.py +12 -0
- {dstamp-1.0.0 → dstamp-2/src}/dstamp/console.py +2 -5
- dstamp-2/src/dstamp/format.py +24 -0
- dstamp-2/src/dstamp/main.py +172 -0
- dstamp-2/src/dstamp/parse/__init__.py +15 -0
- dstamp-2/src/dstamp/parse/datetime.py +127 -0
- dstamp-2/src/dstamp/parse/exceptions.py +13 -0
- dstamp-2/src/dstamp/parse/offset.py +62 -0
- dstamp-2/src/dstamp/parse/precision.py +30 -0
- dstamp-2/src/dstamp/round/__init__.py +10 -0
- dstamp-2/src/dstamp/round/precision.py +38 -0
- dstamp-2/src/dstamp/round/round.py +30 -0
- dstamp-1.0.0/LICENSE +0 -21
- dstamp-1.0.0/PKG-INFO +0 -79
- dstamp-1.0.0/README.md +0 -57
- dstamp-1.0.0/dstamp/__main__.py +0 -3
- dstamp-1.0.0/dstamp/clipboard.py +0 -26
- dstamp-1.0.0/dstamp/config.py +0 -76
- dstamp-1.0.0/dstamp/format.py +0 -29
- dstamp-1.0.0/dstamp/main.py +0 -211
- dstamp-1.0.0/dstamp/parse.py +0 -225
- dstamp-1.0.0/dstamp/round.py +0 -59
- dstamp-1.0.0/pyproject.toml +0 -56
- {dstamp-1.0.0 → dstamp-2/src}/dstamp/__init__.py +0 -0
dstamp-2/PKG-INFO
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dstamp
|
|
3
|
+
Version: 2
|
|
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: cyclopts>=4.11.0
|
|
14
|
+
Requires-Dist: platformdirs>=4.9.6
|
|
15
|
+
Requires-Dist: pyperclip>=1.11.0
|
|
16
|
+
Requires-Dist: pytest-mock>=3.15.1
|
|
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
|
+
## TODO
|
|
27
|
+
|
|
28
|
+
I intend to rewrite this project to use the standard argparse library instead
|
|
29
|
+
of cyclopts. This makes the whole typer-to-cyclopts migration seem rather silly.
|
|
30
|
+
My reasoning is that cyclopts is a bit too heavy-handed for my tastes, and I
|
|
31
|
+
prefer how argparse forces you to separate parsing from handling functions.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
Install using [pipx] or [uv]:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pipx install dstamp
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
uv tool install dstamp
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
[pipx]: https://pipx.pypa.io/stable/
|
|
46
|
+
[uv]: https://docs.astral.sh/uv/
|
|
47
|
+
|
|
48
|
+
## Example usage
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# For the current time
|
|
52
|
+
dstamp get
|
|
53
|
+
|
|
54
|
+
# Two hours in the future
|
|
55
|
+
dstamp get --offset 2h
|
|
56
|
+
|
|
57
|
+
# Round to the nearest hour
|
|
58
|
+
dstamp get --offset 2h --round --precision h
|
|
59
|
+
|
|
60
|
+
# Copy to clipboard
|
|
61
|
+
dstamp --copy-to-clipboard
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
For full documentation see the help messages:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
dstamp --help
|
|
68
|
+
dstamp get --help
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
dstamp accepts a TOML configuration file which can override any command-line
|
|
74
|
+
argument. Keys should be structured as `command.option`. For example, to enable
|
|
75
|
+
rounding by default and set a custom default rounding precision for the `get`
|
|
76
|
+
command, you could do the following:
|
|
77
|
+
|
|
78
|
+
```toml
|
|
79
|
+
[get]
|
|
80
|
+
round = true
|
|
81
|
+
precision = "15m"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Use the `show-config` command to find the default config location. Specify the
|
|
85
|
+
`--config` flag to use a different file.
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
dstamp show-config
|
|
89
|
+
dstamp --config ./path-to-a-different-file.toml
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Versioning
|
|
93
|
+
|
|
94
|
+
This project uses an `X.Y` versioning scheme. Bugfixes will move the version to
|
|
95
|
+
`X.Y+1`, and anything else will move it to `X+1.0`. If `Y` is 0, we omit it,
|
|
96
|
+
so `X+1.0` actually just becomes `X+1`.
|
|
97
|
+
|
|
98
|
+
## Contributing
|
|
99
|
+
|
|
100
|
+
If you'd like to see something added to dstamp or if you would like to add something
|
|
101
|
+
yourself, please see [CONTRIBUTING](./CONTRIBUTING.md)!
|
|
102
|
+
|
|
103
|
+
Please also see the above if you have a bug to report.
|
dstamp-2/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# dstamp
|
|
2
|
+
|
|
3
|
+
CLI app for generating timestamps for use in Discord chats.
|
|
4
|
+
|
|
5
|
+
## TODO
|
|
6
|
+
|
|
7
|
+
I intend to rewrite this project to use the standard argparse library instead
|
|
8
|
+
of cyclopts. This makes the whole typer-to-cyclopts migration seem rather silly.
|
|
9
|
+
My reasoning is that cyclopts is a bit too heavy-handed for my tastes, and I
|
|
10
|
+
prefer how argparse forces you to separate parsing from handling functions.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
Install using [pipx] or [uv]:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pipx install dstamp
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
uv tool install dstamp
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
[pipx]: https://pipx.pypa.io/stable/
|
|
25
|
+
[uv]: https://docs.astral.sh/uv/
|
|
26
|
+
|
|
27
|
+
## Example usage
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# For the current time
|
|
31
|
+
dstamp get
|
|
32
|
+
|
|
33
|
+
# Two hours in the future
|
|
34
|
+
dstamp get --offset 2h
|
|
35
|
+
|
|
36
|
+
# Round to the nearest hour
|
|
37
|
+
dstamp get --offset 2h --round --precision h
|
|
38
|
+
|
|
39
|
+
# Copy to clipboard
|
|
40
|
+
dstamp --copy-to-clipboard
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
For full documentation see the help messages:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
dstamp --help
|
|
47
|
+
dstamp get --help
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Configuration
|
|
51
|
+
|
|
52
|
+
dstamp accepts a TOML configuration file which can override any command-line
|
|
53
|
+
argument. Keys should be structured as `command.option`. For example, to enable
|
|
54
|
+
rounding by default and set a custom default rounding precision for the `get`
|
|
55
|
+
command, you could do the following:
|
|
56
|
+
|
|
57
|
+
```toml
|
|
58
|
+
[get]
|
|
59
|
+
round = true
|
|
60
|
+
precision = "15m"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Use the `show-config` command to find the default config location. Specify the
|
|
64
|
+
`--config` flag to use a different file.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
dstamp show-config
|
|
68
|
+
dstamp --config ./path-to-a-different-file.toml
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Versioning
|
|
72
|
+
|
|
73
|
+
This project uses an `X.Y` versioning scheme. Bugfixes will move the version to
|
|
74
|
+
`X.Y+1`, and anything else will move it to `X+1.0`. If `Y` is 0, we omit it,
|
|
75
|
+
so `X+1.0` actually just becomes `X+1`.
|
|
76
|
+
|
|
77
|
+
## Contributing
|
|
78
|
+
|
|
79
|
+
If you'd like to see something added to dstamp or if you would like to add something
|
|
80
|
+
yourself, please see [CONTRIBUTING](./CONTRIBUTING.md)!
|
|
81
|
+
|
|
82
|
+
Please also see the above if you have a bug to report.
|
dstamp-2/pyproject.toml
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dstamp"
|
|
3
|
+
version = "2"
|
|
4
|
+
description = "CLI app for generating timestamps for use in Discord chats."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [{ name = "Mariusz Tang", email = "dev@mariusztang.com" }]
|
|
9
|
+
keywords = ["discord", "generator", "timestamp"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Environment :: Console",
|
|
13
|
+
"Intended Audience :: End Users/Desktop",
|
|
14
|
+
"Topic :: Communications :: Chat",
|
|
15
|
+
]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"cyclopts>=4.11.0",
|
|
18
|
+
"platformdirs>=4.9.6",
|
|
19
|
+
"pyperclip>=1.11.0",
|
|
20
|
+
"pytest-mock>=3.15.1",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Issues = "https://github.com/mariusz-tang/dstamp/issues"
|
|
25
|
+
Repository = "https://github.com/mariusz-tang/dstamp"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
dstamp = "dstamp.main:app.meta"
|
|
29
|
+
|
|
30
|
+
[dependency-groups]
|
|
31
|
+
dev = [
|
|
32
|
+
"coverage>=7.13.5",
|
|
33
|
+
"freezegun>=1.5.5",
|
|
34
|
+
"pytest>=9.0.3",
|
|
35
|
+
"ty>=0.0.34",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["uv_build>=0.11.8,<0.12"]
|
|
40
|
+
build-backend = "uv_build"
|
|
41
|
+
|
|
42
|
+
[tool.coverage.run]
|
|
43
|
+
branch = true
|
|
44
|
+
command_line = "-m pytest"
|
|
45
|
+
source = ["src/"]
|
|
46
|
+
omit = ["src/dstamp/__main__.py", "src/dstamp/console.py"]
|
|
47
|
+
|
|
48
|
+
[tool.coverage.report]
|
|
49
|
+
skip_empty = true
|
|
50
|
+
skip_covered = true
|
|
51
|
+
|
|
52
|
+
[tool.coverage.html]
|
|
53
|
+
skip_empty = true
|
|
54
|
+
skip_covered = true
|
|
55
|
+
|
|
56
|
+
[tool.pytest]
|
|
57
|
+
addopts = ["--import-mode=importlib"]
|
|
58
|
+
strict = true
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint]
|
|
61
|
+
select = [
|
|
62
|
+
"ANN", # flake8-annotations
|
|
63
|
+
"ARG", # flake8-unused-arguments
|
|
64
|
+
"B", # flake8-bugbear
|
|
65
|
+
"C4", # flake8-comprehensions
|
|
66
|
+
"C90", # mccabe
|
|
67
|
+
"E", # pycodestyle error
|
|
68
|
+
"F", # pyflakes
|
|
69
|
+
"FURB", # refurb
|
|
70
|
+
"I", # isort
|
|
71
|
+
"N", # pep8-naming
|
|
72
|
+
"PT", # flake8-pytest-style
|
|
73
|
+
"PTH", # flake8-use-pathlib
|
|
74
|
+
"RET", # flake8-return
|
|
75
|
+
"RSE", # flake8-raise
|
|
76
|
+
"SIM", # flake8-simplify
|
|
77
|
+
"SLF", # flake8-self
|
|
78
|
+
"TRY", # tryceratops
|
|
79
|
+
"UP", # pyupgrade
|
|
80
|
+
"W", # pycodestyle warning
|
|
81
|
+
]
|
|
82
|
+
ignore = ["TRY003"] # This rule is too strict, especially for ValueError.
|
|
83
|
+
flake8-pytest-style.parametrize-names-type = "csv"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Datetime formatting utilities."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Format(StrEnum):
|
|
8
|
+
SHORT_TIME = "t"
|
|
9
|
+
LONG_TIME = "T"
|
|
10
|
+
SHORT_DATE = "d"
|
|
11
|
+
LONG_DATE = "D"
|
|
12
|
+
SHORT_DATETIME = "f"
|
|
13
|
+
LONG_DATETIME = "F"
|
|
14
|
+
RELATIVE = "R"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def human_readable(time: datetime) -> str:
|
|
18
|
+
return time.strftime("%I:%M:%S on %B %d, %Y")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def discord(time: datetime, format: Format) -> str:
|
|
22
|
+
"""Convert a datetime object into a Discord-friendly timestamp."""
|
|
23
|
+
timestamp = int(time.timestamp())
|
|
24
|
+
return f"<t:{timestamp}:{format.value}>"
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""CLI commands."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import cyclopts.config
|
|
8
|
+
import pyperclip
|
|
9
|
+
from cyclopts import App, Parameter
|
|
10
|
+
|
|
11
|
+
from dstamp import config, console, format, parse
|
|
12
|
+
from dstamp.round import Precision, time_to_precision
|
|
13
|
+
|
|
14
|
+
app = App(help="Discord timestamp generator")
|
|
15
|
+
app.register_install_completion_command(add_to_startup=False)
|
|
16
|
+
|
|
17
|
+
COPY_SUCCESS_TEXT = "Copied to clipboard!"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.meta.default
|
|
21
|
+
def launch_with_config_file(
|
|
22
|
+
*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)],
|
|
23
|
+
config_path: Annotated[Path | None, Parameter("config")] = None,
|
|
24
|
+
) -> int:
|
|
25
|
+
"""
|
|
26
|
+
Launch the application with a config file.
|
|
27
|
+
|
|
28
|
+
:param config_path: Alternate configuration file path.
|
|
29
|
+
"""
|
|
30
|
+
if config_path is None:
|
|
31
|
+
config_path = config.get_config_path()
|
|
32
|
+
app.config = cyclopts.config.Toml(config_path)
|
|
33
|
+
return app(tokens)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command(name="get")
|
|
37
|
+
def get_timestamp(
|
|
38
|
+
time: str | None = None,
|
|
39
|
+
/,
|
|
40
|
+
*,
|
|
41
|
+
offset: Annotated[str | None, Parameter(alias="-o")] = None,
|
|
42
|
+
output_format: Annotated[
|
|
43
|
+
format.Format, Parameter(alias="-f")
|
|
44
|
+
] = format.Format.RELATIVE,
|
|
45
|
+
copy_to_clipboard: Annotated[bool, Parameter(alias="-x")] = False,
|
|
46
|
+
round: Annotated[bool, Parameter(alias="-r")] = False,
|
|
47
|
+
precision: Annotated[str, Parameter(alias="-p")] = "10m",
|
|
48
|
+
) -> int:
|
|
49
|
+
r"""
|
|
50
|
+
Generate a Discord timestamp.
|
|
51
|
+
|
|
52
|
+
If TIME is omitted, uses the current time.
|
|
53
|
+
|
|
54
|
+
TIME should be of the form \<date>,\<time>.
|
|
55
|
+
Either \<date> or \<time> can be omitted, in which case you should omit the
|
|
56
|
+
comma as well.
|
|
57
|
+
|
|
58
|
+
\<date> should be of the following form: ddmmmyyyy.
|
|
59
|
+
|
|
60
|
+
dd and yyyy should be numbers.
|
|
61
|
+
|
|
62
|
+
mmm should be at least the first three letters of the month. For instance,
|
|
63
|
+
for August you can specify aug, augu, augus, or august.
|
|
64
|
+
|
|
65
|
+
You can omit the year to specify the current year. You can omit \<date>
|
|
66
|
+
entirely to specify the current date.
|
|
67
|
+
|
|
68
|
+
The command will also accept the following special values:
|
|
69
|
+
- today
|
|
70
|
+
- tmrw (tomorrow)
|
|
71
|
+
- tomorrow
|
|
72
|
+
- yesterday
|
|
73
|
+
|
|
74
|
+
\<time> should be of the following form: hhmmss(am/pm).
|
|
75
|
+
|
|
76
|
+
You can omit seconds to specify 0. If you do, you can omit minutes to specify
|
|
77
|
+
0 as well. If you omit (am/pm), 24-hour time will be used. You can omit \<time>
|
|
78
|
+
entirely to specify midnight.
|
|
79
|
+
|
|
80
|
+
If you omit both \<date> and \<time>, the current date and time will be used.
|
|
81
|
+
|
|
82
|
+
The command will also accept the following special values:
|
|
83
|
+
now (the current time, but not necessarily the current date),
|
|
84
|
+
midnight,
|
|
85
|
+
noon.
|
|
86
|
+
|
|
87
|
+
Examples:
|
|
88
|
+
- 5jun,1pm (5th June of the current year, 1:00pm)
|
|
89
|
+
- 8january2025,1530 (8th January 2025, 3:30pm)
|
|
90
|
+
- 25nov2000,13002pm (25th November 2000, 1:30:02pm)
|
|
91
|
+
- 1jan (1st January of the current year, midnight)
|
|
92
|
+
- 7feb2023,15 (7th February 2023, 3:00pm)
|
|
93
|
+
- 5 (the current date, 5:00am)
|
|
94
|
+
- 0 (the current date, midnight)
|
|
95
|
+
- tmrw,now (tomorrow, the current time, ie. 24 hours from the current time)
|
|
96
|
+
- tmrw (tomorrow, midnight)
|
|
97
|
+
|
|
98
|
+
:param time: The date and time to which the timestamp should point. If
|
|
99
|
+
omitted, the current time is used.
|
|
100
|
+
:param offset: Optional offset to apply to TIME. Optional offset to apply
|
|
101
|
+
to TIME. Examples: 2d3h1m, +3s, -3d+1d, 3m-3h2h. The only acceptable units
|
|
102
|
+
are d(ays), h(ours), m(inutes), and s(econds). Subtraction applies to all
|
|
103
|
+
times after the - until the next +. Units can be repeated.
|
|
104
|
+
:param output_format: The format in which the timestamp will be displayed
|
|
105
|
+
in Discord.
|
|
106
|
+
:param copy_to_clipboard: If set, copy the timestamp to clipboard. On Linux,
|
|
107
|
+
requires that xsel or xclip be installed.
|
|
108
|
+
:param do_rounding: If specified, round TIME based on PRECISION.
|
|
109
|
+
:param precision: The precision to which TIME will be rounded if ROUND is
|
|
110
|
+
specified.
|
|
111
|
+
"""
|
|
112
|
+
time_obj = try_parse(parse.datetime, time, "datetime")
|
|
113
|
+
time_delta = try_parse(parse.offset, offset, "offset")
|
|
114
|
+
|
|
115
|
+
if time_obj is None or time_delta is None:
|
|
116
|
+
return 1
|
|
117
|
+
|
|
118
|
+
time_obj = time_delta + time_obj
|
|
119
|
+
|
|
120
|
+
console.info(f"Using time: {format.human_readable(time_obj)}.")
|
|
121
|
+
|
|
122
|
+
if round:
|
|
123
|
+
try:
|
|
124
|
+
precision: Precision = parse.rounding_precision(precision)
|
|
125
|
+
except parse.ParserInputError as e:
|
|
126
|
+
console.error(str(e))
|
|
127
|
+
return 1
|
|
128
|
+
time_obj = time_to_precision(time_obj, precision)
|
|
129
|
+
console.info(f"After rounding: {format.human_readable(time_obj)}.")
|
|
130
|
+
|
|
131
|
+
output = format.discord(time_obj, output_format)
|
|
132
|
+
|
|
133
|
+
console.print(output)
|
|
134
|
+
|
|
135
|
+
if copy_to_clipboard:
|
|
136
|
+
try:
|
|
137
|
+
pyperclip.copy(output)
|
|
138
|
+
console.info(COPY_SUCCESS_TEXT)
|
|
139
|
+
except pyperclip.PyperclipException as e:
|
|
140
|
+
console.error(f"There was a problem with the clipboard manager:{e}")
|
|
141
|
+
return 1
|
|
142
|
+
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@app.command
|
|
147
|
+
def show_config() -> int:
|
|
148
|
+
"""Show the default config location."""
|
|
149
|
+
print(config.get_config_path())
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def try_parse[T](
|
|
154
|
+
parser: Callable[[str | None], T], raw_input: str | None, quantity_type: str
|
|
155
|
+
) -> T | None:
|
|
156
|
+
"""Attempt to parse a quantity, printing an error message on failure.
|
|
157
|
+
|
|
158
|
+
:param parser: Parsing function to use.
|
|
159
|
+
:param raw_input: Input string to parse.
|
|
160
|
+
:param quantity_type: Type of quantity being parsed. Used in the error
|
|
161
|
+
message.
|
|
162
|
+
|
|
163
|
+
:returns: The parsed quantity, or None on failure.
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
return parser(raw_input)
|
|
167
|
+
except parse.InvalidFormatError:
|
|
168
|
+
console.error(f"{raw_input} could not be parsed as a {quantity_type}.")
|
|
169
|
+
except parse.InvalidValueError:
|
|
170
|
+
console.error(f"{raw_input} represents an invalid {quantity_type}.")
|
|
171
|
+
|
|
172
|
+
return None
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Provides input parsers for various types."""
|
|
2
|
+
|
|
3
|
+
from .datetime import datetime
|
|
4
|
+
from .exceptions import InvalidFormatError, InvalidValueError, ParserInputError
|
|
5
|
+
from .offset import offset
|
|
6
|
+
from .precision import rounding_precision
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"datetime",
|
|
10
|
+
"InvalidFormatError",
|
|
11
|
+
"InvalidValueError",
|
|
12
|
+
"ParserInputError",
|
|
13
|
+
"offset",
|
|
14
|
+
"rounding_precision",
|
|
15
|
+
]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Provides input parsers for dates and times."""
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from .exceptions import InvalidFormatError, InvalidValueError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def datetime(raw_datetime: str | None) -> dt.datetime:
|
|
10
|
+
"""
|
|
11
|
+
Parse raw_datetime into a datetime.
|
|
12
|
+
|
|
13
|
+
raw_datetime should be of the form "ddmmmyyyy,hhmmss", albeit with some
|
|
14
|
+
flexibility and special values allowed.
|
|
15
|
+
|
|
16
|
+
If the date is omitted, use the current date.
|
|
17
|
+
If the only time is omitted, use midnight.
|
|
18
|
+
If both are omitted, use the current date and time.
|
|
19
|
+
|
|
20
|
+
Valid examples:
|
|
21
|
+
07jun2025,1230
|
|
22
|
+
9august,330pm
|
|
23
|
+
today,now
|
|
24
|
+
tomorrow
|
|
25
|
+
12pm
|
|
26
|
+
73002pm
|
|
27
|
+
noon
|
|
28
|
+
midnight
|
|
29
|
+
"""
|
|
30
|
+
if raw_datetime is None or raw_datetime == "":
|
|
31
|
+
return dt.datetime.now().replace(microsecond=0)
|
|
32
|
+
# Ensure there is both a date and time supplied.
|
|
33
|
+
if (x := raw_datetime.count(",")) == 0:
|
|
34
|
+
try:
|
|
35
|
+
return datetime("today," + raw_datetime)
|
|
36
|
+
except InvalidFormatError:
|
|
37
|
+
return datetime(raw_datetime + ",midnight")
|
|
38
|
+
elif x >= 2:
|
|
39
|
+
raise InvalidFormatError
|
|
40
|
+
|
|
41
|
+
datestr, timestr = raw_datetime.split(",")
|
|
42
|
+
parsed_date = _date(datestr)
|
|
43
|
+
parsed_time = _time(timestr).replace(microsecond=0)
|
|
44
|
+
timezone = dt.datetime.now().tzinfo
|
|
45
|
+
return dt.datetime.combine(parsed_date, parsed_time, timezone)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _date(datestr: str) -> dt.date:
|
|
49
|
+
if datestr == "today":
|
|
50
|
+
return dt.date.today()
|
|
51
|
+
|
|
52
|
+
if datestr in ("tmrw", "tomorrow"):
|
|
53
|
+
return dt.date.today() + dt.timedelta(days=1)
|
|
54
|
+
|
|
55
|
+
if datestr == "yesterday":
|
|
56
|
+
return dt.date.today() - dt.timedelta(days=1)
|
|
57
|
+
|
|
58
|
+
m = re.fullmatch(r"(\d{1,2})([a-zA-Z]{3,})(\d*)", datestr)
|
|
59
|
+
if m is None:
|
|
60
|
+
raise InvalidFormatError
|
|
61
|
+
|
|
62
|
+
day = int(m[1])
|
|
63
|
+
month = _get_month_from_shortening(m[2].lower())
|
|
64
|
+
year = int(m[3]) if m[3] else dt.date.today().year
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
return dt.date(year, month, day)
|
|
68
|
+
except ValueError as e:
|
|
69
|
+
raise InvalidValueError from e
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _time(timestr: str) -> dt.time:
|
|
73
|
+
if timestr == "now":
|
|
74
|
+
return dt.datetime.now().time()
|
|
75
|
+
|
|
76
|
+
if timestr == "midnight":
|
|
77
|
+
return dt.time()
|
|
78
|
+
|
|
79
|
+
if timestr == "noon":
|
|
80
|
+
return dt.time(12)
|
|
81
|
+
|
|
82
|
+
m = re.fullmatch(r"(\d{1,2}?)(\d{2})?(\d{2})?([ap]m)?", timestr)
|
|
83
|
+
if m is None:
|
|
84
|
+
raise InvalidFormatError
|
|
85
|
+
|
|
86
|
+
hour = int(m[1])
|
|
87
|
+
minute = int(m[2]) if m[2] else 0
|
|
88
|
+
second = int(m[3]) if m[3] else 0
|
|
89
|
+
noonstr = m[4]
|
|
90
|
+
|
|
91
|
+
if noonstr is not None and (hour == 0 or hour > 12):
|
|
92
|
+
# Disallow noonstrings with 24h format.
|
|
93
|
+
raise InvalidFormatError
|
|
94
|
+
|
|
95
|
+
if noonstr == "pm" and hour != 12:
|
|
96
|
+
hour += 12
|
|
97
|
+
elif noonstr == "am" and hour == 12:
|
|
98
|
+
hour = 0
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
return dt.time(hour, minute, second)
|
|
102
|
+
except ValueError as e:
|
|
103
|
+
raise InvalidValueError from e
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
_months = [
|
|
107
|
+
"january",
|
|
108
|
+
"february",
|
|
109
|
+
"march",
|
|
110
|
+
"april",
|
|
111
|
+
"may",
|
|
112
|
+
"june",
|
|
113
|
+
"july",
|
|
114
|
+
"august",
|
|
115
|
+
"september",
|
|
116
|
+
"october",
|
|
117
|
+
"november",
|
|
118
|
+
"december",
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _get_month_from_shortening(shortening: str) -> int:
|
|
123
|
+
"""Returns the index of the first month which starts with the given string."""
|
|
124
|
+
for ix, name in enumerate(_months):
|
|
125
|
+
if name.startswith(shortening):
|
|
126
|
+
return ix + 1
|
|
127
|
+
raise InvalidFormatError
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Parsing-related exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ParserInputError(ValueError):
|
|
5
|
+
"""Raised when there is a problem with the input received by a parser."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InvalidFormatError(ParserInputError):
|
|
9
|
+
"""Raised when a parser is provided an improperly-formatted value."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InvalidValueError(ParserInputError):
|
|
13
|
+
"""Raised when a parser is provided a correctly-formatted but invalid value."""
|