config-cli-gui 0.2.9__tar.gz → 0.3.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 (86) hide show
  1. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/HISTORY.md +20 -0
  2. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/PKG-INFO +1 -1
  3. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/README.md +42 -38
  4. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/config.yaml +36 -39
  5. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/src/config_cli_gui/_version.py +3 -3
  6. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/src/config_cli_gui/cli.py +13 -8
  7. config_cli_gui-0.3.0/src/config_cli_gui/config.py +251 -0
  8. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/src/config_cli_gui/configtypes/font.py +35 -1
  9. config_cli_gui-0.3.0/src/config_cli_gui/logging.py +216 -0
  10. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/src/config_cli_gui.egg-info/PKG-INFO +1 -1
  11. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/src/config_cli_gui.egg-info/SOURCES.txt +1 -1
  12. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/tests/example_project/cli/cli_example.py +29 -8
  13. config_cli_gui-0.3.0/tests/example_project/core/base.py +116 -0
  14. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/tests/example_project/gui/gui_example.py +4 -4
  15. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/tests/test_cli.py +1 -1
  16. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/tests/test_config_manager.py +6 -1
  17. config_cli_gui-0.2.9/src/config_cli_gui/config.py +0 -223
  18. config_cli_gui-0.2.9/tests/example_project/core/base.py +0 -55
  19. config_cli_gui-0.2.9/tests/example_project/core/logging.py +0 -219
  20. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/.github/FUNDING.yml +0 -0
  21. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  22. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  23. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  24. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/.github/actions/setup-environment/action.yml +0 -0
  25. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/.github/dependabot.yml +0 -0
  26. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/.github/init.sh +0 -0
  27. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/.github/release_message.sh +0 -0
  28. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/.github/update_funding.py +0 -0
  29. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/.github/workflows/main.yml +0 -0
  30. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/.github/workflows/release.yml +0 -0
  31. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/.github/workflows/update_readme.yml +0 -0
  32. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/.gitignore +0 -0
  33. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/.idea/runConfigurations/config_generate.xml +0 -0
  34. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/.idea/runConfigurations/example_project_cli.xml +0 -0
  35. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/.idea/runConfigurations/example_project_gui.xml +0 -0
  36. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/.pre-commit-config.yaml +0 -0
  37. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/.readthedocs.yaml +0 -0
  38. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/LICENSE +0 -0
  39. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/Makefile +0 -0
  40. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/docs/.nav.yml +0 -0
  41. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/docs/_static/img/favicon.png +0 -0
  42. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/docs/_static/img/logo.png +0 -0
  43. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/docs/css/custom.css +0 -0
  44. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/docs/develop/contributing.md +0 -0
  45. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/docs/develop/make_windows.md +0 -0
  46. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/docs/develop/naming_convention.md +0 -0
  47. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/docs/funding/funding.md +0 -0
  48. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/docs/getting-started/install.md +0 -0
  49. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/docs/getting-started/virtual-environment.md +0 -0
  50. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/docs/index.md +0 -0
  51. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/docs/usage/cli.md +0 -0
  52. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/docs/usage/config.md +0 -0
  53. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/mkdocs.yml +0 -0
  54. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/pyproject.toml +0 -0
  55. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/scripts/show_filelist.ps1 +0 -0
  56. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/scripts/show_tree.ps1 +0 -0
  57. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/scripts/show_tree.py +0 -0
  58. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/scripts/update_readme.py +0 -0
  59. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/setup.cfg +0 -0
  60. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/src/__init__.py +0 -0
  61. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/src/config_cli_gui/__init__.py +0 -0
  62. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/src/config_cli_gui/configtypes/__init__.py +0 -0
  63. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/src/config_cli_gui/configtypes/color.py +0 -0
  64. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/src/config_cli_gui/configtypes/vector.py +0 -0
  65. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/src/config_cli_gui/docs.py +0 -0
  66. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/src/config_cli_gui/gui.py +0 -0
  67. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/src/config_cli_gui.egg-info/dependency_links.txt +0 -0
  68. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/src/config_cli_gui.egg-info/entry_points.txt +0 -0
  69. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/src/config_cli_gui.egg-info/requires.txt +0 -0
  70. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/src/config_cli_gui.egg-info/top_level.txt +0 -0
  71. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/template.yml.url +0 -0
  72. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/tests/__init__.py +0 -0
  73. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/tests/example_project/__init__.py +0 -0
  74. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/tests/example_project/__main__.py +0 -0
  75. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/tests/example_project/cli/__init__.py +0 -0
  76. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/tests/example_project/cli/__main__.py +0 -0
  77. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/tests/example_project/config/__init__.py +0 -0
  78. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/tests/example_project/config/config_example.py +0 -0
  79. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/tests/example_project/core/__init__.py +0 -0
  80. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/tests/example_project/example.gpx +0 -0
  81. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/tests/example_project/gui/__init__.py +0 -0
  82. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/tests/example_project/gui/__main__.py +0 -0
  83. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/tests/example_project/gui/config.yaml +0 -0
  84. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/tests/test_docs.py +0 -0
  85. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/tests/test_generic_cli.py +0 -0
  86. {config_cli_gui-0.2.9 → config_cli_gui-0.3.0}/uv.lock +0 -0
