Metadata-Version: 2.4
Name: conf_base
Version: 0.4.0
Summary: 一个通过类和属性构建的Python配置管理框架，更加便捷与安全的操作与存储配置数据。
Home-page: https://gitee.com/kuangjianke/conf_base
Author: 加点盐
Author-email: 1431705288@qq.com
License: MIT
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: filelock
Requires-Dist: filelock; extra == "filelock"
Provides-Extra: aes
Requires-Dist: cryptography; extra == "aes"
Provides-Extra: yaml
Requires-Dist: PyYAML; extra == "yaml"
Provides-Extra: toml
Requires-Dist: tomli-w; extra == "toml"
Requires-Dist: tomli; python_version < "3.11" and extra == "toml"
Provides-Extra: test
Requires-Dist: pytest; extra == "test"
Requires-Dist: pytest-cov; extra == "test"
Provides-Extra: type
Requires-Dist: mypy; extra == "type"
Provides-Extra: dev
Requires-Dist: filelock; extra == "dev"
Requires-Dist: cryptography; extra == "dev"
Requires-Dist: PyYAML; extra == "dev"
Requires-Dist: tomli-w; extra == "dev"
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Requires-Dist: mypy; extra == "dev"
Requires-Dist: build; extra == "dev"
Requires-Dist: twine; extra == "dev"
Dynamic: author
Dynamic: author-email
Dynamic: classifier
Dynamic: description
Dynamic: description-content-type
Dynamic: home-page
Dynamic: license
Dynamic: license-file
Dynamic: provides-extra
Dynamic: requires-python
Dynamic: summary

# ConfBase

