Files
redmine-reporter/redmine_reporter/yaml_config.py
Кокос Артем Николаевич 0968560090 style: apply ruff format to all source files
2026-07-10 12:39:24 +07:00

191 lines
5.2 KiB
Python

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
def expand_filename_template(
template: str,
*,
author: str = "",
from_date: str = "",
to_date: str = "",
ext: str = "",
) -> str:
"""Expand placeholders in a filename template.
Supported placeholders:
{author} — author name
{from} — start date (YYYY-MM-DD)
{to} — end date (YYYY-MM-DD)
{date} — end date formatted as DD_MM_YYYY
{ext} — file extension without dot
Unknown placeholders are left as-is.
"""
date_dd_mm_yyyy = ""
if to_date:
try:
from datetime import datetime
dt = datetime.strptime(to_date, "%Y-%m-%d")
date_dd_mm_yyyy = dt.strftime("%d_%m_%Y")
except ValueError:
date_dd_mm_yyyy = to_date
replacements = {
"author": author.replace(" ", "_"),
"from": from_date,
"to": to_date,
"date": date_dd_mm_yyyy,
"ext": ext,
}
result = template
for key, value in replacements.items():
result = result.replace("{" + key + "}", value)
return result
KNOWN_FORMAT_NAMES = {"xlsx", "odt", "csv", "md", "html", "json"}
def resolve_output_path(
output_arg: str | None,
*,
output_dir: str = "",
filename_template: str = "{author}_{from}_{to}.{ext}",
default_format: str = "xlsx",
author: str = "",
from_date: str = "",
to_date: str = "",
) -> str | None:
"""Resolve the final output path from --output argument and config settings.
Returns None if output_arg is None (meaning no file output, use console).
Resolution rules:
- None → None (console output)
- Bare format name (xlsx, odt, csv, md, html, json) → use template path
- Path without extension → append .{default_format}
- Path with known extension → use as-is
- Path with unknown extension → use as-is
"""
if output_arg is None:
return None
arg = output_arg.strip()
import os
if arg.lower() in KNOWN_FORMAT_NAMES:
ext = arg.lower()
resolved_dir = os.path.expanduser(output_dir) if output_dir else "."
filename = expand_filename_template(
filename_template,
author=author,
from_date=from_date,
to_date=to_date,
ext=ext,
)
return os.path.join(resolved_dir, filename)
_, ext = os.path.splitext(arg)
if not ext:
arg = f"{arg}.{default_format}"
return arg
def save_period_to_config(
config_path: str,
from_str: str,
to_str: str,
precision: str,
dynamic: bool,
) -> None:
"""Save committed period to YAML config file.
Writes period.last_used.from / period.last_used.to.
When dynamic=False, also overwrites period.default_from / period.default_to.
Preserves all other sections unchanged. Creates config file if missing.
"""
import yaml
path = Path(config_path)
raw: dict = {}
if path.exists():
with open(path, "r", encoding="utf-8") as fh:
raw = yaml.safe_load(fh) or {}
period = raw.setdefault("period", {})
period["last_used"] = {"from": from_str, "to": to_str}
if not dynamic:
period["default_from"] = from_str.split("T")[0] if "T" in from_str else from_str
period["default_to"] = to_str.split("T")[0] if "T" in to_str else to_str
ensure_config_dir(path.parent)
with open(path, "w", encoding="utf-8") as fh:
yaml.dump(
raw, fh, allow_unicode=True, default_flow_style=False, sort_keys=False
)
path.chmod(0o600)