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 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)
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.