#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
152 lines
4.1 KiB
Python
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
|