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.
@@ -0,0 +1,2 @@
1
+ .specstory/
2
+ *.pyc
@@ -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"