Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions aw_client/sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""
ActivityWatch Sync Tool
Syncs bucket data between ActivityWatch instances on different devices.
"""
import json
import logging
from datetime import datetime, timezone
from typing import Dict, List, Optional
Comment on lines +7 to +8
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unused imports — datetime and timezone are imported but never referenced anywhere in the module.

Suggested change
from datetime import datetime, timezone
from typing import Dict, List, Optional
from typing import Dict, List, Optional


import requests

logger = logging.getLogger(__name__)


class SyncClient:
"""Client for syncing ActivityWatch data between devices."""

def __init__(self, source_url: str, target_url: str, api_key: Optional[str] = None):
self.source_url = source_url.rstrip("/")
self.target_url = target_url.rstrip("/")
self.headers = {}
if api_key:
self.headers["Authorization"] = f"Bearer {api_key}"

def _get(self, url: str) -> dict:
resp = requests.get(url, headers=self.headers, timeout=30)
resp.raise_for_status()
return resp.json()

def _post(self, url: str, data: dict) -> dict:
resp = requests.post(url, json=data, headers=self.headers, timeout=60)
resp.raise_for_status()
return resp.json()

def export_from_source(self) -> dict:
"""Export all data from the source server."""
logger.info(f"Exporting from {self.source_url}")
return self._get(f"{self.source_url}/api/0/sync/export")

def import_to_target(self, data: dict) -> dict:
"""Import data to the target server."""
logger.info(f"Importing to {self.target_url}")
result = self._post(f"{self.target_url}/api/0/sync/import", data)
logger.info(f"Imported {result.get('imported', 0)} events, skipped {result.get('skipped', 0)}")
Comment on lines +37 to +44
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Wrong API endpoint paths

SyncClient calls /api/0/sync/export, /api/0/sync/import, and /api/0/sync/status, but these endpoints do not exist in aw-server. The existing ActivityWatchClient in client.py uses /api/0/export for export and /api/0/import for import (see export_all() and import_bucket()). Every call through this client will receive a 404 unless a corresponding aw-server change is shipped simultaneously — and there is no mention of it in this PR.

return result

def sync(self) -> dict:
"""Full sync: export from source, import to target."""
data = self.export_from_source()
result = self.import_to_target(data)
result["exported_buckets"] = len(data.get("buckets", {}))
return result

def status(self, url: Optional[str] = None) -> dict:
"""Get sync status from a server."""
base = (url or self.source_url).rstrip("/")
return self._get(f"{base}/api/0/sync/status")

Comment on lines +15 to +58
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Duplicates HTTP client logic already in ActivityWatchClient

SyncClient hand-rolls its own _get/_post methods and manages auth headers manually, duplicating what ActivityWatchClient in client.py already provides (including export_all() and import_bucket()). Building SyncClient on top of two ActivityWatchClient instances would reuse connection handling, auth, error logging via always_raise_for_request_errors, and the Content-type header that this implementation omits on POST requests.


def sync_bidirectional(device_a: str, device_b: str, api_key: Optional[str] = None):
"""Sync data in both directions between two devices."""
client_ab = SyncClient(device_a, device_b, api_key)
client_ba = SyncClient(device_b, device_a, api_key)

logger.info("Syncing A -> B")
result_ab = client_ab.sync()

logger.info("Syncing B -> A")
result_ba = client_ba.sync()

return {
"a_to_b": result_ab,
"b_to_a": result_ba,
}

Comment on lines +62 to +75
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Single shared API key for bidirectional sync

sync_bidirectional accepts one api_key and passes it to both SyncClient(device_a, device_b, api_key) and SyncClient(device_b, device_a, api_key). In practice, each aw-server instance generates its own local API key (see load_local_server_api_key in client.py), so the two devices will typically have different keys. One of the two sync directions will authenticate against the wrong key and receive 401 errors. The function signature should accept separate keys for each device, or the caller should be warned about this constraint.


def cli():
"""Command-line interface for the sync tool."""
import argparse

parser = argparse.ArgumentParser(description="ActivityWatch Sync Tool")
parser.add_argument("command", choices=["sync", "status", "export"],
help="Command to run")
parser.add_argument("--source", "-s", help="Source server URL")
parser.add_argument("--target", "-t", help="Target server URL")
parser.add_argument("--bidirectional", "-b", action="store_true",
help="Sync in both directions")
parser.add_argument("--api-key", "-k", help="API key for authentication")

args = parser.parse_args()

if args.command == "status":
url = args.source or "http://localhost:5600"
client = SyncClient(url, url, args.api_key)
status = client.status(url)
print(json.dumps(status, indent=2))

elif args.command == "export":
url = args.source or "http://localhost:5600"
client = SyncClient(url, url, args.api_key)
data = client.export_from_source()
print(json.dumps(data, indent=2))

elif args.command == "sync":
if not args.source or not args.target:
print("Error: --source and --target are required for sync")
return
Comment on lines +105 to +107
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 CLI error path exits with code 0 instead of a non-zero code. Using return after printing an error message leaves $? as 0, which breaks shell scripts that check for failure via if ! python -m aw_client.sync sync ....

Suggested change
if not args.source or not args.target:
print("Error: --source and --target are required for sync")
return
if not args.source or not args.target:
print("Error: --source and --target are required for sync")
raise SystemExit(1)


if args.bidirectional:
result = sync_bidirectional(args.source, args.target, args.api_key)
else:
client = SyncClient(args.source, args.target, args.api_key)
result = client.sync()

print(json.dumps(result, indent=2))

if __name__ == "__main__":
cli()