Metadata-Version: 2.4
Name: config-tree-manager
Version: 0.4.1
Summary: Reusable Python package for typed, YAML-backed application configuration with precedence layers.
Project-URL: Homepage, https://github.com/chr7/config-tree-manager
Project-URL: Repository, https://github.com/chr7/config-tree-manager
Project-URL: Documentation, https://github.com/chr7/config-tree-manager/blob/main/README.md
Project-URL: Issues, https://github.com/chr7/config-tree-manager/issues
Project-URL: Releases, https://github.com/chr7/config-tree-manager/releases
Author-email: Chris <cbase2015-pypi@yahoo.com>
License: GPLv3+
Keywords: config,configuration,settings,typed,yaml
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: filelock>=3.12
Requires-Dist: pyyaml>=6.0
Provides-Extra: all
Requires-Dist: pyright>=1.1.350; extra == 'all'
Requires-Dist: pytest-cov>=7.0; extra == 'all'
Requires-Dist: pytest-mock>=3.12; extra == 'all'
Requires-Dist: pytest>=8.0; extra == 'all'
Requires-Dist: ruff>=0.4.0; extra == 'all'
Requires-Dist: sphinx-rtd-theme>=2.0; extra == 'all'
Requires-Dist: sphinx>=7.0; extra == 'all'
Provides-Extra: dev
Requires-Dist: pytest-cov>=7.0; extra == 'dev'
Requires-Dist: pytest-mock>=3.12; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Provides-Extra: docs
Requires-Dist: sphinx-rtd-theme>=2.0; extra == 'docs'
Requires-Dist: sphinx>=7.0; extra == 'docs'
Provides-Extra: lint
Requires-Dist: ruff>=0.4.0; extra == 'lint'
Provides-Extra: type-check
Requires-Dist: pyright>=1.1.350; extra == 'type-check'
Description-Content-Type: text/markdown

# config-tree-manager

Reusable Python library for typed, YAML-backed application configuration with deterministic precedence layers, compact override persistence, and rich field metadata for GUIs and validation.

## Installation

```bash
pip install config-tree-manager
```

## Quick Start

### 1. Define your configuration model

Use standard Python dataclasses. Annotate fields with `field_meta()` to attach descriptions, constraints, and persistence rules.

```python
from dataclasses import dataclass, field
from config_tree_manager import ConfigManager, field_meta


@dataclass
class LoggingConfig:
    level: str = field_meta(default="INFO", description="Log verbosity level")


@dataclass
class ServerConfig:
    host: str = field_meta(default="localhost", description="Bind address")
    port: int = field_meta(
        default=8080,
        description="TCP port",
        min_value=1,
        max_value=65535,
    )


@dataclass
class AppConfig:
    debug: bool = field_meta(default=False, description="Enable debug mode")
    logging: LoggingConfig = field(default_factory=LoggingConfig)
    server: ServerConfig = field(default_factory=ServerConfig)
```

### 2. Create a manager and load configuration

```python
manager: ConfigManager[AppConfig] = ConfigManager(AppConfig, env_prefix="APP")

result = manager.load(
    "config.yaml",
    cli_overrides=["server.port=9090"],   # optional
)

config: AppConfig = result.config
print(config.server.port)   # 9090 (from CLI override)
print(config.logging.level) # "INFO" (default)
```

