Python modules¶
Generated from the backend’s own docstrings, so it cannot drift from the code.
The library¶
The game library: PGN files on disk plus a SQLite index over them.
Layout (DATA_DIR defaults to ./library, or /data on Railway with a volume):
library/
library.db index + repertoires + settings
collections/
my-games/games.pgn plain PGN, appended to; still valid PGN
masters/games.pgn
Games stay in ordinary PGN files you can copy elsewhere or open in any other program; the database only remembers where each one starts and ends.
- class backend.store.Library(data_dir)[source]¶
Bases:
object- collections_for(game_ids)[source]¶
Every collection each of these games sits in, owner first then links.
- link_game(game_id, collection)[source]¶
Shelve an existing game in another collection. Returns False if it is already there.
- unlink_game(game_id, collection)[source]¶
Take a game off a shelf it was linked onto. The owning collection is not a link.
- name_openings(collection=None)[source]¶
Fill in opening names for games imported before, or without, an Opening tag.
- add_games(pgn_text, collection='My games', source='import', skip_duplicates=True, kind='games')[source]¶
Append every game in pgn_text to a collection. Returns a summary dict.
kind only applies when the collection is being created: a collection that already exists keeps whatever kind it was given, so importing into it never moves somebody’s games out of the database behind their back.
- SORTS = {'added': 'added_at DESC, id DESC', 'annotator': 'annotator COLLATE NOCASE ASC, id DESC', 'black': 'black COLLATE NOCASE ASC', 'date': 'date DESC, id DESC', 'date_asc': 'date ASC, id ASC', 'eco': 'eco ASC, date DESC', 'elo': 'MAX(COALESCE(white_elo,0), COALESCE(black_elo,0)) DESC', 'event': 'event COLLATE NOCASE ASC, date DESC', 'event_date': "COALESCE(NULLIF(event_date,''), date) DESC, event COLLATE NOCASE ASC, round ASC", 'length': 'ply_count DESC', 'opening': 'opening COLLATE NOCASE ASC, eco ASC', 'result': 'result ASC, date DESC', 'round': 'event COLLATE NOCASE ASC, LENGTH(round) ASC, round ASC', 'white': 'white COLLATE NOCASE ASC'}¶
- search(query=None, collection=None, player=None, white=None, black=None, eco=None, result=None, opening=None, min_elo=None, year=None, sort='date', limit=100, offset=0, event=None, min_length=None, max_length=None, tag=None, added_from=None, added_to=None, position=None, eco_to=None, max_elo=None, outcome=None, annotator=None, site=None, round=None, termination=None, annotated=None, date_from=None, date_to=None, source=None, category=None, white_min_elo=None, white_max_elo=None, black_min_elo=None, black_max_elo=None, kind=None, team=None, title=None, fide_id=None, source_title=None, variation=None, event_type=None, event_date_from=None, event_date_to=None)[source]¶
- delete_game(game_id)[source]¶
Drops the index entry. The PGN text stays in the file until compacted.
PGN reading¶
PGN splitting and header extraction.
The server never needs to understand chess rules — the browser has a full engine for that. All it does here is cut a PGN stream into games, read the tag pairs, and pull out enough of the movetext to index and search on.
- backend.pgnutil.normalize_date(value)[source]¶
PGN dates are ‘YYYY.MM.DD’ with ‘??’ for unknown parts; make them sortable.
Importing¶
Streaming imports of portable chess files. Archives are read, never extracted.
- backend.importers.sniff_encoding(sample, more_follows=True)[source]¶
Pick the encoding for a PGN file from its opening bytes.
Chess rules¶
Standard chess rules, 0x88 board, legal SAN and canonical position keys.
No runtime dependencies; used only for library indexing, never for UI latency.
The engine¶
The bundled Stockfish, spoken to over UCI.
One long-lived process, guarded by a lock, plus a background worker that can annotate a whole game and store the result. The browser no longer needs its own engine when the app runs on the desktop.
- backend.engine.default_binary()[source]¶
Where the bundled engine lives, in the source tree or inside a build.
- class backend.engine.Engine(path=None, threads=None, hash_mb=256)[source]¶
Bases:
objectA single UCI process. All public methods are safe to call from threads.
- class backend.engine.LiveAnalysis[source]¶
Bases:
objectDedicated UCI process so batch annotation cannot block live updates.
- class backend.engine.AnnotationJob(engine, library)[source]¶
Bases:
objectWalks a game’s positions and scores every one of them.
- JUDGMENTS = [(300, 'blunder'), (150, 'mistake'), (75, 'inaccuracy')]¶
Machine telemetry¶
Best-effort machine telemetry to show beside the engine.
Nothing here is required and nothing here is estimated. Core counts come from the OS; heat and power draw are read from whatever the firmware chooses to publish, which on Windows is a coin toss — plenty of desktops expose no thermal zone at all, and a machine on mains power reports no discharge rate because it is not discharging. Anything the machine will not tell us is reported as None so the interface can say “not available” instead of inventing a number.
Probing costs a subprocess on Windows, so readings are cached and refreshed on a background thread: callers always get the last known values immediately.
- backend.hardware.cores()[source]¶
Logical and physical core counts, physical only when psutil can tell us.
- class backend.hardware.CpuLoad[source]¶
Bases:
objectWhole-machine CPU load, measured from our own reading of the CPU time counters.
psutil.cpu_percent(interval=None)reports the load since the last call made anywhere in the process, and keeps that “last call” in module-level state. The live-analysis panel polls several times a second across many server threads, so that shared state gets reset out from under us and a poll lands on a zero-length interval — reporting 0% while the engine has every core pinned.Taking the
cpu_times()snapshot ourselves removes the shared state: the window is the one between our own two samples, whoever else is asking. Readings are cached between samples so rapid polling is cheap as well as correct.- MIN_INTERVAL = 0.5¶
Repertoire lines from PGN¶
Turn an opening PGN — a lichess study export, or anything with variations — into repertoire lines.
A lichess repertoire study is a tree: one main line with alternatives branching off it, often many levels deep. The repertoire trainer stores flat lines instead, so this walks the tree and writes down every root-to-leaf path through it.
Two decisions are worth stating plainly. A line is trimmed so it ends on a move by the side the repertoire is for: finishing a drill on the opponent’s reply teaches nothing. And a line that is only the opening part of a longer line is dropped, because drilling it would be drilling the same moves twice.
Position index and study folders¶
Position knowledge and portable study folders layered on the PGN library.
- class backend.study.Study(library)[source]¶
Bases:
object- delete_folder(folder_id)[source]¶
Remove a folder and everything nested inside it.
Collections are only unassigned: their PGN files live in the library’s collections directory, not in the study folder. On disk we take back the manifest we wrote and the directories we created, but a directory the user has put their own files in is left alone and reported back.
The opening tree¶
One player’s openings, judged by how they actually went.
A reference database answers “what is played here”. This answers a different and often more useful question: when this player played it, what happened to them. That player may be the reader, and may just as easily be Fischer or whoever a collection was imported for. Every number is from the named player’s side of the board, so a 38% score is 38% for them, not for White.
It reads the position index, so a collection has to be indexed before it has anything to say. Two things fall out of that design:
Transpositions come for free. Positions are stored under a transposition key, so a line reached by a different move order is the same node.
Depth costs nothing at query time. The index already holds every position of every game, so walking twenty moves deep is the same query as walking one.
The weakest-line scan is the part worth explaining. Ranking by score alone surfaces a 0% line played twice; ranking by volume surfaces the main line, which is no news. What a reader wants is where the points actually went, so lines are ranked by points dropped — games multiplied by the shortfall against an even score — and a line has to clear a minimum number of games before it is listed at all.
- class backend.openingtree.Filters(player=None, color=None, collection=None, speed=None, since=None, until=None, min_opponent_elo=None, max_opponent_elo=None, rated=None, kind=None)[source]¶
Bases:
objectThe set of games a report is about, and the SQL to select them.
- property perspective¶
- backend.openingtree.position(library, fen, filters)[source]¶
What happened from this position, move by move, from the named player’s side.
- backend.openingtree.trend(rows, buckets=None)[source]¶
Score by year over rows already read. Kept for callers working in memory.
- backend.openingtree.weakest(library, filters, min_games=3, limit=15, max_ply=24)[source]¶
The lines that cost the most points, ranked by points dropped rather than by score.
A single 0% game is noise; twelve games at 25% is a hole in the repertoire. Each entry names the line by replaying the game that reached it, so the answer reads as moves rather than as a hash.
Automatic imports¶
Keep a lichess account’s games flowing into the library as they are played.
A background thread asks lichess, every so often, for the games played since it last looked. That is deliberately a poll rather than a live stream: streaming an account’s games needs board scopes this app has no business holding, and a game is worth analysing a few minutes after it ends just as much as a few seconds after.
Three decisions worth stating, because each one is a trap avoided:
Windows overlap. Each poll asks for games since a little before the last one finished, not since exactly then. A game that ended during the gap is caught, and the repeat is free because duplicates are detected by lichess game id.
Enabling does not backfill. Switching this on starts from now. Pulling in a decade of blitz because somebody ticked a box is not a welcome surprise; the manual importer is still there for history, and “Import now” fetches a window on request.
Every run is an undoable batch, exactly like an import you started by hand, so a run that brings in something unwanted can be taken back.
lichess client¶
Server-side lichess client.
Doing the network work here rather than in the browser fixes the rate limiting: one process, one queue, one request at a time, with a shared cooldown. The browser can click “load” as often as it likes.
- class backend.lichess.Throttle(min_gap=1.0)[source]¶
Bases:
objectOne request at a time, with a minimum gap and a cooldown after a 429.
- backend.lichess.user_games(user, max_games=100, color=None, rated=None, perf=None, since=None, token=None)[source]¶
- backend.lichess.import_all_user_games(library, user, collection, token=None, batch_size=200, progress=None, **selection)[source]¶
Stream a whole lichess account into the library, however many games that is.
Asking for every game means not knowing how many are coming, which rules out holding the answer in memory: an active account is hundreds of megabytes of PGN. So the response is read as it arrives and written in batches, and the caller is told the running count rather than made to wait in silence.
selection takes the same narrowing arguments as user_games — color, rated, perf, since, until — so “every rated blitz game since 2023” is one call.
- backend.lichess.explorer(db='masters', play=None, fen=None, moves=12, top_games=8, extra=None, token=None)[source]¶
- class backend.lichess.MastersCrawler(library)[source]¶
Bases:
objectWalks the masters opening explorer and files away the games it names.
Breadth-first from the start position: every position it visits contributes its top games, and every move played often enough becomes a new position to visit. Slow by design (the explorer is a free service) but it can be left running, and it picks up where it left off.
- backend.lichess.import_dump(library, path, collection='Masters', limit=None, batch_size=500, progress=None)[source]¶
Import a downloaded lichess PGN dump (plain .pgn, or .pgn.zst with zstandard).
Streamed in batches so a multi-gigabyte file never has to fit in memory.
- backend.lichess.token_scopes(token)[source]¶
What a personal access token is allowed to do, straight from lichess.
The API surface¶
JSON API over the library.
Every handler returns (status, payload). Payloads are dicts (sent as JSON) or a (bytes, content_type) tuple for raw downloads.