config-cli-gui 0.1.0__tar.gz → 0.1.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 (86) hide show
  1. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/.idea/runConfigurations/config_generate.xml +2 -2
  2. config_cli_gui-0.1.2/HISTORY.md +25 -0
  3. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/Makefile +0 -32
  4. {config_cli_gui-0.1.0/src/config_cli_gui.egg-info → config_cli_gui-0.1.2}/PKG-INFO +1 -1
  5. config_cli_gui-0.1.2/config.yaml +51 -0
  6. config_cli_gui-0.1.2/docs/getting-started/install.md +28 -0
  7. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/docs/usage/cli.md +17 -3
  8. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/docs/usage/config.md +8 -0
  9. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/src/config_cli_gui/_version.py +2 -2
  10. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/src/config_cli_gui/cli_generator.py +7 -5
  11. config_cli_gui-0.1.2/src/config_cli_gui/config_framework.py +281 -0
  12. config_cli_gui-0.1.2/src/config_cli_gui/docs_generator.py +201 -0
  13. config_cli_gui-0.1.2/src/config_cli_gui/gui_generator.py +535 -0
  14. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2/src/config_cli_gui.egg-info}/PKG-INFO +1 -1
  15. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/src/config_cli_gui.egg-info/SOURCES.txt +15 -19
  16. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/src/config_cli_gui.egg-info/top_level.txt +0 -1
  17. {config_cli_gui-0.1.0/src → config_cli_gui-0.1.2/tests}/example_project/config/config.py +41 -33
  18. {config_cli_gui-0.1.0/src → config_cli_gui-0.1.2/tests}/example_project/gui/gui.py +3 -3
  19. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/tests/test_generic_cli.py +10 -14
  20. config_cli_gui-0.1.0/.github/workflows/build-macos.yml +0 -48
  21. config_cli_gui-0.1.0/.github/workflows/build.yml +0 -46
  22. config_cli_gui-0.1.0/.idea/runConfigurations/module_cli.xml +0 -25
  23. config_cli_gui-0.1.0/.idea/runConfigurations/module_gui.xml +0 -25
  24. config_cli_gui-0.1.0/HISTORY.md +0 -9
  25. config_cli_gui-0.1.0/config.yaml +0 -96
  26. config_cli_gui-0.1.0/docs/develop/pypi_release.md +0 -233
  27. config_cli_gui-0.1.0/docs/getting-started/install.md +0 -66
  28. config_cli_gui-0.1.0/src/config_cli_gui/config_framework.py +0 -362
  29. config_cli_gui-0.1.0/src/config_cli_gui/gui_generator.py +0 -225
  30. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/.github/FUNDING.yml +0 -0
  31. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  32. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  33. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  34. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/.github/actions/setup-environment/action.yml +0 -0
  35. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/.github/dependabot.yml +0 -0
  36. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/.github/init.sh +0 -0
  37. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/.github/release_message.sh +0 -0
  38. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/.github/update_funding.py +0 -0
  39. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/.github/workflows/main.yml +0 -0
  40. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/.github/workflows/release.yml +0 -0
  41. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/.github/workflows/update_readme.yml +0 -0
  42. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/.gitignore +0 -0
  43. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/.pre-commit-config.yaml +0 -0
  44. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/.readthedocs.yaml +0 -0
  45. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/LICENSE +0 -0
  46. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/README.md +0 -0
  47. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/docs/.nav.yml +1 -1
  48. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/docs/_static/img/favicon.png +0 -0
  49. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/docs/_static/img/logo.png +0 -0
  50. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/docs/css/custom.css +0 -0
  51. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/docs/develop/contributing.md +0 -0
  52. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/docs/develop/make_windows.md +0 -0
  53. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/docs/develop/naming_convention.md +0 -0
  54. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/docs/funding/funding.md +0 -0
  55. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/docs/getting-started/virtual-environment.md +0 -0
  56. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/docs/index.md +0 -0
  57. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/examples/kuhkopfsteig.gpx +0 -0
  58. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/examples/rother_lilienstein.gpx +0 -0
  59. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/examples/teneriffa.gpx +0 -0
  60. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/mkdocs.yml +0 -0
  61. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/pyproject.toml +0 -0
  62. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/scripts/show_filelist.ps1 +0 -0
  63. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/scripts/show_tree.ps1 +0 -0
  64. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/scripts/show_tree.py +0 -0
  65. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/scripts/update_readme.py +0 -0
  66. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/setup.cfg +0 -0
  67. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/src/__init__.py +0 -0
  68. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/src/config_cli_gui/__init__.py +0 -0
  69. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/src/config_cli_gui.egg-info/dependency_links.txt +0 -0
  70. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/src/config_cli_gui.egg-info/entry_points.txt +0 -0
  71. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/src/config_cli_gui.egg-info/requires.txt +0 -0
  72. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/src/main.py +0 -0
  73. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/template.yml.url +0 -0
  74. {config_cli_gui-0.1.0/src/example_project → config_cli_gui-0.1.2/tests}/__init__.py +0 -0
  75. {config_cli_gui-0.1.0/src/example_project/cli → config_cli_gui-0.1.2/tests/example_project}/__init__.py +0 -0
  76. {config_cli_gui-0.1.0/src → config_cli_gui-0.1.2/tests}/example_project/__main__.py +0 -0
  77. {config_cli_gui-0.1.0/src/example_project/config → config_cli_gui-0.1.2/tests/example_project/cli}/__init__.py +0 -0
  78. {config_cli_gui-0.1.0/src → config_cli_gui-0.1.2/tests}/example_project/cli/__main__.py +0 -0
  79. {config_cli_gui-0.1.0/src → config_cli_gui-0.1.2/tests}/example_project/cli/cli.py +0 -0
  80. {config_cli_gui-0.1.0/src/example_project/core → config_cli_gui-0.1.2/tests/example_project/config}/__init__.py +0 -0
  81. {config_cli_gui-0.1.0/src/example_project/gui → config_cli_gui-0.1.2/tests/example_project/core}/__init__.py +0 -0
  82. {config_cli_gui-0.1.0/src → config_cli_gui-0.1.2/tests}/example_project/core/base.py +0 -0
  83. {config_cli_gui-0.1.0/src → config_cli_gui-0.1.2/tests}/example_project/core/logging.py +0 -0
  84. {config_cli_gui-0.1.0/tests → config_cli_gui-0.1.2/tests/example_project/gui}/__init__.py +0 -0
  85. {config_cli_gui-0.1.0/src → config_cli_gui-0.1.2/tests}/example_project/gui/__main__.py +0 -0
  86. {config_cli_gui-0.1.0 → config_cli_gui-0.1.2}/uv.lock +0 -0
