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,140 @@
|
||||
"""Tests for tagsync.platform_macos.
|
||||
|
||||
The plist encoding test runs anywhere. The xattr round-trip runs only on
|
||||
systems where the extended-attribute syscalls are available (macOS and
|
||||
most Linux filesystems).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import plistlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tagsync.platform_macos import FINDER_TAGS_XATTR, MacWriter
|
||||
|
||||
|
||||
def test_mac_writer_supports_all_file_types():
|
||||
w = MacWriter()
|
||||
for path in ("a.jpg", "b.zip", "c.txt", "noextension"):
|
||||
assert w.supports(Path(path))
|
||||
|
||||
|
||||
def test_mac_writer_is_online_only_is_false():
|
||||
w = MacWriter()
|
||||
assert w.is_online_only(Path("/any/file")) is False
|
||||
|
||||
|
||||
def test_plist_encoding_matches_finder_format():
|
||||
"""Finder expects a binary plist of a string array."""
|
||||
from tagsync.platform_macos import plistlib # re-export
|
||||
tags = ["alpha", "beta", "gamma"]
|
||||
encoded = plistlib.dumps(tags, fmt=plistlib.FMT_BINARY)
|
||||
# Binary plists start with 'bplist'.
|
||||
assert encoded.startswith(b"bplist")
|
||||
decoded = plistlib.loads(encoded)
|
||||
assert decoded == tags
|
||||
|
||||
|
||||
def _xattr_supported(path: Path) -> bool:
|
||||
"""Can we setxattr / getxattr on files in this directory?"""
|
||||
if not hasattr(os, "setxattr") or not hasattr(os, "getxattr"):
|
||||
return False
|
||||
try:
|
||||
# Use a neutral namespace; on Linux, user.* always works on ext4.
|
||||
os.setxattr(path, "user.tagsync_probe", b"ok")
|
||||
val = os.getxattr(path, "user.tagsync_probe")
|
||||
os.removexattr(path, "user.tagsync_probe")
|
||||
return val == b"ok"
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def xattr_capable_file(tmp_path):
|
||||
f = tmp_path / "target"
|
||||
f.touch()
|
||||
if not _xattr_supported(f):
|
||||
pytest.skip("filesystem does not support extended attributes")
|
||||
return f
|
||||
|
||||
|
||||
def test_write_and_read_tags_via_xattr(xattr_capable_file):
|
||||
"""Round-trip the actual xattr the script uses in production.
|
||||
|
||||
On Linux, the 'com.apple.metadata:*' namespace may not be allowed,
|
||||
so this test uses the MacWriter only on Darwin.
|
||||
"""
|
||||
if sys.platform != "darwin":
|
||||
pytest.skip("Finder xattr namespace only writable on macOS")
|
||||
|
||||
w = MacWriter()
|
||||
w.write_tags(xattr_capable_file, ["alpha", "beta"])
|
||||
|
||||
raw = os.getxattr(xattr_capable_file, FINDER_TAGS_XATTR)
|
||||
assert plistlib.loads(raw) == ["alpha", "beta"]
|
||||
|
||||
|
||||
def test_clear_tags_via_xattr(xattr_capable_file):
|
||||
if sys.platform != "darwin":
|
||||
pytest.skip("Finder xattr namespace only writable on macOS")
|
||||
|
||||
w = MacWriter()
|
||||
w.write_tags(xattr_capable_file, ["alpha"])
|
||||
w.write_tags(xattr_capable_file, [])
|
||||
|
||||
with pytest.raises(OSError):
|
||||
os.getxattr(xattr_capable_file, FINDER_TAGS_XATTR)
|
||||
|
||||
|
||||
def test_clear_tags_when_none_set_is_noop(tmp_path):
|
||||
"""Removing a non-existent xattr should not raise."""
|
||||
if sys.platform != "darwin":
|
||||
pytest.skip("requires macOS xattr semantics")
|
||||
f = tmp_path / "fresh"
|
||||
f.touch()
|
||||
MacWriter().write_tags(f, []) # should not raise
|
||||
|
||||
|
||||
# --- launchd plist generation -----------------------------------------------
|
||||
|
||||
|
||||
def test_install_schedule_writes_plist(tmp_path, monkeypatch):
|
||||
"""We don't want to actually call launchctl; intercept it and verify
|
||||
the plist contents."""
|
||||
import tagsync.platform_macos as pm
|
||||
|
||||
calls = []
|
||||
|
||||
class FakeResult:
|
||||
returncode = 0
|
||||
stderr = ""
|
||||
stdout = ""
|
||||
|
||||
monkeypatch.setattr(pm.subprocess, "run", lambda args, **kw: calls.append(args) or FakeResult())
|
||||
|
||||
# Redirect plist + log paths into tmp to avoid touching real HOME.
|
||||
fake_plist = tmp_path / "agent.plist"
|
||||
fake_log = tmp_path / "log"
|
||||
monkeypatch.setattr(pm, "LAUNCHD_PLIST", fake_plist)
|
||||
monkeypatch.setattr(pm, "LOG_PATH", fake_log)
|
||||
|
||||
entry = tmp_path / "tagsync.py"
|
||||
entry.write_text("# placeholder")
|
||||
|
||||
result_path = pm.install_schedule(entry, interval_minutes=30)
|
||||
assert result_path == fake_plist
|
||||
content = fake_plist.read_text()
|
||||
assert "StartInterval" in content
|
||||
assert "1800" in content # 30 min -> 1800 sec
|
||||
assert str(entry) in content
|
||||
assert "launchctl" in " ".join(" ".join(c) for c in calls)
|
||||
|
||||
|
||||
def test_uninstall_schedule_when_not_installed(tmp_path, monkeypatch):
|
||||
import tagsync.platform_macos as pm
|
||||
|
||||
monkeypatch.setattr(pm, "LAUNCHD_PLIST", tmp_path / "missing.plist")
|
||||
assert pm.uninstall_schedule() is False
|
||||
Reference in New Issue
Block a user