structui 0.3.0__tar.gz → 0.5.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 (34) hide show
  1. {structui-0.3.0 → structui-0.5.0}/PKG-INFO +2 -1
  2. {structui-0.3.0 → structui-0.5.0}/README.md +1 -0
  3. {structui-0.3.0 → structui-0.5.0}/pyproject.toml +36 -36
  4. {structui-0.3.0 → structui-0.5.0}/src/structui/__init__.py +7 -7
  5. {structui-0.3.0 → structui-0.5.0}/src/structui/app.py +25 -25
  6. {structui-0.3.0 → structui-0.5.0}/src/structui/cli.py +20 -20
  7. {structui-0.3.0 → structui-0.5.0}/src/structui/file_picker.py +100 -100
  8. {structui-0.3.0 → structui-0.5.0}/src/structui/parser.py +92 -66
  9. {structui-0.3.0 → structui-0.5.0}/src/structui/schema.py +136 -123
  10. {structui-0.3.0 → structui-0.5.0}/src/structui/state.py +139 -139
  11. {structui-0.3.0 → structui-0.5.0}/src/structui/ui.py +636 -515
  12. {structui-0.3.0 → structui-0.5.0}/src/structui/xml_parser.py +108 -108
  13. {structui-0.3.0 → structui-0.5.0}/src/structui.egg-info/PKG-INFO +2 -1
  14. {structui-0.3.0 → structui-0.5.0}/tests/test_app.py +62 -62
  15. {structui-0.3.0 → structui-0.5.0}/tests/test_cli.py +44 -44
  16. {structui-0.3.0 → structui-0.5.0}/tests/test_coverage_boost.py +292 -292
  17. {structui-0.3.0 → structui-0.5.0}/tests/test_file_picker.py +143 -143
  18. {structui-0.3.0 → structui-0.5.0}/tests/test_final_gap.py +131 -131
  19. {structui-0.3.0 → structui-0.5.0}/tests/test_parser.py +119 -93
  20. {structui-0.3.0 → structui-0.5.0}/tests/test_schema.py +163 -163
  21. {structui-0.3.0 → structui-0.5.0}/tests/test_state.py +161 -161
  22. {structui-0.3.0 → structui-0.5.0}/tests/test_ui.py +481 -481
  23. {structui-0.3.0 → structui-0.5.0}/tests/test_ui_blur.py +227 -227
  24. {structui-0.3.0 → structui-0.5.0}/tests/test_ui_coverage.py +160 -160
  25. {structui-0.3.0 → structui-0.5.0}/tests/test_ui_extra.py +146 -146
  26. {structui-0.3.0 → structui-0.5.0}/tests/test_ui_extra2.py +66 -66
  27. {structui-0.3.0 → structui-0.5.0}/tests/test_ui_final.py +506 -506
  28. {structui-0.3.0 → structui-0.5.0}/tests/test_xml_parser.py +154 -154
  29. {structui-0.3.0 → structui-0.5.0}/setup.cfg +0 -0
  30. {structui-0.3.0 → structui-0.5.0}/src/structui.egg-info/SOURCES.txt +0 -0
  31. {structui-0.3.0 → structui-0.5.0}/src/structui.egg-info/dependency_links.txt +0 -0
  32. {structui-0.3.0 → structui-0.5.0}/src/structui.egg-info/entry_points.txt +0 -0
  33. {structui-0.3.0 → structui-0.5.0}/src/structui.egg-info/requires.txt +0 -0
  34. {structui-0.3.0 → structui-0.5.0}/src/structui.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: structui
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: A format-agnostic, schema-driven, hierarchical configuration UI.
5
5
  Author: structui contributors
6
6
  License: MIT
@@ -36,6 +36,7 @@ The architecture is explicitly decoupled, making it readily extensible to strict
36
36
  - **Pillar B: Hierarchical UI:** Dynamic tree-based rendering with full support for multidimensional containers, dynamic polymorphic list additions, and node mapping. Powered natively by NiceGUI.