@@ -8,12 +8,12 @@
8
8
  <env name="PYTHONUNBUFFERED" value="1" />
9
9
  </envs>
10
10
  <option name="SDK_HOME" value="" />
11
- <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/src/config_cli_gui" />
11
+ <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/tests/example_project/config" />
12
12
  <option name="IS_MODULE_SDK" value="true" />
13
13
  <option name="ADD_CONTENT_ROOTS" value="true" />
14
14
  <option name="ADD_SOURCE_ROOTS" value="true" />
15
15
  <EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
16
- <option name="SCRIPT_NAME" value="$PROJECT_DIR$/src/config_cli_gui/config/config.py" />
16
+ <option name="SCRIPT_NAME" value="$PROJECT_DIR$/tests/example_project/config/config.py" />
17
17
  <option name="PARAMETERS" value="" />
18
18
  <option name="SHOW_COMMAND_LINE" value="false" />
19
19
  <option name="EMULATE_TERMINAL" value="false" />
@@ -0,0 +1,25 @@
1
+ Changelog
2
+ =========
3
+
4
+
5
+ (unreleased)
6
+ ------------
7
+ - Feat #3: avoid type_ [Paul Magister]
8
+ - Feat #3: Improved paramter types: [Paul Magister]
9
+ - Feat #3: Improved paramter types: * move example project to tests *
10
+ remove type_ * add improved widgets to gui_generator.py * separate
11
+ module for docs_generator.py. [Paul Magister]
12
+
13
+
14
+ 0.1.0 (2025-06-22)
15
+ ------------------
16
+ - Remove unnecessary example files and deps. [Paul Magister]
17
+ - Update README.md from docs/index.md. [github-actions]
18
+ - Fix doc: formatting. [Paul Magister]
19
+
20
+
21
+ 0.0.2 (2025-06-22)
22
+ ------------------
23
+ - Remove _version.py. [Paul Magister]
24
+
25
+
@@ -55,38 +55,6 @@ test: lint ## Run tests and generate coverage report.
55
55
  uv run coverage xml
