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 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 ![Yumako](doc/yumako.png)
17
+
18
+ Vanilla python utilities, for humans.
19
+
20
+ [![PyPI version](https://badge.fury.io/py/yumako.svg)](https://badge.fury.io/py/yumako)
21
+ [![Python Versions](https://img.shields.io/pypi/pyversions/yumako.svg)](https://pypi.org/project/yumako/)
22
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
23
+ [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
24
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
25
+ [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
26
+ [![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/)
27
+ [![Typed](https://img.shields.io/badge/Typed-Yes-blue.svg)](https://github.com/yumako/yumako)
28
+ [![Downloads](https://static.pepy.tech/badge/yumako)](https://pepy.tech/projects/yumako)
29
+ [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit)
30
+ [![security: bandit](https://img.shields.io/badge/security-bandit-yellow.svg)](https://github.com/PyCQA/bandit)
31
+ [![Sourcery](https://img.shields.io/badge/Sourcery-enabled-brightgreen)](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
+
@@ -0,0 +1,55 @@
1
+ # Yumako ![Yumako](doc/yumako.png)
2
+
3
+ Vanilla python utilities, for humans.
4
+
5
+ [![PyPI version](https://badge.fury.io/py/yumako.svg)](https://badge.fury.io/py/yumako)
6
+ [![Python Versions](https://img.shields.io/pypi/pyversions/yumako.svg)](https://pypi.org/project/yumako/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
+ [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
9
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
10
+ [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
11
+ [![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/)
12
+ [![Typed](https://img.shields.io/badge/Typed-Yes-blue.svg)](https://github.com/yumako/yumako)
13
+ [![Downloads](https://static.pepy.tech/badge/yumako)](https://pepy.tech/projects/yumako)
14
+ [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit)
15
+ [![security: bandit](https://img.shields.io/badge/security-bandit-yellow.svg)](https://github.com/PyCQA/bandit)
16
+ [![Sourcery](https://img.shields.io/badge/Sourcery-enabled-brightgreen)](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}")