37
37
  - **Pillar C: Data Validity:** Enforces schema metadata strictly at the UI layer. Missing fields gracefully populate via defaults, required flags trigger locking, and nested typings are continuously evaluated.
38
38
  - **Pillar D: Extensibility & Programmatic Control:** Decomposed core logic (App, Parser, State, Schema, UI) allowing external tools and wrappers (e.g. CLI, Agent Workflows) to invoke the editor or inject properties safely.
39
+ - **Hex/Decimal Toggling:** Automatically detects and preserves hex formatting (`0x...`) loaded from YAML configurations. Supports inline format toggling between hex and decimal, with validation logic to restrict inputs to valid hex formats and enforce platform/64-bit size limits.
39
40
 
40
41
  ## Installation
41
42
 
@@ -15,6 +15,7 @@ The architecture is explicitly decoupled, making it readily extensible to strict
15
15
  - **Pillar B: Hierarchical UI:** Dynamic tree-based rendering with full support for multidimensional containers, dynamic polymorphic list additions, and node mapping. Powered natively by NiceGUI.
16
16
  - **Pillar C: Data Validity:** Enforces schema metadata strictly at the UI layer. Missing fields gracefully populate via defaults, required flags trigger locking, and nested typings are continuously evaluated.
17
17
  - **Pillar D: Extensibility & Programmatic Control:** Decomposed core logic (App, Parser, State, Schema, UI) allowing external tools and wrappers (e.g. CLI, Agent Workflows) to invoke the editor or inject properties safely.
18
+ - **Hex/Decimal Toggling:** Automatically detects and preserves hex formatting (`0x...`) loaded from YAML configurations. Supports inline format toggling between hex and decimal, with validation logic to restrict inputs to valid hex formats and enforce platform/64-bit size limits.
18
19
 
19
20
  ## Installation
20
21
 
