- Add pyproject.toml: hatchling build, many-rsync entrypoint, ruff/mypy/pytest config with 60% coverage floor - Add uv.lock for reproducible dev installs - Add .pre-commit-config.yaml: ruff (with --fix) + mypy hooks - Add test_main.py: unit tests for _build_rsync_cmd, _load_raw, and load_config covering happy paths and FATAL exit cases - Add explanation.md: architecture overview with flowchart - main.py: refactor into typed, testable functions (_load_raw, _build_rsync_cmd extracted); add RsyncParameters/Config TypedDicts; add rsync_parameters config support (rsync_path, exclude_from); harden validation (n clamped, log_level validated) - README.md: update install instructions and document all config fields including rsync_parameters
106 lines
3.4 KiB
Python
106 lines
3.4 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from main import _build_rsync_cmd, _load_raw, load_config
|
|
|
|
# ── _build_rsync_cmd ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_build_rsync_cmd_no_params() -> None:
|
|
cmd = _build_rsync_cmd({})
|
|
assert cmd == ("rsync", "-avh", "--progress", "--delete", "--stats")
|
|
|
|
|
|
def test_build_rsync_cmd_with_rsync_path() -> None:
|
|
cmd = _build_rsync_cmd({"rsync_path": "/usr/local/bin/rsync"})
|
|
assert "--rsync-path=/usr/local/bin/rsync" in cmd
|
|
|
|
|
|
def test_build_rsync_cmd_with_exclude_from() -> None:
|
|
cmd = _build_rsync_cmd({"exclude_from": ".gitignore"})
|
|
assert "--exclude-from=.gitignore" in cmd
|
|
|
|
|
|
def test_build_rsync_cmd_with_all_params() -> None:
|
|
cmd = _build_rsync_cmd({"rsync_path": "/opt/rsync", "exclude_from": "exc.txt"})
|
|
assert "--rsync-path=/opt/rsync" in cmd
|
|
assert "--exclude-from=exc.txt" in cmd
|
|
|
|
|
|
# ── _load_raw ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_load_raw_toml(tmp_path: Path) -> None:
|
|
p = tmp_path / "cfg.toml"
|
|
p.write_text('remote_folder = "host:/dst"\nlocal_folders = ["a"]\n')
|
|
raw = _load_raw(p)
|
|
assert raw["remote_folder"] == "host:/dst"
|
|
|
|
|
|
def test_load_raw_json(tmp_path: Path) -> None:
|
|
p = tmp_path / "cfg.json"
|
|
p.write_text('{"remote_folder": "/dst", "local_folders": ["a"]}')
|
|
raw = _load_raw(p)
|
|
assert raw["remote_folder"] == "/dst"
|
|
|
|
|
|
def test_load_raw_unsupported_format(tmp_path: Path) -> None:
|
|
p = tmp_path / "cfg.yaml"
|
|
p.write_text("key: val")
|
|
with pytest.raises(SystemExit):
|
|
_load_raw(p)
|
|
|
|
|
|
# ── load_config ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_load_config_valid(tmp_path: Path) -> None:
|
|
d = tmp_path / "src"
|
|
d.mkdir()
|
|
p = tmp_path / "cfg.toml"
|
|
p.write_text(f'remote_folder = "host:/dst"\nlocal_folders = ["{d}"]\nn = 1\n')
|
|
cfg = load_config(p)
|
|
assert cfg["remote_folder"] == "host:/dst"
|
|
assert cfg["local_folders"] == [d]
|
|
assert cfg["n"] == 1
|
|
assert cfg["log_level"] == "INFO"
|
|
|
|
|
|
def test_load_config_empty_folders(tmp_path: Path) -> None:
|
|
p = tmp_path / "cfg.toml"
|
|
p.write_text('remote_folder = "host:/dst"\nlocal_folders = []\n')
|
|
with pytest.raises(SystemExit):
|
|
load_config(p)
|
|
|
|
|
|
def test_load_config_missing_remote(tmp_path: Path) -> None:
|
|
d = tmp_path / "src"
|
|
d.mkdir()
|
|
p = tmp_path / "cfg.toml"
|
|
p.write_text(f'local_folders = ["{d}"]\n')
|
|
with pytest.raises(SystemExit):
|
|
load_config(p)
|
|
|
|
|
|
def test_load_config_n_clamped(tmp_path: Path) -> None:
|
|
d = tmp_path / "src"
|
|
d.mkdir()
|
|
p = tmp_path / "cfg.toml"
|
|
p.write_text(f'remote_folder = "host:/dst"\nlocal_folders = ["{d}"]\nn = 99\n')
|
|
cfg = load_config(p)
|
|
assert cfg["n"] == 1 # clamped to len(folders)
|
|
|
|
|
|
def test_load_config_invalid_log_level(tmp_path: Path) -> None:
|
|
d = tmp_path / "src"
|
|
d.mkdir()
|
|
p = tmp_path / "cfg.toml"
|
|
p.write_text(
|
|
f'remote_folder = "host:/dst"\nlocal_folders = ["{d}"]\nlog_level = "TRACE"\n'
|
|
)
|
|
with pytest.raises(SystemExit):
|
|
load_config(p)
|