Initial commit
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/BackupCopier.spec
|
||||
/dist/
|
||||
/build/
|
||||
/.venv/
|
||||
77
build.py
Normal file
77
build.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
APP_NAME = "BackupCopier"
|
||||
VERSION = "1.0.0"
|
||||
MAIN_SCRIPT = "main.py"
|
||||
ICON_PATH = "icon.ico"
|
||||
EXE_PATH = os.path.join("dist", f"{APP_NAME}.exe")
|
||||
ZIP_PATH = os.path.join("dist", f"{APP_NAME}-{VERSION}.zip")
|
||||
|
||||
|
||||
def ensure_build_deps() -> None:
|
||||
try:
|
||||
__import__("PyInstaller")
|
||||
except Exception:
|
||||
print("[ERROR] Missing dependency: PyInstaller")
|
||||
print(f"[ERROR] Install in this interpreter: {sys.executable} -m pip install pyinstaller")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run_build() -> None:
|
||||
icon_abs = os.path.abspath(ICON_PATH)
|
||||
has_icon = os.path.exists(icon_abs)
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"PyInstaller",
|
||||
"--noconfirm",
|
||||
"--clean",
|
||||
"--onefile",
|
||||
"--windowed",
|
||||
f"--name={APP_NAME}",
|
||||
"--hidden-import=schedule",
|
||||
"--collect-submodules=schedule",
|
||||
"--hidden-import=pystray",
|
||||
"--hidden-import=PIL",
|
||||
"--collect-submodules=pystray",
|
||||
"--collect-submodules=PIL",
|
||||
f"--icon={icon_abs}" if has_icon else "",
|
||||
f"--add-data={icon_abs}{os.pathsep}." if has_icon else "",
|
||||
MAIN_SCRIPT,
|
||||
]
|
||||
cmd = [x for x in cmd if x]
|
||||
subprocess.check_call(cmd)
|
||||
|
||||
|
||||
def write_version_file() -> str:
|
||||
path = os.path.join("dist", "version.txt")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(VERSION + "\n")
|
||||
return path
|
||||
|
||||
|
||||
def create_zip(version_file: str) -> None:
|
||||
with zipfile.ZipFile(ZIP_PATH, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
z.write(EXE_PATH, arcname=os.path.basename(EXE_PATH))
|
||||
z.write(version_file, arcname="version.txt")
|
||||
if os.path.exists(ICON_PATH):
|
||||
z.write(ICON_PATH, arcname="icon.ico")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ensure_build_deps()
|
||||
|
||||
for folder in ("build", "dist"):
|
||||
if os.path.exists(folder):
|
||||
shutil.rmtree(folder)
|
||||
|
||||
run_build()
|
||||
vfile = write_version_file()
|
||||
create_zip(vfile)
|
||||
print(f"[OK] Built: {EXE_PATH}")
|
||||
print(f"[OK] Archive: {ZIP_PATH}")
|
||||
1
requirements-dev.txt
Normal file
1
requirements-dev.txt
Normal file
@@ -0,0 +1 @@
|
||||
pytest>=8.0
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
schedule>=1.2
|
||||
pystray>=0.19
|
||||
pillow>=10.0
|
||||
95
tests/test_copy_logic.py
Normal file
95
tests/test_copy_logic.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from main import find_latest_file_in_folder, should_copy_file, compute_file_checksum, verify_copy
|
||||
|
||||
|
||||
|
||||
def _touch(path: Path, mtime: float) -> None:
|
||||
path.write_text('data', encoding='utf-8')
|
||||
os.utime(path, (mtime, mtime))
|
||||
|
||||
|
||||
|
||||
def test_find_latest_file_returns_none_for_missing_folder(tmp_path):
|
||||
missing = tmp_path / 'missing'
|
||||
assert find_latest_file_in_folder(str(missing)) is None
|
||||
|
||||
|
||||
|
||||
def test_find_latest_file_picks_newest(tmp_path):
|
||||
folder = tmp_path / 'src'
|
||||
folder.mkdir()
|
||||
|
||||
now = time.time()
|
||||
older = folder / 'older.bak'
|
||||
newer = folder / 'newer.bak'
|
||||
other = folder / 'other.sql'
|
||||
|
||||
_touch(older, now - 10)
|
||||
_touch(newer, now - 5)
|
||||
_touch(other, now - 1)
|
||||
|
||||
latest = find_latest_file_in_folder(str(folder))
|
||||
assert latest is not None
|
||||
assert latest.name == 'other.sql'
|
||||
|
||||
|
||||
|
||||
def test_should_copy_file_when_target_missing(tmp_path):
|
||||
src = tmp_path / 'src.bak'
|
||||
src.write_text('x', encoding='utf-8')
|
||||
dst = tmp_path / 'dst.bak'
|
||||
|
||||
assert should_copy_file(src, dst) is True
|
||||
|
||||
|
||||
|
||||
def test_should_copy_file_when_source_newer(tmp_path):
|
||||
now = time.time()
|
||||
src = tmp_path / 'src.bak'
|
||||
dst = tmp_path / 'dst.bak'
|
||||
|
||||
_touch(dst, now - 10)
|
||||
_touch(src, now)
|
||||
|
||||
assert should_copy_file(src, dst) is True
|
||||
|
||||
|
||||
|
||||
def test_should_copy_file_when_target_newer_or_equal(tmp_path):
|
||||
now = time.time()
|
||||
src = tmp_path / 'src.bak'
|
||||
dst = tmp_path / 'dst.bak'
|
||||
|
||||
_touch(src, now)
|
||||
_touch(dst, now)
|
||||
|
||||
assert should_copy_file(src, dst) is False
|
||||
|
||||
|
||||
def test_compute_file_checksum_stable(tmp_path):
|
||||
src = tmp_path / 'a.bak'
|
||||
src.write_text('hello', encoding='utf-8')
|
||||
c1 = compute_file_checksum(src)
|
||||
c2 = compute_file_checksum(src)
|
||||
assert c1 == c2
|
||||
|
||||
|
||||
def test_verify_copy_true_for_equal_files(tmp_path):
|
||||
src = tmp_path / 'src.bak'
|
||||
dst = tmp_path / 'dst.bak'
|
||||
src.write_text('data', encoding='utf-8')
|
||||
dst.write_text('data', encoding='utf-8')
|
||||
assert verify_copy(src, dst) is True
|
||||
|
||||
|
||||
def test_verify_copy_false_for_different_files(tmp_path):
|
||||
src = tmp_path / 'src.bak'
|
||||
dst = tmp_path / 'dst.bak'
|
||||
src.write_text('data1', encoding='utf-8')
|
||||
dst.write_text('data2', encoding='utf-8')
|
||||
assert verify_copy(src, dst) is False
|
||||
Reference in New Issue
Block a user