Files
redmine-reporter/redmine_reporter/yaml_config.py
Кокос Артем Николаевич 47152f8f04 feat: output path defaults (#43) + datetime precision & dedup (#47)
#43 — Имя файла и пути по умолчанию:
- resolve_output_path() в yaml_config.py: резолвит --output с учётом
  output.dir, filename_template, default_format из YAML-конфига
- Bare format (xlsx/odt/...) → путь по шаблону
- Без расширения → автодописывается default_format (.xlsx)
- Config.get_output_dir/filename/default_format()

#47 — datetime precision и дедупликация:
- period.last_used.from/to в YAML-конфиге и AppConfig
- period.precision: date|datetime
- _compute_dedup_cutoff() в cli.py: при precision=datetime вычисляет
  cutoff из period.last_used.to
- _parse_datetime() + AND-логика дедупликации в client.py:
  запись исключается если created_on И updated_on < cutoff
- Пропуск issues без часов после дедупликации

Closes #43
Closes #47
2026-07-07 10:16:46 +07:00

152 lines
4.1 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