`load()` returns a [`LoadResult`](#loadresult) with the typed config, any warnings, and a live metadata index.

### 3. Access effective values as plain attributes

```python
# Values are plain Python types — no wrapper objects.
assert isinstance(config.server.port, int)
assert config.debug is False
```

### 4. Apply runtime overrides (e.g. from a GUI)

```python
manager.set_runtime_override("logging.level", "DEBUG")
# config.logging.level is now "DEBUG" and the metadata index is updated.

manager.clear_runtime_override("logging.level")  # revert
manager.clear_all_runtime_overrides()             # revert all
```

### 5. Save compact overrides

Only fields that differ from their defaults are written. Fields reverted to defaults are removed from the file.

```python
config.debug = True
config.server.port = 9090

manager.save("config.yaml", config, backup=True)
```

Resulting `config.yaml`:
```yaml
debug: true
server:
  port: 9090
```

---

## Export Modes

The `save()` method supports flexible export modes via two independent boolean flags:
`full` and `include_descriptions`. This allows you to generate configuration files
suited to different use cases.

### Compact overrides (default)

Write only fields that differ from their defaults:

```python
manager.save("config.yaml", config)
# or explicitly:
manager.save("config.yaml", config, full=False, include_descriptions=False)
```

### Full export

Write all settings, including defaults:

```python
manager.save("config.yaml", config, full=True)
```

Useful for generating reference configurations that show all available options.

### Export with descriptions

Add field descriptions as YAML comments. Requires that `load()` has been called first:

```python
# Compact with descriptions
manager.save("config.yaml", config, include_descriptions=True)

# Full with descriptions
manager.save("config.yaml", config, full=True, include_descriptions=True)
```

Example output with descriptions:

```yaml
# Enable debug mode
debug: true
logging:
  # Log verbosity level
  level: INFO
server:
  # Bind address
  host: localhost
  # TCP port
  port: 9090
```

### Line wrapping and long values

The `max_line_length` parameter (default 80) controls wrapping of long descriptions
and string values at word boundaries:

```python
manager.save("config.yaml", config, include_descriptions=True, max_line_length=60)
```

Multi-line descriptions (using `\n` in the description string) are split and
re-wrapped independently:

```python
field_meta(
    default="...",
    description="First line of explanation.\nSecond line continues here."
)
```

Long string values (exceeding `max_line_length`) are automatically formatted as
YAML block scalars for readability.

---

## Value Precedence

Layers are merged in this order (lower → higher priority):

```
DEFAULT < FILE < ENV < CLI < RUNTIME
```

| Layer   | Source                                        |
|---------|-----------------------------------------------|
| DEFAULT | Declared in the dataclass (`field_meta`)      |
| FILE    | YAML file passed to `load()`                  |
| ENV     | Environment variables (`APP_LOGGING_LEVEL`)   |
| CLI     | `cli_overrides` list passed to `load()`       |
| RUNTIME | In-memory via `set_runtime_override()`        |

**Environment variable naming:** `{PREFIX}_{SECTION}_{KEY}` in upper-snake-case.
Example: `logging.level` with prefix `APP` → `APP_LOGGING_LEVEL`.

---

## Validation and Warnings

### Fail-fast validation

`load()` raises `ValidationError` on the first field that violates its constraints.
You can also validate ad-hoc:

```python
manager.validate(config)           # typed config instance
manager.validate({"debug": True})  # or a flat dict
```

Constraint codes:

| Code               | Trigger                                          |
|--------------------|--------------------------------------------------|
| `TYPE_MISMATCH`    | Value type does not match declared field type    |
| `OUT_OF_RANGE`     | Numeric value outside `min_value`/`max_value`    |
| `NULL_NOT_ALLOWED` | `None` value on a `nullable=False` field         |
| `COERCE_FAIL`      | `allow_coerce=True` but conversion failed        |
| `PARSE_ERROR`      | Malformed YAML or malformed CLI `key=value`      |
| `LOCK_CONFLICT`    | Advisory lock could not be acquired on save      |

### Unknown-key warnings

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.

```python
result = manager.load("config.yaml")
for warning in result.warnings:
    print(f"[{warning.code}] {warning.path}: {warning.message}")
```

Warning code `UNKNOWN_KEY` is emitted per unknown leaf path.
An entire unknown subtree (unknown parent) produces a single warning.

---

## LoadResult

```python
@dataclass
class LoadResult[TConfig]:
    config: TConfig                    # typed config object
    warnings: list[WarningItem]        # non-fatal diagnostics
    metadata_index: dict[str, FieldMeta]  # live metadata, keyed by dot-path
```

The `metadata_index` gives you per-field metadata useful for building GUIs:

```python
meta = result.metadata_index["server.port"]
print(meta.effective_value)  # 9090
print(meta.source)           # ValueSource.FILE
print(meta.default_value)    # 8080
print(meta.description)      # "TCP port"
```

---

## field_meta reference

```python
field_meta(
    default=...,            # scalar default value
    default_factory=...,    # zero-argument callable (mutually exclusive with default)
    description="",         # human-readable label
    min_value=None,         # inclusive lower bound (numeric fields only)
    max_value=None,         # inclusive upper bound (numeric fields only)
    nullable=True,          # whether None is a valid value
    never_persist=False,    # exclude from all file writes (e.g. tokens)
    allow_coerce=False,     # attempt safe coercion from env/CLI strings
)
```

---

## Advisory file locking

Enable an advisory lock to prevent concurrent saves from silently overwriting each other:

```python
manager = ConfigManager(AppConfig, use_file_lock=True)
```

When the lock cannot be acquired, `LockConflictError` is raised.
Without locking (default), last-writer-wins semantics apply.

---

## Running the tests

```bash
pip install -e ".[dev]"
pytest
```
