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