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.
Files changed (32) hide show
  1. zero_3rdparty-0.101.0/.gitignore +36 -0
  2. zero_3rdparty-0.101.0/PKG-INFO +15 -0
  3. zero_3rdparty-0.101.0/pyproject.toml +105 -0
  4. zero_3rdparty-0.101.0/readme.md +4 -0
  5. zero_3rdparty-0.101.0/zero_3rdparty/__init__.py +8 -0
  6. zero_3rdparty-0.101.0/zero_3rdparty/_internal/__init__.py +0 -0
  7. zero_3rdparty-0.101.0/zero_3rdparty/_internal/sections.py +257 -0
  8. zero_3rdparty-0.101.0/zero_3rdparty/cache_ttl.py +78 -0
  9. zero_3rdparty-0.101.0/zero_3rdparty/closable_queue.py +97 -0
  10. zero_3rdparty-0.101.0/zero_3rdparty/dataclass_utils.py +37 -0
  11. zero_3rdparty-0.101.0/zero_3rdparty/datetime_utils.py +322 -0
  12. zero_3rdparty-0.101.0/zero_3rdparty/decorator_globals.py +25 -0
  13. zero_3rdparty-0.101.0/zero_3rdparty/dependency.py +140 -0
  14. zero_3rdparty-0.101.0/zero_3rdparty/dict_nested.py +245 -0
  15. zero_3rdparty-0.101.0/zero_3rdparty/dict_utils.py +202 -0
  16. zero_3rdparty-0.101.0/zero_3rdparty/enum_utils.py +15 -0
  17. zero_3rdparty-0.101.0/zero_3rdparty/env_reader.py +62 -0
  18. zero_3rdparty-0.101.0/zero_3rdparty/env_temp.py +44 -0
  19. zero_3rdparty-0.101.0/zero_3rdparty/error.py +216 -0
  20. zero_3rdparty-0.101.0/zero_3rdparty/file_utils.py +234 -0
  21. zero_3rdparty-0.101.0/zero_3rdparty/future.py +189 -0
  22. zero_3rdparty-0.101.0/zero_3rdparty/humps.py +251 -0
  23. zero_3rdparty-0.101.0/zero_3rdparty/id_creator.py +132 -0
  24. zero_3rdparty-0.101.0/zero_3rdparty/iter_utils.py +334 -0
  25. zero_3rdparty-0.101.0/zero_3rdparty/logging_utils.py +89 -0
  26. zero_3rdparty-0.101.0/zero_3rdparty/object_name.py +216 -0
  27. zero_3rdparty-0.101.0/zero_3rdparty/py.typed +0 -0
  28. zero_3rdparty-0.101.0/zero_3rdparty/run_env.py +51 -0
  29. zero_3rdparty-0.101.0/zero_3rdparty/sections.py +30 -0
  30. zero_3rdparty-0.101.0/zero_3rdparty/str_utils.py +366 -0
  31. zero_3rdparty-0.101.0/zero_3rdparty/timeparse.py +155 -0
  32. 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
+ [![codecov](https://codecov.io/gh/EspenAlbert/zero-3rdparty/graph/badge.svg?token=47B15SDYMF)](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
+ ]
@@ -0,0 +1,4 @@
1
+ # Zero 3rd-party
2
+ [![codecov](https://codecov.io/gh/EspenAlbert/zero-3rdparty/graph/badge.svg?token=47B15SDYMF)](https://codecov.io/gh/EspenAlbert/zero-3rdparty)
3
+
4
+ - May add some examples here...
@@ -0,0 +1,8 @@
1
+ # Generated by pkg-ext
2
+ # flake8: noqa
3
+ from zero_3rdparty import sections
4
+
5
+ VERSION = "0.101.0"
6
+ __all__ = [
7
+ "sections",
8
+ ]
@@ -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
+ }