pybasemkit 0.2.1__tar.gz → 0.2.2__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.
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/PKG-INFO +1 -1
- pybasemkit-0.2.2/basemkit/__init__.py +1 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/basemkit/yamlable.py +60 -8
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/tests/test_yamlable.py +66 -0
- pybasemkit-0.2.1/basemkit/__init__.py +0 -1
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/.github/workflows/build.yml +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/.github/workflows/upload-to-pypi.yml +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/.gitignore +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/.project +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/.pydevproject +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/AGENTS.md +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/LICENSE +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/README.md +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/basemkit/argparse_action.py +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/basemkit/base_cmd.py +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/basemkit/basetest.py +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/basemkit/docker_util.py +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/basemkit/persistent_log.py +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/basemkit/profiler.py +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/basemkit/remotedebug.py +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/basemkit/shell.py +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/mkdocs.yml +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/pyproject.toml +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/scripts/blackisort +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/scripts/doc +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/scripts/install +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/scripts/release +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/scripts/test +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/tests/__init__.py +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/tests/test_argparse_action.py +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/tests/test_avro.py +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/tests/test_base_cmd.py +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/tests/test_docker_util.py +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/tests/test_persistent_log.py +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/tests/test_remotedebug.py +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/tests/test_shell.py +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/tests/test_timeout_decorator.py +0 -0
- {pybasemkit-0.2.1 → pybasemkit-0.2.2}/yamable.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pybasemkit
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Python base module kit: YAML/JSON I/O, structured logging, CLI tooling, shell execution, and pydevd remote debug support.
|
|
5
5
|
Project-URL: Home, https://github.com/WolfgangFahl/pybasemkit
|
|
6
6
|
Project-URL: Documentation, https://wiki.bitplan.com/index.php/pybasemkit
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.2"
|
|
@@ -40,7 +40,7 @@ from collections.abc import Iterable, Mapping
|
|
|
40
40
|
from dataclasses import asdict, dataclass, is_dataclass
|
|
41
41
|
from datetime import date, datetime
|
|
42
42
|
from pathlib import Path
|
|
43
|
-
from typing import Any, Generic, TextIO, Type, TypeVar, Union
|
|
43
|
+
from typing import Any, Generic, Optional, TextIO, Tuple, Type, TypeVar, Union
|
|
44
44
|
|
|
45
45
|
import yaml
|
|
46
46
|
from dacite import from_dict
|
|
@@ -107,6 +107,40 @@ class YamlAble(Generic[T]):
|
|
|
107
107
|
self._yaml_dumper.add_representer(type(None), self.represent_none)
|
|
108
108
|
self._yaml_dumper.add_representer(str, self.represent_literal)
|
|
109
109
|
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _split_yaml_header(text: str) -> Tuple[str, str]:
|
|
112
|
+
"""
|
|
113
|
+
Split raw YAML text into a leading comment block and the data body.
|
|
114
|
+
|
|
115
|
+
Scans lines from the top; a line belongs to the header if it starts
|
|
116
|
+
with '#' or is blank (blank lines between comment lines are kept).
|
|
117
|
+
Scanning stops at the first line that is neither a comment nor blank.
|
|
118
|
+
Trailing blank lines are trimmed from the header and prepended to the
|
|
119
|
+
body so that the body remains valid standalone YAML.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
text: Raw YAML file content.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
A tuple (header, body) where header is the extracted comment block
|
|
126
|
+
(including a trailing newline) and body is the remainder.
|
|
127
|
+
If no leading comments are found, header is '' and body is text.
|
|
128
|
+
"""
|
|
129
|
+
lines = text.splitlines(keepends=True)
|
|
130
|
+
split_idx = 0
|
|
131
|
+
for i, line in enumerate(lines):
|
|
132
|
+
stripped = line.strip()
|
|
133
|
+
if stripped.startswith("#") or stripped == "":
|
|
134
|
+
split_idx = i + 1
|
|
135
|
+
else:
|
|
136
|
+
break
|
|
137
|
+
# Trim trailing blank lines from the header, move them to the body
|
|
138
|
+
while split_idx > 0 and lines[split_idx - 1].strip() == "":
|
|
139
|
+
split_idx -= 1
|
|
140
|
+
header = "".join(lines[:split_idx])
|
|
141
|
+
body = "".join(lines[split_idx:])
|
|
142
|
+
return header, body
|
|
143
|
+
|
|
110
144
|
def represent_none(self, _, __) -> yaml.Node:
|
|
111
145
|
"""
|
|
112
146
|
Custom representer for ignoring None values in the YAML output.
|
|
@@ -169,33 +203,42 @@ class YamlAble(Generic[T]):
|
|
|
169
203
|
return instance
|
|
170
204
|
|
|
171
205
|
@classmethod
|
|
172
|
-
def load_from_yaml_stream(cls: Type[T], stream: TextIO) -> T:
|
|
206
|
+
def load_from_yaml_stream(cls: Type[T], stream: TextIO, with_header_comment: bool = False) -> T:
|
|
173
207
|
"""
|
|
174
208
|
Loads a dataclass instance from a YAML stream.
|
|
175
209
|
|
|
176
210
|
Args:
|
|
177
211
|
stream (TextIO): The input stream containing YAML data.
|
|
212
|
+
with_header_comment: If True, extract any leading comment block from the
|
|
213
|
+
raw text and store it on the instance as ``_yaml_header`` so it can
|
|
214
|
+
be re-emitted by :meth:`save_to_yaml_stream`.
|
|
178
215
|
|
|
179
216
|
Returns:
|
|
180
217
|
T: An instance of the dataclass.
|
|
181
218
|
"""
|
|
182
219
|
yaml_str: str = stream.read()
|
|
220
|
+
if with_header_comment:
|
|
221
|
+
header, yaml_str = cls._split_yaml_header(yaml_str)
|
|
183
222
|
instance: T = cls.from_yaml(yaml_str)
|
|
223
|
+
if with_header_comment:
|
|
224
|
+
instance._yaml_header = header if header else None
|
|
184
225
|
return instance
|
|
185
226
|
|
|
186
227
|
@classmethod
|
|
187
|
-
def load_from_yaml_file(cls: Type[T], filename: str) -> T:
|
|
228
|
+
def load_from_yaml_file(cls: Type[T], filename: str, with_header_comment: bool = False) -> T:
|
|
188
229
|
"""
|
|
189
230
|
Loads a dataclass instance from a YAML file.
|
|
190
231
|
|
|
191
232
|
Args:
|
|
192
233
|
filename (str): The path to the YAML file.
|
|
234
|
+
with_header_comment: If True, preserve any leading comment block found in
|
|
235
|
+
the file; see :meth:`load_from_yaml_stream` for details.
|
|
193
236
|
|
|
194
237
|
Returns:
|
|
195
238
|
T: An instance of the dataclass.
|
|
196
239
|
"""
|
|
197
240
|
with open(filename, "r") as file:
|
|
198
|
-
return cls.load_from_yaml_stream(file)
|
|
241
|
+
return cls.load_from_yaml_stream(file, with_header_comment=with_header_comment)
|
|
199
242
|
|
|
200
243
|
@classmethod
|
|
201
244
|
def load_from_yaml_url(cls: Type[T], url: str) -> T:
|
|
@@ -212,26 +255,35 @@ class YamlAble(Generic[T]):
|
|
|
212
255
|
instance: T = cls.from_yaml(yaml_str)
|
|
213
256
|
return instance
|
|
214
257
|
|
|
215
|
-
def save_to_yaml_stream(self, file: TextIO):
|
|
258
|
+
def save_to_yaml_stream(self, file: TextIO, with_header_comment: bool = False):
|
|
216
259
|
"""
|
|
217
260
|
Saves the current dataclass instance to the given YAML stream.
|
|
218
261
|
|
|
219
262
|
Args:
|
|
220
263
|
file (TextIO): The stream to which YAML content will be saved.
|
|
264
|
+
with_header_comment: If True and ``self._yaml_header`` is set (populated
|
|
265
|
+
by a previous :meth:`load_from_yaml_stream` call with the same flag),
|
|
266
|
+
the comment block is written before the YAML body. If no header is
|
|
267
|
+
stored the flag is silently a no-op.
|
|
221
268
|
"""
|
|
222
269
|
yaml_content: str = self.to_yaml()
|
|
270
|
+
header: Optional[str] = getattr(self, "_yaml_header", None)
|
|
271
|
+
if with_header_comment and header:
|
|
272
|
+
file.write(header)
|
|
273
|
+
file.write("\n")
|
|
223
274
|
file.write(yaml_content)
|
|
224
275
|
|
|
225
|
-
def save_to_yaml_file(self, filename: str):
|
|
276
|
+
def save_to_yaml_file(self, filename: str, with_header_comment: bool = False):
|
|
226
277
|
"""
|
|
227
278
|
Saves the current dataclass instance to a YAML file.
|
|
228
279
|
|
|
229
280
|
Args:
|
|
230
281
|
filename (str): The path where the YAML file will be saved.
|
|
282
|
+
with_header_comment: If True, re-emit any leading comment block that was
|
|
283
|
+
captured during loading; see :meth:`save_to_yaml_stream` for details.
|
|
231
284
|
"""
|
|
232
|
-
|
|
233
285
|
with open(filename, "w", encoding="utf-8") as file:
|
|
234
|
-
self.save_to_yaml_stream(file)
|
|
286
|
+
self.save_to_yaml_stream(file, with_header_comment=with_header_comment)
|
|
235
287
|
|
|
236
288
|
@classmethod
|
|
237
289
|
def load_from_json_file(cls: Type[T], filename: Union[str, Path]) -> T:
|
|
@@ -131,6 +131,72 @@ class TestYamlAble(Basetest):
|
|
|
131
131
|
# Clean up the temp file
|
|
132
132
|
os.remove(temp_file.name)
|
|
133
133
|
|
|
134
|
+
def test_header_comment_preserved(self) -> None:
|
|
135
|
+
"""
|
|
136
|
+
Test that a leading YAML comment block survives a full file round-trip
|
|
137
|
+
when with_header_comment=True is used on both load and save.
|
|
138
|
+
"""
|
|
139
|
+
yaml_with_header = (
|
|
140
|
+
"# This is a project config file\n"
|
|
141
|
+
"# Generated by the system — do not edit manually\n"
|
|
142
|
+
"\n"
|
|
143
|
+
"name: Example\n"
|
|
144
|
+
"id: 123\n"
|
|
145
|
+
"flag: true\n"
|
|
146
|
+
)
|
|
147
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, encoding="utf-8") as f:
|
|
148
|
+
src_path = f.name
|
|
149
|
+
f.write(yaml_with_header)
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
# Load with header preservation
|
|
153
|
+
loaded = MockDataClass.load_from_yaml_file(src_path, with_header_comment=True)
|
|
154
|
+
if self.debug:
|
|
155
|
+
print(f"_yaml_header: {repr(loaded._yaml_header)}")
|
|
156
|
+
self.assertIsNotNone(loaded._yaml_header, "_yaml_header should be set")
|
|
157
|
+
self.assertIn("project config file", loaded._yaml_header)
|
|
158
|
+
self.assertIn("do not edit manually", loaded._yaml_header)
|
|
159
|
+
self.assertEqual(loaded.name, "Example")
|
|
160
|
+
self.assertEqual(loaded.id, 123)
|
|
161
|
+
|
|
162
|
+
# Modify a field and save back with header
|
|
163
|
+
loaded.id = 999
|
|
164
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, encoding="utf-8") as f:
|
|
165
|
+
dst_path = f.name
|
|
166
|
+
loaded.save_to_yaml_file(dst_path, with_header_comment=True)
|
|
167
|
+
|
|
168
|
+
with open(dst_path, "r", encoding="utf-8") as f:
|
|
169
|
+
result = f.read()
|
|
170
|
+
if self.debug:
|
|
171
|
+
print(result)
|
|
172
|
+
|
|
173
|
+
# Header must be at the top
|
|
174
|
+
self.assertTrue(
|
|
175
|
+
result.startswith("# This is a project config file"),
|
|
176
|
+
"Header must be first",
|
|
177
|
+
)
|
|
178
|
+
self.assertIn("do not edit manually", result)
|
|
179
|
+
# Modified data must be present
|
|
180
|
+
self.assertIn("id: 999", result)
|
|
181
|
+
self.assertIn("name: Example", result)
|
|
182
|
+
|
|
183
|
+
# Saving without flag must NOT include the header
|
|
184
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, encoding="utf-8") as f:
|
|
185
|
+
no_header_path = f.name
|
|
186
|
+
loaded.save_to_yaml_file(no_header_path, with_header_comment=False)
|
|
187
|
+
with open(no_header_path, "r", encoding="utf-8") as f:
|
|
188
|
+
no_header_result = f.read()
|
|
189
|
+
self.assertFalse(
|
|
190
|
+
no_header_result.startswith("#"),
|
|
191
|
+
"Header must not appear when with_header_comment=False",
|
|
192
|
+
)
|
|
193
|
+
finally:
|
|
194
|
+
for path in [src_path, dst_path, no_header_path]:
|
|
195
|
+
try:
|
|
196
|
+
os.remove(path)
|
|
197
|
+
except Exception:
|
|
198
|
+
pass
|
|
199
|
+
|
|
134
200
|
def test_load_with_none_optional_field(self) -> None:
|
|
135
201
|
"""
|
|
136
202
|
Test that loading YAML with a None value for an Optional[str] field works without error.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.1"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|