feat: YAML config support (~/.config/redmine-reporter/config.yml)
- Add AppConfig/SmtpConfig/EmailConfig dataclasses with from_yaml()/from_env()
- Add yaml_config.py: ${VAR} resolver, 0700/0600 permission helpers
- Config.get_*() methods gain YAML fallback in priority chain
- Priority: CLI > env > .env > YAML > code defaults
- CLI --init-config generates YAML from current environment
- --force flag allows overwriting existing config
- Secrets default to ${VAR} references, plaintext allowed
- Full backward compatibility with existing .env setups
Closes #46
This commit is contained in:
58
redmine_reporter/yaml_config.py
Normal file
58
redmine_reporter/yaml_config.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ENV_VAR_RE = re.compile(r"\$\{([^}]+)\}")
|
||||
|
||||
|
||||
def resolve_env_vars(value: str) -> str:
|
||||
"""Replace ${VAR} patterns with environment variable values.
|
||||
|
||||
Non-recursive: if ${A} expands to a literal ${B}, ${B} is NOT resolved.
|
||||
Unknown variables are replaced with empty string and a warning is logged.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
|
||||
def _replacer(match: re.Match) -> str:
|
||||
var_name = match.group(1)
|
||||
env_value = os.environ.get(var_name)
|
||||
if env_value is None:
|
||||
logger.warning("Environment variable %s is not set, using empty string", var_name)
|
||||
return ""
|
||||
return env_value
|
||||
|
||||
return _ENV_VAR_RE.sub(_replacer, value)
|
||||
|
||||
|
||||
def ensure_config_dir(path: Path | None = None) -> Path:
|
||||
"""Create ~/.config/redmine-reporter/ with 0o700 permissions.
|
||||
|
||||
Returns the path to the config directory.
|
||||
No-op if the directory already exists.
|
||||
"""
|
||||
if path is None:
|
||||
path = Path.home() / ".config" / "redmine-reporter"
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def check_file_permissions(path: Path) -> list[str]:
|
||||
"""Check that a config file has safe permissions (0o600 or stricter).
|
||||
|
||||
Returns a list of warning messages. Empty list means no issues.
|
||||
"""
|
||||
warnings: list[str] = []
|
||||
if not path.exists():
|
||||
return warnings
|
||||
|
||||
mode = path.stat().st_mode & 0o777
|
||||
if mode > 0o600:
|
||||
warnings.append(
|
||||
f"Config file permissions are too open ({mode:04o}). "
|
||||
f"Expected 0600. Fix with: chmod 600 {path}"
|
||||
)
|
||||
return warnings
|
||||
Reference in New Issue
Block a user