feat: initial Dropbox tag sync tool
CI / Tests (Linux, Python 3.12) (push) Failing after 4m51s
CI / Tests (Linux, Python 3.11) (push) Failing after 5m19s
CI / Tests (Linux, Python 3.9) (push) Failing after 5m3s
CI / Tests (macOS) (push) Has been cancelled
CI / Tests (Windows) (push) Has been cancelled
CI / Tests (Linux, Python 3.12) (push) Failing after 4m51s
CI / Tests (Linux, Python 3.11) (push) Failing after 5m19s
CI / Tests (Linux, Python 3.9) (push) Failing after 5m3s
CI / Tests (macOS) (push) Has been cancelled
CI / Tests (Windows) (push) Has been cancelled
Cross-platform CLI that mirrors Dropbox web tags to OS-native tags: - macOS: Finder xattrs (content-preserving, no re-upload) - Windows: XMP:Subject via ExifTool (re-upload expected once per file) Includes an interactive wizard that auto-detects the OS and Dropbox folder, walks the user through read-only token creation, previews tagged files, applies tags, and installs a background scheduler (launchd on macOS, Task Scheduler on Windows). Ships with: - 47-test suite (pytest) covering config, state DB, run_sync, writers - Gitea Actions CI for Linux / macOS / Windows with coverage reporting
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
"""Tests for tagsync.platform_windows: sidecar writer + support matrix.
|
||||
|
||||
Runs on any OS — the Windows-specific online-only stat test is skipped
|
||||
except on Windows.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tagsync.platform_windows import (
|
||||
XMP_WRITABLE_EXTS,
|
||||
WindowsWriter,
|
||||
)
|
||||
|
||||
|
||||
# --- supports() --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_supports_xmp_formats():
|
||||
w = WindowsWriter(exiftool="exiftool", write_sidecars=False)
|
||||
for ext in (".jpg", ".pdf", ".docx", ".mp4"):
|
||||
assert w.supports(Path(f"foo{ext}")), f"expected support for {ext}"
|
||||
|
||||
|
||||
def test_supports_rejects_unknown_without_sidecars():
|
||||
w = WindowsWriter(exiftool="exiftool", write_sidecars=False)
|
||||
assert not w.supports(Path("archive.zip"))
|
||||
assert not w.supports(Path("notes.txt"))
|
||||
|
||||
|
||||
def test_supports_accepts_unknown_with_sidecars():
|
||||
w = WindowsWriter(exiftool="exiftool", write_sidecars=True)
|
||||
assert w.supports(Path("archive.zip"))
|
||||
assert w.supports(Path("notes.txt"))
|
||||
|
||||
|
||||
def test_supports_is_case_insensitive():
|
||||
w = WindowsWriter(exiftool="exiftool", write_sidecars=False)
|
||||
assert w.supports(Path("IMAGE.JPG"))
|
||||
assert w.supports(Path("report.PDF"))
|
||||
|
||||
|
||||
# --- sidecar writer ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_sidecar_written_for_unsupported_type(tmp_path):
|
||||
w = WindowsWriter(exiftool="exiftool", write_sidecars=True)
|
||||
target = tmp_path / "archive.zip"
|
||||
target.touch()
|
||||
|
||||
w.write_tags(target, ["project-alpha", "urgent"])
|
||||
|
||||
sidecar = tmp_path / "archive.zip.tags.json"
|
||||
assert sidecar.exists()
|
||||
data = json.loads(sidecar.read_text())
|
||||
assert data == {"tags": ["project-alpha", "urgent"]}
|
||||
|
||||
|
||||
def test_sidecar_cleared_when_tags_empty(tmp_path):
|
||||
w = WindowsWriter(exiftool="exiftool", write_sidecars=True)
|
||||
target = tmp_path / "archive.zip"
|
||||
target.touch()
|
||||
(tmp_path / "archive.zip.tags.json").write_text('{"tags": ["x"]}')
|
||||
|
||||
w.write_tags(target, [])
|
||||
assert not (tmp_path / "archive.zip.tags.json").exists()
|
||||
|
||||
|
||||
def test_sidecar_clear_nonexistent_is_ok(tmp_path):
|
||||
w = WindowsWriter(exiftool="exiftool", write_sidecars=True)
|
||||
target = tmp_path / "archive.zip"
|
||||
target.touch()
|
||||
# Should not raise even though no sidecar exists.
|
||||
w.write_tags(target, [])
|
||||
|
||||
|
||||
def test_unsupported_without_sidecars_raises(tmp_path):
|
||||
w = WindowsWriter(exiftool="exiftool", write_sidecars=False)
|
||||
target = tmp_path / "archive.zip"
|
||||
target.touch()
|
||||
with pytest.raises(RuntimeError):
|
||||
w.write_tags(target, ["tag"])
|
||||
|
||||
|
||||
# --- exiftool invocation ----------------------------------------------------
|
||||
#
|
||||
# We don't actually run exiftool; monkeypatch subprocess.run to capture args.
|
||||
|
||||
|
||||
def test_exiftool_args_are_well_formed(tmp_path, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_run(args, **kwargs):
|
||||
captured["args"] = args
|
||||
class R:
|
||||
returncode = 0
|
||||
stderr = ""
|
||||
return R()
|
||||
|
||||
import tagsync.platform_windows as pw
|
||||
monkeypatch.setattr(pw.subprocess, "run", fake_run)
|
||||
|
||||
w = WindowsWriter(exiftool="exiftool", write_sidecars=False)
|
||||
target = tmp_path / "pic.jpg"
|
||||
target.touch()
|
||||
w.write_tags(target, ["alpha", "beta"])
|
||||
|
||||
args = captured["args"]
|
||||
assert args[0] == "exiftool"
|
||||
assert "-overwrite_original" in args
|
||||
# Subject should be cleared then appended for each tag.
|
||||
assert "-XMP-dc:Subject=" in args
|
||||
assert "-XMP-dc:Subject+=alpha" in args
|
||||
assert "-XMP-dc:Subject+=beta" in args
|
||||
assert str(target) in args
|
||||
|
||||
|
||||
def test_exiftool_failure_raises(tmp_path, monkeypatch):
|
||||
def fake_run(args, **kwargs):
|
||||
class R:
|
||||
returncode = 1
|
||||
stderr = "bad things happened"
|
||||
return R()
|
||||
|
||||
import tagsync.platform_windows as pw
|
||||
monkeypatch.setattr(pw.subprocess, "run", fake_run)
|
||||
|
||||
w = WindowsWriter(exiftool="exiftool", write_sidecars=False)
|
||||
target = tmp_path / "pic.jpg"
|
||||
target.touch()
|
||||
with pytest.raises(RuntimeError, match="bad things happened"):
|
||||
w.write_tags(target, ["x"])
|
||||
|
||||
|
||||
# --- online-only detection --------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.windows
|
||||
def test_is_online_only_returns_false_for_normal_files(tmp_path):
|
||||
w = WindowsWriter(exiftool="exiftool")
|
||||
f = tmp_path / "regular.txt"
|
||||
f.touch()
|
||||
assert w.is_online_only(f) is False
|
||||
|
||||
|
||||
def test_is_online_only_returns_false_when_attribute_unavailable(tmp_path):
|
||||
"""On non-Windows, st_file_attributes doesn't exist -> should return False."""
|
||||
w = WindowsWriter(exiftool="exiftool")
|
||||
f = tmp_path / "regular.txt"
|
||||
f.touch()
|
||||
assert w.is_online_only(f) is False
|
||||
Reference in New Issue
Block a user