- synthgen/config.py: Config, load_config (YAML + --set), dump_config, проверки диапазонов §3 - synthgen/rng.py: rng_for (SHA-256 → Random), stable_hash - synthgen/__main__.py: разбор --config/--set/--out/--force, ошибка конфигурации → код 2 - synthgen/examples/default.yaml: все ключи §3 со значениями по умолчанию - копии из synthetic-group/gen (#35): inn, xlsx, ledger, chart (+80.09, 91.09 из seed arm-core) - тесты: config, inn, ledger; conftest с маркерами parser/slow - .gitignore: out/ - спека: решения 15–16 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
152 lines
6.2 KiB
Python
152 lines
6.2 KiB
Python
"""Настройки генератора (спека §3): YAML-файл + переопределения `--set ключ=значение`.
|
||
|
||
Неизвестный ключ или значение вне диапазона — ConfigError до начала генерации.
|
||
"""
|
||
|
||
import re
|
||
from dataclasses import dataclass, fields
|
||
from decimal import Decimal, InvalidOperation
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import yaml
|
||
|
||
|
||
class ConfigError(ValueError):
|
||
"""Ошибка настроек: неизвестный ключ, неверный тип или значение вне диапазона."""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Config:
|
||
group_size: int = 5
|
||
prefix: str = "SYN"
|
||
periods: int = 4
|
||
start: str = "2025-Q1"
|
||
osv_counterparties_mean: int = 60
|
||
no_inn_companies: int = 1
|
||
vgo_mismatch_share: Decimal = Decimal("0.1")
|
||
seed: int = 1
|
||
out: Path | None = None
|
||
|
||
|
||
_PREFIX_RE = re.compile(r"[0-9A-Za-zА-Яа-яЁё]{2,12}")
|
||
_START_RE = re.compile(r"(\d{4})-Q([1-4])")
|
||
|
||
|
||
def _as_int(name: str, value: Any) -> int:
|
||
if isinstance(value, bool):
|
||
raise ConfigError(f"{name}: ожидается целое число, получено {value!r}")
|
||
if isinstance(value, int):
|
||
return value
|
||
if isinstance(value, str) and re.fullmatch(r"[+-]?\d+", value.strip()):
|
||
return int(value.strip())
|
||
raise ConfigError(f"{name}: ожидается целое число, получено {value!r}")
|
||
|
||
|
||
def _as_str(name: str, value: Any) -> str:
|
||
if isinstance(value, bool):
|
||
raise ConfigError(f"{name}: ожидается строка, получено {value!r}")
|
||
if isinstance(value, (str, int)):
|
||
return str(value)
|
||
raise ConfigError(f"{name}: ожидается строка, получено {value!r}")
|
||
|
||
|
||
def _as_decimal(name: str, value: Any) -> Decimal:
|
||
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
||
raise ConfigError(f"{name}: ожидается число, получено {value!r}")
|
||
try:
|
||
number = Decimal(str(value).strip())
|
||
except InvalidOperation as exc:
|
||
raise ConfigError(f"{name}: ожидается число, получено {value!r}") from exc
|
||
if not number.is_finite():
|
||
raise ConfigError(f"{name}: ожидается конечное число, получено {value!r}")
|
||
return number
|
||
|
||
|
||
def _as_path(name: str, value: Any) -> Path | None:
|
||
if value is None or value == "":
|
||
return None
|
||
if isinstance(value, str):
|
||
return Path(value)
|
||
raise ConfigError(f"{name}: ожидается путь, получено {value!r}")
|
||
|
||
|
||
_COERCERS = {
|
||
"group_size": _as_int,
|
||
"prefix": _as_str,
|
||
"periods": _as_int,
|
||
"start": _as_str,
|
||
"osv_counterparties_mean": _as_int,
|
||
"no_inn_companies": _as_int,
|
||
"vgo_mismatch_share": _as_decimal,
|
||
"seed": _as_int,
|
||
"out": _as_path,
|
||
}
|
||
|
||
|
||
def _validate(cfg: Config) -> None:
|
||
if not 2 <= cfg.group_size <= 40:
|
||
raise ConfigError(f"group_size: вне диапазона 2–40, получено {cfg.group_size}")
|
||
if not _PREFIX_RE.fullmatch(cfg.prefix):
|
||
raise ConfigError(f"prefix: 2–12 букв/цифр без пробелов, получено {cfg.prefix!r}")
|
||
if not 1 <= cfg.periods <= 12:
|
||
raise ConfigError(f"periods: вне диапазона 1–12, получено {cfg.periods}")
|
||
match = _START_RE.fullmatch(cfg.start)
|
||
if match is None:
|
||
raise ConfigError(f"start: ожидается ГГГГ-Qn, получено {cfg.start!r}")
|
||
start_key = (int(match.group(1)), int(match.group(2)))
|
||
if not (2020, 1) <= start_key <= (2030, 4):
|
||
raise ConfigError(f"start: вне диапазона 2020-Q1 … 2030-Q4, получено {cfg.start!r}")
|
||
if not 0 <= cfg.osv_counterparties_mean <= 150:
|
||
raise ConfigError(f"osv_counterparties_mean: вне диапазона 0–150, получено {cfg.osv_counterparties_mean}")
|
||
if not 0 <= cfg.no_inn_companies <= cfg.group_size:
|
||
raise ConfigError(
|
||
f"no_inn_companies: вне диапазона 0–group_size ({cfg.group_size}), получено {cfg.no_inn_companies}"
|
||
)
|
||
if not Decimal(0) <= cfg.vgo_mismatch_share <= Decimal(1):
|
||
raise ConfigError(f"vgo_mismatch_share: вне диапазона 0–1, получено {cfg.vgo_mismatch_share}")
|
||
|
||
|
||
def load_config(path: Path | None, overrides: list[str]) -> Config:
|
||
"""Читает YAML (если задан) и накладывает переопределения `ключ=значение`."""
|
||
known = {f.name for f in fields(Config)}
|
||
data: dict[str, Any] = {}
|
||
if path is not None:
|
||
try:
|
||
loaded = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
|
||
except OSError as exc:
|
||
raise ConfigError(f"не удалось прочитать {path}: {exc}") from exc
|
||
except yaml.YAMLError as exc:
|
||
raise ConfigError(f"некорректный YAML в {path}: {exc}") from exc
|
||
if loaded is None:
|
||
loaded = {}
|
||
if not isinstance(loaded, dict):
|
||
raise ConfigError(f"{path}: верхний уровень должен быть отображением ключ: значение")
|
||
data.update(loaded)
|
||
for item in overrides:
|
||
if "=" not in item:
|
||
raise ConfigError(f"--set: ожидается ключ=значение, получено {item!r}")
|
||
key, value = item.split("=", 1)
|
||
data[key.strip()] = value
|
||
kwargs: dict[str, Any] = {}
|
||
for key, value in data.items():
|
||
if key not in known:
|
||
raise ConfigError(f"неизвестный ключ: {key!r}")
|
||
kwargs[key] = _COERCERS[key](key, value)
|
||
cfg = Config(**kwargs)
|
||
_validate(cfg)
|
||
return cfg
|
||
|
||
|
||
def dump_config(cfg: Config) -> str:
|
||
"""YAML с ключами в порядке полей Config; значение `out` None → null."""
|
||
data: dict[str, Any] = {}
|
||
for f in fields(Config):
|
||
value = getattr(cfg, f.name)
|
||
if isinstance(value, Path):
|
||
value = str(value)
|
||
elif isinstance(value, Decimal):
|
||
value = float(value)
|
||
data[f.name] = value
|
||
return yaml.safe_dump(data, sort_keys=False, allow_unicode=True, default_flow_style=False)
|