zero-3rdparty 0.101.0__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.
- zero_3rdparty-0.101.0/.gitignore +36 -0
- zero_3rdparty-0.101.0/PKG-INFO +15 -0
- zero_3rdparty-0.101.0/pyproject.toml +105 -0
- zero_3rdparty-0.101.0/readme.md +4 -0
- zero_3rdparty-0.101.0/zero_3rdparty/__init__.py +8 -0
- zero_3rdparty-0.101.0/zero_3rdparty/_internal/__init__.py +0 -0
- zero_3rdparty-0.101.0/zero_3rdparty/_internal/sections.py +257 -0
- zero_3rdparty-0.101.0/zero_3rdparty/cache_ttl.py +78 -0
- zero_3rdparty-0.101.0/zero_3rdparty/closable_queue.py +97 -0
- zero_3rdparty-0.101.0/zero_3rdparty/dataclass_utils.py +37 -0
- zero_3rdparty-0.101.0/zero_3rdparty/datetime_utils.py +322 -0
- zero_3rdparty-0.101.0/zero_3rdparty/decorator_globals.py +25 -0
- zero_3rdparty-0.101.0/zero_3rdparty/dependency.py +140 -0
- zero_3rdparty-0.101.0/zero_3rdparty/dict_nested.py +245 -0
- zero_3rdparty-0.101.0/zero_3rdparty/dict_utils.py +202 -0
- zero_3rdparty-0.101.0/zero_3rdparty/enum_utils.py +15 -0
- zero_3rdparty-0.101.0/zero_3rdparty/env_reader.py +62 -0
- zero_3rdparty-0.101.0/zero_3rdparty/env_temp.py +44 -0
- zero_3rdparty-0.101.0/zero_3rdparty/error.py +216 -0
- zero_3rdparty-0.101.0/zero_3rdparty/file_utils.py +234 -0
- zero_3rdparty-0.101.0/zero_3rdparty/future.py +189 -0
- zero_3rdparty-0.101.0/zero_3rdparty/humps.py +251 -0
- zero_3rdparty-0.101.0/zero_3rdparty/id_creator.py +132 -0
- zero_3rdparty-0.101.0/zero_3rdparty/iter_utils.py +334 -0
- zero_3rdparty-0.101.0/zero_3rdparty/logging_utils.py +89 -0
- zero_3rdparty-0.101.0/zero_3rdparty/object_name.py +216 -0
- zero_3rdparty-0.101.0/zero_3rdparty/py.typed +0 -0
- zero_3rdparty-0.101.0/zero_3rdparty/run_env.py +51 -0
- zero_3rdparty-0.101.0/zero_3rdparty/sections.py +30 -0
- zero_3rdparty-0.101.0/zero_3rdparty/str_utils.py +366 -0
- zero_3rdparty-0.101.0/zero_3rdparty/timeparse.py +155 -0
- zero_3rdparty-0.101.0/zero_3rdparty/type_dict.py +47 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# path-sync copy -n python-template
|
|
2
|
+
|
|
3
|
+
# === DO_NOT_EDIT: path-sync gitignore ===
|
|
4
|
+
# Python bytecode
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.pyc
|
|
7
|
+
*.pyo
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
.venv/
|
|
11
|
+
venv/
|
|
12
|
+
|
|
13
|
+
# Build artifacts
|
|
14
|
+
dist/
|
|
15
|
+
build/
|
|
16
|
+
*.egg-info/
|
|
17
|
+
|
|
18
|
+
# Coverage
|
|
19
|
+
.coverage
|
|
20
|
+
htmlcov/
|
|
21
|
+
coverage.xml
|
|
22
|
+
|
|
23
|
+
# Cache directories
|
|
24
|
+
.ruff_cache/
|
|
25
|
+
.pytest_cache/
|
|
26
|
+
.mypy_cache/
|
|
27
|
+
|
|
28
|
+
# IDE
|
|
29
|
+
.idea/
|
|
30
|
+
*.iml
|
|
31
|
+
.vscode/
|
|
32
|
+
|
|
33
|
+
# pkg-ext dev mode files
|
|
34
|
+
.groups-dev.yaml
|
|
35
|
+
CHANGELOG-dev.md
|
|
36
|
+
# === OK_EDIT: path-sync gitignore ===
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zero-3rdparty
|
|
3
|
+
Version: 0.101.0
|
|
4
|
+
Author-email: EspenAlbert <espen.albert1@gmail.com>
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Classifier: Development Status :: 4 - Beta
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
9
|
+
Requires-Python: >=3.13
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# Zero 3rd-party
|
|
13
|
+
[](https://codecov.io/gh/EspenAlbert/zero-3rdparty)
|
|
14
|
+
|
|
15
|
+
- May add some examples here...
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "zero-3rdparty"
|
|
3
|
+
version = "0.101.0"
|
|
4
|
+
requires-python = ">=3.13"
|
|
5
|
+
classifiers = [
|
|
6
|
+
"Development Status :: 4 - Beta",
|
|
7
|
+
"Programming Language :: Python :: 3.13",
|
|
8
|
+
"Programming Language :: Python :: 3.14",
|
|
9
|
+
]
|
|
10
|
+
readme = "readme.md"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
keywords = []
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "EspenAlbert", email = "espen.albert1@gmail.com" },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = [
|
|
19
|
+
"hatchling",
|
|
20
|
+
]
|
|
21
|
+
build-backend = "hatchling.build"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.sdist]
|
|
24
|
+
include = [
|
|
25
|
+
"zero_3rdparty/*.py",
|
|
26
|
+
"zero_3rdparty/_internal/*.py",
|
|
27
|
+
"zero_3rdparty/py.typed",
|
|
28
|
+
]
|
|
29
|
+
exclude = [
|
|
30
|
+
"*_examples.py",
|
|
31
|
+
"*_test.py",
|
|
32
|
+
"conftest.py",
|
|
33
|
+
"test_*.json",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[tool.pkg-ext]
|
|
37
|
+
tag_prefix = "v"
|
|
38
|
+
|
|
39
|
+
[tool.pkg-ext.groups.sections]
|
|
40
|
+
examples_enabled = true
|
|
41
|
+
examples_include = [
|
|
42
|
+
"CommentConfig",
|
|
43
|
+
"parse_sections",
|
|
44
|
+
"replace_sections",
|
|
45
|
+
"get_comment_config",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
line-length = 120
|
|
50
|
+
target-version = "py313"
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint]
|
|
53
|
+
extend-ignore = [
|
|
54
|
+
"E501",
|
|
55
|
+
"UP006",
|
|
56
|
+
"UP007",
|
|
57
|
+
"UP035",
|
|
58
|
+
"UP040",
|
|
59
|
+
"UP046",
|
|
60
|
+
"UP047",
|
|
61
|
+
]
|
|
62
|
+
extend-select = [
|
|
63
|
+
"Q",
|
|
64
|
+
"RUF100",
|
|
65
|
+
"C90",
|
|
66
|
+
"UP",
|
|
67
|
+
"I",
|
|
68
|
+
"T",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[tool.pytest.ini_options]
|
|
72
|
+
addopts = "-s -vv --log-cli-level=INFO"
|
|
73
|
+
python_files = [
|
|
74
|
+
"*_test.py",
|
|
75
|
+
"test_*.py",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
[tool.coverage.report]
|
|
79
|
+
omit = [
|
|
80
|
+
"*_test.py",
|
|
81
|
+
]
|
|
82
|
+
exclude_lines = [
|
|
83
|
+
"no cov",
|
|
84
|
+
"if __name__ == .__main__.:",
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
[tool.pyright]
|
|
88
|
+
include = [
|
|
89
|
+
"zero_3rdparty",
|
|
90
|
+
]
|
|
91
|
+
exclude = [
|
|
92
|
+
"zero_3rdparty/humps.py",
|
|
93
|
+
"zero_3rdparty/timeparse.py",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
[dependency-groups]
|
|
97
|
+
dev = [
|
|
98
|
+
"pytest>=9.0",
|
|
99
|
+
"pytest-cov>=7.0",
|
|
100
|
+
"pytest-freezer>=0.4.9",
|
|
101
|
+
"ruff>=0.14",
|
|
102
|
+
"xdoctest>=1.3.0",
|
|
103
|
+
"pyright>=1.1.404",
|
|
104
|
+
"pydantic",
|
|
105
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def slug(text: str) -> str:
|
|
9
|
+
"""Convert text to lowercase slug suitable for section marker IDs."""
|
|
10
|
+
s = re.sub(r"[^\w\s-]", "", text.lower())
|
|
11
|
+
return re.sub(r"[-\s]+", "_", s).strip("_")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class CommentConfig:
|
|
16
|
+
prefix: str
|
|
17
|
+
suffix: str = ""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Section:
|
|
22
|
+
id: str
|
|
23
|
+
content: str
|
|
24
|
+
start_line: int
|
|
25
|
+
end_line: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
EXTENSION_COMMENT_MAP: dict[str, CommentConfig] = {
|
|
29
|
+
# Hash comments
|
|
30
|
+
".py": CommentConfig("#"),
|
|
31
|
+
".yaml": CommentConfig("#"),
|
|
32
|
+
".yml": CommentConfig("#"),
|
|
33
|
+
".toml": CommentConfig("#"),
|
|
34
|
+
".sh": CommentConfig("#"),
|
|
35
|
+
".bash": CommentConfig("#"),
|
|
36
|
+
".zsh": CommentConfig("#"),
|
|
37
|
+
".r": CommentConfig("#"),
|
|
38
|
+
".R": CommentConfig("#"),
|
|
39
|
+
# HTML-style comments
|
|
40
|
+
".md": CommentConfig("<!--", " -->"),
|
|
41
|
+
".mdc": CommentConfig("<!--", " -->"),
|
|
42
|
+
".html": CommentConfig("<!--", " -->"),
|
|
43
|
+
".xml": CommentConfig("<!--", " -->"),
|
|
44
|
+
".svg": CommentConfig("<!--", " -->"),
|
|
45
|
+
# C-style line comments
|
|
46
|
+
".js": CommentConfig("//"),
|
|
47
|
+
".ts": CommentConfig("//"),
|
|
48
|
+
".jsx": CommentConfig("//"),
|
|
49
|
+
".tsx": CommentConfig("//"),
|
|
50
|
+
".go": CommentConfig("//"),
|
|
51
|
+
".c": CommentConfig("//"),
|
|
52
|
+
".cpp": CommentConfig("//"),
|
|
53
|
+
".h": CommentConfig("//"),
|
|
54
|
+
".java": CommentConfig("//"),
|
|
55
|
+
".kt": CommentConfig("//"),
|
|
56
|
+
".swift": CommentConfig("//"),
|
|
57
|
+
".rs": CommentConfig("//"),
|
|
58
|
+
".scala": CommentConfig("//"),
|
|
59
|
+
".groovy": CommentConfig("//"),
|
|
60
|
+
# C-style block comments (single line)
|
|
61
|
+
".css": CommentConfig("/*", " */"),
|
|
62
|
+
".scss": CommentConfig("/*", " */"),
|
|
63
|
+
".less": CommentConfig("/*", " */"),
|
|
64
|
+
# SQL
|
|
65
|
+
".sql": CommentConfig("--"),
|
|
66
|
+
# Lua
|
|
67
|
+
".lua": CommentConfig("--"),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
FILENAME_COMMENT_MAP: dict[str, CommentConfig] = {
|
|
71
|
+
"justfile": CommentConfig("#"),
|
|
72
|
+
"Makefile": CommentConfig("#"),
|
|
73
|
+
"Dockerfile": CommentConfig("#"),
|
|
74
|
+
".gitignore": CommentConfig("#"),
|
|
75
|
+
".dockerignore": CommentConfig("#"),
|
|
76
|
+
".env": CommentConfig("#"),
|
|
77
|
+
".editorconfig": CommentConfig("#"),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_comment_config(path: Path | str, override: CommentConfig | None = None) -> CommentConfig:
|
|
82
|
+
if override:
|
|
83
|
+
return override
|
|
84
|
+
p = Path(path) if isinstance(path, str) else path
|
|
85
|
+
if config := EXTENSION_COMMENT_MAP.get(p.suffix):
|
|
86
|
+
return config
|
|
87
|
+
if config := FILENAME_COMMENT_MAP.get(p.name):
|
|
88
|
+
return config
|
|
89
|
+
raise ValueError(f"No comment config for: {p.name} (extension={p.suffix!r})")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _build_start_pattern(tool_name: str, config: CommentConfig) -> re.Pattern[str]:
|
|
93
|
+
return re.compile(
|
|
94
|
+
rf"^{re.escape(config.prefix)}\s*===\s*DO_NOT_EDIT:\s*"
|
|
95
|
+
rf"{re.escape(tool_name)}\s+(?P<id>\w+)\s*==={re.escape(config.suffix)}$",
|
|
96
|
+
re.MULTILINE,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _build_end_pattern(tool_name: str, config: CommentConfig) -> re.Pattern[str]:
|
|
101
|
+
return re.compile(
|
|
102
|
+
rf"^{re.escape(config.prefix)}\s*===\s*OK_EDIT:\s*"
|
|
103
|
+
rf"{re.escape(tool_name)}\s+(?P<end_id>\w+)\s*==={re.escape(config.suffix)}$",
|
|
104
|
+
re.MULTILINE,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _start_marker(tool_name: str, section_id: str, config: CommentConfig) -> str:
|
|
109
|
+
return f"{config.prefix} === DO_NOT_EDIT: {tool_name} {section_id} ==={config.suffix}"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _end_marker(tool_name: str, section_id: str, config: CommentConfig) -> str:
|
|
113
|
+
return f"{config.prefix} === OK_EDIT: {tool_name} {section_id} ==={config.suffix}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def parse_sections(content: str, tool_name: str, config: CommentConfig) -> list[Section]:
|
|
117
|
+
start_pattern = _build_start_pattern(tool_name, config)
|
|
118
|
+
end_pattern = _build_end_pattern(tool_name, config)
|
|
119
|
+
lines = content.split("\n")
|
|
120
|
+
sections: list[Section] = []
|
|
121
|
+
current_id: str | None = None
|
|
122
|
+
current_start: int = -1
|
|
123
|
+
content_lines: list[str] = []
|
|
124
|
+
|
|
125
|
+
for i, line in enumerate(lines):
|
|
126
|
+
if start_match := start_pattern.match(line):
|
|
127
|
+
if current_id is not None:
|
|
128
|
+
raise ValueError(f"Nested section at line {i}: found '{start_match.group('id')}' inside '{current_id}'")
|
|
129
|
+
current_id = start_match.group("id")
|
|
130
|
+
current_start = i
|
|
131
|
+
content_lines = []
|
|
132
|
+
elif end_match := end_pattern.match(line):
|
|
133
|
+
if current_id is None:
|
|
134
|
+
continue # standalone OK_EDIT is valid, ignored
|
|
135
|
+
end_id = end_match.group("end_id")
|
|
136
|
+
if end_id != current_id:
|
|
137
|
+
raise ValueError(f"Mismatched section end at line {i}: expected '{current_id}', got '{end_id}'")
|
|
138
|
+
sections.append(
|
|
139
|
+
Section(
|
|
140
|
+
id=current_id,
|
|
141
|
+
content="\n".join(content_lines),
|
|
142
|
+
start_line=current_start,
|
|
143
|
+
end_line=i,
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
current_id = None
|
|
147
|
+
current_start = -1
|
|
148
|
+
content_lines = []
|
|
149
|
+
elif current_id is not None:
|
|
150
|
+
content_lines.append(line)
|
|
151
|
+
|
|
152
|
+
if current_id is not None:
|
|
153
|
+
raise ValueError(f"Unclosed section '{current_id}' starting at line {current_start}")
|
|
154
|
+
|
|
155
|
+
return sections
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def has_sections(content: str, tool_name: str, config: CommentConfig) -> bool:
|
|
159
|
+
return bool(_build_start_pattern(tool_name, config).search(content))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def extract_sections(content: str, tool_name: str, config: CommentConfig) -> dict[str, str]:
|
|
163
|
+
return {s.id: s.content for s in parse_sections(content, tool_name, config)}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def compare_sections(
|
|
167
|
+
baseline_content: str,
|
|
168
|
+
current_content: str,
|
|
169
|
+
tool_name: str,
|
|
170
|
+
config: CommentConfig,
|
|
171
|
+
skip: set[str] | None = None,
|
|
172
|
+
) -> list[str]:
|
|
173
|
+
"""Return section IDs with changes (modified or removed), excluding skipped sections."""
|
|
174
|
+
skip_ids = skip or set()
|
|
175
|
+
baseline_secs = extract_sections(baseline_content, tool_name, config)
|
|
176
|
+
current_secs = extract_sections(current_content, tool_name, config)
|
|
177
|
+
return [
|
|
178
|
+
sec_id
|
|
179
|
+
for sec_id, baseline_text in baseline_secs.items()
|
|
180
|
+
if sec_id not in skip_ids and baseline_text != current_secs.get(sec_id, "")
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def wrap_section(content: str, section_id: str, tool_name: str, config: CommentConfig) -> str:
|
|
185
|
+
start = _start_marker(tool_name, section_id, config)
|
|
186
|
+
end = _end_marker(tool_name, section_id, config)
|
|
187
|
+
return f"{start}\n{content}\n{end}"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def wrap_in_default_section(content: str, tool_name: str, config: CommentConfig) -> str:
|
|
191
|
+
return wrap_section(content, "default", tool_name, config)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def replace_sections(
|
|
195
|
+
dest_content: str,
|
|
196
|
+
src_sections: dict[str, str],
|
|
197
|
+
tool_name: str,
|
|
198
|
+
config: CommentConfig,
|
|
199
|
+
skip_sections: list[str] | None = None,
|
|
200
|
+
) -> str:
|
|
201
|
+
"""Replace sections in dest_content with src_sections, excluding skipped sections. New sections are added at the end"""
|
|
202
|
+
skip = set(skip_sections or [])
|
|
203
|
+
dest_parsed = parse_sections(dest_content, tool_name, config)
|
|
204
|
+
dest_ids = {s.id for s in dest_parsed}
|
|
205
|
+
dest_sections = {s.id: s.content for s in dest_parsed}
|
|
206
|
+
|
|
207
|
+
start_pattern = _build_start_pattern(tool_name, config)
|
|
208
|
+
end_pattern = _build_end_pattern(tool_name, config)
|
|
209
|
+
lines = dest_content.split("\n")
|
|
210
|
+
result: list[str] = []
|
|
211
|
+
|
|
212
|
+
current_section_id: str | None = None
|
|
213
|
+
for line in lines:
|
|
214
|
+
if start_match := start_pattern.match(line):
|
|
215
|
+
current_section_id = start_match.group("id")
|
|
216
|
+
result.append(line)
|
|
217
|
+
elif end_pattern.match(line):
|
|
218
|
+
if current_section_id:
|
|
219
|
+
should_replace = current_section_id in src_sections and current_section_id not in skip
|
|
220
|
+
section_content = (
|
|
221
|
+
src_sections[current_section_id] if should_replace else dest_sections.get(current_section_id, "")
|
|
222
|
+
)
|
|
223
|
+
if section_content:
|
|
224
|
+
result.append(section_content)
|
|
225
|
+
result.append(line)
|
|
226
|
+
current_section_id = None
|
|
227
|
+
elif current_section_id is None:
|
|
228
|
+
result.append(line)
|
|
229
|
+
|
|
230
|
+
# Append new sections from source not in dest
|
|
231
|
+
for sid in src_sections:
|
|
232
|
+
if sid not in dest_ids and sid not in skip:
|
|
233
|
+
result.extend(
|
|
234
|
+
(
|
|
235
|
+
_start_marker(tool_name, sid, config),
|
|
236
|
+
src_sections[sid],
|
|
237
|
+
_end_marker(tool_name, sid, config),
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
return "\n".join(result)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# Path-based convenience functions
|
|
245
|
+
def parse_sections_from_path(path: Path, tool_name: str) -> list[Section]:
|
|
246
|
+
config = get_comment_config(path)
|
|
247
|
+
return parse_sections(path.read_text(), tool_name, config)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def has_sections_in_path(path: Path, tool_name: str) -> bool:
|
|
251
|
+
config = get_comment_config(path)
|
|
252
|
+
return has_sections(path.read_text(), tool_name, config)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def extract_sections_from_path(path: Path, tool_name: str) -> dict[str, str]:
|
|
256
|
+
config = get_comment_config(path)
|
|
257
|
+
return extract_sections(path.read_text(), tool_name, config)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from collections.abc import Hashable
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from inspect import signature
|
|
7
|
+
from time import monotonic
|
|
8
|
+
from typing import Any, Callable, ParamSpec, TypeVar
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
P = ParamSpec("P")
|
|
12
|
+
_sentinel = object()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _wrap_func(func, seconds):
|
|
16
|
+
expire_result = (0, _sentinel)
|
|
17
|
+
|
|
18
|
+
@wraps(func)
|
|
19
|
+
def inner(*args, **kwargs):
|
|
20
|
+
nonlocal expire_result
|
|
21
|
+
now_seconds = monotonic()
|
|
22
|
+
expire, call_result = expire_result
|
|
23
|
+
if now_seconds < expire and call_result is not _sentinel:
|
|
24
|
+
return call_result
|
|
25
|
+
call_result = func(*args, **kwargs)
|
|
26
|
+
expire_result = (now_seconds + seconds, call_result)
|
|
27
|
+
return call_result
|
|
28
|
+
|
|
29
|
+
def clear():
|
|
30
|
+
nonlocal expire_result
|
|
31
|
+
expire_result = (0, _sentinel)
|
|
32
|
+
|
|
33
|
+
inner.clear = clear # type: ignore
|
|
34
|
+
return inner
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _wrap_method(seconds: float, instance_key: Callable[[T], Hashable], meth: Callable[P, Any]):
|
|
38
|
+
expire_times: dict[Hashable, tuple[float, Any]] = defaultdict(lambda: (0, _sentinel))
|
|
39
|
+
|
|
40
|
+
@wraps(meth)
|
|
41
|
+
def inner(self, *args, **kwargs):
|
|
42
|
+
now_seconds = monotonic()
|
|
43
|
+
key = instance_key(self)
|
|
44
|
+
expire, call_result = expire_times[key]
|
|
45
|
+
if now_seconds < expire and call_result is not _sentinel:
|
|
46
|
+
return call_result
|
|
47
|
+
call_result = meth(self, *args, **kwargs) # type: ignore
|
|
48
|
+
expire_times[key] = now_seconds + seconds, call_result
|
|
49
|
+
return call_result
|
|
50
|
+
|
|
51
|
+
def clear():
|
|
52
|
+
nonlocal expire_times
|
|
53
|
+
keys = list(expire_times.keys())
|
|
54
|
+
for key in keys:
|
|
55
|
+
expire_times[key] = (0, _sentinel)
|
|
56
|
+
|
|
57
|
+
inner.clear = clear # type: ignore
|
|
58
|
+
return inner
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cache_ttl(seconds: float | int) -> Callable[[Callable[P, T]], Callable[P, T]]:
|
|
62
|
+
"""simple decorator if you want to cache the results of a call ignoring arguments
|
|
63
|
+
Warning:
|
|
64
|
+
1. Only caches a 'single value'
|
|
65
|
+
2. Expects it to be a method if 'self' is in parameters
|
|
66
|
+
'"""
|
|
67
|
+
assert isinstance(seconds, float | int), "ttl seconds must be int/float"
|
|
68
|
+
|
|
69
|
+
def decorator(func: Callable[P, T]) -> Callable[P, T]:
|
|
70
|
+
if "self" in signature(func).parameters:
|
|
71
|
+
return _wrap_method(seconds, id, func)
|
|
72
|
+
return _wrap_func(func, seconds) # type: ignore
|
|
73
|
+
|
|
74
|
+
return decorator
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def clear_cache(func):
|
|
78
|
+
func.clear()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from contextlib import suppress
|
|
7
|
+
from functools import partial
|
|
8
|
+
from queue import Empty, Queue
|
|
9
|
+
from threading import RLock
|
|
10
|
+
from typing import Generator, Generic, TypeVar
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
QueueT = TypeVar("QueueT")
|
|
15
|
+
|
|
16
|
+
_empty = object()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class QueueIsClosed(Exception):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _raise_queue_is_closed(item: object, *, queue: ClosableQueue, **kwargs):
|
|
24
|
+
if item is not ClosableQueue.SENTINEL:
|
|
25
|
+
raise QueueIsClosed
|
|
26
|
+
Queue.put(queue, item, **kwargs)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ClosableQueue(Queue[QueueT], Generic[QueueT]):
|
|
30
|
+
SENTINEL = object()
|
|
31
|
+
__QUEUES: list[ClosableQueue] = []
|
|
32
|
+
|
|
33
|
+
def __init__(self, maxsize: int = 0): # 0 == infinite
|
|
34
|
+
super().__init__(maxsize=maxsize)
|
|
35
|
+
self.__QUEUES.append(self)
|
|
36
|
+
# ensure close doesn't block and can set raise error
|
|
37
|
+
self.mutex = RLock() # type: ignore
|
|
38
|
+
|
|
39
|
+
self.not_empty = threading.Condition(self.mutex)
|
|
40
|
+
self.not_full = threading.Condition(self.mutex)
|
|
41
|
+
self.all_tasks_done = threading.Condition(self.mutex)
|
|
42
|
+
|
|
43
|
+
def close(self):
|
|
44
|
+
with self.mutex:
|
|
45
|
+
self.put(self.SENTINEL) # type: ignore
|
|
46
|
+
self.put = partial(_raise_queue_is_closed, queue=self) # type: ignore
|
|
47
|
+
with suppress(Exception):
|
|
48
|
+
self.__QUEUES.remove(self)
|
|
49
|
+
|
|
50
|
+
def close_safely(self):
|
|
51
|
+
with suppress(QueueIsClosed):
|
|
52
|
+
self.close()
|
|
53
|
+
|
|
54
|
+
def __iter__(self) -> Generator[QueueT]:
|
|
55
|
+
try:
|
|
56
|
+
while True:
|
|
57
|
+
item = super().get(block=True)
|
|
58
|
+
if item is self.SENTINEL:
|
|
59
|
+
with self.mutex:
|
|
60
|
+
# ensure next iterator will finish immediately
|
|
61
|
+
self.queue.append(item)
|
|
62
|
+
self.not_empty.notify()
|
|
63
|
+
return # Cause the thread to exit
|
|
64
|
+
yield item
|
|
65
|
+
except BaseException as e:
|
|
66
|
+
logger.exception(e)
|
|
67
|
+
finally:
|
|
68
|
+
with suppress(ValueError):
|
|
69
|
+
self.task_done()
|
|
70
|
+
|
|
71
|
+
def pop(self, default=_empty):
|
|
72
|
+
try:
|
|
73
|
+
out = self.get_nowait()
|
|
74
|
+
except Empty:
|
|
75
|
+
return default
|
|
76
|
+
else:
|
|
77
|
+
if out is self.SENTINEL:
|
|
78
|
+
self.put(self.SENTINEL) # type: ignore # ensure next iterator will finish immediately
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
def iter_non_blocking(self) -> Iterable[QueueT]:
|
|
82
|
+
next_or_sentinel = partial(self.pop, self.SENTINEL)
|
|
83
|
+
return iter(next_or_sentinel, self.SENTINEL) # type: ignore
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def close_all(cls):
|
|
87
|
+
if cls.__QUEUES:
|
|
88
|
+
logger.info("closing all queues")
|
|
89
|
+
for q in list(cls.__QUEUES): # avoid modification during iteration
|
|
90
|
+
q.close()
|
|
91
|
+
|
|
92
|
+
def get(self, block: bool = True, timeout: float | None = None) -> QueueT:
|
|
93
|
+
item = super().get(block, timeout)
|
|
94
|
+
if item is self.SENTINEL:
|
|
95
|
+
self.queue.append(self.SENTINEL)
|
|
96
|
+
raise QueueIsClosed
|
|
97
|
+
return item
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Container
|
|
4
|
+
from dataclasses import fields
|
|
5
|
+
from typing import Any, Callable, TypeVar
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def field_names(cls: type[T] | T) -> list[str]:
|
|
11
|
+
return [f.name for f in fields(cls)] # type: ignore
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def values(instance: object) -> list:
|
|
15
|
+
return [getattr(instance, name) for name in field_names(instance)]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def copy_dataclass(instance: T, exclude: Container[str] | None = None, update: dict | None = None) -> T:
|
|
19
|
+
if exclude:
|
|
20
|
+
|
|
21
|
+
def include(field_name: str):
|
|
22
|
+
return field_name not in exclude # type: ignore
|
|
23
|
+
|
|
24
|
+
kwargs = as_dict(instance, filter=include)
|
|
25
|
+
else:
|
|
26
|
+
kwargs = as_dict(instance)
|
|
27
|
+
if update:
|
|
28
|
+
kwargs.update(update)
|
|
29
|
+
return type(instance)(**kwargs)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def as_dict(instance: object, filter: Callable[[str], bool] | None = None) -> dict[str, Any]:
|
|
33
|
+
return {
|
|
34
|
+
field_name: getattr(instance, field_name)
|
|
35
|
+
for field_name in field_names(instance)
|
|
36
|
+
if filter is None or filter(field_name)
|
|
37
|
+
}
|