feat: --commit flag (#44)

- --commit: после генерации отчёта сохраняет период в
  period.last_used.from/to YAML-конфига
- При period.dynamic=true следующий запуск вычисляет период от last_used:
  полный месяц → следующий месяц, произвольный диапазон → та же длина
- При period.dynamic=false перезаписывает default_from/default_to
- При period.precision=datetime сохраняет timestamps с точностью до секунд
- --commit без --output сохраняет отчёт по шаблону из конфига
- compute_next_period() в config.py
- save_period_to_config() в yaml_config.py
- 23 новых теста (7 CLI, 6 config, 6 yaml, 4 date_range)

Closes #44
This commit is contained in:
Кокос Артем Николаевич
2026-07-07 10:47:08 +07:00
parent 485be063d2
commit 9b78d66769
6 changed files with 602 additions and 2 deletions

View File

@@ -14,7 +14,7 @@ from .client import RedmineAPIError, fetch_issues_with_spent_time
from .config import Config from .config import Config
from .formatters.factory import get_console_formatter, get_formatter_by_extension from .formatters.factory import get_console_formatter, get_formatter_by_extension
from .report_builder import build_grouped_report, calculate_summary from .report_builder import build_grouped_report, calculate_summary
from .yaml_config import ensure_config_dir, resolve_output_path from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config
def parse_date_range(date_arg: str) -> tuple[str, str]: def parse_date_range(date_arg: str) -> tuple[str, str]:
@@ -195,6 +195,11 @@ def main(argv: Optional[List[str]] = None) -> int:
default=str(Path.home() / ".config" / "redmine-reporter" / "config.yml"), default=str(Path.home() / ".config" / "redmine-reporter" / "config.yml"),
help="Path for --init-config output (default: ~/.config/redmine-reporter/config.yml)", help="Path for --init-config output (default: ~/.config/redmine-reporter/config.yml)",
) )
parser.add_argument(
"--commit",
action="store_true",
help="Save used period as last_used in YAML config and auto-commit to file",
)
args = parser.parse_args(argv) args = parser.parse_args(argv)
# --init-config: обработка до всего остального # --init-config: обработка до всего остального
@@ -365,6 +370,42 @@ def main(argv: Optional[List[str]] = None) -> int:
print(f"{fmt} export error: {e}", file=sys.stderr) print(f"{fmt} export error: {e}", file=sys.stderr)
return 1 return 1
elif args.commit:
default_format = Config.get_output_default_format()
output_arg = resolve_output_path(
default_format,
output_dir=Config.get_output_dir(),
filename_template=Config.get_output_filename(),
default_format=default_format,
author=Config.get_author(args.author),
from_date=from_date,
to_date=to_date,
)
if output_arg is None:
print("Не удалось определить путь для сохранения отчёта.", file=sys.stderr)
return 1
output_ext = os.path.splitext(output_arg)[1].lower()
formatter = get_formatter_by_extension(
output_ext,
author=Config.get_author(args.author),
from_date=from_date,
to_date=to_date,
no_time=args.no_time,
)
if not formatter:
print(f"Не удалось создать форматтер для {output_ext}", file=sys.stderr)
return 1
try:
formatter.save(rows, output_arg)
print(f"✅ Report saved to {output_arg}")
except Exception as e:
print(f"❌ Export error: {e}", file=sys.stderr)
return 1
else: else:
if args.compact: if args.compact:
formatter = get_console_formatter("compact") formatter = get_console_formatter("compact")
@@ -382,6 +423,26 @@ def main(argv: Optional[List[str]] = None) -> int:
print(f"❌ Formatting error: {e}", file=sys.stderr) print(f"❌ Formatting error: {e}", file=sys.stderr)
return 1 return 1
if args.commit:
precision = Config.get_period_precision()
dynamic = Config._app.period_dynamic if Config._app else False
if precision == "datetime":
from datetime import datetime as dt_mod
now = dt_mod.now().isoformat(timespec="seconds")
from_str = now
to_str = now
else:
from_str = from_date
to_str = to_date
save_period_to_config(args.config_path, from_str, to_str, precision, dynamic)
print(
f"📌 Period committed [{from_str} -- {to_str}] → {args.config_path}",
file=sys.stderr,
)
return 0 return 0

View File

