gpconfig 0.3.2__py3-none-any.whl
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.
- gpconfig/__init__.py +31 -0
- gpconfig/config.py +59 -0
- gpconfig/configurable.py +31 -0
- gpconfig/exceptions.py +43 -0
- gpconfig/manager.py +744 -0
- gpconfig-0.3.2.dist-info/METADATA +200 -0
- gpconfig-0.3.2.dist-info/RECORD +9 -0
- gpconfig-0.3.2.dist-info/WHEEL +4 -0
- gpconfig-0.3.2.dist-info/licenses/LICENSE +21 -0
gpconfig/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# src/gpconfig/__init__.py
|
|
2
|
+
"""gpconfig - General Purpose Configuration management for Python."""
|
|
3
|
+
|
|
4
|
+
from gpconfig.config import GPConfig
|
|
5
|
+
from gpconfig.configurable import GPConfigurable
|
|
6
|
+
from gpconfig.manager import GPConfigManager, GPConfigFolder
|
|
7
|
+
from gpconfig.exceptions import (
|
|
8
|
+
GPConfigError,
|
|
9
|
+
ConfigFolderError,
|
|
10
|
+
ConfigNotFoundError,
|
|
11
|
+
ConfigReadonlyError,
|
|
12
|
+
RegistrationError,
|
|
13
|
+
ConfigValidationError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
# Core classes
|
|
18
|
+
"GPConfig",
|
|
19
|
+
"GPConfigurable",
|
|
20
|
+
"GPConfigManager",
|
|
21
|
+
"GPConfigFolder",
|
|
22
|
+
# Exceptions
|
|
23
|
+
"GPConfigError",
|
|
24
|
+
"ConfigFolderError",
|
|
25
|
+
"ConfigNotFoundError",
|
|
26
|
+
"ConfigReadonlyError",
|
|
27
|
+
"RegistrationError",
|
|
28
|
+
"ConfigValidationError",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
__version__ = "0.3.2"
|
gpconfig/config.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""GPConfig base class for configuration management."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import ClassVar, Optional
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
8
|
+
|
|
9
|
+
from gpconfig.exceptions import ConfigReadonlyError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GPConfig(BaseSettings):
|
|
13
|
+
"""
|
|
14
|
+
Base class for all configuration classes.
|
|
15
|
+
Each subclass is initialized from a YAML config file.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
model_config = SettingsConfigDict(
|
|
19
|
+
extra="forbid",
|
|
20
|
+
env_prefix="GPCFG_",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Class-level defaults (can be overridden in subclasses)
|
|
24
|
+
cfg_class_name: ClassVar[str] = "GPConfig"
|
|
25
|
+
default_cfg_path: ClassVar[Optional[str]] = None
|
|
26
|
+
|
|
27
|
+
# Instance fields (set during initialization, NOT from YAML)
|
|
28
|
+
name: str = ""
|
|
29
|
+
cfg_file_path: Path = Path()
|
|
30
|
+
readonly: bool = False
|
|
31
|
+
configured_class_name: Optional[str] = None
|
|
32
|
+
|
|
33
|
+
def save(self) -> None:
|
|
34
|
+
"""Save current config state back to the YAML file.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
ConfigReadonlyError: If readonly is True
|
|
38
|
+
"""
|
|
39
|
+
if self.readonly:
|
|
40
|
+
raise ConfigReadonlyError(self.name)
|
|
41
|
+
|
|
42
|
+
# Get model dump, excluding non-YAML fields
|
|
43
|
+
data = self.model_dump(
|
|
44
|
+
mode="python", exclude={"name", "cfg_file_path", "readonly"}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Add cfg_class_name to the saved data
|
|
48
|
+
data["cfg_class_name"] = self.cfg_class_name
|
|
49
|
+
|
|
50
|
+
# Include configured_class_name if set
|
|
51
|
+
if self.configured_class_name:
|
|
52
|
+
data["configured_class_name"] = self.configured_class_name
|
|
53
|
+
else:
|
|
54
|
+
# Remove it from data if it exists (from model_dump)
|
|
55
|
+
data.pop("configured_class_name", None)
|
|
56
|
+
|
|
57
|
+
# Write to YAML file
|
|
58
|
+
with open(self.cfg_file_path, "w", encoding="utf-8") as f:
|
|
59
|
+
yaml.dump(data, f, default_flow_style=False, allow_unicode=True)
|
gpconfig/configurable.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""GPConfigurable base class for configurable objects."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from gpconfig.config import GPConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GPConfigurable:
|
|
10
|
+
"""
|
|
11
|
+
Base class for objects that are configured by GPConfig subclasses.
|
|
12
|
+
|
|
13
|
+
Subclasses must accept a single config parameter in __init__:
|
|
14
|
+
def __init__(self, config: MyConfigSubclass) -> None:
|
|
15
|
+
super().__init__(config)
|
|
16
|
+
# Initialize from config values
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, config: "GPConfig") -> None:
|
|
20
|
+
"""
|
|
21
|
+
Initialize the configurable object from its config.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
config: A GPConfig subclass instance containing this object's settings
|
|
25
|
+
"""
|
|
26
|
+
self._config = config
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def config(self) -> "GPConfig":
|
|
30
|
+
"""Access the configuration object."""
|
|
31
|
+
return self._config
|
gpconfig/exceptions.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Custom exceptions for gpconfig library."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class GPConfigError(Exception):
|
|
5
|
+
"""Base exception for all gpconfig errors."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigFolderError(GPConfigError):
|
|
11
|
+
"""Raised when the config folder cannot be found or is invalid."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigNotFoundError(GPConfigError):
|
|
17
|
+
"""Raised when a requested config path doesn't exist."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, path: str, message: str = ""):
|
|
20
|
+
self.path = path
|
|
21
|
+
super().__init__(message or f"Config not found: {path}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConfigReadonlyError(GPConfigError):
|
|
25
|
+
"""Raised when trying to modify or save a readonly config."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, config_name: str):
|
|
28
|
+
super().__init__(f"Config '{config_name}' is readonly and cannot be modified")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RegistrationError(GPConfigError):
|
|
32
|
+
"""Raised when there's an issue with class registration."""
|
|
33
|
+
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ConfigValidationError(GPConfigError):
|
|
38
|
+
"""Raised when a config file fails validation."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, path: str, original_error: Exception):
|
|
41
|
+
self.path = path
|
|
42
|
+
self.original_error = original_error
|
|
43
|
+
super().__init__(f"Validation failed for '{path}': {original_error}")
|
gpconfig/manager.py
ADDED
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
"""GPConfigManager - Central configuration manager for gpconfig."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING, Any, List, Optional, Type, TypeVar, Union
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from gpconfig.config import GPConfig
|
|
10
|
+
|
|
11
|
+
from gpconfig.exceptions import (
|
|
12
|
+
ConfigFolderError,
|
|
13
|
+
ConfigNotFoundError,
|
|
14
|
+
ConfigValidationError,
|
|
15
|
+
RegistrationError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GPConfigFolder:
|
|
22
|
+
"""Represent a subfolder in the config folder hierarchy.
|
|
23
|
+
|
|
24
|
+
This class provides convenient access to configs within a specific folder.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
path: Relative path from cfg_folder (dot notation, e.g., "llm.providers").
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, manager: "GPConfigManager", relative_path: str):
|
|
31
|
+
"""Initialize GPConfigFolder.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
manager: The GPConfigManager instance.
|
|
35
|
+
relative_path: Relative path from cfg_folder using dot notation.
|
|
36
|
+
"""
|
|
37
|
+
self._manager = manager
|
|
38
|
+
self._relative_path = relative_path
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def path(self) -> str:
|
|
42
|
+
"""Get the relative path of this folder."""
|
|
43
|
+
return self._relative_path
|
|
44
|
+
|
|
45
|
+
def get_config(
|
|
46
|
+
self, path: str, config_cls: Optional[Type[T]] = None
|
|
47
|
+
) -> Union[T, Any]:
|
|
48
|
+
"""Get a config object or value from this folder.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
path: Config path relative to this folder (e.g., "api" or "nested.config").
|
|
52
|
+
config_cls: Optional GPConfig subclass to use for loading.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
GPConfig instance, GPConfigFolder, or config value.
|
|
56
|
+
"""
|
|
57
|
+
full_path = f"{self._relative_path}.{path}" if self._relative_path else path
|
|
58
|
+
return self._manager.get_config(full_path, config_cls)
|
|
59
|
+
|
|
60
|
+
def get_object(self, path: str) -> Any:
|
|
61
|
+
"""Get a configurable object instance from this folder.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
path: Config path relative to this folder.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
A new instance of the configured GPConfigurable subclass.
|
|
68
|
+
"""
|
|
69
|
+
full_path = f"{self._relative_path}.{path}" if self._relative_path else path
|
|
70
|
+
return self._manager.get_object(full_path)
|
|
71
|
+
|
|
72
|
+
def list_configs(self) -> List[str]:
|
|
73
|
+
"""List all config objects in this folder.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
List of object names (config names and subfolder names).
|
|
77
|
+
"""
|
|
78
|
+
return self._manager.list_configs(self._relative_path)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class GPConfigManager:
|
|
82
|
+
"""Central manager for configuration management.
|
|
83
|
+
|
|
84
|
+
This class handles initialization, config folder resolution, and provides
|
|
85
|
+
access to configuration data.
|
|
86
|
+
|
|
87
|
+
Attributes:
|
|
88
|
+
project_name: Name of the project using gpconfig.
|
|
89
|
+
cfg_folder: Path to the resolved configuration folder.
|
|
90
|
+
global_env: Dictionary containing global environment configuration.
|
|
91
|
+
|
|
92
|
+
Config Folder Search Rules:
|
|
93
|
+
1. Explicit parameter - cfg_folder argument if provided
|
|
94
|
+
2. Environment variable - {PROJECT_NAME}_CFG_PATH (uppercase)
|
|
95
|
+
3. User home subfolder - ~/.{project_name}/
|
|
96
|
+
|
|
97
|
+
A valid config folder must contain global_env.yaml.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
# Class-level registry: cfg_class_name -> GPConfig subclass
|
|
101
|
+
_config_classes: dict[str, Type[Any]] = {}
|
|
102
|
+
# Class-level registry: configurable class name -> GPConfigurable subclass
|
|
103
|
+
_configurable_classes: dict[str, Type[Any]] = {}
|
|
104
|
+
|
|
105
|
+
def __init__(self, project_name: str, cfg_folder: Optional[Path | str] = None):
|
|
106
|
+
"""Initialize GPConfigManager.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
project_name: Name of the project.
|
|
110
|
+
cfg_folder: Optional explicit path to config folder.
|
|
111
|
+
If not provided, will search using the resolution rules.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
ConfigFolderError: If config folder doesn't exist or lacks global_env.yaml.
|
|
115
|
+
"""
|
|
116
|
+
self._project_name = project_name
|
|
117
|
+
self._cfg_folder = self._resolve_cfg_folder(project_name, cfg_folder)
|
|
118
|
+
self._global_env = self._load_global_env()
|
|
119
|
+
self._config_cache: dict[str, Any] = {}
|
|
120
|
+
self._folder_cache: dict[str, GPConfigFolder] = {}
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def project_name(self) -> str:
|
|
124
|
+
"""Get the project name."""
|
|
125
|
+
return self._project_name
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def cfg_folder(self) -> Path:
|
|
129
|
+
"""Get the config folder path."""
|
|
130
|
+
return self._cfg_folder
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def global_env(self) -> dict:
|
|
134
|
+
"""Get the global environment configuration."""
|
|
135
|
+
return self._global_env
|
|
136
|
+
|
|
137
|
+
def _resolve_cfg_folder(
|
|
138
|
+
self, project_name: str, cfg_folder: Optional[Path | str]
|
|
139
|
+
) -> Path:
|
|
140
|
+
"""Resolve the config folder using search rules.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
project_name: Name of the project.
|
|
144
|
+
cfg_folder: Optional explicit path to config folder.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Resolved Path to config folder.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
ConfigFolderError: If no valid config folder can be found.
|
|
151
|
+
"""
|
|
152
|
+
# Rule 1: Explicit parameter
|
|
153
|
+
if cfg_folder is not None:
|
|
154
|
+
cfg_path = Path(cfg_folder).resolve()
|
|
155
|
+
self._validate_cfg_folder(cfg_path, "explicit parameter")
|
|
156
|
+
return cfg_path
|
|
157
|
+
|
|
158
|
+
# Rule 2: Environment variable
|
|
159
|
+
env_var_name = f"{project_name.upper()}_CFG_PATH"
|
|
160
|
+
import os
|
|
161
|
+
|
|
162
|
+
env_path = os.environ.get(env_var_name)
|
|
163
|
+
if env_path:
|
|
164
|
+
cfg_path = Path(env_path).resolve()
|
|
165
|
+
self._validate_cfg_folder(cfg_path, f"environment variable {env_var_name}")
|
|
166
|
+
return cfg_path
|
|
167
|
+
|
|
168
|
+
# Rule 3: User home subfolder
|
|
169
|
+
home_path = Path.home() / f".{project_name}"
|
|
170
|
+
if home_path.exists():
|
|
171
|
+
cfg_path = home_path.resolve()
|
|
172
|
+
self._validate_cfg_folder(cfg_path, f"home folder {home_path}")
|
|
173
|
+
return cfg_path
|
|
174
|
+
|
|
175
|
+
# No valid folder found
|
|
176
|
+
raise ConfigFolderError(
|
|
177
|
+
f"Could not find valid config folder for project '{project_name}'. "
|
|
178
|
+
f"Searched: explicit parameter, environment variable {env_var_name}, "
|
|
179
|
+
f"and home folder {home_path}"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def _validate_cfg_folder(self, cfg_path: Path, source: str) -> None:
|
|
183
|
+
"""Validate that the config folder is valid.
|
|
184
|
+
|
|
185
|
+
A valid config folder must:
|
|
186
|
+
- Exist as a directory
|
|
187
|
+
- Contain global_env.yaml
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
cfg_path: Path to validate.
|
|
191
|
+
source: Description of where the path came from (for error messages).
|
|
192
|
+
|
|
193
|
+
Raises:
|
|
194
|
+
ConfigFolderError: If the folder is invalid.
|
|
195
|
+
"""
|
|
196
|
+
if not cfg_path.exists():
|
|
197
|
+
raise ConfigFolderError(
|
|
198
|
+
f"Config folder does not exist: {cfg_path} (from {source})"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
if not cfg_path.is_dir():
|
|
202
|
+
raise ConfigFolderError(
|
|
203
|
+
f"Config folder path is not a directory: {cfg_path} (from {source})"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
global_env_path = cfg_path / "global_env.yaml"
|
|
207
|
+
if not global_env_path.exists():
|
|
208
|
+
raise ConfigFolderError(
|
|
209
|
+
f"Config folder must contain global_env.yaml: {cfg_path} (from {source})"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def _load_global_env(self) -> dict:
|
|
213
|
+
"""Load global_env.yaml into a dictionary.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Dictionary with global environment configuration.
|
|
217
|
+
Returns empty dict if file is empty or contains only comments.
|
|
218
|
+
"""
|
|
219
|
+
global_env_path = self._cfg_folder / "global_env.yaml"
|
|
220
|
+
|
|
221
|
+
with open(global_env_path, "r", encoding="utf-8") as f:
|
|
222
|
+
content = yaml.safe_load(f)
|
|
223
|
+
|
|
224
|
+
# yaml.safe_load returns None for empty files or files with only comments
|
|
225
|
+
return content if content is not None else {}
|
|
226
|
+
|
|
227
|
+
def _parse_path(self, path: str) -> tuple[Path, Optional[str]]:
|
|
228
|
+
"""
|
|
229
|
+
Parse a config path into (file_path, optional_key).
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
path: Config path string
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Tuple of (yaml_file_path, optional_key_path)
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
ConfigNotFoundError: If the path cannot be resolved
|
|
239
|
+
"""
|
|
240
|
+
from gpconfig.exceptions import ConfigNotFoundError
|
|
241
|
+
|
|
242
|
+
parts = path.split(".")
|
|
243
|
+
|
|
244
|
+
# Strip optional project name prefix
|
|
245
|
+
if parts[0] == self._project_name:
|
|
246
|
+
parts = parts[1:]
|
|
247
|
+
|
|
248
|
+
if not parts:
|
|
249
|
+
raise ConfigNotFoundError(path, "Empty config path")
|
|
250
|
+
|
|
251
|
+
# Handle global_env specially
|
|
252
|
+
if parts[0] == "global_env":
|
|
253
|
+
if len(parts) == 1:
|
|
254
|
+
raise ConfigNotFoundError(
|
|
255
|
+
path, "global_env requires a key path (e.g., 'global_env.debug')"
|
|
256
|
+
)
|
|
257
|
+
return (self._cfg_folder / "global_env.yaml", ".".join(parts[1:]))
|
|
258
|
+
|
|
259
|
+
# Try to find the config file by progressively treating
|
|
260
|
+
# trailing parts as keys if file not found
|
|
261
|
+
for i in range(len(parts), 0, -1):
|
|
262
|
+
folder_parts = parts[: i - 1]
|
|
263
|
+
file_part = parts[i - 1]
|
|
264
|
+
key_parts = parts[i:]
|
|
265
|
+
|
|
266
|
+
candidate_path = self._cfg_folder
|
|
267
|
+
for fp in folder_parts:
|
|
268
|
+
candidate_path = candidate_path / fp
|
|
269
|
+
|
|
270
|
+
yaml_path = candidate_path / f"{file_part}.yaml"
|
|
271
|
+
if yaml_path.exists():
|
|
272
|
+
return (yaml_path, ".".join(key_parts) if key_parts else None)
|
|
273
|
+
|
|
274
|
+
raise ConfigNotFoundError(path)
|
|
275
|
+
|
|
276
|
+
def _check_folder_exists(self, path: str) -> tuple[bool, Path]:
|
|
277
|
+
"""Check if a folder exists at the given path.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
path: Config path (dot notation).
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
Tuple of (exists, full_path).
|
|
284
|
+
"""
|
|
285
|
+
parts = path.split(".")
|
|
286
|
+
|
|
287
|
+
# Strip optional project name prefix
|
|
288
|
+
if parts[0] == self._project_name:
|
|
289
|
+
parts = parts[1:]
|
|
290
|
+
|
|
291
|
+
if not parts:
|
|
292
|
+
return (False, Path())
|
|
293
|
+
|
|
294
|
+
folder_path = self._cfg_folder
|
|
295
|
+
for part in parts:
|
|
296
|
+
folder_path = folder_path / part
|
|
297
|
+
|
|
298
|
+
return (folder_path.is_dir(), folder_path)
|
|
299
|
+
|
|
300
|
+
def _get_or_create_folder(self, path: str) -> GPConfigFolder:
|
|
301
|
+
"""Get or create a cached GPConfigFolder for the given path.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
path: Config path (dot notation), optionally prefixed with project name.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
A cached GPConfigFolder instance for the path.
|
|
308
|
+
"""
|
|
309
|
+
# Normalize path
|
|
310
|
+
parts = path.split(".")
|
|
311
|
+
if parts[0] == self._project_name:
|
|
312
|
+
parts = parts[1:]
|
|
313
|
+
normalized_path = ".".join(parts)
|
|
314
|
+
|
|
315
|
+
if normalized_path not in self._folder_cache:
|
|
316
|
+
self._folder_cache[normalized_path] = GPConfigFolder(self, normalized_path)
|
|
317
|
+
|
|
318
|
+
return self._folder_cache[normalized_path]
|
|
319
|
+
|
|
320
|
+
def get_config(
|
|
321
|
+
self,
|
|
322
|
+
path: str,
|
|
323
|
+
config_cls: Optional[Type[T]] = None,
|
|
324
|
+
*,
|
|
325
|
+
_force_file: bool = False,
|
|
326
|
+
) -> Union[T, Any]:
|
|
327
|
+
"""
|
|
328
|
+
Get a config object, folder, or a specific config value.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
path: Config path (e.g., "cfg_name" or "cfg_class.cfg_name" or "cfg.path.key")
|
|
332
|
+
config_cls: Optional GPConfig subclass to use for loading.
|
|
333
|
+
If not provided, will try to auto-detect from cfg_class_name in the file.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
- GPConfigFolder if path points to a folder (and no config_cls, or no file exists)
|
|
337
|
+
- GPConfig instance if path points to a config file
|
|
338
|
+
- Config value if path points to a specific key
|
|
339
|
+
|
|
340
|
+
Raises:
|
|
341
|
+
ConfigNotFoundError: If the path doesn't exist
|
|
342
|
+
ConfigValidationError: If the config fails validation
|
|
343
|
+
"""
|
|
344
|
+
from pydantic import ValidationError
|
|
345
|
+
|
|
346
|
+
# First check if folder exists
|
|
347
|
+
folder_exists, folder_path = self._check_folder_exists(path)
|
|
348
|
+
|
|
349
|
+
# Try to parse as file path
|
|
350
|
+
try:
|
|
351
|
+
file_path, key = self._parse_path(path)
|
|
352
|
+
file_exists = file_path.exists()
|
|
353
|
+
except ConfigNotFoundError:
|
|
354
|
+
file_path = None
|
|
355
|
+
key = None
|
|
356
|
+
file_exists = False
|
|
357
|
+
|
|
358
|
+
# Determine what to return based on existence and config_cls
|
|
359
|
+
if file_exists and folder_exists:
|
|
360
|
+
# Both exist: config_cls or _force_file determines priority
|
|
361
|
+
if config_cls is not None or _force_file:
|
|
362
|
+
# File priority
|
|
363
|
+
pass # Continue with file loading below
|
|
364
|
+
else:
|
|
365
|
+
# Folder priority
|
|
366
|
+
return self._get_or_create_folder(path)
|
|
367
|
+
elif folder_exists and not file_exists:
|
|
368
|
+
# Only folder exists
|
|
369
|
+
return self._get_or_create_folder(path)
|
|
370
|
+
elif not file_exists and not folder_exists:
|
|
371
|
+
# Neither exists
|
|
372
|
+
raise ConfigNotFoundError(path)
|
|
373
|
+
|
|
374
|
+
# File loading logic (existing code)
|
|
375
|
+
# Generate cache key
|
|
376
|
+
cache_key = str(file_path)
|
|
377
|
+
|
|
378
|
+
# Handle global_env key access
|
|
379
|
+
if file_path.name == "global_env.yaml" and key:
|
|
380
|
+
return self._get_nested_value(self._global_env, key, path)
|
|
381
|
+
|
|
382
|
+
# Load or retrieve cached config
|
|
383
|
+
if cache_key not in self._config_cache:
|
|
384
|
+
# Load raw data first
|
|
385
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
386
|
+
raw_data = yaml.safe_load(f) or {}
|
|
387
|
+
|
|
388
|
+
if key:
|
|
389
|
+
# Key access - just return the value
|
|
390
|
+
return self._get_nested_value(raw_data, key, path)
|
|
391
|
+
|
|
392
|
+
# Auto-detect config class from cfg_class_name if not provided
|
|
393
|
+
if config_cls is None:
|
|
394
|
+
cfg_class_name = raw_data.get("cfg_class_name")
|
|
395
|
+
if cfg_class_name and cfg_class_name in self._config_classes:
|
|
396
|
+
config_cls = self._config_classes[cfg_class_name]
|
|
397
|
+
|
|
398
|
+
if config_cls is None:
|
|
399
|
+
# No config class found - return raw dict
|
|
400
|
+
return raw_data
|
|
401
|
+
|
|
402
|
+
try:
|
|
403
|
+
# Remove cfg_class_name from data before passing to config class
|
|
404
|
+
# since it's a ClassVar, not an instance field
|
|
405
|
+
data_for_config = {
|
|
406
|
+
k: v for k, v in raw_data.items() if k != "cfg_class_name"
|
|
407
|
+
}
|
|
408
|
+
config = self._load_config(file_path, config_cls, data_for_config)
|
|
409
|
+
except ValidationError as e:
|
|
410
|
+
raise ConfigValidationError(path, e)
|
|
411
|
+
|
|
412
|
+
self._config_cache[cache_key] = config
|
|
413
|
+
|
|
414
|
+
config = self._config_cache[cache_key]
|
|
415
|
+
|
|
416
|
+
if key:
|
|
417
|
+
return self._get_nested_value(config.model_dump(), key, path)
|
|
418
|
+
|
|
419
|
+
return config
|
|
420
|
+
|
|
421
|
+
def _load_config(
|
|
422
|
+
self, file_path: Path, config_cls: Type[T], data: Optional[dict] = None
|
|
423
|
+
) -> T:
|
|
424
|
+
"""Load a config from a YAML file.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
file_path: Path to the YAML file.
|
|
428
|
+
config_cls: The GPConfig subclass to instantiate.
|
|
429
|
+
data: Optional pre-loaded data dict. If None, will load from file.
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
Configured GPConfig instance.
|
|
433
|
+
"""
|
|
434
|
+
if data is None:
|
|
435
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
436
|
+
data = yaml.safe_load(f) or {}
|
|
437
|
+
|
|
438
|
+
config = config_cls(**data)
|
|
439
|
+
config.name = file_path.stem
|
|
440
|
+
config.cfg_file_path = file_path
|
|
441
|
+
return config
|
|
442
|
+
|
|
443
|
+
def _get_nested_value(self, data: dict, key_path: str, full_path: str) -> Any:
|
|
444
|
+
"""Get a nested value from a dict using dot notation."""
|
|
445
|
+
keys = key_path.split(".")
|
|
446
|
+
current = data
|
|
447
|
+
|
|
448
|
+
for key in keys:
|
|
449
|
+
if not isinstance(current, dict) or key not in current:
|
|
450
|
+
raise ConfigNotFoundError(
|
|
451
|
+
full_path, f"Key '{key}' not found in path '{full_path}'"
|
|
452
|
+
)
|
|
453
|
+
current = current[key]
|
|
454
|
+
|
|
455
|
+
return current
|
|
456
|
+
|
|
457
|
+
@classmethod
|
|
458
|
+
def register_config_class(cls, config_cls: Type[Any]) -> None:
|
|
459
|
+
"""Register a config class by its cfg_class_name.
|
|
460
|
+
|
|
461
|
+
This method registers config classes that don't configure specific objects,
|
|
462
|
+
or can be called independently before register_configurable_class.
|
|
463
|
+
|
|
464
|
+
Args:
|
|
465
|
+
config_cls: The GPConfig subclass to register.
|
|
466
|
+
|
|
467
|
+
Raises:
|
|
468
|
+
RegistrationError: If a class with the same cfg_class_name is already registered.
|
|
469
|
+
"""
|
|
470
|
+
cfg_class_name = config_cls.cfg_class_name
|
|
471
|
+
if cfg_class_name in cls._config_classes:
|
|
472
|
+
# Idempotent: allow re-registration of same class
|
|
473
|
+
if cls._config_classes[cfg_class_name] is config_cls:
|
|
474
|
+
return
|
|
475
|
+
raise RegistrationError(
|
|
476
|
+
f"Config class name '{cfg_class_name}' is already registered "
|
|
477
|
+
f"with a different class"
|
|
478
|
+
)
|
|
479
|
+
cls._config_classes[cfg_class_name] = config_cls
|
|
480
|
+
|
|
481
|
+
@classmethod
|
|
482
|
+
def register_configurable_class(cls, configurable_cls: Type[Any]) -> None:
|
|
483
|
+
"""Register a GPConfigurable subclass by its class name.
|
|
484
|
+
|
|
485
|
+
This method registers configurable classes that can be instantiated
|
|
486
|
+
from config files that specify configured_class_name.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
configurable_cls: The GPConfigurable subclass to register.
|
|
490
|
+
|
|
491
|
+
Raises:
|
|
492
|
+
RegistrationError: If a different class with the same name is already registered.
|
|
493
|
+
"""
|
|
494
|
+
class_name = configurable_cls.__name__
|
|
495
|
+
if class_name in cls._configurable_classes:
|
|
496
|
+
# Idempotent: allow re-registration of same class
|
|
497
|
+
if cls._configurable_classes[class_name] is configurable_cls:
|
|
498
|
+
return
|
|
499
|
+
raise RegistrationError(
|
|
500
|
+
f"Configurable class name '{class_name}' is already registered "
|
|
501
|
+
f"with a different class"
|
|
502
|
+
)
|
|
503
|
+
cls._configurable_classes[class_name] = configurable_cls
|
|
504
|
+
|
|
505
|
+
def get_object(self, path: str) -> Any:
|
|
506
|
+
"""Get a configurable object instance from a config path.
|
|
507
|
+
|
|
508
|
+
This method:
|
|
509
|
+
1. Loads the config using get_config (auto-detects class from cfg_class_name)
|
|
510
|
+
2. Reads configured_class_name from the config instance
|
|
511
|
+
3. Looks up the configurable class in _configurable_classes
|
|
512
|
+
4. Creates a new instance (no caching)
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
path: Config path (e.g., "database" or "services.api").
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
A new instance of the configured GPConfigurable subclass.
|
|
519
|
+
|
|
520
|
+
Raises:
|
|
521
|
+
RegistrationError: If the configured_class_name is missing or not registered.
|
|
522
|
+
ConfigNotFoundError: If the config path doesn't exist.
|
|
523
|
+
"""
|
|
524
|
+
# Load the config (will auto-detect class from cfg_class_name)
|
|
525
|
+
# Use _force_file=True to ensure we get file even when folder exists
|
|
526
|
+
config = self.get_config(path, _force_file=True)
|
|
527
|
+
|
|
528
|
+
# If config is a dict, we can still proceed if it has configured_class_name
|
|
529
|
+
if isinstance(config, dict):
|
|
530
|
+
class_name = config.get("configured_class_name")
|
|
531
|
+
if not class_name:
|
|
532
|
+
raise RegistrationError(
|
|
533
|
+
f"Config at path '{path}' was loaded as dict (no registered config class). "
|
|
534
|
+
f"Use register_config_class() first, or add 'configured_class_name' to the config."
|
|
535
|
+
)
|
|
536
|
+
else:
|
|
537
|
+
# Get the configured_class_name from the config instance
|
|
538
|
+
class_name = config.configured_class_name
|
|
539
|
+
|
|
540
|
+
if not class_name:
|
|
541
|
+
raise RegistrationError(
|
|
542
|
+
f"Config at path '{path}' has no configured_class_name. "
|
|
543
|
+
f"Add 'configured_class_name' to the config file."
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
if class_name not in self._configurable_classes:
|
|
547
|
+
raise RegistrationError(
|
|
548
|
+
f"Configured class '{class_name}' not registered for config at '{path}'. "
|
|
549
|
+
f"Use register_configurable_class() to register it."
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
configurable_cls = self._configurable_classes[class_name]
|
|
553
|
+
return configurable_cls(config)
|
|
554
|
+
|
|
555
|
+
def list_configs(self, path: str = "") -> list[str]:
|
|
556
|
+
"""List all config objects in a folder.
|
|
557
|
+
|
|
558
|
+
This method returns names of YAML config files and subfolders,
|
|
559
|
+
excluding global_env.yaml.
|
|
560
|
+
|
|
561
|
+
Args:
|
|
562
|
+
path: Optional folder path relative to cfg_folder.
|
|
563
|
+
Empty string means root folder.
|
|
564
|
+
|
|
565
|
+
Returns:
|
|
566
|
+
List of object names (config names without .yaml extension, and folder names).
|
|
567
|
+
|
|
568
|
+
Raises:
|
|
569
|
+
ConfigNotFoundError: If the folder doesn't exist.
|
|
570
|
+
"""
|
|
571
|
+
if path:
|
|
572
|
+
# Convert dot notation to path separators
|
|
573
|
+
folder_path = self._cfg_folder / Path(
|
|
574
|
+
path.replace(".", "/") if "." in path else path
|
|
575
|
+
)
|
|
576
|
+
else:
|
|
577
|
+
folder_path = self._cfg_folder
|
|
578
|
+
|
|
579
|
+
if not folder_path.exists() or not folder_path.is_dir():
|
|
580
|
+
raise ConfigNotFoundError(path or "/", f"Folder '{path}' does not exist")
|
|
581
|
+
|
|
582
|
+
items = []
|
|
583
|
+
for item in folder_path.iterdir():
|
|
584
|
+
# Skip global_env.yaml
|
|
585
|
+
if item.name == "global_env.yaml":
|
|
586
|
+
continue
|
|
587
|
+
|
|
588
|
+
if item.is_file() and item.suffix == ".yaml":
|
|
589
|
+
# Add config name without extension
|
|
590
|
+
items.append(item.stem)
|
|
591
|
+
elif item.is_dir():
|
|
592
|
+
# Add folder name
|
|
593
|
+
items.append(item.name)
|
|
594
|
+
|
|
595
|
+
return sorted(items)
|
|
596
|
+
|
|
597
|
+
def save(self, config: "GPConfig", path: Optional[str] = None) -> None:
|
|
598
|
+
"""Save a GPConfig instance to a config file.
|
|
599
|
+
|
|
600
|
+
This method determines the file path using:
|
|
601
|
+
1. If `path` is provided: use it as relative path from cfg_folder
|
|
602
|
+
2. Otherwise: use config's default_cfg_path + name
|
|
603
|
+
|
|
604
|
+
The config's cfg_file_path will be set before saving.
|
|
605
|
+
|
|
606
|
+
Args:
|
|
607
|
+
config: The GPConfig instance to save.
|
|
608
|
+
path: Optional relative path from cfg_folder (e.g., "cache/redis" or "database").
|
|
609
|
+
If not provided, uses config.default_cfg_path + config.name.
|
|
610
|
+
|
|
611
|
+
Raises:
|
|
612
|
+
ValueError: If config.name is empty or path cannot be determined.
|
|
613
|
+
ConfigReadonlyError: If config.readonly is True.
|
|
614
|
+
"""
|
|
615
|
+
from gpconfig.config import GPConfig
|
|
616
|
+
from gpconfig.exceptions import ConfigReadonlyError
|
|
617
|
+
|
|
618
|
+
if not isinstance(config, GPConfig):
|
|
619
|
+
raise TypeError(f"Expected GPConfig instance, got {type(config).__name__}")
|
|
620
|
+
|
|
621
|
+
if config.readonly:
|
|
622
|
+
raise ConfigReadonlyError(config.name or "unnamed")
|
|
623
|
+
|
|
624
|
+
if not config.name:
|
|
625
|
+
raise ValueError(
|
|
626
|
+
"Config must have a non-empty 'name' attribute to be saved"
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
# Determine the file path
|
|
630
|
+
if path is not None:
|
|
631
|
+
# Use provided relative path
|
|
632
|
+
# Normalize path: remove leading slashes, convert dots to slashes
|
|
633
|
+
relative_path = path.lstrip("/\\").replace(".", "/")
|
|
634
|
+
file_path = self._cfg_folder / f"{relative_path}.yaml"
|
|
635
|
+
else:
|
|
636
|
+
# Use default_cfg_path + name
|
|
637
|
+
default_path = config.default_cfg_path or ""
|
|
638
|
+
# Normalize default_path: remove leading/trailing slashes
|
|
639
|
+
default_path = default_path.strip("/\\")
|
|
640
|
+
|
|
641
|
+
if default_path:
|
|
642
|
+
file_path = self._cfg_folder / default_path / f"{config.name}.yaml"
|
|
643
|
+
else:
|
|
644
|
+
file_path = self._cfg_folder / f"{config.name}.yaml"
|
|
645
|
+
|
|
646
|
+
# Create parent directories if needed
|
|
647
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
648
|
+
|
|
649
|
+
# Set the config's file path
|
|
650
|
+
config.cfg_file_path = file_path
|
|
651
|
+
|
|
652
|
+
# Call the config's save method
|
|
653
|
+
config.save()
|
|
654
|
+
|
|
655
|
+
# Update cache with the new config
|
|
656
|
+
cache_key = str(file_path)
|
|
657
|
+
self._config_cache[cache_key] = config
|
|
658
|
+
|
|
659
|
+
@classmethod
|
|
660
|
+
def make_new_project_config_folder(
|
|
661
|
+
cls,
|
|
662
|
+
project_name: str,
|
|
663
|
+
cfgs: List["GPConfig"],
|
|
664
|
+
global_env: Optional[dict] = None,
|
|
665
|
+
cfg_folder_path: Optional[str | Path] = None,
|
|
666
|
+
) -> Path:
|
|
667
|
+
"""Create a new project configuration folder with initial configs.
|
|
668
|
+
|
|
669
|
+
Args:
|
|
670
|
+
project_name: Name of the project.
|
|
671
|
+
cfgs: List of GPConfig instances to save. Each must have a non-empty 'name'.
|
|
672
|
+
global_env: Optional dictionary for global_env.yaml content.
|
|
673
|
+
cfg_folder_path: Optional explicit path for the config folder.
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
Path to the created configuration folder.
|
|
677
|
+
|
|
678
|
+
Raises:
|
|
679
|
+
ConfigFolderError: If the config folder already exists.
|
|
680
|
+
ValueError: If any config in cfgs has an empty 'name'.
|
|
681
|
+
ConfigReadonlyError: If any config in cfgs has readonly=True.
|
|
682
|
+
"""
|
|
683
|
+
from gpconfig.config import GPConfig
|
|
684
|
+
from gpconfig.exceptions import ConfigReadonlyError
|
|
685
|
+
import os
|
|
686
|
+
|
|
687
|
+
# Resolve folder path
|
|
688
|
+
if cfg_folder_path is not None:
|
|
689
|
+
folder_path = Path(cfg_folder_path).resolve()
|
|
690
|
+
else:
|
|
691
|
+
# Try environment variable
|
|
692
|
+
env_var_name = f"{project_name.upper()}_CFG_PATH"
|
|
693
|
+
env_path = os.environ.get(env_var_name)
|
|
694
|
+
if env_path:
|
|
695
|
+
folder_path = Path(env_path).resolve()
|
|
696
|
+
else:
|
|
697
|
+
# Fallback to home directory
|
|
698
|
+
folder_path = Path.home() / f".{project_name}"
|
|
699
|
+
|
|
700
|
+
# Check if folder already exists
|
|
701
|
+
if folder_path.exists():
|
|
702
|
+
raise ConfigFolderError(f"Config folder already exists: {folder_path}")
|
|
703
|
+
|
|
704
|
+
# Create folder
|
|
705
|
+
folder_path.mkdir(parents=True, exist_ok=False)
|
|
706
|
+
|
|
707
|
+
# Create global_env.yaml
|
|
708
|
+
global_env_path = folder_path / "global_env.yaml"
|
|
709
|
+
with open(global_env_path, "w", encoding="utf-8") as f:
|
|
710
|
+
yaml.dump(global_env or {}, f, default_flow_style=False, allow_unicode=True)
|
|
711
|
+
|
|
712
|
+
# Validate and save configs
|
|
713
|
+
for config in cfgs:
|
|
714
|
+
if not isinstance(config, GPConfig):
|
|
715
|
+
raise TypeError(
|
|
716
|
+
f"Expected GPConfig instance, got {type(config).__name__}"
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
if not config.name:
|
|
720
|
+
raise ValueError(
|
|
721
|
+
f"Config of type '{type(config).__name__}' has empty 'name' attribute. "
|
|
722
|
+
"All configs must have a non-empty 'name' to be saved."
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
if config.readonly:
|
|
726
|
+
raise ConfigReadonlyError(config.name)
|
|
727
|
+
|
|
728
|
+
# Determine file path from default_cfg_path + name
|
|
729
|
+
default_path = config.default_cfg_path or ""
|
|
730
|
+
default_path = default_path.strip("/\\")
|
|
731
|
+
|
|
732
|
+
if default_path:
|
|
733
|
+
file_path = folder_path / default_path / f"{config.name}.yaml"
|
|
734
|
+
else:
|
|
735
|
+
file_path = folder_path / f"{config.name}.yaml"
|
|
736
|
+
|
|
737
|
+
# Create subdirectories if needed
|
|
738
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
739
|
+
|
|
740
|
+
# Set file path and save
|
|
741
|
+
config.cfg_file_path = file_path
|
|
742
|
+
config.save()
|
|
743
|
+
|
|
744
|
+
return folder_path
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gpconfig
|
|
3
|
+
Version: 0.3.2
|
|
4
|
+
Summary: A type-safe, YAML-based configuration management library built on Pydantic
|
|
5
|
+
Project-URL: Homepage, https://github.com/LinnetCodes/gpconfig
|
|
6
|
+
Project-URL: Documentation, https://linnetcodes.github.io/gpconfig
|
|
7
|
+
Project-URL: Repository, https://github.com/LinnetCodes/gpconfig
|
|
8
|
+
Project-URL: Issues, https://github.com/LinnetCodes/gpconfig/issues
|
|
9
|
+
Author-email: HAN Lin <lin_han@foxmail.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: config,configuration,pydantic,settings,type-safe,yaml
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: pydantic-settings>=2.0
|
|
27
|
+
Requires-Dist: pydantic>=2.0
|
|
28
|
+
Requires-Dist: pyyaml>=6.0
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff>=0.4.0; extra == 'dev'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# gpconfig
|
|
36
|
+
|
|
37
|
+
[简体中文](README.zh-CN.md) | English
|
|
38
|
+
|
|
39
|
+
General Purpose Configuration management library for Python projects.
|
|
40
|
+
|
|
41
|
+
A type-safe, YAML-based configuration management library built on Pydantic.
|
|
42
|
+
|
|
43
|
+
## Features
|
|
44
|
+
|
|
45
|
+
- **Type-safe configuration** - Built on Pydantic with full type validation
|
|
46
|
+
- **YAML-based** - Human-readable configuration files
|
|
47
|
+
- **Nested configs** - Organize configs in directories (e.g., `llm/openai.yaml`)
|
|
48
|
+
- **Auto-detection** - Automatically detect config classes from YAML files
|
|
49
|
+
- **Configurable objects** - Create object instances directly from configs
|
|
50
|
+
- **Environment variable support** - Configure paths via environment variables
|
|
51
|
+
- **Readonly configs** - Protect sensitive configurations
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install gpconfig
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Quick Start
|
|
60
|
+
|
|
61
|
+
### 1. Define Config Classes
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from typing import ClassVar
|
|
65
|
+
from gpconfig import GPConfig
|
|
66
|
+
|
|
67
|
+
class DatabaseConfig(GPConfig):
|
|
68
|
+
cfg_class_name: ClassVar[str] = "DatabaseConfig"
|
|
69
|
+
host: str
|
|
70
|
+
port: int = 5432
|
|
71
|
+
username: str
|
|
72
|
+
password: str
|
|
73
|
+
database: str
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 2. Create Config Folder
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
myapp/
|
|
80
|
+
├── global_env.yaml # Required: global environment config
|
|
81
|
+
├── database.yaml # Your config files
|
|
82
|
+
└── llm/ # Nested configs
|
|
83
|
+
├── openai.yaml
|
|
84
|
+
└── anthropic.yaml
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**global_env.yaml:**
|
|
88
|
+
```yaml
|
|
89
|
+
version: "1.0.0"
|
|
90
|
+
debug: true
|
|
91
|
+
log_level: INFO
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**database.yaml:**
|
|
95
|
+
```yaml
|
|
96
|
+
cfg_class_name: "DatabaseConfig"
|
|
97
|
+
host: localhost
|
|
98
|
+
port: 5432
|
|
99
|
+
username: admin
|
|
100
|
+
password: secret
|
|
101
|
+
database: myapp
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### 3. Initialize Manager and Load Configs
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from gpconfig import GPConfigManager
|
|
108
|
+
|
|
109
|
+
# Config folder search order:
|
|
110
|
+
# 1. Explicit cfg_folder parameter
|
|
111
|
+
# 2. Environment variable: {PROJECT_NAME}_CFG_PATH
|
|
112
|
+
# 3. User directory: ~/.{project_name}/
|
|
113
|
+
manager = GPConfigManager("myapp", cfg_folder="/path/to/myapp")
|
|
114
|
+
|
|
115
|
+
# Read global_env values
|
|
116
|
+
debug = manager.get_config("global_env.debug")
|
|
117
|
+
|
|
118
|
+
# Load config (auto-detect class by cfg_class_name)
|
|
119
|
+
db_config = manager.get_config("database")
|
|
120
|
+
|
|
121
|
+
# Load nested config
|
|
122
|
+
llm_config = manager.get_config("llm.openai")
|
|
123
|
+
|
|
124
|
+
# Read specific field
|
|
125
|
+
host = manager.get_config("database.host")
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### 4. Create Configurable Objects
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from gpconfig import GPConfigurable
|
|
132
|
+
|
|
133
|
+
class Database(GPConfigurable):
|
|
134
|
+
def __init__(self, config: DatabaseConfig) -> None:
|
|
135
|
+
super().__init__(config)
|
|
136
|
+
self.host = config.host
|
|
137
|
+
self.port = config.port
|
|
138
|
+
self.username = config.username
|
|
139
|
+
self.password = config.password
|
|
140
|
+
self.database = config.database
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def connection_string(self) -> str:
|
|
144
|
+
return f"postgresql://{self.username}:{self.password}@{self.host}:{self.port}/{self.database}"
|
|
145
|
+
|
|
146
|
+
# Register classes
|
|
147
|
+
GPConfigManager.register_config_class(DatabaseConfig)
|
|
148
|
+
GPConfigManager.register_configurable_class(Database)
|
|
149
|
+
|
|
150
|
+
# Create object instance
|
|
151
|
+
db = manager.get_object("database")
|
|
152
|
+
print(db.connection_string)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**Note:** Add `configured_class_name` to your YAML for `get_object()`:
|
|
156
|
+
|
|
157
|
+
```yaml
|
|
158
|
+
cfg_class_name: "DatabaseConfig"
|
|
159
|
+
configured_class_name: "Database"
|
|
160
|
+
host: localhost
|
|
161
|
+
port: 5432
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### 5. Save Configs
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
# Modify and save
|
|
168
|
+
db_config.port = 5433
|
|
169
|
+
db_config.save()
|
|
170
|
+
|
|
171
|
+
# Save to new location
|
|
172
|
+
manager.save(db_config, "backups/database_backup")
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Core Components
|
|
176
|
+
|
|
177
|
+
| Component | Description |
|
|
178
|
+
|-----------|-------------|
|
|
179
|
+
| `GPConfig` | Base class for all config classes |
|
|
180
|
+
| `GPConfigurable` | Base class for objects created from configs |
|
|
181
|
+
| `GPConfigManager` | Manages config folder, loading, and object creation |
|
|
182
|
+
|
|
183
|
+
## Exceptions
|
|
184
|
+
|
|
185
|
+
| Exception | Description |
|
|
186
|
+
|-----------|-------------|
|
|
187
|
+
| `GPConfigError` | Base exception for all gpconfig errors |
|
|
188
|
+
| `ConfigFolderError` | Config folder not found or invalid |
|
|
189
|
+
| `ConfigNotFoundError` | Requested config path does not exist |
|
|
190
|
+
| `ConfigReadonlyError` | Attempted to modify readonly config |
|
|
191
|
+
| `RegistrationError` | Class registration issues |
|
|
192
|
+
| `ConfigValidationError` | Config file validation failed |
|
|
193
|
+
|
|
194
|
+
## API Reference
|
|
195
|
+
|
|
196
|
+
See the [API documentation](docs/api/en/index.md) for detailed usage.
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
gpconfig/__init__.py,sha256=NVlzJrH_rzjU5cNz9XLSSXXv8M2AP63-UChHwOBOB7k,734
|
|
2
|
+
gpconfig/config.py,sha256=COOxjsETWZwKcq9OMr4OETomGE5cNyA18FG5wlQ9nf0,1833
|
|
3
|
+
gpconfig/configurable.py,sha256=lUxfZLKmGmo8BZ9DKNM54WL3my2XD9OLYlu7yZeRHKU,864
|
|
4
|
+
gpconfig/exceptions.py,sha256=EbHDkiu50Qj0dtg3IwutQjHOzVDZ3p9-BRqdrM5cJiQ,1180
|
|
5
|
+
gpconfig/manager.py,sha256=PxPydEbg0u3kREZGJkPmEUSS2Z08E0-cmcMxten-eQo,26768
|
|
6
|
+
gpconfig-0.3.2.dist-info/METADATA,sha256=QXn9R-7GORBihAOdLgH7Xvf8axJhemgFkyaJyfTp5Yw,5548
|
|
7
|
+
gpconfig-0.3.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
gpconfig-0.3.2.dist-info/licenses/LICENSE,sha256=k7HiaHh7rMfWYAQztyDYhTxCdeuNIuCS-VvOwWgA1iA,1068
|
|
9
|
+
gpconfig-0.3.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LinnetCodes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|