@@ -4,6 +4,26 @@ Changelog
4
4
 
5
5
  (unreleased)
6
6
  ------------
7
+ - Docs: Update HISTORY.md for release 0.2.9. [Paul Magister]
8
+ - Refactoring: config parameters will stay ordered like in the config.
9
+ [Paul Magister]
10
+ - Refactoring: config parameters will stay ordered like in the config.
11
+ [Paul Magister]
12
+ - Refactor cli.py, move logging into this project. [Paul Magister]
13
+ - Docs: Update HISTORY.md for release 0.2.9. [Paul Magister]
14
+ - Docs: Update HISTORY.md for release 0.2.10. [Paul Magister]
15
+ - Docs: Update HISTORY.md for release 0.2.9. [Paul Magister]
16
+ - Update README.md from docs/index.md. [github-actions]
17
+
18
+
19
+ 0.2.10 (2025-12-03)
20
+ -------------------
21
+ - Docs: Update HISTORY.md for release 0.2.10. [Paul Magister]
22
+
23
+
24
+ 0.2.9 (2025-12-03)
25
+ ------------------
26
+ - Docs: Update HISTORY.md for release 0.2.9. [Paul Magister]
7
27
  - Spinbox for vector. [Paul Magister]
8
28
  - Better example, make get_image_font sensitive to dpi, [Paul Magister]
9
29
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: config-cli-gui
3
- Version: 0.2.9
3
+ Version: 0.3.0
4
4
  Summary: Feature-rich Python project template for config-cli-gui.
5
5
  Author: pamagister
6
6
  Requires-Python: <3.12,>=3.10
@@ -30,11 +30,6 @@ You can install `config-cli-gui` using pip:
30
30
  pip install config-cli-gui
