config-tree-manager 0.4.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.
- config_tree_manager-0.4.0/.gitignore +2 -0
- config_tree_manager-0.4.0/PKG-INFO +333 -0
- config_tree_manager-0.4.0/README.md +293 -0
- config_tree_manager-0.4.0/pyproject.toml +60 -0
- config_tree_manager-0.4.0/src/config_tree_manager/__init__.py +44 -0
- config_tree_manager-0.4.0/src/config_tree_manager/manager.py +695 -0
- config_tree_manager-0.4.0/src/config_tree_manager/model.py +145 -0
- config_tree_manager-0.4.0/src/config_tree_manager/resolver.py +289 -0
- config_tree_manager-0.4.0/src/config_tree_manager/schema.py +201 -0
- config_tree_manager-0.4.0/src/config_tree_manager/source_yaml.py +151 -0
- config_tree_manager-0.4.0/src/config_tree_manager/types.py +310 -0
- config_tree_manager-0.4.0/src/config_tree_manager/validator.py +96 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: config-tree-manager
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Reusable Python package for typed, YAML-backed application configuration with precedence layers.
|
|
5
|
+
Author-email: Chris <cbase2015-pypi@yahoo.com>
|
|
6
|
+
License: GPLv3+
|
|
7
|
+
Keywords: config,configuration,settings,typed,yaml
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Requires-Dist: filelock>=3.12
|
|
19
|
+
Requires-Dist: pyyaml>=6.0
|
|
20
|
+
Provides-Extra: all
|
|
21
|
+
Requires-Dist: pyright>=1.1.350; extra == 'all'
|
|
22
|
+
Requires-Dist: pytest-cov>=7.0; extra == 'all'
|
|
23
|
+
Requires-Dist: pytest-mock>=3.12; extra == 'all'
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == 'all'
|
|
25
|
+
Requires-Dist: ruff>=0.4.0; extra == 'all'
|
|
26
|
+
Requires-Dist: sphinx-rtd-theme>=2.0; extra == 'all'
|
|
27
|
+
Requires-Dist: sphinx>=7.0; extra == 'all'
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest-cov>=7.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-mock>=3.12; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
32
|
+
Provides-Extra: docs
|
|
33
|
+
Requires-Dist: sphinx-rtd-theme>=2.0; extra == 'docs'
|
|
34
|
+
Requires-Dist: sphinx>=7.0; extra == 'docs'
|
|
35
|
+
Provides-Extra: lint
|
|
36
|
+
Requires-Dist: ruff>=0.4.0; extra == 'lint'
|
|
37
|
+
Provides-Extra: type-check
|
|
38
|
+
Requires-Dist: pyright>=1.1.350; extra == 'type-check'
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# config-tree-manager
|
|
42
|
+
|
|
43
|
+
Reusable Python library for typed, YAML-backed application configuration with deterministic precedence layers, compact override persistence, and rich field metadata for GUIs and validation.
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install config-tree-manager
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quick Start
|
|
52
|
+
|
|
53
|
+
### 1. Define your configuration model
|
|
54
|
+
|
|
55
|
+
Use standard Python dataclasses. Annotate fields with `field_meta()` to attach descriptions, constraints, and persistence rules.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from dataclasses import dataclass, field
|
|
59
|
+
from config_tree_manager import ConfigManager, field_meta
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class LoggingConfig:
|
|
64
|
+
level: str = field_meta(default="INFO", description="Log verbosity level")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class ServerConfig:
|
|
69
|
+
host: str = field_meta(default="localhost", description="Bind address")
|
|
70
|
+
port: int = field_meta(
|
|
71
|
+
default=8080,
|
|
72
|
+
description="TCP port",
|
|
73
|
+
min_value=1,
|
|
74
|
+
max_value=65535,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class AppConfig:
|
|
80
|
+
debug: bool = field_meta(default=False, description="Enable debug mode")
|
|
81
|
+
logging: LoggingConfig = field(default_factory=LoggingConfig)
|
|
82
|
+
server: ServerConfig = field(default_factory=ServerConfig)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### 2. Create a manager and load configuration
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
manager: ConfigManager[AppConfig] = ConfigManager(AppConfig, env_prefix="APP")
|
|
89
|
+
|
|
90
|
+
result = manager.load(
|
|
91
|
+
"config.yaml",
|
|
92
|
+
cli_overrides=["server.port=9090"], # optional
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
config: AppConfig = result.config
|
|
96
|
+
print(config.server.port) # 9090 (from CLI override)
|
|
97
|
+
print(config.logging.level) # "INFO" (default)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`load()` returns a [`LoadResult`](#loadresult) with the typed config, any warnings, and a live metadata index.
|
|
101
|
+
|
|
102
|
+
### 3. Access effective values as plain attributes
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
# Values are plain Python types — no wrapper objects.
|
|
106
|
+
assert isinstance(config.server.port, int)
|
|
107
|
+
assert config.debug is False
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### 4. Apply runtime overrides (e.g. from a GUI)
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
manager.set_runtime_override("logging.level", "DEBUG")
|
|
114
|
+
# config.logging.level is now "DEBUG" and the metadata index is updated.
|
|
115
|
+
|
|
116
|
+
manager.clear_runtime_override("logging.level") # revert
|
|
117
|
+
manager.clear_all_runtime_overrides() # revert all
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### 5. Save compact overrides
|
|
121
|
+
|
|
122
|
+
Only fields that differ from their defaults are written. Fields reverted to defaults are removed from the file.
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
config.debug = True
|
|
126
|
+
config.server.port = 9090
|
|
127
|
+
|
|
128
|
+
manager.save("config.yaml", config, backup=True)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Resulting `config.yaml`:
|
|
132
|
+
```yaml
|
|
133
|
+
debug: true
|
|
134
|
+
server:
|
|
135
|
+
port: 9090
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Export Modes
|
|
141
|
+
|
|
142
|
+
The `save()` method supports flexible export modes via two independent boolean flags:
|
|
143
|
+
`full` and `include_descriptions`. This allows you to generate configuration files
|
|
144
|
+
suited to different use cases.
|
|
145
|
+
|
|
146
|
+
### Compact overrides (default)
|
|
147
|
+
|
|
148
|
+
Write only fields that differ from their defaults:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
manager.save("config.yaml", config)
|
|
152
|
+
# or explicitly:
|
|
153
|
+
manager.save("config.yaml", config, full=False, include_descriptions=False)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Full export
|
|
157
|
+
|
|
158
|
+
Write all settings, including defaults:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
manager.save("config.yaml", config, full=True)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Useful for generating reference configurations that show all available options.
|
|
165
|
+
|
|
166
|
+
### Export with descriptions
|
|
167
|
+
|
|
168
|
+
Add field descriptions as YAML comments. Requires that `load()` has been called first:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
# Compact with descriptions
|
|
172
|
+
manager.save("config.yaml", config, include_descriptions=True)
|
|
173
|
+
|
|
174
|
+
# Full with descriptions
|
|
175
|
+
manager.save("config.yaml", config, full=True, include_descriptions=True)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Example output with descriptions:
|
|
179
|
+
|
|
180
|
+
```yaml
|
|
181
|
+
# Enable debug mode
|
|
182
|
+
debug: true
|
|
183
|
+
logging:
|
|
184
|
+
# Log verbosity level
|
|
185
|
+
level: INFO
|
|
186
|
+
server:
|
|
187
|
+
# Bind address
|
|
188
|
+
host: localhost
|
|
189
|
+
# TCP port
|
|
190
|
+
port: 9090
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Line wrapping and long values
|
|
194
|
+
|
|
195
|
+
The `max_line_length` parameter (default 80) controls wrapping of long descriptions
|
|
196
|
+
and string values at word boundaries:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
manager.save("config.yaml", config, include_descriptions=True, max_line_length=60)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Multi-line descriptions (using `\n` in the description string) are split and
|
|
203
|
+
re-wrapped independently:
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
field_meta(
|
|
207
|
+
default="...",
|
|
208
|
+
description="First line of explanation.\nSecond line continues here."
|
|
209
|
+
)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Long string values (exceeding `max_line_length`) are automatically formatted as
|
|
213
|
+
YAML block scalars for readability.
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Value Precedence
|
|
218
|
+
|
|
219
|
+
Layers are merged in this order (lower → higher priority):
|
|
220
|
+
|
|
221
|
+
```
|
|
222
|
+
DEFAULT < FILE < ENV < CLI < RUNTIME
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
| Layer | Source |
|
|
226
|
+
|---------|-----------------------------------------------|
|
|
227
|
+
| DEFAULT | Declared in the dataclass (`field_meta`) |
|
|
228
|
+
| FILE | YAML file passed to `load()` |
|
|
229
|
+
| ENV | Environment variables (`APP_LOGGING_LEVEL`) |
|
|
230
|
+
| CLI | `cli_overrides` list passed to `load()` |
|
|
231
|
+
| RUNTIME | In-memory via `set_runtime_override()` |
|
|
232
|
+
|
|
233
|
+
**Environment variable naming:** `{PREFIX}_{SECTION}_{KEY}` in upper-snake-case.
|
|
234
|
+
Example: `logging.level` with prefix `APP` → `APP_LOGGING_LEVEL`.
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## Validation and Warnings
|
|
239
|
+
|
|
240
|
+
### Fail-fast validation
|
|
241
|
+
|
|
242
|
+
`load()` raises `ValidationError` on the first field that violates its constraints.
|
|
243
|
+
You can also validate ad-hoc:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
manager.validate(config) # typed config instance
|
|
247
|
+
manager.validate({"debug": True}) # or a flat dict
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Constraint codes:
|
|
251
|
+
|
|
252
|
+
| Code | Trigger |
|
|
253
|
+
|--------------------|--------------------------------------------------|
|
|
254
|
+
| `TYPE_MISMATCH` | Value type does not match declared field type |
|
|
255
|
+
| `OUT_OF_RANGE` | Numeric value outside `min_value`/`max_value` |
|
|
256
|
+
| `NULL_NOT_ALLOWED` | `None` value on a `nullable=False` field |
|
|
257
|
+
| `COERCE_FAIL` | `allow_coerce=True` but conversion failed |
|
|
258
|
+
| `PARSE_ERROR` | Malformed YAML or malformed CLI `key=value` |
|
|
259
|
+
| `LOCK_CONFLICT` | Advisory lock could not be acquired on save |
|
|
260
|
+
|
|
261
|
+
### Unknown-key warnings
|
|
262
|
+
|
|
263
|
+
Keys in the YAML file that are not registered in the schema are **ignored with a warning**, not silently dropped and not a hard error.
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
result = manager.load("config.yaml")
|
|
267
|
+
for warning in result.warnings:
|
|
268
|
+
print(f"[{warning.code}] {warning.path}: {warning.message}")
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Warning code `UNKNOWN_KEY` is emitted per unknown leaf path.
|
|
272
|
+
An entire unknown subtree (unknown parent) produces a single warning.
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## LoadResult
|
|
277
|
+
|
|
278
|
+
```python
|
|
279
|
+
@dataclass
|
|
280
|
+
class LoadResult[TConfig]:
|
|
281
|
+
config: TConfig # typed config object
|
|
282
|
+
warnings: list[WarningItem] # non-fatal diagnostics
|
|
283
|
+
metadata_index: dict[str, FieldMeta] # live metadata, keyed by dot-path
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
The `metadata_index` gives you per-field metadata useful for building GUIs:
|
|
287
|
+
|
|
288
|
+
```python
|
|
289
|
+
meta = result.metadata_index["server.port"]
|
|
290
|
+
print(meta.effective_value) # 9090
|
|
291
|
+
print(meta.source) # ValueSource.FILE
|
|
292
|
+
print(meta.default_value) # 8080
|
|
293
|
+
print(meta.description) # "TCP port"
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## field_meta reference
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
field_meta(
|
|
302
|
+
default=..., # scalar default value
|
|
303
|
+
default_factory=..., # zero-argument callable (mutually exclusive with default)
|
|
304
|
+
description="", # human-readable label
|
|
305
|
+
min_value=None, # inclusive lower bound (numeric fields only)
|
|
306
|
+
max_value=None, # inclusive upper bound (numeric fields only)
|
|
307
|
+
nullable=True, # whether None is a valid value
|
|
308
|
+
never_persist=False, # exclude from all file writes (e.g. tokens)
|
|
309
|
+
allow_coerce=False, # attempt safe coercion from env/CLI strings
|
|
310
|
+
)
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
---
|
|
314
|
+
|
|
315
|
+
## Advisory file locking
|
|
316
|
+
|
|
317
|
+
Enable an advisory lock to prevent concurrent saves from silently overwriting each other:
|
|
318
|
+
|
|
319
|
+
```python
|
|
320
|
+
manager = ConfigManager(AppConfig, use_file_lock=True)
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
When the lock cannot be acquired, `LockConflictError` is raised.
|
|
324
|
+
Without locking (default), last-writer-wins semantics apply.
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
## Running the tests
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
pip install -e ".[dev]"
|
|
332
|
+
pytest
|
|
333
|
+
```
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# config-tree-manager
|
|
2
|
+
|
|
3
|
+
Reusable Python library for typed, YAML-backed application configuration with deterministic precedence layers, compact override persistence, and rich field metadata for GUIs and validation.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install config-tree-manager
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
### 1. Define your configuration model
|
|
14
|
+
|
|
15
|
+
Use standard Python dataclasses. Annotate fields with `field_meta()` to attach descriptions, constraints, and persistence rules.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from config_tree_manager import ConfigManager, field_meta
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class LoggingConfig:
|
|
24
|
+
level: str = field_meta(default="INFO", description="Log verbosity level")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ServerConfig:
|
|
29
|
+
host: str = field_meta(default="localhost", description="Bind address")
|
|
30
|
+
port: int = field_meta(
|
|
31
|
+
default=8080,
|
|
32
|
+
description="TCP port",
|
|
33
|
+
min_value=1,
|
|
34
|
+
max_value=65535,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class AppConfig:
|
|
40
|
+
debug: bool = field_meta(default=False, description="Enable debug mode")
|
|
41
|
+
logging: LoggingConfig = field(default_factory=LoggingConfig)
|
|
42
|
+
server: ServerConfig = field(default_factory=ServerConfig)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### 2. Create a manager and load configuration
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
manager: ConfigManager[AppConfig] = ConfigManager(AppConfig, env_prefix="APP")
|
|
49
|
+
|
|
50
|
+
result = manager.load(
|
|
51
|
+
"config.yaml",
|
|
52
|
+
cli_overrides=["server.port=9090"], # optional
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
config: AppConfig = result.config
|
|
56
|
+
print(config.server.port) # 9090 (from CLI override)
|
|
57
|
+
print(config.logging.level) # "INFO" (default)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`load()` returns a [`LoadResult`](#loadresult) with the typed config, any warnings, and a live metadata index.
|
|
61
|
+
|
|
62
|
+
### 3. Access effective values as plain attributes
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
# Values are plain Python types — no wrapper objects.
|
|
66
|
+
assert isinstance(config.server.port, int)
|
|
67
|
+
assert config.debug is False
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### 4. Apply runtime overrides (e.g. from a GUI)
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
manager.set_runtime_override("logging.level", "DEBUG")
|
|
74
|
+
# config.logging.level is now "DEBUG" and the metadata index is updated.
|
|
75
|
+
|
|
76
|
+
manager.clear_runtime_override("logging.level") # revert
|
|
77
|
+
manager.clear_all_runtime_overrides() # revert all
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 5. Save compact overrides
|
|
81
|
+
|
|
82
|
+
Only fields that differ from their defaults are written. Fields reverted to defaults are removed from the file.
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
config.debug = True
|
|
86
|
+
config.server.port = 9090
|
|
87
|
+
|
|
88
|
+
manager.save("config.yaml", config, backup=True)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Resulting `config.yaml`:
|
|
92
|
+
```yaml
|
|
93
|
+
debug: true
|
|
94
|
+
server:
|
|
95
|
+
port: 9090
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Export Modes
|
|
101
|
+
|
|
102
|
+
The `save()` method supports flexible export modes via two independent boolean flags:
|
|
103
|
+
`full` and `include_descriptions`. This allows you to generate configuration files
|
|
104
|
+
suited to different use cases.
|
|
105
|
+
|
|
106
|
+
### Compact overrides (default)
|
|
107
|
+
|
|
108
|
+
Write only fields that differ from their defaults:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
manager.save("config.yaml", config)
|
|
112
|
+
# or explicitly:
|
|
113
|
+
manager.save("config.yaml", config, full=False, include_descriptions=False)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Full export
|
|
117
|
+
|
|
118
|
+
Write all settings, including defaults:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
manager.save("config.yaml", config, full=True)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Useful for generating reference configurations that show all available options.
|
|
125
|
+
|
|
126
|
+
### Export with descriptions
|
|
127
|
+
|
|
128
|
+
Add field descriptions as YAML comments. Requires that `load()` has been called first:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
# Compact with descriptions
|
|
132
|
+
manager.save("config.yaml", config, include_descriptions=True)
|
|
133
|
+
|
|
134
|
+
# Full with descriptions
|
|
135
|
+
manager.save("config.yaml", config, full=True, include_descriptions=True)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Example output with descriptions:
|
|
139
|
+
|
|
140
|
+
```yaml
|
|
141
|
+
# Enable debug mode
|
|
142
|
+
debug: true
|
|
143
|
+
logging:
|
|
144
|
+
# Log verbosity level
|
|
145
|
+
level: INFO
|
|
146
|
+
server:
|
|
147
|
+
# Bind address
|
|
148
|
+
host: localhost
|
|
149
|
+
# TCP port
|
|
150
|
+
port: 9090
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Line wrapping and long values
|
|
154
|
+
|
|
155
|
+
The `max_line_length` parameter (default 80) controls wrapping of long descriptions
|
|
156
|
+
and string values at word boundaries:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
manager.save("config.yaml", config, include_descriptions=True, max_line_length=60)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Multi-line descriptions (using `\n` in the description string) are split and
|
|
163
|
+
re-wrapped independently:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
field_meta(
|
|
167
|
+
default="...",
|
|
168
|
+
description="First line of explanation.\nSecond line continues here."
|
|
169
|
+
)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Long string values (exceeding `max_line_length`) are automatically formatted as
|
|
173
|
+
YAML block scalars for readability.
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Value Precedence
|
|
178
|
+
|
|
179
|
+
Layers are merged in this order (lower → higher priority):
|
|
180
|
+
|
|
181
|
+
```
|
|
182
|
+
DEFAULT < FILE < ENV < CLI < RUNTIME
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
| Layer | Source |
|
|
186
|
+
|---------|-----------------------------------------------|
|
|
187
|
+
| DEFAULT | Declared in the dataclass (`field_meta`) |
|
|
188
|
+
| FILE | YAML file passed to `load()` |
|
|
189
|
+
| ENV | Environment variables (`APP_LOGGING_LEVEL`) |
|
|
190
|
+
| CLI | `cli_overrides` list passed to `load()` |
|
|
191
|
+
| RUNTIME | In-memory via `set_runtime_override()` |
|
|
192
|
+
|
|
193
|
+
**Environment variable naming:** `{PREFIX}_{SECTION}_{KEY}` in upper-snake-case.
|
|
194
|
+
Example: `logging.level` with prefix `APP` → `APP_LOGGING_LEVEL`.
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## Validation and Warnings
|
|
199
|
+
|
|
200
|
+
### Fail-fast validation
|
|
201
|
+
|
|
202
|
+
`load()` raises `ValidationError` on the first field that violates its constraints.
|
|
203
|
+
You can also validate ad-hoc:
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
manager.validate(config) # typed config instance
|
|
207
|
+
manager.validate({"debug": True}) # or a flat dict
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Constraint codes:
|
|
211
|
+
|
|
212
|
+
| Code | Trigger |
|
|
213
|
+
|--------------------|--------------------------------------------------|
|
|
214
|
+
| `TYPE_MISMATCH` | Value type does not match declared field type |
|
|
215
|
+
| `OUT_OF_RANGE` | Numeric value outside `min_value`/`max_value` |
|
|
216
|
+
| `NULL_NOT_ALLOWED` | `None` value on a `nullable=False` field |
|
|
217
|
+
| `COERCE_FAIL` | `allow_coerce=True` but conversion failed |
|
|
218
|
+
| `PARSE_ERROR` | Malformed YAML or malformed CLI `key=value` |
|
|
219
|
+
| `LOCK_CONFLICT` | Advisory lock could not be acquired on save |
|
|
220
|
+
|
|
221
|
+
### Unknown-key warnings
|
|
222
|
+
|
|
223
|
+
Keys in the YAML file that are not registered in the schema are **ignored with a warning**, not silently dropped and not a hard error.
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
result = manager.load("config.yaml")
|
|
227
|
+
for warning in result.warnings:
|
|
228
|
+
print(f"[{warning.code}] {warning.path}: {warning.message}")
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Warning code `UNKNOWN_KEY` is emitted per unknown leaf path.
|
|
232
|
+
An entire unknown subtree (unknown parent) produces a single warning.
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## LoadResult
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
@dataclass
|
|
240
|
+
class LoadResult[TConfig]:
|
|
241
|
+
config: TConfig # typed config object
|
|
242
|
+
warnings: list[WarningItem] # non-fatal diagnostics
|
|
243
|
+
metadata_index: dict[str, FieldMeta] # live metadata, keyed by dot-path
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
The `metadata_index` gives you per-field metadata useful for building GUIs:
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
meta = result.metadata_index["server.port"]
|
|
250
|
+
print(meta.effective_value) # 9090
|
|
251
|
+
print(meta.source) # ValueSource.FILE
|
|
252
|
+
print(meta.default_value) # 8080
|
|
253
|
+
print(meta.description) # "TCP port"
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## field_meta reference
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
field_meta(
|
|
262
|
+
default=..., # scalar default value
|
|
263
|
+
default_factory=..., # zero-argument callable (mutually exclusive with default)
|
|
264
|
+
description="", # human-readable label
|
|
265
|
+
min_value=None, # inclusive lower bound (numeric fields only)
|
|
266
|
+
max_value=None, # inclusive upper bound (numeric fields only)
|
|
267
|
+
nullable=True, # whether None is a valid value
|
|
268
|
+
never_persist=False, # exclude from all file writes (e.g. tokens)
|
|
269
|
+
allow_coerce=False, # attempt safe coercion from env/CLI strings
|
|
270
|
+
)
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## Advisory file locking
|
|
276
|
+
|
|
277
|
+
Enable an advisory lock to prevent concurrent saves from silently overwriting each other:
|
|
278
|
+
|
|
279
|
+
```python
|
|
280
|
+
manager = ConfigManager(AppConfig, use_file_lock=True)
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
When the lock cannot be acquired, `LockConflictError` is raised.
|
|
284
|
+
Without locking (default), last-writer-wins semantics apply.
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## Running the tests
|
|
289
|
+
|
|
290
|
+
```bash
|
|
291
|
+
pip install -e ".[dev]"
|
|
292
|
+
pytest
|
|
293
|
+
```
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "config-tree-manager"
|
|
7
|
+
version = "0.4.0"
|
|
8
|
+
description = "Reusable Python package for typed, YAML-backed application configuration with precedence layers."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = { text = "GPLv3+" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Chris", email = "cbase2015-pypi@yahoo.com" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["config", "configuration", "yaml", "typed", "settings"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Topic :: Software Development :: Libraries",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"PyYAML>=6.0",
|
|
29
|
+
"filelock>=3.12",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=8.0",
|
|
35
|
+
"pytest-cov>=7.0",
|
|
36
|
+
"pytest-mock>=3.12",
|
|
37
|
+
]
|
|
38
|
+
lint = [
|
|
39
|
+
"ruff>=0.4.0",
|
|
40
|
+
]
|
|
41
|
+
type-check = [
|
|
42
|
+
"pyright>=1.1.350",
|
|
43
|
+
]
|
|
44
|
+
docs = [
|
|
45
|
+
"sphinx>=7.0",
|
|
46
|
+
"sphinx-rtd-theme>=2.0",
|
|
47
|
+
]
|
|
48
|
+
all = [
|
|
49
|
+
"config-tree-manager[dev,lint,type-check,docs]",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.sdist]
|
|
53
|
+
include = ["src/"]
|
|
54
|
+
|
|
55
|
+
[tool.hatch.build.targets.wheel]
|
|
56
|
+
packages = ["src/config_tree_manager"]
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
line-length = 99
|
|
60
|
+
target-version = "py312"
|