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.
Files changed (38) hide show
  1. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/PKG-INFO +1 -1
  2. pybasemkit-0.2.2/basemkit/__init__.py +1 -0
  3. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/basemkit/yamlable.py +60 -8
  4. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/tests/test_yamlable.py +66 -0
  5. pybasemkit-0.2.1/basemkit/__init__.py +0 -1
  6. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/.github/workflows/build.yml +0 -0
  7. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/.github/workflows/upload-to-pypi.yml +0 -0
  8. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/.gitignore +0 -0
  9. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/.project +0 -0
  10. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/.pydevproject +0 -0
  11. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/AGENTS.md +0 -0
  12. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/LICENSE +0 -0
  13. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/README.md +0 -0
  14. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/basemkit/argparse_action.py +0 -0
  15. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/basemkit/base_cmd.py +0 -0
  16. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/basemkit/basetest.py +0 -0
  17. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/basemkit/docker_util.py +0 -0
  18. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/basemkit/persistent_log.py +0 -0
  19. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/basemkit/profiler.py +0 -0
  20. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/basemkit/remotedebug.py +0 -0
  21. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/basemkit/shell.py +0 -0
  22. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/mkdocs.yml +0 -0
  23. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/pyproject.toml +0 -0
  24. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/scripts/blackisort +0 -0
  25. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/scripts/doc +0 -0
  26. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/scripts/install +0 -0
  27. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/scripts/release +0 -0
  28. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/scripts/test +0 -0
  29. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/tests/__init__.py +0 -0
  30. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/tests/test_argparse_action.py +0 -0
  31. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/tests/test_avro.py +0 -0
  32. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/tests/test_base_cmd.py +0 -0
  33. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/tests/test_docker_util.py +0 -0
  34. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/tests/test_persistent_log.py +0 -0
  35. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/tests/test_remotedebug.py +0 -0
  36. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/tests/test_shell.py +0 -0
  37. {pybasemkit-0.2.1 → pybasemkit-0.2.2}/tests/test_timeout_decorator.py +0 -0
  38. {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.1
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