31
31
  ```
32
32
 
33
- ## Contribution
34
-
35
- Refer to this how-to in the referenced project for getting started to install and develop on this project:
36
- https://github.com/pamagister/python-template-project
37
-
38
33
  ---
39
34
 
40
35
  ## ✨ Features
@@ -67,44 +62,27 @@ from config_cli_gui.config import (
67
62
  ConfigParameter,
68
63
  )
69
64
  from config_cli_gui.configtypes.color import Color
70
- from config_cli_gui.docs import DocumentationGenerator
71
-
65
+ from config_cli_gui.configtypes.font import Font
66
+ from config_cli_gui.configtypes.vector import Vector
72
67
 
73
- class CliConfig(ConfigCategory):
74
- """CLI-specific configuration parameters."""
75
68
 
69
+ class MiscConfig(ConfigCategory):
76
70
  def get_category_name(self) -> str:
77
- return "cli"
78
-
79
- # Positional argument
80
- input: ConfigParameter = ConfigParameter(
81
- name="input",
82
- value="",
83
- help="Path to input (file or folder)",
84
- required=True,
85
- is_cli=True,
86
- )
71
+ return "misc"
87
72
 
88
- # Optional CLI arguments
89
- output: ConfigParameter = ConfigParameter(
90
- name="output",
91
- value="",
92
- help="Path to output destination",
73
+ some_numeric: ConfigParameter = ConfigParameter(
74
+ name="some_numeric",
75
+ value=int(42),
76
+ help="Example integer",
93
77
  is_cli=True,
94
78
  )
95
79
 
96
- min_dist: ConfigParameter = ConfigParameter(
97
- name="min_dist",
98
- value=20,
99
- help="Maximum distance between two waypoints",
100
- is_cli=True,
80
+ some_vector: ConfigParameter = ConfigParameter(
81
+ name="some_vector",
82
+ value=Vector(1, 2, 3),
83
+ help="Example vector",
101
84
  )
102
85
 
103
-
104
- class MiscConfig(ConfigCategory):
105
- def get_category_name(self) -> str:
106
- return "misc"
107
-
108
86
  some_file: ConfigParameter = ConfigParameter(
109
87
  name="some_file",
110
88
  value=Path("some_file.txt"),
@@ -119,20 +97,46 @@ class MiscConfig(ConfigCategory):
119
97
 
120
98
  some_date: ConfigParameter = ConfigParameter(
121
99
  name="some_date",
122
- value=datetime.now(),
100
+ value=datetime.fromisoformat("2025-12-31 10:30:45"),
123
101
  help="Date setting for the application",
124
102
  )
125
103
 
104
+ some_font: ConfigParameter = ConfigParameter(
105
+ name="some_font",
106
+ value=Font("DejaVuSans.ttf", size=12, color=Color(0, 0, 255)),
107
+ help="Font setting for the application",
108
+ )
109
+
110
+
111
+ class AppConfig(ConfigCategory):
112
+ """Application-specific configuration parameters."""
113
+
114
+ def get_category_name(self) -> str:
115
+ return "app"
116
+
117
+ log_level: ConfigParameter = ConfigParameter(
118
+ name="log_level",
119
+ value="INFO",
120
+ choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
121
+ help="Logging level for the application",
122
+ )
123
+
124
+ log_file_max_size: ConfigParameter = ConfigParameter(
125
+ name="log_file_max_size",
126
+ value=10,
127
+ help="Maximum log file size in MB before rotation",
128
+ )
129
+
126
130
 
127
131
  class ProjectConfigManager(ConfigManager): # Inherit from ConfigManager
128
132
  """Main configuration manager that handles all parameter categories."""
129
133
 
130
- cli: CliConfig
134
+ app: AppConfig
131
135
  misc: MiscConfig
132
-
136
+
133
137
  def __init__(self, config_file: str | None = None, **kwargs):
134
138
  """Initialize the configuration manager with all parameter categories."""
135
- categories = (CliConfig(), MiscConfig())
139
+ categories = (MiscConfig(), AppConfig())
136
140
  super().__init__(categories, config_file, **kwargs)
137
141
 
138
142
 
@@ -1,63 +1,60 @@
1
+ cli:
2
+ # Path to input (file or folder) | type=str | [CLI]
3
+ input: ''
4
+ # Path to output destination | type=str | [CLI]
5
+ output: ''
6
+ # Maximum distance between two waypoints | type=int | [CLI]
7
+ min_dist: 20
8
+ # Extract starting points of each track as waypoint | type=bool | [CLI] | choices=[True, False]
9
+ extract_waypoints: true
10
+ # Include elevation data in waypoints | type=bool | [CLI] | choices=[True, False]
11
+ elevation: false
1
12
  app:
2
13
  # Date format to use | type=str
3
14
  date_format: '%Y-%m-%d'
4
- # Enable logging to console | type=bool | choices=[True, False]
5
- enable_console_logging: true
6
- # Enable logging to file | type=bool | choices=[True, False]
7
- enable_file_logging: true
8
- # Number of backup log files to keep | type=int
9
- log_backup_count: 5
15
+ # Logging level for the application | type=str | choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
16
+ log_level: INFO
10
17
  # Maximum log file size in MB before rotation | type=int
11
18
  log_file_max_size: 10
19
+ # Number of backup log files to keep | type=int
20
+ log_backup_count: 5
12
21
  # Log message format style | type=str | choices=['simple', 'detailed', 'json']
13
22
  log_format: detailed
14
- # Logging level for the application | type=str | choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
15
- log_level: INFO
16
23
  # Maximum number of worker threads | type=int
17
24
  max_workers: 4
18
- cli:
19
- # Include elevation data in waypoints | type=bool [CLI] | choices=[True, False]
20
- elevation: false
21
- # Extract starting points of each track as waypoint | type=bool [CLI] | choices=[True, False]
22
- extract_waypoints: true
23
- # Path to input (file or folder) | type=str [CLI]
24
- input: ''
25
- # Maximum distance between two waypoints | type=int [CLI]
26
- min_dist: 20
27
- # Path to output destination | type=str [CLI]
28
- output: ''
25
+ # Enable logging to file | type=bool | choices=[True, False]
26
+ enable_file_logging: true
27
+ # Enable logging to console | type=bool | choices=[True, False]
28
+ enable_console_logging: true
29
29
  gui:
30
- # Automatically scroll to the newest log entries | type=bool | choices=[True, False]
31
- auto_scroll_log: true
30
+ # GUI theme setting | type=str | choices=['light', 'dark', 'auto']
31
+ theme: light
32
+ # Default window width | type=int
33
+ window_width: 800
34
+ # Default window height | type=int
35
+ window_height: 600
32
36
  # Height of the log window in pixels | type=int
33
37
  log_window_height: 200
38
+ # Automatically scroll to the newest log entries | type=bool | choices=[True, False]
39
+ auto_scroll_log: true
34
40
  # Maximum number of log lines to keep in GUI | type=int
35
41
  max_log_lines: 1000
36
42
  # Point in 2D space | type=Vector
37
43
  point2D: (7, 11)
38
44
  # Point in 3D space | type=Vector
39
45
  point3D: (1.2, 3.4, 5.6)
40
- # GUI theme setting | type=str | choices=['light', 'dark', 'auto']
41
- theme: light
42
- # Default window height | type=int
43
- window_height: 600
44
- # Default window width | type=int
45
- window_width: 800
46
46
  misc:
47
- # Color setting for the application | type=Color
48
- some_color: '#ff0000'
49
- # Date setting for the application | type=datetime
50
- some_date: '2025-12-31T10:30:45'
51
- # Path to the file to use | type=PosixPath
52
- some_file: some_file.txt
53
- # Font setting for the application | type=Font
54
- some_font:
55
- - DejaVuSans.ttf
56
- - 12
57
- - '#0000ff'
58
47
  # Example integer | type=int
59
48
  some_numeric: 42
60
49
  # Example vector 2D | type=Vector
61
50
  some_vector2d: (1, 2)
62
51
  # Example vector 3D | type=Vector
63
- some_vector3d: (1.1, 2.2, 3.3)
52
+ some_vector3d: (1.1, 2.2, 3.3)
53
+ # Path to the file to use | type=PosixPath
54
+ some_file: some_file.txt
55
+ # Color setting for the application | type=Color
56
+ some_color: '#ff0000'
57
+ # Date setting for the application | type=datetime
58
+ some_date: '2025-12-31T10:30:45'
59
+ # Font setting for the application | type=Font
60
+ some_font: 'DejaVuSans.ttf, 12, #0000ff'
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.2.9'
32
- __version_tuple__ = version_tuple = (0, 2, 9)
31
+ __version__ = version = '0.3.0'
32
+ __version_tuple__ = version_tuple = (0, 3, 0)
33
33
 
34
- __commit_id__ = commit_id = 'gc34b684af'
34
+ __commit_id__ = commit_id = 'g10ea5e914'
@@ -4,6 +4,7 @@
4
4
  import argparse
5
5
  import traceback
6
6
  from collections.abc import Callable
7
+ from logging import Logger, getLogger
7
8
  from typing import Any
8
9
 
9
10
  from config_cli_gui.config import ConfigManager
@@ -103,13 +104,17 @@ class CliGenerator:
103
104
  # ----------------------------------------------------------------------
104
105
  def run_cli(
105
106
  self,
106
- main_function: Callable[[ConfigManager], int],
107
+ main_function: Callable[[ConfigManager, Logger], int],
107
108
  description: str = None,
108
- validator: Callable[[ConfigManager], bool] = None,
109
+ validator: Callable[[ConfigManager, Logger], bool] = None,
110
+ logger: Logger = None,
109
111
  ) -> int:
110
112
  parser = self.create_argument_parser(description)
111
113
  args = parser.parse_args()
112
114
 
115
+ if logger is None:
116
+ logger = getLogger(self.app_name)
117
+
113
118
  # Load config_file only ONCE
114
119
  config = ConfigManager(
115
120
  categories=tuple(self.config_manager._categories.values()),
@@ -121,17 +126,17 @@ class CliGenerator:
121
126
  config.apply_overrides(overrides)
122
127
 
123
128
  # Optional validation
124
- if validator and not validator(config):
125
- print("Configuration validation failed.")
129
+ if validator and not validator(config, logger):
130
+ logger.error("Configuration validation failed.")
126
131
  return 1
127
132
 
128
133
  # Execute main
129
134
  try:
130
- return main_function(config)
135
+ return main_function(config, logger)
131
136
  except KeyboardInterrupt:
132
- print("Interrupted.")
137
+ logger.info("Interrupted.")
133
138
  return 130
134
139
  except Exception as e:
135
- print(f"Unexpected error: {e}")
136
- traceback.print_exc()
140
+ logger.error(f"Unexpected error: {e}")
141
+ logger.debug(traceback.format_exc())
137
142
  return 1
@@ -0,0 +1,251 @@
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
+ from config_cli_gui.configtypes.color import Color
12
+ from config_cli_gui.configtypes.font import Font
13
+ from config_cli_gui.configtypes.vector import Vector
14
+
15
+
16
+ @dataclass
17
+ class ConfigParameter:
18
+ """Represents a single configuration parameter with metadata."""
19
+
20
+ name: str
21
+ value: Any
22
+ choices: list[Any] | None = None
23
+ help: str = ""
24
+ cli_arg: str | None = None
25
+ required: bool = False
26
+ is_cli: bool = False
27
+ category: str = "general"
28
+
29
+ def __post_init__(self):
30
+ if self.is_cli and self.cli_arg is None and not self.required:
31
+ self.cli_arg = f"--{self.name}"
32
+ if isinstance(self.value, bool) and self.choices is None:
33
+ self.choices = [True, False]
34
+
35
+ @property
36
+ def type_(self) -> type[Any]:
37
+ """Return the Python type of this parameter’s value."""
38
+ return type(self.value)
39
+
40
+
41
+ class ConfigCategory(BaseModel, ABC):
42
+ """Base class for configuration categories."""
43
+
44
+ @abstractmethod
45
+ def get_category_name(self) -> str:
46
+ """Return the unique name for this configuration category."""
47
+ pass
48
+
49
+ def get_parameters(self) -> list[ConfigParameter]:
50
+ """Return all `ConfigParameter` instances from this category."""
51
+ params = []
52
+ for value in vars(self).values(): # faster, only instance attrs
53
+ if isinstance(value, ConfigParameter):
54
+ value.category = self.get_category_name()
55
+ params.append(value)
56
+ return params
57
+
58
+
59
+ class ConfigSerializer:
60
+ """Handles serialization and deserialization of custom config types."""
61
+
62
+ TYPE_MAPPING = {
63
+ Font: {
64
+ "to_serializable": lambda v: v.to_str(),
65
+ "from_serializable": lambda v: Font.from_list(v)
66
+ if isinstance(v, list)
67
+ else Font.from_str(v),
68
+ },
69
+ Color: {
70
+ "to_serializable": lambda v: v.to_hex(),
71
+ "from_serializable": lambda v: Color.from_list(v)
72
+ if isinstance(v, list)
73
+ else (Color.from_hex(v) if isinstance(v, str) else v),
74
+ },
75
+ Vector: {
76
+ "to_serializable": lambda v: v.to_str(),
77
+ "from_serializable": lambda v: Vector.from_list(v)
78
+ if isinstance(v, list)
79
+ else (Vector.from_str(v) if isinstance(v, str) else v),
80
+ },
81
+ Path: {
82
+ "to_serializable": lambda v: str(v.as_posix()),
83
+ "from_serializable": lambda v: Path(v) if isinstance(v, str) else v,
84
+ },
85
+ datetime: {
86
+ "to_serializable": lambda v: v.isoformat(),
87
+ "from_serializable": lambda v: datetime.fromisoformat(v) if isinstance(v, str) else v,
88
+ },
89
+ }
90
+
91
+ def to_serializable(self, value: Any) -> Any:
92
+ """Convert a value to a serializable format."""
93
+ for type_class, methods in self.TYPE_MAPPING.items():
94
+ if isinstance(value, type_class):
95
+ return methods["to_serializable"](value)
96
+ return value
97
+
98
+ def from_serializable(self, value: Any, target_type: type[Any]) -> Any:
99
+ """Convert a value from a serializable format to its original type."""
100
+ for type_class, methods in self.TYPE_MAPPING.items():
101
+ if target_type == type_class:
102
+ return methods["from_serializable"](value)
103
+ return value
104
+
105
+
106
+ class ConfigManager:
107
+ """Manages loading, saving, and accessing configuration categories."""
108
+
109
+ def __init__(
110
+ self,
111
+ categories: tuple[ConfigCategory, ...],
112
+ config_file: str | None = None,
113
+ **overrides: Any,
114
+ ):
115
+ self._categories: dict[str, ConfigCategory] = {}
116
+ self._serializer = ConfigSerializer()
117
+
118
+ for category in categories:
119
+ if not isinstance(category, ConfigCategory):
120
+ raise TypeError(f"Expected ConfigCategory instance, got {type(category)}")
121
+ self.add_category(category.get_category_name(), category)
122
+
123
+ if config_file:
124
+ self.load_from_file(config_file)
125
+
126
+ self.apply_overrides(overrides)
127
+
128
+ def add_category(self, name: str, category: ConfigCategory) -> None:
129
+ """Register a new configuration category."""
130
+ self._categories[name] = category
131
+ setattr(self, name, category)
132
+
133
+ def get_category(self, name: str) -> ConfigCategory | None:
134
+ """Retrieve a category by name."""
135
+ return self._categories.get(name)
136
+
137
+ def apply_overrides(self, overrides: dict[str, Any]) -> None:
138
+ """Apply keyword overrides in the format `category__param=value`."""
139
+ for key, value in overrides.items():
140
+ if "__" not in key:
141
+ continue
142
+ category_name, param_name = key.split("__", 1)
143
+ category = self._categories.get(category_name)
144
+ if category and hasattr(category, param_name):
145
+ param = getattr(category, param_name)
146
+ if isinstance(param, ConfigParameter):
147
+ param.value = value
148
+ else:
149
+ setattr(category, param_name, value)
150
+
151
+ def load_from_file(self, config_file: str) -> None:
152
+ """Load configuration from a YAML or JSON file."""
153
+ path = Path(config_file)
154
+ if not path.exists():
155
+ raise FileNotFoundError(f"Configuration file not found: {config_file}")
156
+
157
+ with open(path, "r", encoding="utf-8") as f:
158
+ data = yaml.safe_load(f) if path.suffix.lower() in [".yml", ".yaml"] else json.load(f)
159
+
160
+ self._apply_config_data(data)
161
+
162
+ def _apply_config_data(self, data: dict[str, Any]) -> None:
163
+ """Apply loaded data to the configuration parameters."""
164
+ for category_name, category_data in data.items():
165
+ category = self._categories.get(category_name)
166
+ if not category:
167
+ continue
168
+ for param_name, param_value in category_data.items():
169
+ if hasattr(category, param_name):
170
+ param: ConfigParameter = getattr(category, param_name)
171
+ if isinstance(param, ConfigParameter):
172
+ target_type = param.type_
173
+ if isinstance(param.value, Path):
174
+ target_type = Path
175
+ deserialized_value = self._serializer.from_serializable(
176
+ param_value, target_type
177
+ )
178
+ param.value = deserialized_value
179
+
180
+ def save_to_file(self, config_file: str, format_: str = "auto") -> None:
181
+ """Save the current configuration to a file."""
182
+ path = Path(config_file)
183
+ data = self.to_dict()
184
+
185
+ file_format = format_
186
+ if file_format == "auto":
187
+ file_format = "yaml" if path.suffix.lower() in [".yml", ".yaml"] else "json"
188
+
189
+ path.parent.mkdir(parents=True, exist_ok=True)
190
+ with open(path, "w", encoding="utf-8") as f:
191
+ if file_format == "yaml":
192
+ yaml.dump(data, f, indent=2, sort_keys=False)
193
+ else:
194
+ json.dump(data, f, indent=2)
195
+
196
+ if file_format == "yaml":
197
+ self._append_comments_to_yaml(path)
198
+
199
+ def to_dict(self) -> dict[str, Any]:
200
+ """Convert all configuration categories to a dictionary."""
201
+ result: dict[str, dict[str, Any]] = {}
202
+ for category_name, category in self._categories.items():
203
+ result[category_name] = {}
204
+ for param in category.get_parameters():
205
+ value = self._serializer.to_serializable(param.value)
206
+ result[category_name][param.name] = value
207
+ return result
208
+
209
+ def get_all_parameters(self) -> list[ConfigParameter]:
210
+ """Return a flat list of all parameters from all categories."""
211
+ return [p for c in self._categories.values() for p in c.get_parameters()]
212
+
213
+ def get_cli_parameters(self) -> list[ConfigParameter]:
214
+ """Return a list of all parameters that are exposed to the CLI."""
215
+ return [p for p in self.get_all_parameters() if p.is_cli]
216
+
217
+ def _append_comments_to_yaml(self, path: Path) -> None:
218
+ """Append helpful metadata comments to the YAML file."""
219
+ lines = path.read_text(encoding="utf-8").splitlines()
220
+ new_lines = []
221
+ all_params = {p.name: p for p in self.get_all_parameters()}
222
+ current_category = ""
223
+
224
+ for line in lines:
225
+ stripped = line.strip()
226
+ if (
227
+ stripped.endswith(":")
228
+ and not stripped.startswith("#")
229
+ and line.lstrip() == stripped
230
+ ):
231
+ current_category = stripped[:-1]
232
+ new_lines.append(line)
233
+ continue
234
+
235
+ param_name = stripped.split(":")[0].strip()
236
+ if param_name in all_params:
237
+ param = all_params[param_name]
238
+ if param.category == current_category:
239
+ indent = " " * (line.find(param_name))
240
+ comment_parts = [param.help, f"type={param.type_.__name__}"]
241
+ if param.is_cli:
242
+ comment_parts.append("[CLI]")
243
+ if param.choices:
244
+ comment_parts.append(f"choices={param.choices}")
245
+
246
+ comment = f"{indent}# " + " | ".join(filter(None, comment_parts))
247
+ new_lines.append(comment)
248
+
249
+ new_lines.append(line)
250
+
251
+ path.write_text("\n".join(new_lines), encoding="utf-8")
@@ -54,6 +54,40 @@ class Font:
54
54
  )
55
55
  return cls(str(font_type), float(size), color)
56
56
 
57
+ def to_str(self) -> str:
58
+ return f"{self.name}, {self.size}, {self.color.to_hex()}"
59
+
60
+ @classmethod
61
+ def from_str(cls, font_init_str: str) -> "Font":
62
+ """
63
+ Parses a string of the form:
64
+ "FontName, size, #rrggbb"
65
+ or alternative color formats.
66
+ """
67
+ if not isinstance(font_init_str, str):
68
+ return cls("Arial", 12, Color(0, 0, 0))
69
+
70
+ parts = [p.strip() for p in font_init_str.split(",")]
71
+
72
+ # at least name, size, color
73
+ if len(parts) < 3:
74
+ return cls("Arial", 12, Color(0, 0, 0))
75
+
76
+ # parse font name
77
+ font_name = parts[0]
78
+
79
+ # parse size
80
+ try:
81
+ size = float(parts[1])
82
+ except ValueError:
83
+ size = 12.0
84
+
85
+ # parse color
86
+ color_raw = ",".join(parts[2:]).strip()
87
+ color = Color.from_hex(color_raw)
88
+
89
+ return cls(font_name, size, color)
90
+
57
91
  def get_image_font(self, dpi=25.4) -> ImageFont.FreeTypeFont:
58
92
  """
59
93
  Return a PIL FreeTypeFont, with fallback to default.
@@ -77,4 +111,4 @@ class Font:
77
111
  return f"Font(type='{self.name}', size={self.size}, color={self.color!r})"
78
112
 
79
113
  def __str__(self) -> str:
80
- return f"{self.name}, {self.size}pt, {self.color}"
114
+ return f"{self.to_str()}"