@@ -350,10 +350,22 @@ class Config:
if from_env and to_env: if from_env and to_env:
return f"{from_env}--{to_env}" return f"{from_env}--{to_env}"
if (
cls._app
and cls._app.period_dynamic
and cls._app.period_last_used_from
and cls._app.period_last_used_to
):
nf, nt = compute_next_period(
cls._app.period_last_used_from,
cls._app.period_last_used_to,
cls._app.period_precision or "date",
)
return f"{nf}--{nt}"
if cls._app and cls._app.period_default_from and cls._app.period_default_to: if cls._app and cls._app.period_default_from and cls._app.period_default_to:
return f"{cls._app.period_default_from}--{cls._app.period_default_to}" return f"{cls._app.period_default_from}--{cls._app.period_default_to}"
# fallback: текущий месяц
today = date.today() today = date.today()
start = today.replace(day=1) start = today.replace(day=1)
if today.month == 12: if today.month == 12:
@@ -373,3 +385,50 @@ class Config:
raise ValueError( raise ValueError(
"REDMINE_API_KEY is required, or set both REDMINE_USER and REDMINE_PASSWORD" "REDMINE_API_KEY is required, or set both REDMINE_USER and REDMINE_PASSWORD"
) )
def compute_next_period(last_from: str, last_to: str, precision: str) -> tuple[str, str]:
"""Compute the next report period based on the last committed period.
- For a full calendar month → next full calendar month.
- For an arbitrary range → same duration, starting the day after last_to.
- For datetime precision → same duration, starting 1 second after last_to.
"""
from datetime import datetime as dt_mod
if precision == "datetime":
fmt = "%Y-%m-%dT%H:%M:%S"
from_dt = dt_mod.fromisoformat(last_from.replace("Z", "+00:00"))
to_dt = dt_mod.fromisoformat(last_to.replace("Z", "+00:00"))
duration = to_dt - from_dt
next_from_dt = to_dt + timedelta(seconds=1)
next_to_dt = next_from_dt + duration
return next_from_dt.strftime(fmt), next_to_dt.strftime(fmt)
from_parts = last_from.split("-")
to_parts = last_to.split("-")
from_d = date(int(from_parts[0]), int(from_parts[1]), int(from_parts[2]))
to_d = date(int(to_parts[0]), int(to_parts[1]), int(to_parts[2]))
first_of_month = from_d.replace(day=1)
if from_d == first_of_month:
if to_d.month == 12:
last_of_month = date(to_d.year, 12, 31)
else:
next_first = date(to_d.year, to_d.month + 1, 1)
last_of_month = next_first - timedelta(days=1)
if to_d == last_of_month:
if to_d.month == 12:
nstart = date(to_d.year + 1, 1, 1)
else:
nstart = date(to_d.year, to_d.month + 1, 1)
if nstart.month == 12:
nend = date(nstart.year, 12, 31)
else:
nend = date(nstart.year, nstart.month + 1, 1) - timedelta(days=1)
return nstart.isoformat(), nend.isoformat()
duration = to_d - from_d
next_from = to_d + timedelta(days=1)
next_to = next_from + duration
return next_from.isoformat(), next_to.isoformat()

View File

@@ -149,3 +149,38 @@ def resolve_output_path(
arg = f"{arg}.{default_format}" arg = f"{arg}.{default_format}"
return arg 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)

View File