56
56
  uv run coverage html
57
57
 
58
- .PHONY: build-win
59
- build-win: ## Build the Windows executable.
60
- echo "Building unified CLI/GUI application"
61
- uv run pyinstaller --onefile src/main.py --name config-cli-gui --add-data "config.yaml;." --hidden-import config_cli_gui.cli.cli --hidden-import config_cli_gui.gui.gui
62
- rm -rf release
63
- mkdir release
64
- cp dist/config-cli-gui.exe release
65
- cp config.yaml release
66
- cp README.md release
67
-
68
- .PHONY: build-macos
69
- build-macos: ## Build the macOS executable.
70
- echo "Building unified CLI/GUI application as executable"
71
- uv run pyinstaller --onefile src/main.py --name config-cli-gui --add-data "config.yaml:." --hidden-import config_cli_gui.cli.cli --hidden-import config_cli_gui.gui.gui
72
-
73
- echo "Building unified CLI/GUI application as .app bundle"
74
- # --windowed is important to hide the console for GUI mode
75
- # The name "TemplateApp" becomes the name of the .app
76
- uv run pyinstaller --windowed --name "TemplateApp" src/main.py --add-data "config.yaml:." --hidden-import config_cli_gui.cli.cli --hidden-import config_cli_gui.gui.gui
77
-
78
- # Prepare ZIP file for release
79
- rm -rf release
80
- mkdir release
81
- echo "Copy the CLI/GUI executable"
82
- cp dist/config-cli-gui release/
83
- echo "Copy the .app bundle (directory) recursively"
84
- cp -R "dist/TemplateApp.app" release/
85
- echo "Copy configuration and documentation"
86
- cp config.yaml release/
87
- cp README.md release/
88
- echo "Create usage instructions"
89
-
90
58
  .PHONY: watch
91
59
  watch: ## Run tests on every change.
