import tempfile
from fastcore.test import *
from google.protobuf.json_format import MessageToDictSyncing
AnkiWeb’s protocol is simpler than its lack of documentation suggests: JSON over POST, zstd-compressed, with auth in a header. A sync session walks a fixed sequence of endpoints under {endpoint}sync/: meta to compare collection state, then for a normal (delta) sync start, applyGraves, applyChanges, chunk (repeated), applyChunk (repeated), sanityCheck2 and finish. When the two sides have diverged too far, a full sync replaces one side’s whole sqlite file via upload or download. Everything here was built against Anki’s Rust implementation as the reference, and is tested below against a real Anki sync server, with Anki itself as the second client.
The wire
Every request is a POST whose body is zstd-compressed JSON, with an anki-sync header carrying the protocol version (v, currently 11), the auth key (k, empty only for login), a client version string (c), and a random session id (s). Responses come back zstd-compressed too. A 308 means “use this other host from now on”: AnkiWeb spreads users across shards this way, so the new endpoint gets saved with the auth key.
SyncServer
def SyncServer(
endpoint:NoneType=None, hkey:str=''
):One AnkiWeb-protocol server (AnkiWeb itself, or self-hosted)
Schema 11 conversions
Here’s a wrinkle: although both sides store the modern schema, the delta protocol exchanges notetypes, decks and deck configs in the 2012 file format, JSON dicts nicknamed “schema 11”, for compatibility with older clients. So we need mappings between those dicts and our tables. Two rules matter for round-trip safety: unknown keys accumulate in an other JSON blob within the modern config so nothing is dropped, and a handful of fields changed units or sign along the way (autoplay becomes disable_autoplay, initial ease moves from thousands to a float, and so on).
nt_to_s11
def nt_to_s11(
con, ntid
):Convert notetype ntid’s rows to a schema-11 dict
nt_from_s11
def nt_from_s11(
m
):Convert a schema-11 notetype dict to (notetype_row, field_rows, template_rows)
deck_to_s11
def deck_to_s11(
con, did
):Convert deck did’s row to a schema-11 dict
deck_from_s11
def deck_from_s11(
d
):Convert a schema-11 deck dict to a decks table row (filtered decks unsupported: kept as-is if present locally)
dconf_to_s11
def dconf_to_s11(
con, dcid
):Convert deck config dcid’s row to a schema-11 dict
dconf_from_s11
def dconf_from_s11(
d
):Convert a schema-11 deck config dict to a deck_config table row
To test the conversions, we get genuine schema-11 dicts from Anki itself (its Python API still hands out models and decks in exactly this format), push them through *_from_s11, read them back with *_to_s11, and check every value Anki sent is preserved:
from anki.collection import Collection as AnkiCollection
import anki.langdef sub_eq(a, b, path=''):
"Assert every key/value in `a` appears in `b` (recursively, with numeric tolerance)"
for k,v in a.items():
w = b.get(k)
if isinstance(v,dict): sub_eq(v, w or {}, f'{path}{k}.')
elif isinstance(v,float) or isinstance(w,float): assert abs(v-(w or 0))<1e-6, f'{path}{k}: {v} vs {w}'
elif isinstance(v,bool) or isinstance(w,bool): assert bool(v)==bool(w), f'{path}{k}: {v} vs {w}'
else: assert v==w, f'{path}{k}: {v!r} vs {w!r}'
td = Path(tempfile.mkdtemp())
ac = AnkiCollection(str(td/'oracle.anki2'))
mcon = connect(td/'scratch.anki2')
mcon.execute(SCHEMA)
m11 = ac.models.by_name('Cloze')
row,flds,tmpls = nt_from_s11(m11)
mcon.execute('insert into notetypes values (?,?,?,?,?)', row)
mcon.executemany('insert into fields values (?,?,?,?)', flds)
mcon.executemany('insert into templates values (?,?,?,?,?,?)', tmpls)
back = nt_to_s11(mcon, row[0])
sub_eq(dict(m11), back)d11 = ac.decks.get(1)
mcon.execute('insert into decks values (?,?,?,?,?,?)', deck_from_s11(d11))
sub_eq({k:v for k,v in d11.items() if k!='mid'}, deck_to_s11(mcon, 1))
c11 = ac.decks.get_config(1)
mcon.execute('insert into deck_config values (?,?,?,?,?)', dconf_from_s11(c11))
sub_eq(dict(c11), dconf_to_s11(mcon, 1))Gathering and applying changes
A delta sync moves four kinds of data. Graves (deletion tombstones) go first in both directions. Then “unchunked changes”: notetypes, decks, deck configs and tags, sent whole. Then notes, cards and review log entries stream in chunks of positional JSON arrays. Rows pending sync are marked usn=-1; as each side sends them it rewrites the mark to the server’s current usn, so both ends converge on the same sequence number at finish.
The sync state machine
sync_collection ties it together, following the same sequence as Anki’s client. meta decides between nothing-to-do, a delta sync, and a full sync; a delta walks graves, changes, chunks down, chunks up, sanity check, finish. The sanity check compares object counts on both ends; on a mismatch we bump our schema time so the next sync is a full one, which is exactly what Anki does.
Collection.sync_collection
def sync_collection(
srv
):Delta-sync with srv, returning ‘noChanges’, ‘success’, or raising FullSyncRequired
Collection.sync_meta
def sync_meta():Call self as a function.
SanityCheckFailed
def SanityCheckFailed(
*args, **kwargs
):Common base class for all non-exit exceptions.
FullSyncRequired
def FullSyncRequired(
*args, **kwargs
):Common base class for all non-exit exceptions.
SyncRequired
def SyncRequired(
*args, **kwargs
):Common base class for all non-exit exceptions.
Full sync and login
A full download returns the server’s whole sqlite file; upload sends ours (the server integrity-checks it before accepting, answering with the literal string OK). Auth is a host key from hostKey plus the endpoint any 308 redirect settled on, saved as JSON next to the collection.
Collection.sync
def sync(
user:NoneType=None, passw:NoneType=None, endpoint:NoneType=None, upload:bool=False
):Sync with AnkiWeb (or endpoint): logs in if needed, handles delta and full syncs
Collection.load_auth
def load_auth(
endpoint:NoneType=None
):A SyncServer from saved auth, or None
Collection.save_auth
def save_auth(
srv
):Call self as a function.
Collection.full_upload
def full_upload(
srv
):Replace the server’s collection with ours
Collection.full_download
def full_download(
srv
):Replace the local collection with the server’s copy
Against a real server
The anki wheel ships the real sync server (the same code AnkiWeb’s protocol front end is built from). That makes the test story write itself: fastanki syncs up to a genuine Anki server on localhost, then Anki, acting as a second client, syncs down and checks what arrived; then the same in reverse. The cells below tell that story but are marked eval: false: they need a server process, so tests/test_sync.py runs this exact sequence under pytest, where a fixture launches the server in a subprocess with a temp data folder and kills it afterwards. Nothing here talks to the real AnkiWeb.
Because “start a real server” is something the tests, these story cells, and any interactive session all need, it’s a library function: pick a free port, launch python -m anki.syncserver from the installed wheel, wait for it to answer, and register an atexit stop so a forgotten terminate() can’t leave orphans. It needs the anki package, which is a dev dependency only.
start_sync_server
def start_sync_server(
base, # Folder for the server's data
user:str='tester', # Username the server will accept
passw:str='s3kret', # Password for `user`
):Start the anki wheel’s sync server on a free port, returning (process, endpoint); it stops at exit, or sooner via .terminate()
server, EP = start_sync_server(td/'server')Our client logs in, adds some content, and full-uploads (a fresh server and a fresh client have different schema stamps, so the first sync is always a full one):
col = Collection.open(td/'ours'/'collection.anki2')
n1 = col.add(Front='syncme', Back='please', tags='wire', deck='Sync::Deep')
ncz = col.add(model='Cloze', Text='{{c1::first}} {{c2::second}}')
test_fail(lambda: col.sync_collection(SyncServer(EP, hkey='bad')))
res = col.sync(user='tester', passw='s3kret', endpoint=EP, upload=True)
test_eq(res, 'full sync')test_eq(col.sync(), 'noChanges')Now Anki plays second client: log in with its own machinery, full-download, and inspect what we uploaded.
(td/'second').mkdir()
oc2 = AnkiCollection(str(td/'second'/'collection.anki2'))
auth = oc2.sync_login('tester', 's3kret', endpoint=EP)
st = oc2.sync_collection(auth, sync_media=False)
oc2.close_for_full_sync()
oc2.full_upload_or_download(auth=auth, server_usn=st.server_media_usn, upload=False)
oc2.reopen(after_full_sync=True)
test_eq(sorted(oc2.find_notes('')), sorted([n1.id, ncz.id]))
test_eq(oc2.find_notes('tag:wire'), [n1.id])
test_eq(oc2.find_notes('deck:Sync::Deep'), [n1.id])
onote = oc2.get_note(ncz.id)
test_eq(onote['Text'], '{{c1::first}} {{c2::second}}')Then it makes changes of its own: a new note, a new deck, an edit and a deletion, delta-synced up with Anki’s client code:
theirs = oc2.new_note(oc2.models.by_name('Basic'))
theirs['Front'] = 'from the other side'
oc2.add_note(theirs, oc2.decks.add_normal_deck_with_name('Oracle').id)
on = oc2.get_note(n1.id)
on['Back'] = 'edited remotely'
oc2.update_note(on)
oc2.remove_notes([ncz.id])
st = oc2.sync_collection(auth, sync_media=False)
test_eq(st.required, st.NO_CHANGES)And a delta sync brings all three changes to us, passing the server’s count check on the way:
test_eq(col.sync(), 'success')
test_eq(col.find_note_ids(Front='from the other side'), [theirs.id])
test_eq(col.get_note(n1.id)['Back'], 'edited remotely')
test_fail(lambda: col.get_note(ncz.id))
test_eq([d for d in col.decks() if d=='Oracle'], ['Oracle'])Finally the reverse direction once more: our deletion and a new tagged note, verified by the oracle.
n2 = col.add(Front='round trip', tags=['back','forth'])
col.remove_notes(n1.id)
test_eq(col.sync(), 'success')
st = oc2.sync_collection(auth, sync_media=False)
oc2.close()
oc3 = AnkiCollection(str(td/'second'/'collection.anki2'))
test_eq(sorted(oc3.find_notes('')), sorted([theirs.id, n2.id]))
test_eq(oc3.find_notes('tag:forth'), [n2.id])
oc3.close()
col.close()
server.terminate()