说明:本提交是工作区中此前的未提交工作(在 cf6d4d2 之后产生),**非本次会话所写**,
按用户要求**不跑测试、直接记录变更并推送**。
已完成推送前的基础安全检查:无明文凭据、无大文件、`.env`/`logs/`/`output/` 仍被忽略。
测试状态:**本次未执行测试套件**。
## 一、TTM 股息率的两处残余缺陷 + 卖出复核
起因:用户报告 `600690.SH` 在 2026-07-30 触发清仓、07-31 开盘卖出,实际不该卖。
### 缺陷一:同一除权日的多条「实施」记录被逐行累加
- 成因:`hd_dividend` 写入侧刻意保留全量公告记录,去重键含 `ann_date`,
同一笔分红会有多条「实施」记录落在**同一除权日**;查询侧逐行累加即重复计入。
- 规模:5724 只有现金分红的股票中 **953 只**存在同除权日重复(多出 1313 行)。
- 效果:`600690.SH` 的 `ttm_dps` 长期虚高约一倍(2.46848 vs 真实 1.23424),
窗口到期时又必然回落,把假象放大成一次 −78% 的塌陷。
- 修法:新增 `factor.dividend_yield.dedupe_dividend_events()`,按 `(symbol, ex_date)`
聚合成**一笔经济事件**(金额/股数逐字段取最大 → 收敛「分项 + 合计」;
日期取最晚 → PIT 保守)。三处入口统一调用:`ttm_dps_series`、
`Repo.dividend_events`、`universe/filters/dividend.py`。
### 缺陷二:只看相邻间隔,漏掉「年度 → 中期 → 下一年度」的跳法
- 成因:7.6 的「按后继接管」只看相邻两次除权的间隔。实测 `600690.SH`:
FY2024 年度 2025-07-25、FY2025 中期 2025-11-07、FY2025 年度 2026-08-21。
105 天的间隔使前两笔被判为「年内多次分红」而互不取代,392 天又超过 `365+45`
→ **2026-07-25~08-21 出现 28 天空窗**,可见现金只剩 0.26920。
- 修法:`ttm_dps_series` 的覆盖窗口由「按相邻间隔」升级为「**按财年 `end_date`**」:
① 后继接管(保留 7.6 行为,阈值 `ttm_days - grace_days` = 320 天);
② **跨财年补位**:每个财年最后一笔 → 下一财年最后一笔入场,上限 `365 + grace`;
③ **末笔宽限兜底**:无后继时覆盖 `365 + grace`(真停发仍如实归零)。
- 验收(作者实测):600690 在 2026-07-27 的 `ttm_dps` 由 0.53840 变为 **1.23424**,
股息率 5.30%、历史分位 92.98%,**不再触发 P25 清仓**。
### 缺陷三(设计缺口):卖出只认「已除权的现金」,不认「已公告的分红」
- 成因:FY2025 年度分红 0.89151 的**实施公告日是 2026-06-25**,除权日 2026-08-21。
TTM 现金口径看不到它 → 「股息率处于历史低位」在字面上为真,
实际描述的是**现金流时点**而非分红能力恶化。
- 修法:新增 `entry/exit.confirm`(`enabled` / `min_ratio` / `announce_lookback_days`):
若「已公告未除权」的分红说明股息率本应更高,且
`TTM ÷ (TTM + 已公告未除权) < min_ratio`,则判定**未确认**:
保持仓位并记录 `EXIT_UNCONFIRMED`(不进成交流水)。真降息不会命中。
## 二、公司行为的三处静默错误(分红/送转/配股口径)
### 问题一:纯送转被整行丢弃(凭空亏损)
- `_apply_dividends` 在算送股**之前**就按 `cash_div_tax <= 0` 整行 `continue`,
于是「10 送 10」这类**无现金分红**的送转完全不调股数 ——
而价格是不复权价、除权日照常腰斩 → 记出一笔不存在的亏损。
- 规模:全库「实施且 `stk_div > 0`」13,038 行,其中**纯送转 3,268 行**;
高股息池成员在 2015-2026 区间内 **824 笔**(如 `000793.SZ` 每 10 股转增 12 股,
单笔约 −54% 的该持仓市值)。
- 修法:现金与送转**各自独立判断**,只有「既无现金也无送转」才跳过;
并把 `stk_bo_rate`/`stk_co_rate` 写入分红台账留痕。
### 问题二:同一除权日的重复记录被重复入账
- 全库 **1401 组**同 `(symbol, ex_date)` 的多条实施记录(1240 组字段相同;
96 组报告期不同、122 组金额不同)。实测 `002352.SZ 2024-11-07` 同时有
0.4 / 1.0 / 1.4 三条,而 1.4 = 0.4 + 1.0 是合计口径 → 逐行累加会放大两三倍。
- 修法:复用 `dedupe_dividend_events`(与缺陷一同一个函数)。
### 问题三:分红再投资的声明与行为不一致
- 引擎实际行为一直是「分红现金回到与初始资金同一个 `cash` 变量,
下次调仓按目标权重再配置」= `reinvest` + `portfolio_rebalance`;
但 `backtest.yml` 写的是 `same_stock_next_open`,于是每次 run 都声明
「未实现分红再投资规则,分红留存为现金」,让人误以为分红不可再投资。
- 修法:配置改为已实现组合 `cash_mode: reinvest` + `reinvest_rule: portfolio_rebalance`;
声明逻辑抽成 `dividend_handling_notes()`,**逐档取值都有单测**对应
(`hold`/`cash_out`/`same_stock_next_open`/`handle_stock_dividend=false`/配股
才声明未实现)。顺带接线一直是**死字段**的 `dividend.apply_dividend_tax`。
## 三、新增:回测层面的排除行业清单(黑名单)
- 位置与语义:`config/backtest.yml: universe_exclusions.industries` ——
「**这次回测**特意不要哪些行业」(研究口径),
与 `config/universe.yml`(策略选股定义)**叠加取并集**,只做减法。
三种回测模式(single / walkforward / daily)一律生效。
- 最大的坑:数据库 `stock.industry` 里**没有「房地产业」**,它被拆成四个名字,
写「房地产」或「房地产业」**一只都排除不掉**:
`全国地产` 26 只 + `区域地产` 43 只 + `房产服务` 13 只 + `园区开发` 14 只 = **96 只**(1.6%)。
因此 `MarketFilter` 首次求值时拿名单与表内实际取值核对,
**写错名字直接抛 `ConfigError`**(并按字符重合度提示最接近的真实取值)。
- 接线:三个入口都走生效后的配置;并修掉一处缓存陷阱(配置变更后缓存未失效)。
- 新增测试锁定它。
## 四、其它
- `src/hdiv/core/config.py`:新增配置模型(排除行业、卖出复核等,+102 行)
- `src/hdiv/data/repo.py`(+45)、`src/hdiv/backtest/engine.py`(+121)、
`backtest/daily.py`、`backtest/walk_forward.py`、`web/service.py`、
`report/universe_report.py` 相应接线
- 测试:新增 `tests/test_dividend_fiscal_year.py`;扩充
`test_backtest.py` / `test_config.py` / `test_daily.py` /
`test_dividend_smoothing.py` / `test_universe.py`
- `tools/diag_dividend_artifact.py`:诊断脚本与上述修复对齐
- 文档:`docs/implementation-status.md` 新增 §7.6b / §7.7 / §11;
`docs/user-guide.md` 新增排除行业清单说明
## 待验证
本次按要求**未执行测试**。上述「实测/验收」数字均引自文档中作者自己的记录,
非本次会话验证结果。建议合入后跑一次全量测试(注意:daily 的 DB 标记测试
因 `hd_cashflow` 无界扫描仍然很慢)。
418 lines
14 KiB
Python
418 lines
14 KiB
Python
"""配置加载与严格校验测试(对应 development-plan.md P3 验收:非法配置 100% 被拒)。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
|
||
import pytest
|
||
import yaml
|
||
|
||
from hdiv.core.config import (
|
||
config_hash,
|
||
load_all,
|
||
load_config,
|
||
resolve_strategy_path,
|
||
)
|
||
from hdiv.core.errors import ConfigError, ConfigNotFound
|
||
from hdiv.core.paths import config_dir
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 正向:全部配置文件可加载
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"name", ["datasource", "universe", "profile", "cost", "backtest", "report"]
|
||
)
|
||
def test_all_configs_load(name: str) -> None:
|
||
cfg = load_config(name)
|
||
assert cfg is not None
|
||
assert config_hash(cfg)
|
||
|
||
|
||
def test_load_all() -> None:
|
||
cfgs = load_all()
|
||
assert set(cfgs) == {"datasource", "universe", "profile", "cost", "backtest", "report"}
|
||
|
||
|
||
def test_strategy_config_loads() -> None:
|
||
s = load_config("strategy:high_dividend_v1.yml")
|
||
assert s.strategy.id == "HD_MR_V1"
|
||
assert s.entry.yield_percentile == 75
|
||
assert s.exit.yield_percentile == 25
|
||
# 参数扁平化用于 hd_strategy_param
|
||
pm = s.param_map()
|
||
assert pm["strategy.id"] == "HD_MR_V1"
|
||
assert pm["entry.yield_percentile"] == 75
|
||
assert len(pm) > 20
|
||
|
||
|
||
def test_strategy_path_resolution_variants() -> None:
|
||
names = [
|
||
"high_dividend_v1.yml",
|
||
"strategy/high_dividend_v1.yml",
|
||
"config/strategy/high_dividend_v1.yml",
|
||
]
|
||
paths = {resolve_strategy_path(n) for n in names}
|
||
assert len(paths) == 1
|
||
assert paths.pop().is_file()
|
||
|
||
|
||
def test_resolve_strategy_path_missing() -> None:
|
||
with pytest.raises(ConfigNotFound):
|
||
resolve_strategy_path("不存在.yml")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 反向:拼写错误必须报错(不静默取默认值)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _load_modified(name: str, mutate) -> None:
|
||
"""把配置改坏后写入临时文件并加载,断言抛错。"""
|
||
raw = yaml.safe_load((config_dir() / f"{name}.yml").read_text(encoding="utf-8"))
|
||
mutate(raw)
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
with tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False, encoding="utf-8") as fh:
|
||
yaml.safe_dump(raw, fh, allow_unicode=True)
|
||
tmp = Path(fh.name)
|
||
try:
|
||
with pytest.raises(ConfigError):
|
||
load_config(name, path=tmp)
|
||
finally:
|
||
tmp.unlink(missing_ok=True)
|
||
|
||
|
||
def test_unknown_field_rejected() -> None:
|
||
_load_modified("universe", lambda r: r["market"].update({"min_market_capp": 1}))
|
||
|
||
|
||
def test_wrong_type_rejected() -> None:
|
||
_load_modified("universe", lambda r: r["market"].update({"min_listing_years": "十年"}))
|
||
|
||
|
||
def test_unknown_top_level_section_rejected() -> None:
|
||
_load_modified("universe", lambda r: r.update({"bogus_section": {"a": 1}}))
|
||
|
||
|
||
def test_bad_enum_rejected() -> None:
|
||
_load_modified("cost", lambda r: r["slippage"].update({"mode": "percentage"}))
|
||
|
||
|
||
def test_dividend_years_consistency_rejected() -> None:
|
||
def mutate(r: dict) -> None:
|
||
r["dividend"]["min_continuous_years"] = 10
|
||
r["dividend"]["window_years"] = 6
|
||
|
||
_load_modified("universe", mutate)
|
||
|
||
|
||
def test_negative_capital_rejected() -> None:
|
||
_load_modified("backtest", lambda r: r["capital"].update({"initial": -1}))
|
||
|
||
|
||
def test_period_order_rejected() -> None:
|
||
_load_modified("backtest", lambda r: r["period"].update({"end": "2010-01-01"}))
|
||
|
||
|
||
def test_freeze_params_disabled_rejected() -> None:
|
||
"""plan.md §25:测试阶段禁止重新调参 —— 关闭该开关必须被拒绝。"""
|
||
_load_modified(
|
||
"backtest", lambda r: r["walk_forward"].update({"freeze_params_in_test": False})
|
||
)
|
||
|
||
|
||
def test_duplicate_benchmark_rejected() -> None:
|
||
def mutate(r: dict) -> None:
|
||
r["benchmark"] = [
|
||
{"code": "000300.SH", "name": "沪深300"},
|
||
{"code": "000300.SH", "name": "重复"},
|
||
]
|
||
|
||
_load_modified("backtest", mutate)
|
||
|
||
|
||
def test_profile_percentiles_must_be_sorted() -> None:
|
||
_load_modified("profile", lambda r: r.update({"percentiles": [90, 10, 50]}))
|
||
|
||
|
||
def test_profile_duplicate_metric_rejected() -> None:
|
||
_load_modified(
|
||
"profile", lambda r: r["metrics"].update({"valuation": ["pb", "pb"]})
|
||
)
|
||
|
||
|
||
def test_composite_weights_must_sum_to_one() -> None:
|
||
def mutate(r: dict) -> None:
|
||
r["safety_margin"]["mode"] = "composite"
|
||
r["safety_margin"]["weights"] = {"dividend_yield": 0.5, "valuation": 0.2}
|
||
|
||
_load_modified("profile", mutate)
|
||
|
||
|
||
def test_datasource_readonly_writeoverlap_rejected() -> None:
|
||
def mutate(r: dict) -> None:
|
||
r["database"]["allow_write_tables"] = ["stock"]
|
||
|
||
_load_modified("datasource", mutate)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 策略语义校验
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _load_strategy_mutated(mutate) -> None:
|
||
raw = yaml.safe_load(
|
||
(config_dir() / "strategy" / "high_dividend_v1.yml").read_text(encoding="utf-8")
|
||
)
|
||
mutate(raw)
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
with tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False, encoding="utf-8") as fh:
|
||
yaml.safe_dump(raw, fh, allow_unicode=True)
|
||
tmp = Path(fh.name)
|
||
try:
|
||
with pytest.raises(ConfigError):
|
||
load_config(f"strategy:{tmp}")
|
||
finally:
|
||
tmp.unlink(missing_ok=True)
|
||
|
||
|
||
def test_strategy_entry_must_exceed_exit() -> None:
|
||
_load_strategy_mutated(lambda r: r["entry"].update({"yield_percentile": 20}))
|
||
|
||
|
||
def test_strategy_entry_percentile_range() -> None:
|
||
_load_strategy_mutated(lambda r: r["entry"].update({"yield_percentile": 150}))
|
||
|
||
|
||
def test_strategy_scale_in_must_be_ascending() -> None:
|
||
def mutate(r: dict) -> None:
|
||
r["entry"]["scale_in"] = [
|
||
{"percentile": 80, "weight": 0.25},
|
||
{"percentile": 75, "weight": 0.50},
|
||
]
|
||
|
||
_load_strategy_mutated(mutate)
|
||
|
||
|
||
def test_strategy_scale_out_must_descend() -> None:
|
||
def mutate(r: dict) -> None:
|
||
r["exit"]["scale_out"] = [
|
||
{"percentile": 25, "weight": 0.50},
|
||
{"percentile": 50, "weight": 0.00},
|
||
]
|
||
|
||
_load_strategy_mutated(mutate)
|
||
|
||
|
||
def test_strategy_scale_out_last_must_be_zero() -> None:
|
||
def mutate(r: dict) -> None:
|
||
r["exit"]["scale_out"] = [
|
||
{"percentile": 50, "weight": 0.50},
|
||
{"percentile": 25, "weight": 0.30},
|
||
]
|
||
|
||
_load_strategy_mutated(mutate)
|
||
|
||
|
||
def test_strategy_position_bounds() -> None:
|
||
_load_strategy_mutated(lambda r: r["position"].update({"max_position": 1.5}))
|
||
|
||
|
||
def test_strategy_bad_status() -> None:
|
||
_load_strategy_mutated(lambda r: r["strategy"].update({"status": "RUNNING"}))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 实时画像闸门(profile_gate)配置校验
|
||
#
|
||
# 这些必须挡在配置期:写错指标名若拖到运行时,只会表现为
|
||
# 「无法验证 → 保守不买」,即策略悄悄再也不交易,极难定位。
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_profile_gate_unknown_metric_rejected() -> None:
|
||
def mutate(r: dict) -> None:
|
||
r["entry"]["profile_gate"]["rules"] = [
|
||
{"metric": "not_a_metric", "op": ">=", "value": 1.0}
|
||
]
|
||
|
||
_load_strategy_mutated(mutate)
|
||
|
||
|
||
def test_profile_gate_percentile_on_scalar_metric_rejected() -> None:
|
||
"""标量指标没有历史分位,不能用 current_percentile。"""
|
||
def mutate(r: dict) -> None:
|
||
r["entry"]["profile_gate"]["rules"] = [
|
||
{"metric": "payout_ratio", "stat": "current_percentile",
|
||
"op": "<=", "value": 1.0}
|
||
]
|
||
|
||
_load_strategy_mutated(mutate)
|
||
|
||
|
||
def test_profile_gate_enabled_without_rules_rejected() -> None:
|
||
def mutate(r: dict) -> None:
|
||
r["entry"]["profile_gate"] = {"enabled": True, "rules": []}
|
||
|
||
_load_strategy_mutated(mutate)
|
||
|
||
|
||
def test_profile_gate_bad_operator_rejected() -> None:
|
||
def mutate(r: dict) -> None:
|
||
r["entry"]["profile_gate"]["rules"] = [
|
||
{"metric": "dv_yield", "op": "~=", "value": 1.0}
|
||
]
|
||
|
||
_load_strategy_mutated(mutate)
|
||
|
||
|
||
def test_profile_gate_coverage_bounds() -> None:
|
||
"""min_window_coverage 必须落在 [0,1]:1.0 = 必须完整覆盖名义窗口。"""
|
||
def mutate(r: dict) -> None:
|
||
r["entry"]["profile_gate"]["min_window_coverage"] = 1.5
|
||
|
||
_load_strategy_mutated(mutate)
|
||
|
||
|
||
def test_profile_gate_disabled_without_rules_is_allowed() -> None:
|
||
"""默认(未启用、无规则)必须能正常加载 —— 否则所有历史配置都会失效。"""
|
||
raw = yaml.safe_load(
|
||
(config_dir() / "strategy" / "high_dividend_v1.yml").read_text(encoding="utf-8")
|
||
)
|
||
raw["entry"]["profile_gate"] = {"enabled": False, "rules": []}
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
with tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False, encoding="utf-8") as fh:
|
||
yaml.safe_dump(raw, fh, allow_unicode=True)
|
||
tmp = Path(fh.name)
|
||
try:
|
||
cfg = load_config(f"strategy:{tmp}")
|
||
assert cfg.entry.profile_gate.enabled is False
|
||
finally:
|
||
tmp.unlink(missing_ok=True)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 可复现性:config_hash 稳定性
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_config_hash_is_stable_and_sensitive() -> None:
|
||
a = load_config("universe")
|
||
b = load_config("universe")
|
||
assert config_hash(a) == config_hash(b)
|
||
|
||
raw = yaml.safe_load((config_dir() / "universe.yml").read_text(encoding="utf-8"))
|
||
changed = copy.deepcopy(raw)
|
||
changed["dividend"]["min_dividend_yield"] = 0.035
|
||
assert config_hash(raw) != config_hash(changed)
|
||
|
||
|
||
def test_config_hash_ignores_key_order() -> None:
|
||
raw = yaml.safe_load((config_dir() / "universe.yml").read_text(encoding="utf-8"))
|
||
reordered = {k: raw[k] for k in reversed(list(raw))}
|
||
assert config_hash(raw) == config_hash(reordered)
|
||
|
||
|
||
def test_missing_config_raises() -> None:
|
||
with pytest.raises(ConfigNotFound):
|
||
load_config("universe", path="/nonexistent/nope.yml")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 回测层面的行业排除清单(backtest.yml: universe_exclusions)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_backtest_config_excludes_real_estate_industries() -> None:
|
||
"""当前生效配置必须真的在排除房地产,且四个口径齐全。
|
||
|
||
数据库里没有「房地产业」这个取值,它被拆成四个行业名;
|
||
少写一条就少排一类(例如只写「全国地产」会漏掉全部区域地产公司)。
|
||
"""
|
||
bt = load_config("backtest")
|
||
excl = set(bt.universe_exclusions.industries)
|
||
assert {"全国地产", "区域地产", "房产服务", "园区开发"} <= excl
|
||
|
||
|
||
def test_resolved_universe_applies_backtest_exclusions() -> None:
|
||
from hdiv.core.config import BacktestConfig, UniverseConfig
|
||
|
||
bt = BacktestConfig.model_validate(
|
||
{
|
||
"period": {"start": "2015-01-01", "end": "latest"},
|
||
"universe_exclusions": {"industries": ["全国地产", "区域地产"]},
|
||
}
|
||
)
|
||
uni = UniverseConfig.model_validate({"name": "t"})
|
||
merged = bt.resolved_universe(uni)
|
||
assert merged.industry_exclusions == ["全国地产", "区域地产"]
|
||
assert uni.industry_exclusions == [], "不得就地修改传入的筛选配置"
|
||
|
||
|
||
def test_resolved_universe_is_a_union_not_an_override() -> None:
|
||
"""universe.yml 自带的排除项不能被 backtest.yml 顶掉(只做减法)。"""
|
||
from hdiv.core.config import BacktestConfig, UniverseConfig
|
||
|
||
bt = BacktestConfig.model_validate(
|
||
{
|
||
"period": {"start": "2015-01-01", "end": "latest"},
|
||
"universe_exclusions": {"industries": ["房产服务"]},
|
||
}
|
||
)
|
||
uni = UniverseConfig.model_validate(
|
||
{"name": "t", "industry_exclusions": ["园区开发"]}
|
||
)
|
||
merged = bt.resolved_universe(uni)
|
||
assert merged.industry_exclusions == ["园区开发", "房产服务"]
|
||
|
||
|
||
def test_resolved_universe_without_exclusions_returns_same_object() -> None:
|
||
"""不配排除清单 → 原样返回,保证「不配 = 行为与改动前一致」。"""
|
||
from hdiv.core.config import BacktestConfig, UniverseConfig
|
||
|
||
bt = BacktestConfig.model_validate({"period": {"start": "2015-01-01"}})
|
||
uni = UniverseConfig.model_validate({"name": "t"})
|
||
assert bt.resolved_universe(uni) is uni
|
||
|
||
|
||
def test_duplicate_exclusion_industry_rejected() -> None:
|
||
raw = yaml.safe_load((config_dir() / "backtest.yml").read_text(encoding="utf-8"))
|
||
raw["universe_exclusions"] = {"industries": ["全国地产", "全国地产"]}
|
||
with pytest.raises(ConfigError):
|
||
_validate_tmp("backtest", raw)
|
||
|
||
|
||
def test_unknown_exclusion_field_rejected() -> None:
|
||
"""字段名写错必须报错,不能静默忽略(本项目的一贯纪律)。"""
|
||
raw = yaml.safe_load((config_dir() / "backtest.yml").read_text(encoding="utf-8"))
|
||
raw["universe_exclusions"] = {"industry": ["全国地产"]}
|
||
with pytest.raises(ConfigError):
|
||
_validate_tmp("backtest", raw)
|
||
|
||
|
||
def _validate_tmp(name: str, raw: dict) -> object:
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
with tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False, encoding="utf-8") as fh:
|
||
yaml.safe_dump(raw, fh, allow_unicode=True)
|
||
tmp = Path(fh.name)
|
||
try:
|
||
return load_config(name, path=tmp)
|
||
finally:
|
||
tmp.unlink(missing_ok=True)
|
||
|
||
|
||
def test_unknown_config_name_raises() -> None:
|
||
with pytest.raises(ConfigError):
|
||
load_config("no_such_config")
|