92
60
  ls **/**.py | entr uv run pytest -s -vvv -l --tb=long --maxfail=1 tests/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: config-cli-gui
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Feature-rich Python project template for config-cli-gui.
5
5
  Author: pamagister
6
6
  Requires-Python: <3.12,>=3.10
@@ -0,0 +1,51 @@
1
+ app:
2
+ # Date format to use | type=ConfigParameter, default=%Y-%m-%d
3
+ date_format: '%Y-%m-%d'
4
+ # Enable logging to console | type=ConfigParameter, default=True
5
+ enable_console_logging: true
6
+ # Enable logging to file | type=ConfigParameter, default=True
7
+ enable_file_logging: true
8
+ # Number of backup log files to keep | type=ConfigParameter, default=5
9
+ log_backup_count: 5
10
+ # Maximum log file size in MB before rotation | type=ConfigParameter, default=10
11
+ log_file_max_size: 10
12
+ # Log message format style | type=ConfigParameter, default=detailed
13
+ log_format: detailed
14
+ # Logging level for the application | type=ConfigParameter, default=INFO
15
+ log_level: INFO
16
+ # Maximum number of worker threads | type=ConfigParameter, default=4
17
+ max_workers: 4
18
+ cli:
19
+ # Include elevation data in waypoints | type=ConfigParameter, default=True
20
+ elevation: true
21
+ # Extract starting points of each track as waypoint | type=ConfigParameter, default=True
22
+ extract_waypoints: true
23
+ # Path to input (file or folder) | type=ConfigParameter, default=
24
+ input: ''
25
+ # Maximum distance between two waypoints | type=ConfigParameter, default=20
26
+ min_dist: 20
27
+ # Path to output destination | type=ConfigParameter, default=
28
+ output: ''
29
+ gui:
30
+ # Automatically scroll to newest log entries | type=ConfigParameter, default=True
31
+ auto_scroll_log: true
32
+ # Height of the log window in pixels | type=ConfigParameter, default=200
33
+ log_window_height: 200
34
+ # Maximum number of log lines to keep in GUI | type=ConfigParameter, default=1000
35
+ max_log_lines: 1000
36
+ # GUI theme setting | type=ConfigParameter, default=light
37
+ theme: light
38
+ # Default window height | type=ConfigParameter, default=600
39
+ window_height: 600
40
+ # Default window width | type=ConfigParameter, default=800
41
+ window_width: 800
42
+ misc:
43
+ # Color setting for the application | type=ConfigParameter, default=#ff0000
44
+ some_color:
45
+ - 255
46
+ - 0
47
+ - 0
48
+ # Date setting for the application | type=ConfigParameter, default=2025-06-23 18:58:52.946103
49
+ some_date: '2025-06-23T18:58:52.946103'
50
+ # Path to the file to use | type=ConfigParameter, default=some_file.txt
51
+ some_file: some_file.txt
@@ -0,0 +1,28 @@
1
+ # Installation
2
+
3
+
4
+ ## 🐍 PyPI
5
+
6
+ ### Install the package from PyPI
7
+
8
+ Download from [PyPI](https://pypi.org/):
9
+
10
+ ```bash
11
+ pip install config-cli-gui
12
+ ```
13
+
14
+ ## 👩🏼‍💻 Run from source
15
+
16
+ ### Clone the repository
17
+
18
+ ```bash
19
+ git clone
20
+ ```
21
+
22
+ ### Install dependencies
23
+
24
+ ```bash
25
+ uv venv
26
+ uv pip install -e .[dev,docs]
27
+ ```
28
+
@@ -26,19 +26,33 @@ python -m app [OPTIONS] input
26
26
  python -m app input
27
27
  ```
28
28
 
29
- ### 2. With min_dist parameter
29
+ ### 2. With verbose logging
30
+
31
+ ```bash
32
+ python -m app -v input
33
+ python -m app --verbose input
34
+ ```
35
+
36
+ ### 3. With quiet mode
37
+
38
+ ```bash
39
+ python -m app -q input
40
+ python -m app --quiet input
41
+ ```
42
+
43
+ ### 4. With min_dist parameter
30
44
 
31
45
  ```bash
32
46
  python -m app --min_dist 20 input
33
47
  ```
34
48
 
35
- ### 3. With extract_waypoints parameter
49
+ ### 5. With extract_waypoints parameter
36
50
 
37
51
  ```bash
38
52
  python -m app --extract_waypoints True input
39
53
  ```
40
54
 
41
- ### 4. With elevation parameter
55
+ ### 6. With elevation parameter
42
56
 
43
57
  ```bash
44
58
  python -m app --elevation True input
@@ -37,3 +37,11 @@ The parameters in the cli category can be accessed via the command line interfac
37
37
  | auto_scroll_log | bool | Automatically scroll to newest log entries | True | [True, False] |
38
38
  | max_log_lines | int | Maximum number of log lines to keep in GUI | 1000 | - |
39
39
 
40
+ ## Category "misc"
41
+
42
+ | Name | Type | Description | Default | Choices |
43
+ |------------|-------------|-----------------------------------|----------------------------------------------------|---------|
44
+ | some_file | WindowsPath | Path to the file to use | WindowsPath('some_file.txt') | - |
45
+ | some_color | Color | Color setting for the application | Color(255, 0, 0) | - |
46
+ | some_date | datetime | Date setting for the application | datetime.datetime(2025, 6, 23, 18, 58, 52, 946103) | - |
47
+
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '0.1.0'
21
- __version_tuple__ = version_tuple = (0, 1, 0)
20
+ __version__ = version = '0.1.2'
21
+ __version_tuple__ = version_tuple = (0, 1, 2)
@@ -46,6 +46,8 @@ class CliGenerator:
46
46
 
47
47
  # Generate arguments from CLI config parameters
48
48
  for param in cli_params:
49
+ param_type = type(param.default)
50
+
49
51
  if param.required and param.cli_arg is None:
50
52
  # Positional argument
51
53
  parser.add_argument(param.name, help=param.help)
@@ -57,17 +59,17 @@ class CliGenerator:
57
59
  }
58
60
 
59
61
  # Handle different parameter types
60
- if param.choices and param.type_ != bool:
62
+ if param.choices and param_type != bool:
61
63
  kwargs["choices"] = param.choices
62
64
 
63
- if param.type_ == int:
65
+ if param_type == int:
64
66
  kwargs["type"] = int
65
- elif param.type_ == float:
67
+ elif param_type == float:
66
68
  kwargs["type"] = float
67
- elif param.type_ == bool:
69
+ elif param_type == bool:
68
70
  kwargs["action"] = "store_true" if not param.default else "store_false"
69
71
  kwargs["help"] = f"{param.help} (default: {param.default})"
70
- elif param.type_ == str:
72
+ elif param_type == str:
71
73
  kwargs["type"] = str
72
74
 
73
75
  parser.add_argument(param.cli_arg, **kwargs)
@@ -0,0 +1,281 @@
1
+ import json
2
+ from abc import ABC, abstractmethod
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+ from pydantic import BaseModel
10
+
11
+
12
+ class Color:
13
+ """Simple color class for RGB values."""
14
+
15
+ def __init__(self, r: int = 0, g: int = 0, b: int = 0):
16
+ self.r = max(0, min(255, r))
17
+ self.g = max(0, min(255, g))
18
+ self.b = max(0, min(255, b))
19
+
20
+ def to_list(self) -> list[int]:
21
+ return [self.r, self.g, self.b]
22
+
23
+ def to_hex(self) -> str:
24
+ return f"#{self.r:02x}{self.g:02x}{self.b:02x}"
25
+
26
+ @classmethod
27
+ def from_list(cls, rgb_list: list[int]) -> "Color":
28
+ if len(rgb_list) >= 3:
29
+ return cls(rgb_list[0], rgb_list[1], rgb_list[2])
30
+ return cls()
31
+
32
+ @classmethod
33
+ def from_hex(cls, hex_color: str) -> "Color":
34
+ hex_color = hex_color.lstrip("#")
35
+ if len(hex_color) == 6:
36
+ return cls(int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16))
37
+ return cls()
38
+
39
+ def __str__(self):
40
+ return self.to_hex()
41
+
42
+ def __repr__(self):
43
+ return f"Color({self.r}, {self.g}, {self.b})"
44
+
45
+
46
+ @dataclass
47
+ class ConfigParameter:
48
+ """Represents a single configuration parameter with all its metadata."""
49
+
50
+ name: str
51
+ default: Any
52
+ choices: list | tuple | None = None
53
+ help: str = ""
54
+ cli_arg: str = None
55
+ required: bool = False
56
+ is_cli: bool = False
57
+ category: str = "general"
58
+
59
+ def __post_init__(self):
60
+ if self.is_cli and self.cli_arg is None and not self.required:
61
+ self.cli_arg = f"--{self.name}"
62
+ if isinstance(self.default, bool) and self.choices is None:
63
+ self.choices = [True, False]
64
+
65
+ @property
66
+ def type_(self) -> type:
67
+ """Get the type from the default value."""
68
+ return type(self.default)
69
+
70
+
71
+ class BaseConfigCategory(BaseModel, ABC):
72
+ """Base class for configuration categories."""
73
+
74
+ @abstractmethod
75
+ def get_category_name(self) -> str:
76
+ """Return the category name for this configuration group."""
77
+ pass
78
+
79
+ def get_parameters(self) -> list[ConfigParameter]:
80
+ """Get all ConfigParameter objects from this category."""
81
+ parameters = []
82
+ for field_name in self.__class__.model_fields:
83
+ param = getattr(self, field_name)
84
+ if isinstance(param, ConfigParameter):
85
+ param.category = self.get_category_name()
86
+ parameters.append(param)
87
+ return parameters
88
+
89
+
90
+ class ConfigManager:
91
+ """Generic configuration manager that can handle multiple configuration categories."""
92
+
93
+ def __init__(
94
+ self, categories: tuple[BaseConfigCategory, ...], config_file: str = None, **kwargs
95
+ ):
96
+ """Initialize configuration manager.
97
+
98
+ Args:
99
+ config_file: Path to configuration file (JSON or YAML)
100
+ **kwargs: Override parameters in format category__parameter
101
+ """
102
+ self._categories: dict[str, BaseConfigCategory] = {}
103
+
104
+ for category in categories:
105
+ if isinstance(category, BaseConfigCategory):
106
+ self.add_category(category.get_category_name(), category)
107
+ else:
108
+ raise TypeError(
109
+ f"Category must be an instance of BaseConfigCategory, got {type(category)}"
110
+ )
111
+
112
+ # Load from file if provided
113
+ if config_file:
114
+ self.load_from_file(config_file)
115
+
116
+ # Override with provided kwargs
117
+ self._apply_kwargs(kwargs)
118
+
119
+ def add_category(self, name: str, category: BaseConfigCategory):
120
+ """Add a configuration category.
121
+
122
+ Args:
123
+ name: Name of the category (e.g., 'app', 'database', 'gui')
124
+ category: Configuration category instance
125
+ """
126
+ self._categories[name] = category
127
+
128
+ def get_category(self, name: str) -> BaseConfigCategory:
129
+ """Get a configuration category by name."""
130
+ return self._categories.get(name)
131
+
132
+ def _apply_kwargs(self, kwargs: dict[str, Any]):
133
+ """Apply keyword arguments to override configuration values."""
134
+ for key, value in kwargs.items():
135
+ if "__" in key:
136
+ category_name, param_name = key.split("__", 1)
137
+ if category_name in self._categories:
138
+ category = self._categories[category_name]
139
+ if hasattr(category, param_name):
140
+ param = getattr(category, param_name)
141
+ if isinstance(param, ConfigParameter):
142
+ param.default = value
143
+
144
+ def load_from_file(self, config_file: str):
145
+ """Load configuration from JSON or YAML file."""
146
+ config_path = Path(config_file)
147
+ if not config_path.exists():
148
+ raise FileNotFoundError(f"Configuration file not found: {config_file}")
149
+
150
+ with open(config_path, "r", encoding="utf-8") as f:
151
+ if config_path.suffix.lower() in [".yml", ".yaml"]:
152
+ config_data = yaml.safe_load(f)
153
+ else:
154
+ config_data = json.load(f)
155
+
156
+ # Store loaded data for later application
157
+ self._apply_config_data(config_data)
158
+
159
+ def _apply_config_data(self, _loaded_config_data):
160
+ """Apply configuration data to categories."""
161
+
162
+ # Apply loaded configuration
163
+ for category_name, category_data in _loaded_config_data.items():
164
+ if category_name in self._categories:
165
+ category = self._categories[category_name]
166
+ for param_name, param_value in category_data.items():
167
+ if hasattr(category, param_name):
168
+ param = getattr(category, param_name)
169
+ if isinstance(param, ConfigParameter):
170
+ # Handle special types
171
+ if isinstance(param.default, Color) and isinstance(param_value, list):
172
+ param.default = Color.from_list(param_value)
173
+ elif isinstance(param.default, Path):
174
+ param.default = Path(param_value)
175
+ elif isinstance(param.default, datetime):
176
+ param.default = datetime.fromisoformat(param_value)
177
+ else:
178
+ param.default = param_value
179
+
180
+ def save_to_file(self, config_file: str, format_: str = "auto"):
181
+ """Save current configuration to file with enhanced YAML formatting and comments.
182
+
183
+ Args:
184
+ config_file (str): The path to the configuration file.
185
+ format_ (str): The format to save the file in ('auto', 'json', 'yaml').
186
+ """
187
+ config_path = Path(config_file)
188
+ config_data = self.to_dict()
189
+
190
+ # Determine format
191
+ if format_ == "auto":
192
+ format_ = "yaml" if config_path.suffix.lower() in [".yml", ".yaml"] else "json"
193
+
194
+ # Ensure directory exists
195
+ config_path.parent.mkdir(parents=True, exist_ok=True)
196
+
197
+ with open(config_path, "w", encoding="utf-8") as f:
198
+ if format_ == "yaml":
199
+ yaml.dump(config_data, f, default_flow_style=False, indent=2)
200
+ else:
201
+ json.dump(config_data, f, indent=2)
202
+
203
+ # Append comments for YAML files
204
+ if format_ == "yaml":
205
+ self._append_comments_to_yaml(config_path)
206
+
207
+ def to_dict(self) -> dict[str, Any]:
208
+ """Convert configuration to dictionary."""
209
+ result = {}
210
+ for category_name, category in self._categories.items():
211
+ category_dict = {}
212
+ for param in category.get_parameters():
213
+ value = param.default
214
+ # Handle special types for serialization
215
+ if isinstance(value, Color):
216
+ value = value.to_list()
217
+ elif isinstance(value, Path):
218
+ value = str(value)
219
+ elif isinstance(value, datetime):
220
+ value = value.isoformat()
221
+ category_dict[param.name] = value
222
+ result[category_name] = category_dict
223
+ return result
224
+
225
+ def get_all_parameters(self) -> list[ConfigParameter]:
226
+ """Get all parameters from all categories."""
227
+ parameters = []
228
+ for category in self._categories.values():
229
+ parameters.extend(category.get_parameters())
230
+ return parameters
231
+
232
+ def get_cli_parameters(self) -> list[ConfigParameter]:
233
+ """Get parameters that are CLI-enabled."""
234
+ cli_parameters = []
235
+ for category in self._categories.values():
236
+ for param in category.get_parameters():
237
+ if param.is_cli:
238
+ cli_parameters.append(param)
239
+ return cli_parameters
240
+
241
+ def _append_comments_to_yaml(self, config_path: Path):
242
+ """Appends comments to a YAML file based on ConfigParameter metadata.
243
+
244
+ Args:
245
+ config_path (Path): The path to the YAML configuration file.
246
+ """
247
+ lines = config_path.read_text(encoding="utf-8").splitlines()
248
+ new_lines = []
249
+ all_parameters = {param.name: param for param in self.get_all_parameters()}
250
+ current_category = None
251
+
252
+ for line in lines:
253
+ stripped_line = line.strip()
254
+ # Check for category (e.g., 'app:')
255
+ # A category should end with ':', not start with '#', and not be indented.
256
+ if (
257
+ stripped_line.endswith(":")
258
+ and not stripped_line.startswith("#")
259
+ and line.startswith(stripped_line)
260
+ ):
261
+ current_category = stripped_line[:-1]
262
+ new_lines.append(line)
263
+ else:
264
+ # Check for parameter (e.g., ' date_format: '%Y-%m-%d'')
265
+ # This needs to handle cases where the value spans multiple lines
266
+ parts = stripped_line.split(":", 1)
267
+ if len(parts) > 1: # This line might be a parameter definition
268
+ param_name = parts[0].strip()
269
+ if param_name in all_parameters:
270
+ param = all_parameters[param_name]
271
+ # Ensure the parameter belongs to the current category
272
+ # and is not a sub-item of a multi-line value
273
+ if current_category and param.category == current_category:
274
+ comment_indent = " " * (len(line) - len(stripped_line))
275
+ comment = (
276
+ f"{comment_indent}# {param.help} | "
277
+ f"type={type(param).__name__}, default={param.default}"
278
+ )
279
+ new_lines.append(comment)
280
+ new_lines.append(line)
281
+ config_path.write_text("\n".join(new_lines), encoding="utf-8")