yumako 0.1.7__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.
- yumako-0.1.7/LICENSE +21 -0
- yumako-0.1.7/PKG-INFO +30 -0
- yumako-0.1.7/README.md +7 -0
- yumako-0.1.7/pyproject.toml +88 -0
- yumako-0.1.7/src/yumako/__init__.py +34 -0
- yumako-0.1.7/src/yumako/template.py +23 -0
- yumako-0.1.7/src/yumako/time.py +353 -0
yumako-0.1.7/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Nanw1103
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
yumako-0.1.7/PKG-INFO
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: yumako
|
|
3
|
+
Version: 0.1.7
|
|
4
|
+
Summary:
|
|
5
|
+
Home-page: https://github.com/nanw1103/yumako
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: util,vanilla
|
|
8
|
+
Author: Nanw1103
|
|
9
|
+
Requires-Python: >=3.9,<4.0
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Project-URL: Documentation, https://github.com/nanw1103/amyumakober#readme
|
|
20
|
+
Project-URL: Repository, https://github.com/nanw1103/yumako
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# Yumako 
|
|
24
|
+
|
|
25
|
+
Vanilla python utilities
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
yumako-0.1.7/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "yumako"
|
|
3
|
+
version = "0.1.7"
|
|
4
|
+
description = ""
|
|
5
|
+
authors = ["Nanw1103"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
homepage = "https://github.com/nanw1103/yumako"
|
|
9
|
+
repository = "https://github.com/nanw1103/yumako"
|
|
10
|
+
documentation = "https://github.com/nanw1103/amyumakober#readme"
|
|
11
|
+
keywords = ["util", "vanilla"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3.9",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
packages = [
|
|
24
|
+
{ include = "yumako", from = "src", format = ["sdist"] }
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.poetry.dependencies]
|
|
28
|
+
python = "^3.9"
|
|
29
|
+
|
|
30
|
+
[tool.poetry.group.dev.dependencies]
|
|
31
|
+
pytest = "^7.4.3"
|
|
32
|
+
black = "^23.11.0"
|
|
33
|
+
isort = "^5.12.0"
|
|
34
|
+
mypy = "^1.7.1"
|
|
35
|
+
ruff = "^0.1.6"
|
|
36
|
+
tomlkit = "^0.12.0"
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["poetry-core"]
|
|
40
|
+
build-backend = "poetry.core.masonry.api"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
[tool.black]
|
|
44
|
+
line-length = 120
|
|
45
|
+
target-version = ['py39']
|
|
46
|
+
include = '\.pyi?$'
|
|
47
|
+
extend-exclude = '''
|
|
48
|
+
# A regex preceded with ^/ will apply only to files and directories
|
|
49
|
+
# in the root of the project.
|
|
50
|
+
^/.venv
|
|
51
|
+
'''
|
|
52
|
+
|
|
53
|
+
[tool.isort]
|
|
54
|
+
profile = "black"
|
|
55
|
+
multi_line_output = 3
|
|
56
|
+
line_length = 120
|
|
57
|
+
skip = [".venv"]
|
|
58
|
+
src_paths = ["src", "tests"]
|
|
59
|
+
|
|
60
|
+
[tool.mypy]
|
|
61
|
+
python_version = "3.9"
|
|
62
|
+
warn_return_any = true
|
|
63
|
+
warn_unused_configs = true
|
|
64
|
+
disallow_untyped_defs = true
|
|
65
|
+
check_untyped_defs = true
|
|
66
|
+
exclude = [
|
|
67
|
+
".venv"
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
[tool.ruff]
|
|
71
|
+
line-length = 120
|
|
72
|
+
target-version = "py39"
|
|
73
|
+
select = ["E", "F", "I", "N", "W", "B", "UP"]
|
|
74
|
+
exclude = [
|
|
75
|
+
".venv",
|
|
76
|
+
"__init__.py"
|
|
77
|
+
]
|
|
78
|
+
src = ["src", "tests"]
|
|
79
|
+
|
|
80
|
+
[tool.pytest.ini_options]
|
|
81
|
+
testpaths = ["tests"]
|
|
82
|
+
python_files = ["test_*.py"]
|
|
83
|
+
addopts = "-ra -q"
|
|
84
|
+
filterwarnings = [
|
|
85
|
+
"error",
|
|
86
|
+
"ignore::DeprecationWarning",
|
|
87
|
+
"ignore::UserWarning",
|
|
88
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Yumako - Vanilla python utilities.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.6"
|
|
6
|
+
|
|
7
|
+
import importlib
|
|
8
|
+
from types import ModuleType
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
__submodules = [
|
|
12
|
+
"template",
|
|
13
|
+
"time",
|
|
14
|
+
]
|
|
15
|
+
__submodule_map = {name: f"yumako.{name}" for name in __submodules}
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from . import template # type: ignore
|
|
19
|
+
from . import time # type: ignore
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def __getattr__(name: str) -> ModuleType:
|
|
23
|
+
if name in __submodule_map:
|
|
24
|
+
submodule = importlib.import_module(__submodule_map[name])
|
|
25
|
+
globals()[name] = submodule
|
|
26
|
+
return submodule
|
|
27
|
+
raise AttributeError(f"Module {__name__!r} has no attribute {name!r}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__ = __submodules
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def __dir__() -> list[str]:
|
|
34
|
+
return __all__
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def replace(
|
|
5
|
+
text: str, mapping: dict, strict_on_unresolved_vars: bool = True, strict_on_unused_var: bool = False
|
|
6
|
+
) -> str:
|
|
7
|
+
unused_vars = set(mapping.keys())
|
|
8
|
+
for k, v in mapping.items():
|
|
9
|
+
new_text = text.replace("{{" + k + "}}", str(v))
|
|
10
|
+
if new_text != text:
|
|
11
|
+
text = new_text
|
|
12
|
+
unused_vars.remove(k)
|
|
13
|
+
|
|
14
|
+
if strict_on_unresolved_vars:
|
|
15
|
+
unresolved_vars = re.findall(r"\{\{([^}]+)\}\}", text)
|
|
16
|
+
if unresolved_vars:
|
|
17
|
+
raise Exception(f"Strict mode: template variables unresolved: {unresolved_vars}")
|
|
18
|
+
|
|
19
|
+
if strict_on_unused_var:
|
|
20
|
+
if unused_vars:
|
|
21
|
+
raise Exception(f"Strict mode: var specified but not in template: {unused_vars}")
|
|
22
|
+
|
|
23
|
+
return text
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from datetime import datetime, timedelta, timezone
|
|
3
|
+
from typing import Union
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def display(d: timedelta, use_double_digits: bool = False) -> str:
|
|
7
|
+
"""
|
|
8
|
+
Format a timedelta into a human-readable string.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
d: The timedelta to format
|
|
12
|
+
use_double_digits: If True, pad numbers with leading zeros
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
A formatted string like "1y2w" or "01y02w" depending on use_double_digits
|
|
16
|
+
"""
|
|
17
|
+
is_negative = d.total_seconds() < 0
|
|
18
|
+
if is_negative:
|
|
19
|
+
d = abs(d)
|
|
20
|
+
|
|
21
|
+
total_days = d.days
|
|
22
|
+
|
|
23
|
+
# Only use years if we have exactly 365 days or more
|
|
24
|
+
years = total_days // 365 if total_days >= 365 else 0
|
|
25
|
+
remaining_days = total_days % 365 if years > 0 else total_days
|
|
26
|
+
|
|
27
|
+
weeks = remaining_days // 7
|
|
28
|
+
days = remaining_days % 7
|
|
29
|
+
|
|
30
|
+
hours = d.seconds // 3600
|
|
31
|
+
remaining = d.seconds % 3600
|
|
32
|
+
minutes = remaining // 60
|
|
33
|
+
seconds = remaining % 60
|
|
34
|
+
|
|
35
|
+
parts = []
|
|
36
|
+
if years > 0:
|
|
37
|
+
parts.append((years, "y"))
|
|
38
|
+
if weeks > 0:
|
|
39
|
+
parts.append((weeks, "w"))
|
|
40
|
+
if days > 0:
|
|
41
|
+
parts.append((days, "d"))
|
|
42
|
+
if hours > 0:
|
|
43
|
+
parts.append((hours, "h"))
|
|
44
|
+
if minutes > 0:
|
|
45
|
+
parts.append((minutes, "m"))
|
|
46
|
+
if seconds > 0:
|
|
47
|
+
parts.append((seconds, "s"))
|
|
48
|
+
|
|
49
|
+
if len(parts) > 2:
|
|
50
|
+
parts = parts[:2]
|
|
51
|
+
elif len(parts) == 0:
|
|
52
|
+
return "00s" if use_double_digits else "0s"
|
|
53
|
+
|
|
54
|
+
ret = "-" if is_negative else ""
|
|
55
|
+
for part in parts:
|
|
56
|
+
if use_double_digits:
|
|
57
|
+
ret += f"{part[0]:02d}{part[1]}"
|
|
58
|
+
else:
|
|
59
|
+
ret += f"{part[0]}{part[1]}"
|
|
60
|
+
return ret
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_iso_with_colon_offset(x: str) -> datetime:
|
|
64
|
+
if abs(int(x[-5:-3])) > 14:
|
|
65
|
+
raise ValueError("Invalid timezone offset")
|
|
66
|
+
return datetime.strptime(
|
|
67
|
+
f"{x[:-6]}{x[-6:].replace(':','')}",
|
|
68
|
+
"%Y-%m-%dT%H:%M:%S.%f%z" if "." in x else "%Y-%m-%dT%H:%M:%S%z",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _parse_iso_with_colon_offset2(x: str) -> datetime:
|
|
73
|
+
if abs(int(x[-4:-2])) > 14:
|
|
74
|
+
raise ValueError("Invalid timezone offset")
|
|
75
|
+
return datetime.strptime(x, "%Y-%m-%dT%H:%M:%S.%f%z" if "." in x else "%Y-%m-%dT%H:%M:%S%z")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Try parsing common formats
|
|
79
|
+
_all_human_time_formats = [
|
|
80
|
+
# Unix timestamps
|
|
81
|
+
(r"^\d{13}$", lambda x: datetime.fromtimestamp(int(x) / 1000, tz=timezone.utc)),
|
|
82
|
+
(r"^\d{10}$", lambda x: datetime.fromtimestamp(int(x), tz=timezone.utc)),
|
|
83
|
+
(r"^\d+\.\d+$", lambda x: datetime.fromtimestamp(float(x), tz=timezone.utc)),
|
|
84
|
+
# ISO formats
|
|
85
|
+
(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$", "%Y-%m-%dT%H:%M:%S.%fZ"),
|
|
86
|
+
(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$", "%Y-%m-%dT%H:%M:%SZ"),
|
|
87
|
+
(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$", "%Y-%m-%dT%H:%M:%S"),
|
|
88
|
+
(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$", "%Y-%m-%d %H:%M:%S"),
|
|
89
|
+
(
|
|
90
|
+
r"^\d{2}:\d{2}:\d{2}Z$",
|
|
91
|
+
lambda x: datetime.combine(
|
|
92
|
+
datetime.now(timezone.utc).date(),
|
|
93
|
+
datetime.strptime(x, "%H:%M:%SZ").time(),
|
|
94
|
+
tzinfo=timezone.utc,
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
(r"^\d{4}-\d{2}-\d{2}$", "%Y-%m-%d"),
|
|
98
|
+
(
|
|
99
|
+
r"^\d{2}:\d{2}$",
|
|
100
|
+
lambda x: datetime.combine(
|
|
101
|
+
datetime.now(timezone.utc).date(),
|
|
102
|
+
datetime.strptime(x, "%H:%M").time(),
|
|
103
|
+
tzinfo=timezone.utc,
|
|
104
|
+
),
|
|
105
|
+
),
|
|
106
|
+
# Non-standard formats
|
|
107
|
+
(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+$", "%Y-%m-%d %H:%M:%S.%f"),
|
|
108
|
+
(r"^\d{2}/\d{2}/\d{4}$", "%m/%d/%Y"),
|
|
109
|
+
(r"^\d{4}\.\d{2}\.\d{2}$", "%Y.%m.%d"),
|
|
110
|
+
(r"^\d{8}$", "%Y%m%d"),
|
|
111
|
+
(r"^\d{4}/\d{2}/\d{2}$", "%Y/%m/%d"),
|
|
112
|
+
# RFC 2822
|
|
113
|
+
(
|
|
114
|
+
r"^[A-Z]{3}, \d{2} [A-Z]{3} \d{4} \d{2}:\d{2}:\d{2} [+-]\d{4}$",
|
|
115
|
+
"%a, %d %b %Y %H:%M:%S %z",
|
|
116
|
+
),
|
|
117
|
+
# ISO with timezone offset (with colon)
|
|
118
|
+
(
|
|
119
|
+
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$",
|
|
120
|
+
_parse_iso_with_colon_offset,
|
|
121
|
+
),
|
|
122
|
+
# ISO with timezone offset (without colon)
|
|
123
|
+
(
|
|
124
|
+
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{4}$",
|
|
125
|
+
_parse_iso_with_colon_offset2,
|
|
126
|
+
),
|
|
127
|
+
# European date formats
|
|
128
|
+
(r"^\d{2}-\d{2}-\d{4}$", ["%d-%m-%Y", "%m-%d-%Y"]),
|
|
129
|
+
(r"^\d{2}/\d{2}/\d{4}$", ["%d/%m/%Y", "%m/%d/%Y"]),
|
|
130
|
+
(r"^\d{2}\.\d{2}\.\d{4}$", ["%d.%m.%Y", "%m.%d.%Y"]),
|
|
131
|
+
# Additional common formats
|
|
132
|
+
(r"^[A-Z]{3} \d{1,2}, \d{4}$", "%b %d, %Y"), # DEC 4, 2023
|
|
133
|
+
(r"^[A-Z]{3} \d{1,2} \d{4}$", "%b %d %Y"), # DEC 4 2023
|
|
134
|
+
(r"^\d{1,2} [A-Z]{3} \d{4}$", "%d %b %Y"), # 4 DEC 2023
|
|
135
|
+
(r"^[A-Z]{6,9} \d{1,2}, \d{4}$", "%B %d, %Y"), # DECEMBER 4, 2023
|
|
136
|
+
# Time only with Z
|
|
137
|
+
(
|
|
138
|
+
r"^\d{2}:\d{2}:\d{2}Z$",
|
|
139
|
+
lambda x: datetime.combine(
|
|
140
|
+
datetime.now(timezone.utc).date(),
|
|
141
|
+
datetime.strptime(x, "%H:%M:%SZ").time(),
|
|
142
|
+
tzinfo=timezone.utc,
|
|
143
|
+
),
|
|
144
|
+
),
|
|
145
|
+
# Time only without Z
|
|
146
|
+
(
|
|
147
|
+
r"^\d{2}:\d{2}:\d{2}$",
|
|
148
|
+
lambda x: datetime.combine(
|
|
149
|
+
datetime.now(timezone.utc).date(),
|
|
150
|
+
datetime.strptime(x, "%H:%M:%S").time(),
|
|
151
|
+
tzinfo=timezone.utc,
|
|
152
|
+
),
|
|
153
|
+
),
|
|
154
|
+
# Time only (HH:MM)
|
|
155
|
+
(
|
|
156
|
+
r"^\d{2}:\d{2}$",
|
|
157
|
+
lambda x: datetime.combine(
|
|
158
|
+
datetime.now(timezone.utc).date(),
|
|
159
|
+
datetime.strptime(x, "%H:%M").time(),
|
|
160
|
+
tzinfo=timezone.utc,
|
|
161
|
+
),
|
|
162
|
+
),
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
# Could cache compiled regex patterns
|
|
166
|
+
_COMPILED_PATTERNS = [(re.compile(pattern), handler) for pattern, handler in _all_human_time_formats]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def of(human_time: Union[str, datetime, int, float]) -> datetime:
|
|
170
|
+
"""
|
|
171
|
+
Convert various time formats (ISO-8601, Unix timestamps, relative times, etc.) to a datetime object.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
human_time: Input time in one of these formats:
|
|
175
|
+
- int: Unix timestamp in milliseconds
|
|
176
|
+
- datetime: Returns the input unchanged
|
|
177
|
+
- str: One of:
|
|
178
|
+
- "now": Current UTC time
|
|
179
|
+
- Relative time with +/- (e.g. "-1h", "-7d","+30m", "+1w")
|
|
180
|
+
- Relative time with multiple units (e.g. "-1h30m", "+1w2d", "-7d12h30m")
|
|
181
|
+
- Unix timestamp in milliseconds as string (e.g. "1734010792148") or seconds (e.g. "1734010792")
|
|
182
|
+
- ISO formats:
|
|
183
|
+
- With milliseconds and Z (e.g. "2023-12-04T00:19:22.854Z")
|
|
184
|
+
- With Z (e.g. "2023-12-04T00:19:22Z")
|
|
185
|
+
- Without timezone (e.g. "2023-12-04T00:19:22")
|
|
186
|
+
- With space separator (e.g. "2023-12-04 00:19:22")
|
|
187
|
+
- Time only with Z (e.g. "00:19:22Z")
|
|
188
|
+
- Date only (e.g. "2023-12-04")
|
|
189
|
+
- Time only (e.g. "00:19")
|
|
190
|
+
- With timezone offset (e.g. "2023-12-04T00:19:22+01:00", "2023-12-04T00:19:22-0500")
|
|
191
|
+
Valid offsets are between -14:00 and +14:00
|
|
192
|
+
- Common formats:
|
|
193
|
+
- With milliseconds (e.g. "2023-12-04 00:19:22.854")
|
|
194
|
+
- US date (e.g. "12/04/2023")
|
|
195
|
+
- Dotted date (e.g. "2023.12.04")
|
|
196
|
+
- Compact date (e.g. "20231204")
|
|
197
|
+
- Forward slash date (e.g. "2023/12/04")
|
|
198
|
+
- RFC 2822 (e.g. "Mon, 04 Dec 2023 00:19:22 +0000")
|
|
199
|
+
- European dates (e.g. "04-12-2023", "04/12/2023", "04.12.2023")
|
|
200
|
+
- Month name formats:
|
|
201
|
+
- Short with comma (e.g. "Dec 4, 2023")
|
|
202
|
+
- Short without comma (e.g. "Dec 4 2023")
|
|
203
|
+
- Day first (e.g. "4 Dec 2023")
|
|
204
|
+
- Full month (e.g. "December 4, 2023")
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
datetime: A timezone-aware datetime object
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
ValueError: If the input format is not recognized or invalid (e.g., timezone offset > ±14:00)
|
|
211
|
+
|
|
212
|
+
Examples:
|
|
213
|
+
>>> of("-1d")
|
|
214
|
+
minus 1 day from now
|
|
215
|
+
>>> of("+2w")
|
|
216
|
+
plus 2 weeks from now
|
|
217
|
+
>>> of("2023-12-04T12:30:45+02:00")
|
|
218
|
+
Parsing common time formats
|
|
219
|
+
"""
|
|
220
|
+
if isinstance(human_time, int):
|
|
221
|
+
return datetime.fromtimestamp(human_time / 1000, tz=timezone.utc)
|
|
222
|
+
|
|
223
|
+
if isinstance(human_time, float):
|
|
224
|
+
return datetime.fromtimestamp(human_time / 1000, tz=timezone.utc)
|
|
225
|
+
|
|
226
|
+
if isinstance(human_time, datetime):
|
|
227
|
+
return human_time.astimezone(timezone.utc)
|
|
228
|
+
|
|
229
|
+
if not isinstance(human_time, str):
|
|
230
|
+
raise ValueError("Invalid input type: " + type(human_time).__name__)
|
|
231
|
+
|
|
232
|
+
human_time = human_time.strip()
|
|
233
|
+
original_input = human_time # Keep original for error message
|
|
234
|
+
human_time = human_time.upper() # Convert to uppercase for simpler regex matching
|
|
235
|
+
|
|
236
|
+
if human_time == "NOW":
|
|
237
|
+
return datetime.now(timezone.utc)
|
|
238
|
+
|
|
239
|
+
# Handle relative times (+/-)
|
|
240
|
+
if human_time.startswith("-") or human_time.startswith("+"):
|
|
241
|
+
sign = -1 if human_time.startswith("-") else 1
|
|
242
|
+
|
|
243
|
+
# Remove leading +/- and split into components
|
|
244
|
+
time_str = human_time[1:]
|
|
245
|
+
time_delta = timedelta(seconds=duration(time_str))
|
|
246
|
+
return datetime.now(timezone.utc) + time_delta * sign
|
|
247
|
+
|
|
248
|
+
# Try each format pattern
|
|
249
|
+
for pattern, fmt in _COMPILED_PATTERNS:
|
|
250
|
+
if re.match(pattern, human_time):
|
|
251
|
+
try:
|
|
252
|
+
if callable(fmt):
|
|
253
|
+
dt: datetime = fmt(human_time)
|
|
254
|
+
if not isinstance(dt, datetime):
|
|
255
|
+
raise ValueError(f"Expected datetime, got {type(dt).__name__}")
|
|
256
|
+
return dt
|
|
257
|
+
if isinstance(fmt, list):
|
|
258
|
+
# Try multiple formats
|
|
259
|
+
for f in fmt:
|
|
260
|
+
try:
|
|
261
|
+
dt = datetime.strptime(human_time, f)
|
|
262
|
+
if dt.tzinfo is None:
|
|
263
|
+
return dt.replace(tzinfo=timezone.utc) # Treat naive times as UTC
|
|
264
|
+
else:
|
|
265
|
+
return dt.astimezone(timezone.utc)
|
|
266
|
+
except ValueError:
|
|
267
|
+
continue
|
|
268
|
+
dt = datetime.strptime(human_time, str(fmt))
|
|
269
|
+
if dt.tzinfo is None:
|
|
270
|
+
return dt.replace(tzinfo=timezone.utc)
|
|
271
|
+
else:
|
|
272
|
+
return dt.astimezone(timezone.utc)
|
|
273
|
+
except ValueError:
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
raise ValueError("Unsupported time format: " + original_input)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def duration(human_time: str) -> int:
|
|
280
|
+
"""
|
|
281
|
+
Parse a human-friendly duration string into seconds, either ISO-8601 format or human format.
|
|
282
|
+
Less than 1 second is rounded to 0.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
human_time (str): Duration string in:
|
|
286
|
+
ISO-8601 format (e.g. 'PT5M', 'P1DT2H'),
|
|
287
|
+
human format (e.g. '1h30m', '1w2d', '7d12h30m')
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
timedelta: The parsed duration as a timedelta object
|
|
291
|
+
|
|
292
|
+
Raises:
|
|
293
|
+
ValueError: If the duration string format is not supported
|
|
294
|
+
|
|
295
|
+
Examples:
|
|
296
|
+
>>> duration('PT5S') # 5 seconds
|
|
297
|
+
>>> duration('PT9M15S') # 9 minutes 15 seconds
|
|
298
|
+
>>> duration('PT9H15M') # 9 hours 15 minutes
|
|
299
|
+
>>> duration('P1DT2H') # 1 day 2 hours
|
|
300
|
+
>>> duration('30m') # 30 minutes
|
|
301
|
+
>>> duration('1h30m') # 1 hour 30 minutes
|
|
302
|
+
>>> duration('1w2d') # 1 week 2 days
|
|
303
|
+
>>> duration('7d12h30m') # 7 days 12 hours 30 minutes
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
if not human_time:
|
|
307
|
+
raise ValueError(f"Invalid duration format: {human_time}")
|
|
308
|
+
|
|
309
|
+
human_time = human_time.upper()
|
|
310
|
+
|
|
311
|
+
# First try ISO-8601 format
|
|
312
|
+
iso_pattern = r"^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$"
|
|
313
|
+
iso_match = re.match(iso_pattern, human_time)
|
|
314
|
+
|
|
315
|
+
if iso_match:
|
|
316
|
+
# PT with no values specified is invalid - must have at least one value
|
|
317
|
+
if human_time == "PT" or human_time == "P" or human_time == "":
|
|
318
|
+
raise ValueError(f"Invalid duration format: {human_time}")
|
|
319
|
+
|
|
320
|
+
weeks = int(iso_match.group(1) or 0)
|
|
321
|
+
days = int(iso_match.group(2) or 0)
|
|
322
|
+
hours = int(iso_match.group(3) or 0)
|
|
323
|
+
minutes = int(iso_match.group(4) or 0)
|
|
324
|
+
seconds = float(iso_match.group(5) or 0)
|
|
325
|
+
|
|
326
|
+
return int(timedelta(weeks=weeks, days=days, hours=hours, minutes=minutes, seconds=seconds).total_seconds())
|
|
327
|
+
|
|
328
|
+
# Try human readable format (e.g. 1h30m, 1w2d)
|
|
329
|
+
human_pattern = r"^(?:(\d+)Y)?(?:(\d+)W)?(?:(\d+)D)?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$"
|
|
330
|
+
human_match = re.match(human_pattern, human_time)
|
|
331
|
+
|
|
332
|
+
if human_match:
|
|
333
|
+
years = int(human_match.group(1) or 0)
|
|
334
|
+
weeks = int(human_match.group(2) or 0)
|
|
335
|
+
days = int(human_match.group(3) or 0)
|
|
336
|
+
hours = int(human_match.group(4) or 0)
|
|
337
|
+
minutes = int(human_match.group(5) or 0)
|
|
338
|
+
seconds = int(human_match.group(6) or 0)
|
|
339
|
+
|
|
340
|
+
# Convert years to days (assuming 365 days per year)
|
|
341
|
+
days_from_years = years * 365
|
|
342
|
+
|
|
343
|
+
return int(
|
|
344
|
+
timedelta(
|
|
345
|
+
weeks=weeks,
|
|
346
|
+
days=days + days_from_years,
|
|
347
|
+
hours=hours,
|
|
348
|
+
minutes=minutes,
|
|
349
|
+
seconds=seconds,
|
|
350
|
+
).total_seconds()
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
raise ValueError(f"Unsupported duration format: {human_time}")
|