yumako 0.1.12__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.12/LICENSE +21 -0
- yumako-0.1.12/PKG-INFO +71 -0
- yumako-0.1.12/README.md +55 -0
- yumako-0.1.12/pyproject.toml +94 -0
- yumako-0.1.12/src/yumako/__init__.py +34 -0
- yumako-0.1.12/src/yumako/lru.py +322 -0
- yumako-0.1.12/src/yumako/template.py +23 -0
- yumako-0.1.12/src/yumako/time.py +353 -0
yumako-0.1.12/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.12/PKG-INFO
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: yumako
|
|
3
|
+
Version: 0.1.12
|
|
4
|
+
Summary:
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: util,vanilla,for humans
|
|
7
|
+
Author: Nanw1103
|
|
8
|
+
Author-email: nanw1103@gmail.com
|
|
9
|
+
Requires-Python: >=3.9
|
|
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
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# Yumako 
|
|
17
|
+
|
|
18
|
+
Vanilla python utilities, for humans.
|
|
19
|
+
|
|
20
|
+
[](https://badge.fury.io/py/yumako)
|
|
21
|
+
[](https://pypi.org/project/yumako/)
|
|
22
|
+
[](https://opensource.org/licenses/MIT)
|
|
23
|
+
[](https://github.com/psf/black)
|
|
24
|
+
[](https://github.com/astral-sh/ruff)
|
|
25
|
+
[](https://mypy-lang.org/)
|
|
26
|
+
[](https://pycqa.github.io/isort/)
|
|
27
|
+
[](https://github.com/yumako/yumako)
|
|
28
|
+
[](https://pepy.tech/projects/yumako)
|
|
29
|
+
[](https://github.com/pre-commit/pre-commit)
|
|
30
|
+
[](https://github.com/PyCQA/bandit)
|
|
31
|
+
[](https://sourcery.ai)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
Install:
|
|
38
|
+
```bash
|
|
39
|
+
pip install yumako
|
|
40
|
+
|
|
41
|
+
# Yumako utilities are based on vanilla python: no other dependencies.
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Usage:
|
|
45
|
+
```python
|
|
46
|
+
import yumako
|
|
47
|
+
# Yumako submodules are loaded only when needed.
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------
|
|
50
|
+
# Yumako utilities are designed for human
|
|
51
|
+
# ---------------------------------------
|
|
52
|
+
print(yumako.time.of("2025-01-17H23:00:00.000-05:00")) # Any popular timeformat
|
|
53
|
+
print(yumako.time.of("-3d")) # 3 days ago
|
|
54
|
+
|
|
55
|
+
seconds = yumako.time.duration("3m4s") # 3m4s -> 184 seconds
|
|
56
|
+
delta = timedelta(seconds=seconds)
|
|
57
|
+
print(yumako.time.display(delta)) # 3m4s
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------
|
|
60
|
+
# Yumako utilities are highly performant
|
|
61
|
+
# ---------------------------------------
|
|
62
|
+
lru = yumako.lru.LRUDict()
|
|
63
|
+
lru[1] = True
|
|
64
|
+
lru["hello"] = "mortal"
|
|
65
|
+
print(lru)
|
|
66
|
+
|
|
67
|
+
lru_set = yumako.lru.LRUSet()
|
|
68
|
+
lru_set.add("ユマ果")
|
|
69
|
+
print(lru_set)
|
|
70
|
+
```
|
|
71
|
+
|
yumako-0.1.12/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Yumako 
|
|
2
|
+
|
|
3
|
+
Vanilla python utilities, for humans.
|
|
4
|
+
|
|
5
|
+
[](https://badge.fury.io/py/yumako)
|
|
6
|
+
[](https://pypi.org/project/yumako/)
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
[](https://github.com/psf/black)
|
|
9
|
+
[](https://github.com/astral-sh/ruff)
|
|
10
|
+
[](https://mypy-lang.org/)
|
|
11
|
+
[](https://pycqa.github.io/isort/)
|
|
12
|
+
[](https://github.com/yumako/yumako)
|
|
13
|
+
[](https://pepy.tech/projects/yumako)
|
|
14
|
+
[](https://github.com/pre-commit/pre-commit)
|
|
15
|
+
[](https://github.com/PyCQA/bandit)
|
|
16
|
+
[](https://sourcery.ai)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
Install:
|
|
23
|
+
```bash
|
|
24
|
+
pip install yumako
|
|
25
|
+
|
|
26
|
+
# Yumako utilities are based on vanilla python: no other dependencies.
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Usage:
|
|
30
|
+
```python
|
|
31
|
+
import yumako
|
|
32
|
+
# Yumako submodules are loaded only when needed.
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------
|
|
35
|
+
# Yumako utilities are designed for human
|
|
36
|
+
# ---------------------------------------
|
|
37
|
+
print(yumako.time.of("2025-01-17H23:00:00.000-05:00")) # Any popular timeformat
|
|
38
|
+
print(yumako.time.of("-3d")) # 3 days ago
|
|
39
|
+
|
|
40
|
+
seconds = yumako.time.duration("3m4s") # 3m4s -> 184 seconds
|
|
41
|
+
delta = timedelta(seconds=seconds)
|
|
42
|
+
print(yumako.time.display(delta)) # 3m4s
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------
|
|
45
|
+
# Yumako utilities are highly performant
|
|
46
|
+
# ---------------------------------------
|
|
47
|
+
lru = yumako.lru.LRUDict()
|
|
48
|
+
lru[1] = True
|
|
49
|
+
lru["hello"] = "mortal"
|
|
50
|
+
print(lru)
|
|
51
|
+
|
|
52
|
+
lru_set = yumako.lru.LRUSet()
|
|
53
|
+
lru_set.add("ユマ果")
|
|
54
|
+
print(lru_set)
|
|
55
|
+
```
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "yumako"
|
|
3
|
+
version = "0.1.12"
|
|
4
|
+
description = ""
|
|
5
|
+
authors = [{ name="Nanw1103", email="nanw1103@gmail.com" }]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
requires-python = ">=3.9"
|
|
9
|
+
homepage = "https://github.com/nanw1103/yumako"
|
|
10
|
+
repository = "https://github.com/nanw1103/yumako"
|
|
11
|
+
documentation = "https://github.com/nanw1103/yumako#readme"
|
|
12
|
+
"Bug Tracker" = "https://github.com/nanw1103/yumako/issues"
|
|
13
|
+
|
|
14
|
+
keywords = ["util", "vanilla", "for humans"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
packages = [
|
|
23
|
+
{ include = "yumako", from = "src", format = ["sdist"] }
|
|
24
|
+
]
|
|
25
|
+
exclude = ["tests", "tests.*", "examples"]
|
|
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>=1.0.0"]
|
|
40
|
+
build-backend = "poetry.core.masonry.api"
|
|
41
|
+
|
|
42
|
+
[tool.black]
|
|
43
|
+
line-length = 120
|
|
44
|
+
target-version = ['py39']
|
|
45
|
+
include = '\.pyi?$'
|
|
46
|
+
extend-exclude = '''
|
|
47
|
+
# A regex preceded with ^/ will apply only to files and directories
|
|
48
|
+
# in the root of the project.
|
|
49
|
+
^/.venv
|
|
50
|
+
'''
|
|
51
|
+
|
|
52
|
+
[tool.isort]
|
|
53
|
+
profile = "black"
|
|
54
|
+
multi_line_output = 3
|
|
55
|
+
line_length = 120
|
|
56
|
+
skip = [".venv"]
|
|
57
|
+
src_paths = ["src", "tests"]
|
|
58
|
+
|
|
59
|
+
[tool.mypy]
|
|
60
|
+
python_version = "3.9"
|
|
61
|
+
warn_return_any = true
|
|
62
|
+
warn_unused_configs = true
|
|
63
|
+
disallow_untyped_defs = true
|
|
64
|
+
check_untyped_defs = true
|
|
65
|
+
exclude = [
|
|
66
|
+
".venv"
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
[tool.ruff]
|
|
70
|
+
line-length = 120
|
|
71
|
+
target-version = "py39"
|
|
72
|
+
select = ["E", "F", "I", "N", "W", "B", "UP"]
|
|
73
|
+
exclude = [
|
|
74
|
+
".venv",
|
|
75
|
+
"__init__.py"
|
|
76
|
+
]
|
|
77
|
+
src = ["src", "tests"]
|
|
78
|
+
|
|
79
|
+
[tool.pytest.ini_options]
|
|
80
|
+
testpaths = ["tests"]
|
|
81
|
+
python_files = ["test_*.py"]
|
|
82
|
+
addopts = "-ra -q"
|
|
83
|
+
filterwarnings = [
|
|
84
|
+
"error",
|
|
85
|
+
"ignore::DeprecationWarning",
|
|
86
|
+
"ignore::UserWarning",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
[tool.poetry-dynamic-versioning]
|
|
90
|
+
enable = true
|
|
91
|
+
vcs = "git"
|
|
92
|
+
style = "pep440"
|
|
93
|
+
pattern = "^(?P<base>\\d+\\.\\d+\\.\\d+)"
|
|
94
|
+
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Yumako - Vanilla python utilities.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
from types import ModuleType
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
__submodules = [
|
|
10
|
+
"template",
|
|
11
|
+
"time",
|
|
12
|
+
"lru",
|
|
13
|
+
]
|
|
14
|
+
__submodule_map = {name: f"yumako.{name}" for name in __submodules}
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from . import lru # type: ignore
|
|
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,322 @@
|
|
|
1
|
+
from collections import ChainMap
|
|
2
|
+
from collections.abc import ItemsView, Iterator, KeysView, MutableMapping, MutableSet, ValuesView
|
|
3
|
+
from typing import TypeVar, cast
|
|
4
|
+
from weakref import WeakSet, WeakValueDictionary
|
|
5
|
+
|
|
6
|
+
T = TypeVar("T") # Item type for Set
|
|
7
|
+
K = TypeVar("K") # Key type
|
|
8
|
+
V = TypeVar("V") # Value type
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LRUSet(MutableSet[T]):
|
|
12
|
+
"""A Least Recently Used (LRU) Set with weak references and fixed capacity.
|
|
13
|
+
|
|
14
|
+
Implementation:
|
|
15
|
+
Uses two set caches for O(1) operations:
|
|
16
|
+
- new_cache: holds recently used items
|
|
17
|
+
- old_cache: holds less recently used items
|
|
18
|
+
|
|
19
|
+
When new_cache reaches capacity:
|
|
20
|
+
1. old_cache becomes the current new_cache
|
|
21
|
+
2. new_cache is reset empty
|
|
22
|
+
|
|
23
|
+
On item access:
|
|
24
|
+
1. Check new_cache first (fast path)
|
|
25
|
+
2. If not found, check old_cache
|
|
26
|
+
3. If found in old_cache, promote to new_cache
|
|
27
|
+
|
|
28
|
+
Performance:
|
|
29
|
+
- add: O(1)
|
|
30
|
+
- contains: O(1)
|
|
31
|
+
- discard: O(1)
|
|
32
|
+
- len: O(1)
|
|
33
|
+
- iter: O(n)
|
|
34
|
+
|
|
35
|
+
Memory:
|
|
36
|
+
- If weak=true, uses weak references, items may be garbage collected
|
|
37
|
+
- Maximum memory: 2 * capacity items
|
|
38
|
+
|
|
39
|
+
Thread Safety:
|
|
40
|
+
- Not thread-safe
|
|
41
|
+
- Use external synchronization if needed
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
capacity: Maximum items per cache. Must be positive integer.
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
>>> class Item:
|
|
48
|
+
... def __init__(self, value): self.value = value
|
|
49
|
+
>>> lru = LRUSet(capacity=2)
|
|
50
|
+
>>> items = [Item(i) for i in range(3)]
|
|
51
|
+
>>> lru.add(items[0]) # new_cache: [0]
|
|
52
|
+
>>> lru.add(items[1]) # new_cache: [0,1]
|
|
53
|
+
>>> items[0] in lru # promotes 0: [1,0]
|
|
54
|
+
>>> lru.add(items[2]) # rotates: old=[1,0], new=[2]
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
_capacity: int
|
|
58
|
+
_new_cache: MutableSet[T]
|
|
59
|
+
_old_cache: MutableSet[T]
|
|
60
|
+
|
|
61
|
+
def __init__(self, capacity: int = 32, weak: bool = False) -> None:
|
|
62
|
+
if not isinstance(capacity, int):
|
|
63
|
+
raise TypeError("capacity must be an integer")
|
|
64
|
+
if capacity <= 0:
|
|
65
|
+
raise ValueError("capacity must be positive")
|
|
66
|
+
|
|
67
|
+
self._capacity = capacity
|
|
68
|
+
self._weak = weak
|
|
69
|
+
self._new_cache = WeakSet() if weak else set() # Recently used items
|
|
70
|
+
self._old_cache = WeakSet() if weak else set() # Less recently used items
|
|
71
|
+
|
|
72
|
+
def add(self, item: T) -> None:
|
|
73
|
+
"""Add an item to the set."""
|
|
74
|
+
# If item exists in new_cache, we're done
|
|
75
|
+
if item in self._new_cache:
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
# Add to new_cache
|
|
79
|
+
self._new_cache.add(item)
|
|
80
|
+
|
|
81
|
+
# If size exceeds capacity, rotate caches
|
|
82
|
+
if not self._ensure_rotation():
|
|
83
|
+
# Remove from old_cache if present
|
|
84
|
+
self._old_cache.discard(item)
|
|
85
|
+
|
|
86
|
+
def discard(self, item: T) -> None:
|
|
87
|
+
"""Remove an item from the set if it exists."""
|
|
88
|
+
self._new_cache.discard(item)
|
|
89
|
+
self._old_cache.discard(item)
|
|
90
|
+
|
|
91
|
+
def __contains__(self, item: object) -> bool:
|
|
92
|
+
"""Check if an item is in the set."""
|
|
93
|
+
if item in self._new_cache:
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
if item in self._old_cache:
|
|
97
|
+
# Promote to new_cache
|
|
98
|
+
item_t = cast(T, item) # Safe cast since we checked type above
|
|
99
|
+
self._new_cache.add(item_t)
|
|
100
|
+
|
|
101
|
+
if not self._ensure_rotation():
|
|
102
|
+
self._old_cache.discard(item_t)
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
def _ensure_rotation(self) -> bool:
|
|
108
|
+
"""Rotate caches if necessary."""
|
|
109
|
+
if len(self._new_cache) <= self._capacity:
|
|
110
|
+
return False
|
|
111
|
+
self._old_cache = self._new_cache
|
|
112
|
+
self._new_cache = WeakSet() if self._weak else set()
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
def __len__(self) -> int:
|
|
116
|
+
"""Return total number of items across both caches."""
|
|
117
|
+
return len(self._new_cache) + len(self._old_cache)
|
|
118
|
+
|
|
119
|
+
def __iter__(self) -> Iterator[T]:
|
|
120
|
+
"""Iterate over all items in the set."""
|
|
121
|
+
# Yield from new_cache first
|
|
122
|
+
yield from self._new_cache
|
|
123
|
+
|
|
124
|
+
# Then yield from old_cache (skipping duplicates)
|
|
125
|
+
yield from self._old_cache
|
|
126
|
+
|
|
127
|
+
def clear(self) -> None:
|
|
128
|
+
"""Remove all items from the set."""
|
|
129
|
+
self._new_cache.clear()
|
|
130
|
+
self._old_cache.clear()
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def capacity(self) -> int:
|
|
134
|
+
"""Get the capacity of each cache."""
|
|
135
|
+
return self._capacity
|
|
136
|
+
|
|
137
|
+
def __repr__(self) -> str:
|
|
138
|
+
"""Return a string representation of the set.
|
|
139
|
+
|
|
140
|
+
Format: LRUSet(capacity=N)
|
|
141
|
+
"""
|
|
142
|
+
return f"{type(self).__name__}(capacity={self._capacity}, weak={self._weak}, size={len(self)})"
|
|
143
|
+
|
|
144
|
+
def __str__(self) -> str:
|
|
145
|
+
"""Return a string of the set contents.
|
|
146
|
+
|
|
147
|
+
Format: {item1, item2, ...}
|
|
148
|
+
"""
|
|
149
|
+
items = ", ".join(str(item) for item in self)
|
|
150
|
+
return "{" + items + "}"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class LRUDict(MutableMapping[K, V]):
|
|
154
|
+
"""A Least Recently Used (LRU) Dictionary with weak references and fixed capacity.
|
|
155
|
+
|
|
156
|
+
Implementation:
|
|
157
|
+
Uses two dictionary caches for O(1) operations:
|
|
158
|
+
- new_cache: holds recently used items
|
|
159
|
+
- old_cache: holds less recently used items
|
|
160
|
+
|
|
161
|
+
When new_cache reaches capacity:
|
|
162
|
+
1. old_cache becomes the current new_cache
|
|
163
|
+
2. new_cache is reset empty
|
|
164
|
+
|
|
165
|
+
On key access:
|
|
166
|
+
1. Check new_cache first (fast path)
|
|
167
|
+
2. If not found, check old_cache
|
|
168
|
+
3. If found in old_cache, promote to new_cache
|
|
169
|
+
|
|
170
|
+
Performance:
|
|
171
|
+
- get/set: O(1)
|
|
172
|
+
- contains: O(1)
|
|
173
|
+
- delete: O(1)
|
|
174
|
+
- len: O(1)
|
|
175
|
+
- iter: O(n)
|
|
176
|
+
|
|
177
|
+
Memory:
|
|
178
|
+
- If weak is true, uses weak references for values, may be garbage collected
|
|
179
|
+
- Keys are stored strongly
|
|
180
|
+
- Maximum memory: 2 * capacity items
|
|
181
|
+
|
|
182
|
+
Thread Safety:
|
|
183
|
+
- Not thread-safe
|
|
184
|
+
- Use external synchronization if needed
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
capacity: Maximum items per cache. Must be positive integer.
|
|
188
|
+
|
|
189
|
+
Example:
|
|
190
|
+
>>> class Value:
|
|
191
|
+
... def __init__(self, x): self.x = x
|
|
192
|
+
>>> lru = LRUDict(capacity=2)
|
|
193
|
+
>>> v1, v2, v3 = Value(1), Value(2), Value(3)
|
|
194
|
+
>>> lru['a'] = v1 # new_cache: {'a':v1}
|
|
195
|
+
>>> lru['b'] = v2 # new_cache: {'a':v1, 'b':v2}
|
|
196
|
+
>>> _ = lru['a'] # promotes 'a': {'b':v2, 'a':v1}
|
|
197
|
+
>>> lru['c'] = v3 # rotates: old={'b':v2, 'a':v1}, new={'c':v3}
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
_capacity: int
|
|
201
|
+
_weak: bool
|
|
202
|
+
_new_cache: MutableMapping[K, V]
|
|
203
|
+
_old_cache: MutableMapping[K, V]
|
|
204
|
+
|
|
205
|
+
def __init__(self, capacity: int = 32, weak: bool = False) -> None:
|
|
206
|
+
if not isinstance(capacity, int):
|
|
207
|
+
raise TypeError("capacity must be an integer")
|
|
208
|
+
if capacity <= 0:
|
|
209
|
+
raise ValueError("capacity must be positive")
|
|
210
|
+
|
|
211
|
+
self._capacity = capacity
|
|
212
|
+
self._weak = weak
|
|
213
|
+
self._new_cache = WeakValueDictionary() if weak else dict()
|
|
214
|
+
self._old_cache = WeakValueDictionary() if weak else dict()
|
|
215
|
+
|
|
216
|
+
def _ensure_rotation(self) -> bool:
|
|
217
|
+
"""Rotate caches if necessary."""
|
|
218
|
+
if len(self._new_cache) <= self._capacity:
|
|
219
|
+
return False
|
|
220
|
+
self._old_cache = self._new_cache
|
|
221
|
+
self._new_cache = WeakValueDictionary() if self._weak else dict()
|
|
222
|
+
return True
|
|
223
|
+
|
|
224
|
+
def __setitem__(self, key: K, value: V) -> None:
|
|
225
|
+
if key in self._new_cache:
|
|
226
|
+
self._new_cache[key] = value
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
self._new_cache[key] = value
|
|
230
|
+
|
|
231
|
+
# If size exceeds capacity, rotate caches
|
|
232
|
+
if not self._ensure_rotation():
|
|
233
|
+
# Remove from old_cache if present
|
|
234
|
+
self._old_cache.pop(key, None)
|
|
235
|
+
|
|
236
|
+
def __getitem__(self, key: K) -> V:
|
|
237
|
+
# Check new_cache first
|
|
238
|
+
try:
|
|
239
|
+
return self._new_cache[key]
|
|
240
|
+
except KeyError:
|
|
241
|
+
# Check old_cache and promote if found
|
|
242
|
+
value = self._old_cache[key]
|
|
243
|
+
# Promote to new_cache
|
|
244
|
+
self._new_cache[key] = value
|
|
245
|
+
|
|
246
|
+
# If size exceeds capacity, rotate caches
|
|
247
|
+
if not self._ensure_rotation():
|
|
248
|
+
self._old_cache.pop(key, None)
|
|
249
|
+
|
|
250
|
+
return value
|
|
251
|
+
|
|
252
|
+
def __delitem__(self, key: K) -> None:
|
|
253
|
+
"""Remove an item from either cache."""
|
|
254
|
+
self._new_cache.pop(key, None)
|
|
255
|
+
self._old_cache.pop(key, None)
|
|
256
|
+
|
|
257
|
+
def __len__(self) -> int:
|
|
258
|
+
return len(self._new_cache) + len(self._old_cache)
|
|
259
|
+
|
|
260
|
+
def __contains__(self, key: object) -> bool:
|
|
261
|
+
"""Return True if key exists in the dictionary."""
|
|
262
|
+
key_k = cast(K, key) # Safe cast since we checked type above
|
|
263
|
+
return key_k in self._new_cache or key_k in self._old_cache
|
|
264
|
+
|
|
265
|
+
def __iter__(self) -> Iterator[K]:
|
|
266
|
+
yield from self._new_cache
|
|
267
|
+
yield from self._old_cache
|
|
268
|
+
|
|
269
|
+
def clear(self) -> None:
|
|
270
|
+
self._new_cache.clear()
|
|
271
|
+
self._old_cache.clear()
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def capacity(self) -> int:
|
|
275
|
+
return self._capacity
|
|
276
|
+
|
|
277
|
+
def items(self) -> ItemsView[K, V]:
|
|
278
|
+
"""Iterate over (key, value) pairs in LRU order."""
|
|
279
|
+
# Create a dict view directly from the caches without intermediate dicts
|
|
280
|
+
combined = ChainMap(self._new_cache, self._old_cache)
|
|
281
|
+
return ItemsView(combined)
|
|
282
|
+
|
|
283
|
+
def keys(self) -> KeysView[K]:
|
|
284
|
+
"""Iterate over keys in LRU order."""
|
|
285
|
+
combined = ChainMap(self._new_cache, self._old_cache)
|
|
286
|
+
return KeysView(combined)
|
|
287
|
+
|
|
288
|
+
def values(self) -> ValuesView[V]:
|
|
289
|
+
"""Iterate over values in LRU order."""
|
|
290
|
+
combined = ChainMap(self._new_cache, self._old_cache)
|
|
291
|
+
return ValuesView(combined)
|
|
292
|
+
|
|
293
|
+
def __repr__(self) -> str:
|
|
294
|
+
return f"{type(self).__name__}(capacity={self._capacity}, weak={self._weak}, size={len(self)})"
|
|
295
|
+
|
|
296
|
+
def __str__(self) -> str:
|
|
297
|
+
return str(dict(self.items()))
|
|
298
|
+
|
|
299
|
+
def popitem(self) -> tuple[K, V]:
|
|
300
|
+
"""Remove and return the most recently used item.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
tuple: A (key, value) pair from new_cache
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
KeyError: If dictionary is empty
|
|
307
|
+
"""
|
|
308
|
+
if not self:
|
|
309
|
+
raise KeyError("dictionary is empty")
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
# Try to pop from new_cache first
|
|
313
|
+
key = next(iter(self._new_cache))
|
|
314
|
+
value = self._new_cache[key]
|
|
315
|
+
del self._new_cache[key]
|
|
316
|
+
return key, value
|
|
317
|
+
except (StopIteration, KeyError):
|
|
318
|
+
# If new_cache is empty, pop from old_cache
|
|
319
|
+
key = next(iter(self._old_cache))
|
|
320
|
+
value = self._old_cache[key]
|
|
321
|
+
del self._old_cache[key]
|
|
322
|
+
return key, value
|
|
@@ -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}")
|