[![Python](https://img.shields.io/badge/python-%3E%3D3.8-blue)](https://www.python.org/)
[![CI](https://github.com/DashSalter/conf_base/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/DashSalter/conf_base/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![Tests](https://img.shields.io/badge/tests-81%20passed-brightgreen)](test/)
[![Coverage](https://img.shields.io/badge/coverage-85%25-brightgreen)](test/)

用**类属性和 Section** 声明配置。一个配置项就是一个类属性，读写时自动完成类型校验、校验函数检查和落盘，不用再手写读写文件的样板代码。

## 适用场景

**适合**

- 桌面应用 / 游戏客户端的本地配置
- CLI 工具、脚本的参数与偏好设置
- 单机服务或小型 Web 服务的配置文件
- 配置需要 JSON / YAML / TOML 多种格式
- 需要配置加密、防篡改签名、版本迁移
- 需要批量修改、变更回调、按分组重置

**不适合**

| 场景 | 更合适的选择 |
|---|---|
| 分布式配置中心、多节点实时同步 | Nacos / Apollo / Consul / etcd |
| Redis / MySQL 等远程存储 | 配置中心，或自行实现 `BaseFormat` / `BaseCipher` 适配 |
| 每秒级高频写入 | 数据库、配置中心 |
| 超大配置或二进制数据 | 数据库、对象存储 |
| 复杂嵌套模型、字段级校验 | pydantic / pydantic-settings |
| 多用户并发编辑同一文件 | 数据库 + 乐观锁 |
| 运行时动态增删配置项 | 动态配置中心 |
| 配置类之间继承复用配置项 | 独立配置类 + 共享默认值常量 |
| 配置审计、权限、回滚 | 配置中心、密钥管理服务 |

> ConfBase 是「本地文件 + 声明式配置」的轻量工具，不是配置中心客户端，也不是 pydantic 的替代品。

## 安装

```bash
pip install conf_base
```

按需安装可选依赖：

| 功能 | 安装命令 |
|---|---|
| YAML 格式 | `pip install conf_base[yaml]` |
| TOML 格式 | `pip install conf_base[toml]` |
| 加密 / 签名 | `pip install conf_base[aes]` |
| 多进程文件锁 | `pip install conf_base[filelock]` |

## 快速开始

以游戏客户端配置为例：

```python
from conf_base import ConfigBase, Section


class GameConfig(ConfigBase):
    # 配置文件路径；格式按后缀推断（无后缀时默认 JSON）
    _path = 'config/game.json'

    # 画面
    screen = Section('Screen')
    width = screen.field(default=1280, validator=lambda v: v >= 800)
    height = screen.field(default=720)
    fullscreen = screen.field(default=False)

    # 声音
    audio = Section('Audio')
    bgm_volume = audio.field(default=0.8, validator=lambda v: 0 <= v <= 1)
    mute = audio.field(default=False)

    # 存档
    save = Section('Save')
    unlocked_levels = save.field(default=[1])
```

类定义时自动创建 `config/game.json`，并把缺失的默认值写入文件：

```json
{
    "_schema_version": 1,
    "Screen": {
        "width": 1280,
        "height": 720,
        "fullscreen": false
    },
    "Audio": {
        "bgm_volume": 0.8,
        "mute": false
    },
    "Save": {
        "unlocked_levels": [
            1
        ]
    }
}
```

结构一目了然：顶层是 Section 名，第二层是配置项名，值与代码中的 `default` 完全对应。`_schema_version` 是内部字段，用于配置版本迁移，`to_dict()` 不会导出它。

之后像操作普通属性一样读写：

```python
# 读取
GameConfig.width.value                      # 1280
GameConfig.screen.current_options           # {'width': 1280, 'height': 720, ...}

# 修改：自动校验类型与 validator，并落盘
GameConfig.width.value = 1920
GameConfig.bgm_volume.value = 0.5
GameConfig.bgm_volume.value = 1.5           # ❌ ConfigValueError（超出 0~1）

# 类型专属快捷操作
GameConfig.fullscreen.toggle()              # False -> True
GameConfig.width.increment(100)             # 1920 -> 2020
GameConfig.unlocked_levels.append(2)        # [1] -> [1, 2]

# 批量修改：所有配置项只落盘一次
GameConfig.audio.update(bgm_volume=0.6, mute=True)

# 全量导出
GameConfig.to_dict()
# {'Screen': {...}, 'Audio': {...}, 'Save': {...}}
```

## 核心用法

### 1. 声明配置项

`Section.field()` 的参数：

```python
port = server.field(
    default=8080,                          # 默认值，同时决定配置值的类型
    option='port',                         # 配置键名，省略时自动使用左侧变量名
    validator=lambda v: 1 <= v <= 65535,   # 可选，返回 False 时拒绝写入
    read_only=False,                       # 可选，True 时禁止运行时修改
    env_key='APP_PORT',                    # 可选，允许环境变量覆盖
)
# 本例 option 与变量名相同，也可省略；需要不同键名时改成 option='listen_port'
# IDE 能通过 __set_name__ 自动推导属性名，field() 返回类型感知的 Proxy 对象（BoolProxy/NumberProxy/ListProxy/DictProxy），提供了 toggle()、increment()、append() 等语义化操作
```

配置值的类型始终锁定为 `default` 的类型，写入其他类型会抛 `ConfigTypeError`。

### 2. 读取

四种方式结果完全一致：

```python
GameConfig.width.value                  # 描述符方式
GameConfig.screen['width']              # Section 索引
GameConfig.screen.width                 # Section 属性
GameConfig.screen.current_options       # 整个 Section
```

批量导出：

```python
GameConfig.screen.default_options   # 默认值字典
GameConfig.to_dict()                # 全部配置的深拷贝
```

### 3. 修改

```python
# 单项
GameConfig.width.value = 1920
GameConfig.width.set_value(1920)        # 等价写法

# 同一 Section 批量写（一次落盘）
GameConfig.audio.update(bgm_volume=0.6, mute=True)

# 任意操作合并落盘
with GameConfig.audio.batch():
    GameConfig.bgm_volume.value = 0.4
    GameConfig.mute.toggle()
```

> 循环里连续修改几十次配置时，用 `batch()` 包起来可以把几十次磁盘写入合并为一次。

### 4. 类型代理

配置项根据默认值类型自动获得专属操作：

| 默认值类型 | 代理类 | 专属方法 |
|---|---|---|
| `bool` | `BoolProxy` | `toggle()` |
| `int` / `float` | `NumberProxy` | `increment(n=1)` |
| `list` | `ListProxy` | `append` `extend` `insert` `remove` `pop` `clear` `sort` `reverse`，以及索引读写 |
| `dict` | `DictProxy` | `pop` `update` `setdefault` `clear` `get` `keys` `values` `items`，以及键读写 |
| 其他 | `PropertyProxy` | `value` `default` `reset` `is_default` `on_change` |

所有代理都提供 `value`、`reset()`、`is_default`、`on_change()`，且写操作全部经过校验并落盘。

```python
GameConfig.unlocked_levels.append(2)
GameConfig.unlocked_levels.remove(1)
GameConfig.unlocked_levels[0] = 3
GameConfig.screen['width'] = 1600
```

> `field()` 的返回值带有泛型类型信息，IDE 会把 `GameConfig.width` 推断为 `NumberProxy`，可直接补全 `increment()`。

### 5. 校验与只读

写入时依次检查：环境变量覆盖 → read_only → 类型 → validator。

```python
net = Section('Net')
port = net.field(default=8080, validator=lambda v: 1 <= v <= 65535)
token = net.field(default='', read_only=True)

port.value = 'big'      # ❌ ConfigTypeError：类型不匹配
port.value = 999999     # ❌ ConfigValueError：validator 不通过
token.value = 'new'     # ❌ WriteError：只读
```

validator 返回 `False` 时抛出 `ConfigValueError`；validator 自身抛出异常时同样包装为 `ConfigValueError`，原始异常保存在 `__cause__`，不会裸抛到业务代码。

### 6. 变更回调

```python
@GameConfig.bgm_volume.on_change
def on_volume_change(section, option, value):
    print(f'{section}.{option} 变为 {value}')


# 普通调用也可以
GameConfig.width.on_change(lambda section, option, value: save_now())
```

回调签名为 `callback(section, option, value)`，在 `value=`、`set_value()`、`update()`、`reset()`、代理写操作后触发。

### 7. 重置

```python
GameConfig.width.reset()        # 单项恢复默认值
GameConfig.screen.reset()       # 整个 Section 批量恢复，一次落盘
```

## 多组同构数据

同一套数据格式需要多份实例时（例如多个存档、多套关卡数据），声明多个独立 Section：

```python
DEFAULT_LEVEL = {'score': 0, 'stars': 0, 'cleared': False}

class GameConfig(ConfigBase):
    _path = 'config/game.json'

    level1 = Section('Level1')
    data1 = level1.field(default=dict(DEFAULT_LEVEL), option='data')

    level2 = Section('Level2')
    data2 = level2.field(default=dict(DEFAULT_LEVEL), option='data')


GameConfig.data1['score'] = 1200
GameConfig.data2['score'] = 800

GameConfig.level1.reset()       # 只重置 Level1，Level2 不受影响
```

`dict(DEFAULT_LEVEL)` 保证每组的默认值互不共享。

## 环境变量覆盖

部署时常用环境变量注入配置，优先级为：

```
环境变量  >  配置文件  >  默认值
```

```python
class ServerConfig(ConfigBase):
    _path = 'config/server.enc'
    _cipher = AesGcmCipher()          # 密钥省略时读取 CONF_BASE_KEY

    net = Section('Net')
    host = net.field(default='0.0.0.0', env_key='APP_HOST')
    port = net.field(default=8080, env_key='APP_PORT')
    debug = net.field(default=False, env_key='APP_DEBUG')
    db_pass = net.field(default='', env_key='APP_DB_PASS', read_only=True)
```

环境变量永远是字符串，框架按 `default` 的类型自动解析：

| 默认值类型 | 环境变量示例 | 解析结果 |
|---|---|---|
| `int` | `"8080"` | `8080` |
| `float` | `"0.5"` | `0.5` |
| `bool` | `"true"` / `"1"` / `"yes"` | `True` |
| `bool` | `"false"` / `"0"` / `"no"` | `False` |
| `list` | `'["10.0.0.1", "10.0.0.2"]'` | 列表（JSON 解析） |
| `dict` | `'{"host": "db.local"}'` | 字典（JSON 解析） |
| `str` | `"hello"` | `"hello"` |

解析失败会立即抛出 `ConfigValueError`。`list` / `dict` 必须使用 JSON 语法，例如 `APP_HOSTS='["10.0.0.1","10.0.0.2"]'`。

`default=None` 的配置项无法从 `default` 推断类型，因此不支持 `env_key`；一旦设置对应环境变量，读取时会抛出 `ConfigValueError`。

被环境变量覆盖的配置项禁止回写文件，避免把部署注入的值写进配置：

```python
ServerConfig.port.value = 9090   # ❌ WriteError：由 APP_PORT 覆盖
ServerConfig.port.has_env_override   # True
```

## 存储格式与加密

存储拆成两个正交的部分，分别用 `_format` 与 `_cipher` 配置：

```
写入：dict --format.serialize--> text --cipher.encrypt--> file
读取：file --cipher.decrypt--> text --format.deserialize--> dict
```

| 类 | 角色 | 说明 | 依赖 |
|---|---|---|---|
| `JsonFormat` | 格式 | 明文 JSON，默认 | 无 |
| `YamlFormat` | 格式 | 明文 YAML，按原生格式落盘 | `conf_base[yaml]` |
| `TomlFormat` | 格式 | 明文 TOML，按原生格式落盘 | `conf_base[toml]` |
| `NoCipher` | 加密 | 不加密，默认 | 无 |
| `HmacCipher` | 加密 | 明文 + HMAC-SHA256 签名，可读但不可改 | 无 |
| `AesCipher` | 加密 | AES-128-CBC + HMAC（Fernet） | `conf_base[aes]` |
| `AesGcmCipher` | 加密 | AES-256-GCM（AEAD），推荐 | `conf_base[aes]` |
| `ChaCha20Poly1305Cipher` | 加密 | ChaCha20-Poly1305（AEAD） | `conf_base[aes]` |

### 路径、格式与加密

```python
from conf_base import AesGcmCipher, ConfigBase, Section, YamlFormat


class AppConfig(ConfigBase):
    _path = 'config/app.yaml'      # 显式路径：原样使用，不做任何改写
    _format = YamlFormat()         # 可省略：按 _path 后缀推断
    _cipher = AesGcmCipher()       # 加密后写入同一路径

    s = Section('Server')
    port = s.field(default=8080)
```

- `_path` 原样使用；未指定时按类名生成 `{类名小写}{格式后缀}{加密后缀}`，如 `appconfig.json`、`appconfig.yaml.enc`
- `_format` 未指定时按后缀推断（`.json` / `.yaml` / `.yml` / `.toml`），无法识别则用 `JsonFormat`
- 路径后缀与显式格式冲突时（如 `_path='app.yaml'` 配 `JsonFormat()`）立即抛 `ValueError`
- `_cipher` 未指定时为 `NoCipher`

| 配置 | 生成的文件名 |
|---|---|
| 全省略 | `appconfig.json` |
| `_format = YamlFormat()` | `appconfig.yaml` |
| `_format = YamlFormat()` + `_cipher = AesGcmCipher()` | `appconfig.yaml.enc` |
| `_path = 'app.conf'` | `app.conf`（后缀无法识别，按 JSON 处理） |

YAML / TOML 写入前会统一校验数据，无法表达的类型会给出明确错误：

| 数据情况 | 行为 |
|---|---|
| `tuple` | 自动转为 `list` |
| 非字符串字典键 | `ConfigValueError`，并指出位置 |
| `set` / `bytes` / 自定义对象 | `ConfigValueError` |
| `None` | YAML 支持；TOML 不支持，抛 `ConfigValueError` |
| `datetime` / `date` / `time` | 原生支持 |

> 文件按所选格式写入，但每次保存都会从内存字典重新序列化，因此手工添加的注释与排版不会保留。

### 签名（内容可读，禁止篡改）

```python
_cipher = HmacCipher(key='signing-key')   # 省略 key 时读取 CONF_BASE_SIGN_KEY
```

签名以注释行附加在文件末尾（`# conf_base-signature: v1:<hex>`），不产生额外文件。加载时先校验签名再解析内容，文件被手工修改、签名缺失或密钥不符都会抛 `DecryptError`。

### 加密（内容不可见，自带完整性校验）

```python
# AES-256-GCM，密钥省略时读取 CONF_BASE_KEY
_cipher = AesGcmCipher(key_id='v1')

# 无 AES 硬件加速的设备
_cipher = ChaCha20Poly1305Cipher()

# 兼容既有数据
_cipher = AesCipher(key='my-app-key')
```

AEAD 加密器每次写入使用随机 nonce，相同明文产生不同密文；密文信封中包含算法与 `key_id`，密钥错误、密文篡改、密钥版本不匹配都会解密失败。

密钥建议：

| 方式 | 写法 |
|---|---|
| 环境变量（推荐） | `AesGcmCipher()`，配合 `CONF_BASE_KEY` |
| 密钥管理服务 | `AesGcmCipher(key=vault.get('app/secret'))` |
| 硬编码 | 仅限本地调试，不要提交到仓库 |

### 自定义格式 / 加密

格式与加密互不影响，按需继承其中一个即可接入任意存储方式：

```python
from typing import Any, Dict

from conf_base import BaseCipher, BaseFormat


class MyFormat(BaseFormat):
    extension = '.dat'

    def serialize(self, data: Dict[str, Any]) -> str:
        ...

    def deserialize(self, text: str) -> Dict[str, Any]:
        ...


class MyCipher(BaseCipher):
    extension = ''          # 自动生成文件名时追加的后缀，不改变文件名则为 ''

    def encrypt(self, text: str) -> str:
        ...

    def decrypt(self, text: str) -> str:
        ...


class AppConfig(ConfigBase):
    _format = MyFormat()
    _cipher = MyCipher()
```

## 配置版本迁移

配置文件会记录 `_schema_version`，启动时按 `_migrate` 中的函数链把旧数据升级到新结构：

```python
def migrate_v1_to_v2(data: dict) -> dict:
    """把 bgm_volume 重命名为 bgm"""
    setting = data.get('Setting', {})
    if 'bgm_volume' in setting:
        setting['bgm'] = setting.pop('bgm_volume')
    return data


class GameConfig(ConfigBase):
    _path = 'config/game.json'
    _schema_version = 2
    _migrate = {1: migrate_v1_to_v2}

    audio = Section('Audio')
    bgm = audio.field(default=1.0)
```

| 情况 | 行为 |
|---|---|
| 新文件首次启动 | 直接按目标版本创建 |
| 文件版本 < 目标版本 | 依次执行迁移函数，完成后写回版本号 |
| 文件版本 = 目标版本 | 不迁移 |
| 文件版本 > 目标版本 | 不迁移，仅同步数据 |
| 缺少某一步迁移函数 | 启动时抛出 `ValueError` |

迁移后新增的配置项不需要在迁移函数里填值，框架会用 `default` 自动补齐。文件中的未知 section / option 默认原样保留，避免误删手工添加的内容；需要与代码严格同步时设置 `_prune_unknown = True`，启动时会删除代码中已不存在的项。

`_schema_version` 是内部字段，`to_dict()` 不会导出。

## 并发与性能

**线程安全**：内置 `threading.RLock`，始终生效。

**多进程**：安装 `filelock` 后自动启用文件锁，避免多个进程同时写同一文件时互相覆盖。

```python
class AppConfig(ConfigBase):
    _use_file_lock = False      # 关闭进程锁（各进程各用各的文件时）
    _lock_timeout = 30.0        # 等待锁的时间，默认 10 秒
```

**外部修改自动重载**：默认配置只在类定义时读取一次，之后全部走内存。需要运维改文件即时生效时开启：

```python
class ServerConfig(ConfigBase):
    _path = 'config/server.json'
    _auto_reload = True         # 读取时比对文件指纹，变化才重新解析
```

参考量级（JSON 文件，约 300 字节）：

| 操作 | 耗时 |
|---|---|
| 单值读取 | ~0.3 µs（纯内存） |
| `auto_reload` 单值读取 | ~5 µs（含文件指纹检查） |
| 单次落盘 | ~2 ms |
| `batch()` 合并 3 项写入 | ~2.3 ms |

读取几乎零成本，写入瓶颈在磁盘；高频修改优先用 `batch()`。

## 异常体系

所有异常继承 `ConfBaseError`，可统一捕获：

```python
import conf_base.error as E

try:
    GameConfig.width.value = -1
except E.ConfBaseError as exc:
    print(f'配置错误: {exc}')
```

| 异常 | 触发场景 |
|---|---|
| `ConfBaseError` | 所有框架异常的基类 |
| `ConfigTypeError` | 写入类型与默认值不一致 |
| `ConfigValueError` | validator 校验失败、validator 自身抛异常、环境变量解析失败 |
| `WriteError` | 写入只读配置项，或写入被环境变量覆盖的配置项 |
| `ParseError` | 配置文件损坏，无法解析 |
| `DecryptError` | 解密失败、签名校验失败、文件被篡改 |

配置文件加载阶段，解密 / 签名失败直接抛 `DecryptError`；`DecryptError` 之外的读取或解析问题统一包装为 `ParseError`。原始异常保存在 `__cause__` 中。

## API 速查

### ConfigBase（配置类）

| 成员 | 说明 |
|---|---|
| `_path` | 配置文件路径，省略时按类名生成 |
| `_format` | `JsonFormat` / `YamlFormat` / `TomlFormat`，省略时按路径后缀推断 |
| `_cipher` | 加密器，省略时为 `NoCipher` |
| `_auto_reload` | 读取时检查文件指纹，变化后重新加载，默认 `False` |
| `_prune_unknown` | 启动时删除代码中不存在的 section / option，默认 `False`（保留） |
| `_use_file_lock` / `_lock_timeout` | 进程锁开关与超时，默认 `True` / `10.0` |
| `_schema_version` / `_migrate` | 目标版本号与迁移函数字典 |
| `to_dict()` | 导出全部配置的深拷贝 |
| `config_node` | 已注册的 section → option 列表 |

### Section

| 成员 | 说明 |
|---|---|
| `field(default='', option=None, validator=None, read_only=False, env_key=None)` | 声明配置项，`option` 省略时使用类属性名 |
| `update(**kwargs)` | 批量写入，一次落盘 |
| `batch()` | 上下文管理器，退出时统一落盘 |
| `reset()` | 重置本 Section 所有配置项 |
| `current_options` / `default_options` | 当前值 / 默认值字典 |
| `section['key']` / `section.key` | 读取单个配置项 |
| `'key' in section` | 判断配置项是否存在 |

### 配置项代理

| 成员 | 说明 |
|---|---|
| `.value` | 当前值，可读写 |
| `.set_value(value)` | 写入值 |
| `.default` | 默认值（只读） |
| `.reset()` | 恢复默认值 |
| `.is_default` | 当前值是否等于默认值 |
| `.on_change(callback)` | 注册变更回调，支持装饰器 |
| `.section` / `.option` | 所属 Section 与配置项名 |
| `.has_env_override` / `.env_key` | 是否被环境变量覆盖 / 环境变量名 |

## 项目结构

```
conf_base/
├── __init__.py       # 对外导出
├── config_base.py    # ConfigBase 配置类引导
├── config_file.py    # 文件读写 + 批量落盘
├── format.py         # JsonFormat / YamlFormat / TomlFormat
├── cipher.py         # NoCipher / HmacCipher / AesCipher / AEAD
├── descriptor.py     # ConfigProperty + 类型代理
├── section.py        # Section 配置块
└── error.py          # 统一异常体系
```

## 测试与 CI

81 个用例，总覆盖率 85%。本地运行：

```bash
pip install -e .[dev]
python -m pytest test/ -v --cov=conf_base
python -m mypy conf_base
```

GitHub Actions 会在每次 push / PR 时执行 Python 3.8–3.12 的测试矩阵、`mypy` 严格检查与打包验证。

## License

MIT License