@@ -1,36 +1,36 @@
1
- [build-system]
2
- requires = ["setuptools>=61.0"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- name = "structui"
7
- version = "0.3.0"
8
- description = "A format-agnostic, schema-driven, hierarchical configuration UI."
9
- readme = "README.md"
10
- authors = [
11
- { name = "structui contributors" }
12
- ]
13
- license = { text = "MIT" }
14
- requires-python = ">=3.9"
15
- dependencies = [
16
- "nicegui>=1.4.0",
17
- "pyyaml>=6.0.1",
18
- "pywin32>=300; sys_platform == 'win32'"
19
- ]
20
- classifiers = [
21
- "Development Status :: 4 - Beta",
22
- "Environment :: Web Environment",
23
- "Intended Audience :: Developers",
24
- "License :: OSI Approved :: MIT License",
25
- "Programming Language :: Python :: 3",
26
- "Programming Language :: Python :: 3.9",
27
- "Programming Language :: Python :: 3.10",
28
- "Programming Language :: Python :: 3.11",
29
- "Programming Language :: Python :: 3.12"
30
- ]
31
-
32
- [project.scripts]
33
- structui = "structui.cli:main"
34
-
35
- [tool.setuptools.packages.find]
36
- where = ["src"]
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "structui"
7
+ version = "0.5.0"
8
+ description = "A format-agnostic, schema-driven, hierarchical configuration UI."
9
+ readme = "README.md"
10
+ authors = [
11
+ { name = "structui contributors" }
12
+ ]
13
+ license = { text = "MIT" }
14
+ requires-python = ">=3.9"
15
+ dependencies = [
16
+ "nicegui>=1.4.0",
17
+ "pyyaml>=6.0.1",
18
+ "pywin32>=300; sys_platform == 'win32'"
19
+ ]
20
+ classifiers = [
21
+ "Development Status :: 4 - Beta",
22
+ "Environment :: Web Environment",
23
+ "Intended Audience :: Developers",
24
+ "License :: OSI Approved :: MIT License",
25
+ "Programming Language :: Python :: 3",
26
+ "Programming Language :: Python :: 3.9",
27
+ "Programming Language :: Python :: 3.10",
28
+ "Programming Language :: Python :: 3.11",
29
+ "Programming Language :: Python :: 3.12"
30
+ ]
31
+
32
+ [project.scripts]
33
+ structui = "structui.cli:main"
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
@@ -1,7 +1,7 @@
1
- """StructUI - A format-agnostic, schema-driven, hierarchical configuration UI."""
2
-
3
- __version__ = "0.1.0"
4
-
5
- from .app import run_app
6
-
7
- __all__ = ["run_app"]
1
+ """StructUI - A format-agnostic, schema-driven, hierarchical configuration UI."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .app import run_app
6
+
7
+ __all__ = ["run_app"]
@@ -1,25 +1,25 @@
1
- from typing import Optional
2
- from nicegui import ui
3
- from structui.ui import StructUI
4
- from structui.state import AppState
5
- from structui.schema import SchemaManager
6
-
7
- def run_app(data_dir: str = ".", schema_filepath: str = ".structui_schema.yaml", port: int = 8080, dark_mode: bool = False):
8
- schema_manager = SchemaManager(schema_filepath)
9
- try:
10
- app_state = AppState(data_dir, schema_manager)
11
- load_error = None
12
- except Exception as e:
13
- app_state = AppState(".", schema_manager)
14
- app_state.config_data = {}
15
- load_error = str(e)
16
-
17
- ui_instance = StructUI(app_state, schema_manager, dark_mode)
18
-
19
- @ui.page('/')
20
- def main_page():
21
- ui_instance.render()
22
- if load_error:
23
- ui.notify(f"XML/Config Load Error: {load_error}", type="negative", position="top", timeout=8000)
24
-
25
- ui.run(port=port, title="StructUI Editor", reload=False)
1
+ from typing import Optional
2
+ from nicegui import ui
3
+ from structui.ui import StructUI
4
+ from structui.state import AppState
5
+ from structui.schema import SchemaManager
6
+
7
+ def run_app(data_dir: str = ".", schema_filepath: str = ".structui_schema.yaml", port: int = 8080, dark_mode: bool = False):
8
+ schema_manager = SchemaManager(schema_filepath)
9
+ try:
10
+ app_state = AppState(data_dir, schema_manager)
11
+ load_error = None
12
+ except Exception as e:
13
+ app_state = AppState(".", schema_manager)
14
+ app_state.config_data = {}
15
+ load_error = str(e)
16
+
17
+ ui_instance = StructUI(app_state, schema_manager, dark_mode)
18
+
19
+ @ui.page('/')
20
+ def main_page():
21
+ ui_instance.render()
22
+ if load_error:
23
+ ui.notify(f"XML/Config Load Error: {load_error}", type="negative", position="top", timeout=8000)
24
+
25
+ ui.run(port=port, title="StructUI Editor", reload=False)
@@ -1,20 +1,20 @@
1
- import argparse
2
- import sys
3
- from structui.app import run_app
4
-
5
- def main():
6
- parser = argparse.ArgumentParser(description="StructUI Configuration Editor")
7
- parser.add_argument("--dir", type=str, default=".", help="Directory containing config files")
8
- parser.add_argument("--schema", type=str, default=".structui_schema.yaml", help="Path to schema file")
9
- parser.add_argument("--port", type=int, default=8080, help="Port to run the UI on (default: 8080)")
10
-
11
- args = parser.parse_args()
12
-
13
- try:
14
- run_app(data_dir=args.dir, schema_filepath=args.schema, port=args.port)
15
- except Exception as e:
16
- print(f"Error starting StructUI: {e}", file=sys.stderr)
17
- sys.exit(1)
18
-
19
- if __name__ in {"__main__", "__mp_main__"}: # pragma: no cover
20
- main()
1
+ import argparse
2
+ import sys
3
+ from structui.app import run_app
4
+
5
+ def main():
6
+ parser = argparse.ArgumentParser(description="StructUI Configuration Editor")
7
+ parser.add_argument("--dir", type=str, default=".", help="Directory containing config files")
8
+ parser.add_argument("--schema", type=str, default=".structui_schema.yaml", help="Path to schema file")
9
+ parser.add_argument("--port", type=int, default=8080, help="Port to run the UI on (default: 8080)")
10
+
11
+ args = parser.parse_args()
12
+
13
+ try:
14
+ run_app(data_dir=args.dir, schema_filepath=args.schema, port=args.port)
15
+ except Exception as e:
16
+ print(f"Error starting StructUI: {e}", file=sys.stderr)
17
+ sys.exit(1)
18
+
19
+ if __name__ in {"__main__", "__mp_main__"}: # pragma: no cover
20
+ main()
@@ -1,100 +1,100 @@
1
- import platform
2
- from pathlib import Path
3
- from typing import Optional
4
-
5
- from nicegui import events, ui
6
-
7
-
8
- class LocalFilePicker(ui.dialog):
9
-
10
- def __init__(self, directory: str, *, allowed_extensions: Optional[list] = None,
11
- upper_limit: Optional[str] = None, multiple: bool = False, show_hidden_files: bool = False,
12
- dirs_only: bool = False) -> None:
13
- """Local File Picker
14
-
15
- This is a simple file picker that allows you to select a file from the local filesystem where NiceGUI is running.
16
-
17
- :param directory: The directory to start in.
18
- :param upper_limit: The directory to stop navigating up (e.g. the root directory).
19
- :param multiple: Whether to allow multiple files to be selected.
20
- :param show_hidden_files: Whether to show hidden files.
21
- :param dirs_only: Whether to only show directories.
22
- """
23
- super().__init__()
24
-
25
- self.path = Path(directory).expanduser().resolve()
26
- if upper_limit is None:
27
- self.upper_limit = None
28
- else:
29
- self.upper_limit = Path(directory if upper_limit == ... else upper_limit).expanduser().resolve()
30
- self.show_hidden_files = show_hidden_files
31
- self.dirs_only = dirs_only
32
- self.allowed_extensions = allowed_extensions
33
-
34
- with self, ui.card():
35
- self.add_drives_toggle()
36
- self.grid = ui.aggrid({
37
- 'columnDefs': [{'field': 'name', 'headerName': 'File'}],
38
- 'rowSelection': 'multiple' if multiple else 'single',
39
- }, html_columns=[0]).classes('w-96').on('cellDoubleClicked', self.handle_double_click)
40
- with ui.row().classes('w-full justify-end'):
41
- ui.button('Cancel', on_click=self.close).props('outline')
42
- ui.button('Ok', on_click=self._handle_ok)
43
- self.update_grid()
44
-
45
- def add_drives_toggle(self):
46
- if platform.system() == 'Windows':
47
- import win32api
48
- drives = win32api.GetLogicalDriveStrings().split('\000')[:-1]
49
- self.drives_toggle = ui.toggle(drives, value=drives[0], on_change=self.update_drive)
50
-
51
- def update_drive(self):
52
- self.path = Path(self.drives_toggle.value).expanduser()
53
- self.update_grid()
54
-
55
- def update_grid(self) -> None:
56
- paths = list(self.path.iterdir())
57
- if not self.show_hidden_files:
58
- paths = [p for p in paths if not p.name.startswith('.')]
59
- if self.dirs_only:
60
- paths = [p for p in paths if p.is_dir()]
61
- elif self.allowed_extensions:
62
- allowed_exts = [ext.lower() if ext.startswith('.') else f'.{ext.lower()}' for ext in self.allowed_extensions]
63
- paths = [p for p in paths if p.is_dir() or p.suffix.lower() in allowed_exts]
64
- paths.sort(key=lambda p: p.name.lower())
65
-
66
- paths.sort(key=lambda p: not p.is_dir())
67
-
68
- self.grid.options['rowData'] = [
69
- {
70
- 'name': f'📁 <strong>{p.name}</strong>' if p.is_dir() else p.name,
71
- 'path': str(p),
72
- }
73
- for p in paths
74
- ]
75
- if self.upper_limit is None or self.path != self.upper_limit:
76
- self.grid.options['rowData'].insert(0, {
77
- 'name': '📁 <strong>..</strong>',
78
- 'path': str(self.path.parent),
79
- })
80
- self.grid.update()
81
-
82
- def handle_double_click(self, e: events.GenericEventArguments) -> None:
83
- self.path = Path(e.args['data']['path'])
84
- if self.path.is_dir():
85
- self.update_grid()
86
- else:
87
- self.submit([str(self.path)])
88
-
89
- async def _handle_ok(self):
90
- try:
91
- rows = await self.grid.get_selected_rows()
92
- except TimeoutError:
93
- rows = []
94
-
95
- if rows:
96
- self.submit([r['path'] for r in rows])
97
- elif self.dirs_only:
98
- self.submit([str(self.path)])
99
- else:
100
- ui.notify('No file selected.')
1
+ import platform
2
+ from pathlib import Path
3
+ from typing import Optional
4
+
5
+ from nicegui import events, ui
6
+
7
+
8
+ class LocalFilePicker(ui.dialog):
9
+
10
+ def __init__(self, directory: str, *, allowed_extensions: Optional[list] = None,
11
+ upper_limit: Optional[str] = None, multiple: bool = False, show_hidden_files: bool = False,
12
+ dirs_only: bool = False) -> None:
13
+ """Local File Picker
14
+
15
+ This is a simple file picker that allows you to select a file from the local filesystem where NiceGUI is running.
16
+
17
+ :param directory: The directory to start in.
18
+ :param upper_limit: The directory to stop navigating up (e.g. the root directory).
19
+ :param multiple: Whether to allow multiple files to be selected.
20
+ :param show_hidden_files: Whether to show hidden files.
21
+ :param dirs_only: Whether to only show directories.
22
+ """
23
+ super().__init__()
24
+
25
+ self.path = Path(directory).expanduser().resolve()
26
+ if upper_limit is None:
27
+ self.upper_limit = None
28
+ else:
29
+ self.upper_limit = Path(directory if upper_limit == ... else upper_limit).expanduser().resolve()
30
+ self.show_hidden_files = show_hidden_files
31
+ self.dirs_only = dirs_only
32
+ self.allowed_extensions = allowed_extensions
33
+
34
+ with self, ui.card():
35
+ self.add_drives_toggle()
36
+ self.grid = ui.aggrid({
37
+ 'columnDefs': [{'field': 'name', 'headerName': 'File'}],
38
+ 'rowSelection': 'multiple' if multiple else 'single',
39
+ }, html_columns=[0]).classes('w-96').on('cellDoubleClicked', self.handle_double_click)
40
+ with ui.row().classes('w-full justify-end'):
41
+ ui.button('Cancel', on_click=self.close).props('outline')
42
+ ui.button('Ok', on_click=self._handle_ok)
43
+ self.update_grid()
44
+
45
+ def add_drives_toggle(self):
46
+ if platform.system() == 'Windows':
47
+ import win32api
48
+ drives = win32api.GetLogicalDriveStrings().split('\000')[:-1]
49
+ self.drives_toggle = ui.toggle(drives, value=drives[0], on_change=self.update_drive)
50
+
51
+ def update_drive(self):
52
+ self.path = Path(self.drives_toggle.value).expanduser()
53
+ self.update_grid()
54
+
55
+ def update_grid(self) -> None:
56
+ paths = list(self.path.iterdir())
57
+ if not self.show_hidden_files:
58
+ paths = [p for p in paths if not p.name.startswith('.')]
59
+ if self.dirs_only:
60
+ paths = [p for p in paths if p.is_dir()]
61
+ elif self.allowed_extensions:
62
+ allowed_exts = [ext.lower() if ext.startswith('.') else f'.{ext.lower()}' for ext in self.allowed_extensions]
63
+ paths = [p for p in paths if p.is_dir() or p.suffix.lower() in allowed_exts]
64
+ paths.sort(key=lambda p: p.name.lower())
65
+
66
+ paths.sort(key=lambda p: not p.is_dir())
67
+
68
+ self.grid.options['rowData'] = [
69
+ {
70
+ 'name': f'📁 <strong>{p.name}</strong>' if p.is_dir() else p.name,
71
+ 'path': str(p),
72
+ }
73
+ for p in paths
74
+ ]
75
+ if self.upper_limit is None or self.path != self.upper_limit:
76
+ self.grid.options['rowData'].insert(0, {
77
+ 'name': '📁 <strong>..</strong>',
78
+ 'path': str(self.path.parent),
79
+ })
80
+ self.grid.update()
81
+
82
+ def handle_double_click(self, e: events.GenericEventArguments) -> None:
83
+ self.path = Path(e.args['data']['path'])
84
+ if self.path.is_dir():
85
+ self.update_grid()
86
+ else:
87
+ self.submit([str(self.path)])
88
+
89
+ async def _handle_ok(self):
90
+ try:
91
+ rows = await self.grid.get_selected_rows()
92
+ except TimeoutError:
93
+ rows = []
94
+
95
+ if rows:
96
+ self.submit([r['path'] for r in rows])
97
+ elif self.dirs_only:
98
+ self.submit([str(self.path)])
99
+ else:
100
+ ui.notify('No file selected.')
@@ -1,66 +1,92 @@
1
- import os
2
- import yaml # type: ignore
3
- import json
4
- import xml.etree.ElementTree as ET
5
- from abc import ABC, abstractmethod
6
- from typing import Dict, Any, Optional
7
- from .xml_parser import load_xml, save_xml
8
-
9
- class DataParser(ABC):
10
- """Abstract base class for format-agnostic configuration parsing."""
11
-
12
- @abstractmethod
13
- def load(self, filepath: str, schema: Optional[Dict[str, Any]] = None) -> Any:
14
- pass
15
-
16
- @abstractmethod
17
- def save(self, filepath: str, data: Any):
18
- pass
19
-
20
- class YamlParser(DataParser):
21
- def load(self, filepath: str, schema: Optional[Dict[str, Any]] = None) -> Any:
22
- try:
23
- with open(filepath, 'r') as f:
24
- return yaml.safe_load(f)
25
- except Exception as e:
26
- print(f"YAML Load Error ({filepath}): {e}")
27
- return None
28
-
29
- def save(self, filepath: str, data: Any):
30
- with open(filepath, 'w') as f:
31
- yaml.dump(data, f, default_flow_style=False, sort_keys=False)
32
-
33
- class JsonParser(DataParser):
34
- def load(self, filepath: str, schema: Optional[Dict[str, Any]] = None) -> Any:
35
- try:
36
- with open(filepath, 'r') as f:
37
- return json.load(f)
38
- except Exception as e:
39
- print(f"JSON Load Error ({filepath}): {e}")
40
- return None
41
-
42
- def save(self, filepath: str, data: Any):
43
- with open(filepath, 'w') as f:
44
- json.dump(data, f, indent=4)
45
-
46
- class XmlParser(DataParser):
47
- def load(self, filepath: str, schema: Optional[Dict[str, Any]] = None) -> Any:
48
- try:
49
- with open(filepath, 'r', encoding='utf-8') as f:
50
- content = f.read()
51
- return load_xml(content, schema)
52
- except ET.ParseError as e:
53
- raise ET.ParseError(f"Malformed XML in {os.path.basename(filepath)}: {str(e)}")
54
-
55
- def save(self, filepath: str, data: Any):
56
- save_xml(data, filepath)
57
-
58
- def get_parser(filepath: str) -> DataParser:
59
- """Factory method to resolve the correct parser by file extension."""
60
- if filepath.endswith(('.yaml', '.yml')):
61
- return YamlParser()
62
- elif filepath.endswith('.json'):
63
- return JsonParser()
64
- elif filepath.endswith('.xml'):
65
- return XmlParser()
66
- return YamlParser()
1
+ import os
2
+ import yaml # type: ignore
3
+ import json
4
+ import xml.etree.ElementTree as ET
5
+ from abc import ABC, abstractmethod
6
+ from typing import Dict, Any, Optional
7
+ from .xml_parser import load_xml, save_xml
8
+
9
+ class HexInt(int):
10
+ """Subclass of int to preserve hex formatting in YAML and UI representation."""
11
+ def __str__(self) -> str:
12
+ if self < 0:
13
+ return f"0x{(self & 0xffffffffffffffff):x}"
14
+ return f"0x{self:x}"
15
+
16
+ def __repr__(self) -> str:
17
+ return self.__str__()
18
+
19
+ def custom_int_constructor(loader, node):
20
+ val_str = loader.construct_scalar(node)
21
+ val = loader.construct_yaml_int(node)
22
+ if '0x' in val_str or '0X' in val_str or '0x' in val_str.lower():
23
+ return HexInt(val)
24
+ return val
25
+
26
+ yaml.SafeLoader.add_constructor('tag:yaml.org,2002:int', custom_int_constructor)
27
+ yaml.Loader.add_constructor('tag:yaml.org,2002:int', custom_int_constructor)
28
+
29
+ def hex_int_representer(dumper, data):
30
+ return dumper.represent_scalar('tag:yaml.org,2002:int', str(data))
31
+
32
+ yaml.SafeDumper.add_representer(HexInt, hex_int_representer)
33
+ yaml.Dumper.add_representer(HexInt, hex_int_representer)
34
+
35
+ class DataParser(ABC):
36
+ """Abstract base class for format-agnostic configuration parsing."""
37
+
38
+ @abstractmethod
39
+ def load(self, filepath: str, schema: Optional[Dict[str, Any]] = None) -> Any:
40
+ pass
41
+
42
+ @abstractmethod
43
+ def save(self, filepath: str, data: Any):
44
+ pass
45
+
46
+ class YamlParser(DataParser):
47
+ def load(self, filepath: str, schema: Optional[Dict[str, Any]] = None) -> Any:
48
+ try:
49
+ with open(filepath, 'r') as f:
50
+ return yaml.safe_load(f)
51
+ except Exception as e:
52
+ print(f"YAML Load Error ({filepath}): {e}")
53
+ return None
54
+
55
+ def save(self, filepath: str, data: Any):
56
+ with open(filepath, 'w') as f:
57
+ yaml.dump(data, f, default_flow_style=False, sort_keys=False)
58
+
59
+ class JsonParser(DataParser):
60
+ def load(self, filepath: str, schema: Optional[Dict[str, Any]] = None) -> Any:
61
+ try:
62
+ with open(filepath, 'r') as f:
63
+ return json.load(f)
64
+ except Exception as e:
65
+ print(f"JSON Load Error ({filepath}): {e}")
66
+ return None
67
+
68
+ def save(self, filepath: str, data: Any):
69
+ with open(filepath, 'w') as f:
70
+ json.dump(data, f, indent=4)
71
+
72
+ class XmlParser(DataParser):
73
+ def load(self, filepath: str, schema: Optional[Dict[str, Any]] = None) -> Any:
74
+ try:
75
+ with open(filepath, 'r', encoding='utf-8') as f:
76
+ content = f.read()
77
+ return load_xml(content, schema)
78
+ except ET.ParseError as e:
79
+ raise ET.ParseError(f"Malformed XML in {os.path.basename(filepath)}: {str(e)}")
80
+
81
+ def save(self, filepath: str, data: Any):
82
+ save_xml(data, filepath)
83
+
84
+ def get_parser(filepath: str) -> DataParser:
85
+ """Factory method to resolve the correct parser by file extension."""
86
+ if filepath.endswith(('.yaml', '.yml')):
87
+ return YamlParser()
88
+ elif filepath.endswith('.json'):
89
+ return JsonParser()
90
+ elif filepath.endswith('.xml'):
91
+ return XmlParser()
92
+ return YamlParser()