@@ -538,3 +538,230 @@ class TestOutputPathResolution:
mock_formatter.save.assert_called_once() mock_formatter.save.assert_called_once()
saved_path = mock_formatter.save.call_args.args[1] saved_path = mock_formatter.save.call_args.args[1]
assert saved_path == "myreport.xlsx" assert saved_path == "myreport.xlsx"
# ---------------------------------------------------------------------------
# #44: --commit tests
# ---------------------------------------------------------------------------
class TestCommitFlag:
"""Tests for --commit flag."""
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_saves_period_to_config(self, mock_save, mock_fetch, tmp_path):
"""--commit сохраняет период в YAML-конфиг."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": True}}))
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-06-01--2026-06-30",
"--commit",
"--output",
str(tmp_path / "report.xlsx"),
"--config-path",
str(config_path),
]
)
assert code == 0
mock_save.assert_called_once_with(
str(config_path), "2026-06-01", "2026-06-30", "date", True
)
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_with_precision_datetime_passes_datetime_strings(
self, mock_save, mock_fetch, tmp_path
):
"""При precision=datetime --commit сохраняет timestamps."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "datetime", "dynamic": True}}))
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-06-30--2026-06-30",
"--commit",
"--output",
str(tmp_path / "report.xlsx"),
"--config-path",
str(config_path),
]
)
assert code == 0
call_args = mock_save.call_args
assert call_args is not None
saved_from, saved_to = call_args.args[1], call_args.args[2]
assert "T" in saved_from
assert "T" in saved_to
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_commit_no_entries_does_not_save(self, mock_fetch, tmp_path):
"""Без записей --commit не пишет конфиг."""
mock_fetch.return_value = None
config_path = tmp_path / "config.yml"
config_path.write_text("period:\n precision: date\n dynamic: true\n")
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--commit",
"--config-path",
str(config_path),
]
)
assert code == 0
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_dynamic_false_overwrites_defaults(self, mock_save, mock_fetch, tmp_path):
"""При dynamic=false --commit перезаписывает default_from/to."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": False}}))
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-06-01--2026-06-30",
"--commit",
"--output",
str(tmp_path / "report.xlsx"),
"--config-path",
str(config_path),
]
)
assert code == 0
mock_save.assert_called_once_with(
str(config_path), "2026-06-01", "2026-06-30", "date", False
)
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_without_output_uses_default_path(self, mock_save, mock_fetch, tmp_path):
"""--commit без --output сохраняет файл по шаблону из конфига."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text(
yaml.dump(
{
"period": {"precision": "date", "dynamic": True},
"output": {
"dir": str(tmp_path / "reports"),
"filename": "report_{date}.{ext}",
"default_format": "xlsx",
},
}
)
)
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-06-01--2026-06-30",
"--commit",
"--config-path",
str(config_path),
]
)
assert code == 0
mock_formatter.save.assert_called_once()
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_prints_info_to_stderr(self, mock_save, mock_fetch, capsys, tmp_path):
"""--commit выводит сообщение о фиксации в stderr."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": True}}))
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-06-01--2026-06-30",
"--commit",
"--output",
str(tmp_path / "report.xlsx"),
"--config-path",
str(config_path),
]
)
assert code == 0
captured = capsys.readouterr()
assert "commit" in captured.err.lower() or "Commit" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_empty_issues_no_save(self, mock_save, mock_fetch, tmp_path):
"""Пустой список задач — --commit не сохраняет конфиг."""
import yaml
mock_fetch.return_value = []
config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": True}}))
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--commit",
"--config-path",
str(config_path),
]
)
assert code == 0
mock_save.assert_not_called()

View File

@@ -451,3 +451,109 @@ class TestConfigPeriodPrecision:
cfg = AppConfig.from_yaml(yaml_path) cfg = AppConfig.from_yaml(yaml_path)
assert cfg.period_last_used_from == "" assert cfg.period_last_used_from == ""
assert cfg.period_last_used_to == "" assert cfg.period_last_used_to == ""
class TestComputeNextPeriod:
"""Tests for compute_next_period()."""
def test_full_month_goes_to_next_full_month(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-01", "2026-06-30", "date")
assert nf == "2026-07-01"
assert nt == "2026-07-31"
def test_december_to_next_year_january(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2025-12-01", "2025-12-31", "date")
assert nf == "2026-01-01"
assert nt == "2026-01-31"
def test_february_2026_non_leap_to_march(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-02-01", "2026-02-28", "date")
assert nf == "2026-03-01"
assert nt == "2026-03-31"
def test_arbitrary_range_same_length_from_to_plus_one(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-15", "2026-06-20", "date")
assert nf == "2026-06-21"
assert nt == "2026-06-26"
def test_single_day_range_moves_one_day(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-15", "2026-06-15", "date")
assert nf == "2026-06-16"
assert nt == "2026-06-16"
def test_cross_month_range(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-25", "2026-07-05", "date")
assert nf == "2026-07-06"
assert nt == "2026-07-16"
def test_datetime_precision_moves_by_seconds(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-30T09:00:00", "2026-06-30T12:00:00", "datetime")
assert nf == "2026-06-30T12:00:01"
assert nt == "2026-06-30T15:00:01"
class TestDefaultDateRangeWithLastUsed:
"""Tests that get_default_date_range() uses last_used when dynamic=True."""
@mock.patch.dict(os.environ, {}, clear=True)
def test_uses_last_used_when_dynamic_true(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n"
" precision: date\n"
" dynamic: true\n"
" last_used:\n"
" from: '2026-05-01'\n"
" to: '2026-05-31'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
result = Config.get_default_date_range()
assert result == "2026-06-01--2026-06-30"
@mock.patch.dict(os.environ, {}, clear=True)
def test_ignores_last_used_when_dynamic_false(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n"
" precision: date\n"
" dynamic: false\n"
" default_from: '2026-03-01'\n"
" default_to: '2026-03-15'\n"
" last_used:\n"
" from: '2026-06-01'\n"
" to: '2026-06-30'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
result = Config.get_default_date_range()
assert result == "2026-03-01--2026-03-15"
@mock.patch.dict(os.environ, {}, clear=True)
def test_falls_back_when_no_last_used_even_with_dynamic(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"period:\n"
" precision: date\n"
" dynamic: true\n"
" default_from: '2026-04-01'\n"
" default_to: '2026-04-15'\n"
)
Config._app = AppConfig.from_yaml(yaml_path)
result = Config.get_default_date_range()
assert result == "2026-04-01--2026-04-15"

View File

@@ -206,3 +206,115 @@ class TestResolveOutputPath:
result = resolve_output_path("unknown_format") result = resolve_output_path("unknown_format")
assert result == "unknown_format.xlsx" assert result == "unknown_format.xlsx"
class TestSavePeriodToConfig:
"""Tests for save_period_to_config()."""
def test_creates_last_used_in_empty_config(self, tmp_path):
import yaml
from redmine_reporter.yaml_config import save_period_to_config
config_path = tmp_path / "config.yml"
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True)
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert data["period"]["last_used"]["from"] == "2026-06-01"
assert data["period"]["last_used"]["to"] == "2026-06-30"
assert "default_from" not in data["period"]
def test_overwrites_existing_last_used(self, tmp_path):
import yaml
from redmine_reporter.yaml_config import save_period_to_config
config_path = tmp_path / "config.yml"
config_path.write_text(
"period:\n" " last_used:\n" " from: '2026-05-01'\n" " to: '2026-05-31'\n"
)
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True)
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert data["period"]["last_used"]["from"] == "2026-06-01"
assert data["period"]["last_used"]["to"] == "2026-06-30"
def test_dynamic_false_overwrites_defaults(self, tmp_path):
import yaml
from redmine_reporter.yaml_config import save_period_to_config
config_path = tmp_path / "config.yml"
config_path.write_text(
"period:\n" " default_from: '2026-01-01'\n" " default_to: '2026-01-31'\n"
)
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", False)
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert data["period"]["default_from"] == "2026-06-01"
assert data["period"]["default_to"] == "2026-06-30"
assert data["period"]["last_used"]["from"] == "2026-06-01"
assert data["period"]["last_used"]["to"] == "2026-06-30"
def test_datetime_precision_stores_timestamps(self, tmp_path):
import yaml
from redmine_reporter.yaml_config import save_period_to_config
config_path = tmp_path / "config.yml"
save_period_to_config(
str(config_path),
"2026-06-30T09:00:00",
"2026-06-30T12:00:00",
"datetime",
True,
)
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert data["period"]["last_used"]["from"] == "2026-06-30T09:00:00"
assert data["period"]["last_used"]["to"] == "2026-06-30T12:00:00"
def test_preserves_existing_sections(self, tmp_path):
import yaml
from redmine_reporter.yaml_config import save_period_to_config
config_path = tmp_path / "config.yml"
config_path.write_text(
"redmine:\n"
" url: https://example.com\n"
" author: Test\n"
"output:\n"
" dir: /tmp\n"
)
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True)
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert data["redmine"]["url"] == "https://example.com"
assert data["redmine"]["author"] == "Test"
assert data["output"]["dir"] == "/tmp"
assert data["period"]["last_used"]["from"] == "2026-06-01"
def test_sets_permissions_0600(self, tmp_path):
from redmine_reporter.yaml_config import save_period_to_config
config_path = tmp_path / "config.yml"
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True)
mode = config_path.stat().st_mode & 0o777
assert mode == 0o600