pgn-postmortem 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pgn_postmortem/__init__.py +39 -0
- pgn_postmortem/__main__.py +5 -0
- pgn_postmortem/analysis.py +288 -0
- pgn_postmortem/cli.py +159 -0
- pgn_postmortem/collection.py +400 -0
- pgn_postmortem/site.py +1544 -0
- pgn_postmortem/static/history.js +363 -0
- pgn_postmortem-0.1.0.dist-info/METADATA +327 -0
- pgn_postmortem-0.1.0.dist-info/RECORD +13 -0
- pgn_postmortem-0.1.0.dist-info/WHEEL +5 -0
- pgn_postmortem-0.1.0.dist-info/entry_points.txt +2 -0
- pgn_postmortem-0.1.0.dist-info/licenses/LICENSE +21 -0
- pgn_postmortem-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""pgn-postmortem: turn a player's PGN collections into analyzed games and a
|
|
2
|
+
Wikipedia-style site, and (in a later release) an EPUB book.
|
|
3
|
+
|
|
4
|
+
The library API::
|
|
5
|
+
|
|
6
|
+
from pgn_postmortem import Collection
|
|
7
|
+
|
|
8
|
+
games = Collection.read(["games/**/*.pgn"], player="Ada Example", aliases=["adaex"])
|
|
9
|
+
games.write("games-clean/") # optional: one stripped PGN per game
|
|
10
|
+
games.analyze("analyzed/", depth=18, workers=4) # Stockfish, [%eval] comments, incremental
|
|
11
|
+
|
|
12
|
+
analyzed = Collection.read("analyzed/", player="Ada Example", aliases=["adaex"], keep_analysis=True)
|
|
13
|
+
analyzed.build_site("site/", title="Games of Ada Example") # an article per game, an index, the quiz
|
|
14
|
+
|
|
15
|
+
The same steps from the command line are ``pgn-postmortem read``,
|
|
16
|
+
``pgn-postmortem analyze`` and ``pgn-postmortem site`` (see ``pgn_postmortem.cli``).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from pgn_postmortem.analysis import AnalysisReport, EngineFailure, Thresholds, analyze_games
|
|
20
|
+
from pgn_postmortem.collection import CollectedGame, Collection, ReadReport, find_pgn_files
|
|
21
|
+
from pgn_postmortem.site import SiteReport, build_site, critical_moments, shown_result
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"AnalysisReport",
|
|
27
|
+
"CollectedGame",
|
|
28
|
+
"Collection",
|
|
29
|
+
"EngineFailure",
|
|
30
|
+
"ReadReport",
|
|
31
|
+
"Thresholds",
|
|
32
|
+
"__version__",
|
|
33
|
+
"SiteReport",
|
|
34
|
+
"analyze_games",
|
|
35
|
+
"build_site",
|
|
36
|
+
"critical_moments",
|
|
37
|
+
"find_pgn_files",
|
|
38
|
+
"shown_result",
|
|
39
|
+
]
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""The Stockfish step: analyze every game of a collection and write it, with
|
|
2
|
+
standard ``[%eval]`` comments, to an output directory.
|
|
3
|
+
|
|
4
|
+
For every move of a game (the mainline only; the collection has already
|
|
5
|
+
stripped whatever the source attached):
|
|
6
|
+
|
|
7
|
+
- the move gets the position's evaluation after it as a standard
|
|
8
|
+
``[%eval ...]`` comment, from White's point of view: ``[%eval 0.23]`` in
|
|
9
|
+
pawns, or ``[%eval #3]`` / ``[%eval #-2]`` for a forced mate. A move that
|
|
10
|
+
gives checkmate gets none, as in lichess's exports: there is nothing left
|
|
11
|
+
to evaluate.
|
|
12
|
+
- a move is flagged with a NAG ($6 inaccuracy, $2 mistake, $4 blunder) by the
|
|
13
|
+
*win percentage* its side lost, not by raw centipawns (CLAUDE.md, *decided*):
|
|
14
|
+
a centipawn eval becomes a 0-100 win chance through lichess's logistic fit
|
|
15
|
+
(https://lichess.org/page/accuracy), and the thresholds default to lichess's
|
|
16
|
+
own 10/20/30 points.
|
|
17
|
+
- a flagged move gets two engine lines as variations: Stockfish's preferred
|
|
18
|
+
move from the position before it, when that differs from the move played
|
|
19
|
+
("better was"), and Stockfish's best continuation from the position after it
|
|
20
|
+
("how to punish it"). Each line's last move carries a position NAG ($10 to
|
|
21
|
+
$19: =, +=, =+, and so on).
|
|
22
|
+
|
|
23
|
+
The step is incremental: a game already analyzed into the output directory
|
|
24
|
+
is not analyzed again. "Analyzed" means a file there carries the game's
|
|
25
|
+
``PostmortemId`` and the ``PostmortemAnalysis`` header, which only this step
|
|
26
|
+
writes (the engine and the search limit, e.g. ``Stockfish 16, depth 18``).
|
|
27
|
+
The file name alone is not enough: ``Collection.write`` uses the same
|
|
28
|
+
``<date>-<id>.pgn`` names for games it has only stripped, so reading into a
|
|
29
|
+
directory and then analyzing in place analyzes every game. Nor is the file
|
|
30
|
+
name needed: the test goes by the id (``analyzed_ids``), so a game analyzed
|
|
31
|
+
under an older name (``2019-3-14-<id>.pgn``, before dates in names were
|
|
32
|
+
zero-padded) is not analyzed again. It runs one
|
|
33
|
+
single-threaded Stockfish process per worker, and each game starts with a
|
|
34
|
+
fresh engine state (``ucinewgame``), so a game's output does not depend on
|
|
35
|
+
which worker analyzed it or on what that worker analyzed before.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import math
|
|
41
|
+
import os
|
|
42
|
+
import queue
|
|
43
|
+
import shutil
|
|
44
|
+
from collections.abc import Callable, Iterable
|
|
45
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
46
|
+
from dataclasses import dataclass, field
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from typing import NamedTuple
|
|
49
|
+
|
|
50
|
+
import chess
|
|
51
|
+
import chess.engine
|
|
52
|
+
import chess.pgn
|
|
53
|
+
|
|
54
|
+
from pgn_postmortem.collection import ANALYSIS_HEADER, CollectedGame, analyzed_ids, format_game
|
|
55
|
+
|
|
56
|
+
FALLBACK_ENGINE_PATH = "/usr/games/stockfish" # where Debian and Ubuntu install it, off the default PATH
|
|
57
|
+
|
|
58
|
+
MATE_SCORE = 100000
|
|
59
|
+
WIN_PCT_LOGISTIC_SCALE = 0.00368208
|
|
60
|
+
DEFAULT_TIME = 0.3
|
|
61
|
+
DEFAULT_PV_PLIES = 8
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Thresholds(NamedTuple):
|
|
65
|
+
"""Win-percentage points a move must lose to be flagged."""
|
|
66
|
+
|
|
67
|
+
inaccuracy: float = 10.0
|
|
68
|
+
mistake: float = 20.0
|
|
69
|
+
blunder: float = 30.0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
LICHESS_THRESHOLDS = Thresholds()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class EngineFailure(RuntimeError):
|
|
76
|
+
"""The engine could not be started, or failed while analyzing a game.
|
|
77
|
+
The run stops at the first failure; the games already written stay, and
|
|
78
|
+
a rerun picks up the rest."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
ENGINE_ERRORS = (chess.engine.EngineError, chess.engine.EngineTerminatedError)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class AnalysisReport:
|
|
86
|
+
analyzed: int = 0
|
|
87
|
+
skipped: int = 0
|
|
88
|
+
written: list[Path] = field(default_factory=list)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def default_engine_path() -> str:
|
|
92
|
+
return shutil.which("stockfish") or FALLBACK_ENGINE_PATH
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def win_percent(cp_white: int) -> float:
|
|
96
|
+
"""White's winning chances (0-100) for a White-POV centipawn eval."""
|
|
97
|
+
return 50 + 50 * (2 / (1 + math.exp(-WIN_PCT_LOGISTIC_SCALE * cp_white)) - 1)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def classify(loss_pct: float, thresholds: Thresholds) -> int | None:
|
|
101
|
+
if loss_pct >= thresholds.blunder:
|
|
102
|
+
return chess.pgn.NAG_BLUNDER
|
|
103
|
+
if loss_pct >= thresholds.mistake:
|
|
104
|
+
return chess.pgn.NAG_MISTAKE
|
|
105
|
+
if loss_pct >= thresholds.inaccuracy:
|
|
106
|
+
return chess.pgn.NAG_DUBIOUS_MOVE
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def classify_position(cp_white: int) -> int:
|
|
111
|
+
"""The standard position-evaluation NAG for a White-POV eval."""
|
|
112
|
+
pawns = cp_white / 100
|
|
113
|
+
if pawns <= -3.0:
|
|
114
|
+
return 19 # -+
|
|
115
|
+
if pawns <= -1.0:
|
|
116
|
+
return 17 # -/+
|
|
117
|
+
if pawns <= -0.4:
|
|
118
|
+
return 15 # =+
|
|
119
|
+
if pawns < 0.4:
|
|
120
|
+
return 10 # =
|
|
121
|
+
if pawns < 1.0:
|
|
122
|
+
return 14 # +=
|
|
123
|
+
if pawns < 3.0:
|
|
124
|
+
return 16 # +/-
|
|
125
|
+
return 18 # +-
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def attach_line(
|
|
129
|
+
parent: chess.pgn.GameNode, board: chess.Board, pv: list[chess.Move], pv_plies: int, final_cp_white: int
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Add ``pv`` (at most ``pv_plies`` moves) as a variation off ``parent``,
|
|
132
|
+
its last move tagged with a position NAG. ``parent``'s mainline child must
|
|
133
|
+
already exist, or the line would become the mainline."""
|
|
134
|
+
board = board.copy()
|
|
135
|
+
node = parent
|
|
136
|
+
for move in pv[:pv_plies]:
|
|
137
|
+
if move not in board.legal_moves:
|
|
138
|
+
break
|
|
139
|
+
node = node.add_variation(move)
|
|
140
|
+
board.push(move)
|
|
141
|
+
if node is not parent:
|
|
142
|
+
node.nags.add(classify_position(final_cp_white))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def analyze_game(
|
|
146
|
+
engine: chess.engine.SimpleEngine,
|
|
147
|
+
limit: chess.engine.Limit,
|
|
148
|
+
source: chess.pgn.Game,
|
|
149
|
+
engine_game: object,
|
|
150
|
+
pv_plies: int = DEFAULT_PV_PLIES,
|
|
151
|
+
thresholds: Thresholds = LICHESS_THRESHOLDS,
|
|
152
|
+
) -> chess.pgn.Game:
|
|
153
|
+
"""The analyzed copy of ``source`` (its headers, its mainline, our
|
|
154
|
+
comments, NAGs and lines, and the ``PostmortemAnalysis`` marker).
|
|
155
|
+
``engine_game`` identifies the game to the engine: a new value makes
|
|
156
|
+
python-chess send ``ucinewgame`` first."""
|
|
157
|
+
out = chess.pgn.Game()
|
|
158
|
+
out.headers = source.headers.copy()
|
|
159
|
+
out.headers[ANALYSIS_HEADER] = describe_analysis(engine, limit)
|
|
160
|
+
board = out.board()
|
|
161
|
+
node: chess.pgn.GameNode = out
|
|
162
|
+
|
|
163
|
+
def search(position: chess.Board) -> tuple[chess.engine.PovScore, list[chess.Move]]:
|
|
164
|
+
info = engine.analyse(position, limit, game=engine_game)
|
|
165
|
+
return info["score"], info.get("pv") or []
|
|
166
|
+
|
|
167
|
+
score, pv = search(board)
|
|
168
|
+
# A flagged move's punishment line hangs off that move's own node, which
|
|
169
|
+
# must first get its mainline child (the next move played), so it is
|
|
170
|
+
# attached one move later.
|
|
171
|
+
pending: tuple[chess.pgn.GameNode, chess.Board, list[chess.Move], int] | None = None
|
|
172
|
+
|
|
173
|
+
for move in source.mainline_moves():
|
|
174
|
+
mover = board.turn
|
|
175
|
+
cp_before = score.white().score(mate_score=MATE_SCORE)
|
|
176
|
+
pv_before = pv
|
|
177
|
+
|
|
178
|
+
node = node.add_variation(move)
|
|
179
|
+
if pending is not None:
|
|
180
|
+
attach_line(*pending[:3], pv_plies, pending[3])
|
|
181
|
+
pending = None
|
|
182
|
+
board.push(move)
|
|
183
|
+
|
|
184
|
+
score, pv = search(board)
|
|
185
|
+
cp_after = score.white().score(mate_score=MATE_SCORE)
|
|
186
|
+
node.set_eval(score)
|
|
187
|
+
|
|
188
|
+
loss = win_percent(cp_before) - win_percent(cp_after)
|
|
189
|
+
if mover == chess.BLACK:
|
|
190
|
+
loss = -loss
|
|
191
|
+
nag = classify(max(loss, 0.0), thresholds)
|
|
192
|
+
if nag is not None:
|
|
193
|
+
node.nags.add(nag)
|
|
194
|
+
if pv_before and pv_before[0] != move:
|
|
195
|
+
attach_line(node.parent, node.parent.board(), pv_before, pv_plies, cp_before)
|
|
196
|
+
if pv:
|
|
197
|
+
pending = (node, board.copy(), pv, cp_after)
|
|
198
|
+
|
|
199
|
+
# A pending line left after the last move is dropped: with no next move,
|
|
200
|
+
# it would become the game's mainline.
|
|
201
|
+
return out
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def describe_analysis(engine: chess.engine.SimpleEngine, limit: chess.engine.Limit) -> str:
|
|
205
|
+
budget = f"depth {limit.depth}" if limit.depth else f"{limit.time:g}s per position"
|
|
206
|
+
return f"{engine.id.get('name', 'UCI engine')}, {budget}"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def analyze_games(
|
|
210
|
+
games: Iterable[CollectedGame],
|
|
211
|
+
out_dir: str | Path,
|
|
212
|
+
*,
|
|
213
|
+
depth: int | None = None,
|
|
214
|
+
time: float = DEFAULT_TIME,
|
|
215
|
+
workers: int = 0,
|
|
216
|
+
engine_path: str | None = None,
|
|
217
|
+
pv_plies: int = DEFAULT_PV_PLIES,
|
|
218
|
+
thresholds: Thresholds = LICHESS_THRESHOLDS,
|
|
219
|
+
progress: Callable[[int, int, CollectedGame], None] | None = None,
|
|
220
|
+
) -> AnalysisReport:
|
|
221
|
+
"""Analyze every game not yet in ``out_dir`` into ``out_dir/<date>-<id>.pgn``.
|
|
222
|
+
|
|
223
|
+
``depth`` searches each position to a fixed depth (reproducible);
|
|
224
|
+
otherwise each position gets ``time`` seconds. ``workers`` Stockfish
|
|
225
|
+
processes run side by side (0: one per CPU). No engine is started when
|
|
226
|
+
there is nothing to analyze. ``progress(done, total, game)`` is called
|
|
227
|
+
after each game.
|
|
228
|
+
"""
|
|
229
|
+
out_dir = Path(out_dir)
|
|
230
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
done_ids = analyzed_ids(out_dir)
|
|
232
|
+
games = list(games)
|
|
233
|
+
todo = [g for g in games if g.id not in done_ids]
|
|
234
|
+
report = AnalysisReport(skipped=len(games) - len(todo))
|
|
235
|
+
if not todo:
|
|
236
|
+
return report
|
|
237
|
+
|
|
238
|
+
limit = chess.engine.Limit(depth=depth) if depth else chess.engine.Limit(time=time)
|
|
239
|
+
workers = max(1, min(workers or os.cpu_count() or 1, len(todo)))
|
|
240
|
+
engines: queue.Queue[chess.engine.SimpleEngine] = queue.Queue()
|
|
241
|
+
|
|
242
|
+
def run(item: CollectedGame) -> Path:
|
|
243
|
+
engine = engines.get()
|
|
244
|
+
try:
|
|
245
|
+
analyzed = analyze_game(engine, limit, item.game, item.id, pv_plies, thresholds)
|
|
246
|
+
except ENGINE_ERRORS as err:
|
|
247
|
+
raise EngineFailure(f"the engine failed on {item.origin}: {err}") from err
|
|
248
|
+
finally:
|
|
249
|
+
engines.put(engine)
|
|
250
|
+
path = out_dir / item.filename
|
|
251
|
+
partial = path.with_name(path.name + ".partial")
|
|
252
|
+
partial.write_text(format_game(analyzed), encoding="utf-8")
|
|
253
|
+
os.replace(partial, path) # a run that is interrupted never leaves a half-written game behind
|
|
254
|
+
return path
|
|
255
|
+
|
|
256
|
+
engine_path = engine_path or default_engine_path()
|
|
257
|
+
try:
|
|
258
|
+
for _ in range(workers):
|
|
259
|
+
try:
|
|
260
|
+
engine = chess.engine.SimpleEngine.popen_uci(engine_path)
|
|
261
|
+
except FileNotFoundError as err:
|
|
262
|
+
raise EngineFailure(f"Stockfish not found at {engine_path} (install it, or pass its path)") from err
|
|
263
|
+
except (OSError, *ENGINE_ERRORS) as err:
|
|
264
|
+
raise EngineFailure(f"could not start the engine {engine_path}: {err}") from err
|
|
265
|
+
engines.put(engine)
|
|
266
|
+
# only the options this engine has: another UCI engine may lack Stockfish's
|
|
267
|
+
engine.configure({k: v for k, v in {"Threads": 1, "Hash": 64}.items() if k in engine.options})
|
|
268
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
269
|
+
futures = {pool.submit(run, item): item for item in todo}
|
|
270
|
+
try:
|
|
271
|
+
for done, future in enumerate(as_completed(futures), start=1):
|
|
272
|
+
report.written.append(future.result())
|
|
273
|
+
report.analyzed += 1
|
|
274
|
+
if progress:
|
|
275
|
+
progress(done, len(todo), futures[future])
|
|
276
|
+
except BaseException:
|
|
277
|
+
# Fail fast: drop the queued games instead of trying each one first.
|
|
278
|
+
pool.shutdown(wait=True, cancel_futures=True)
|
|
279
|
+
raise
|
|
280
|
+
finally:
|
|
281
|
+
while not engines.empty():
|
|
282
|
+
engine = engines.get()
|
|
283
|
+
try:
|
|
284
|
+
engine.quit()
|
|
285
|
+
except ENGINE_ERRORS:
|
|
286
|
+
pass # it already died
|
|
287
|
+
report.written.sort()
|
|
288
|
+
return report
|
pgn_postmortem/cli.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""pgn-postmortem command line.
|
|
2
|
+
|
|
3
|
+
pgn-postmortem read INPUT... [--player NAME] [--alias NAME]... [--out DIR]
|
|
4
|
+
Read PGN collections (files, directories, glob patterns; quote a
|
|
5
|
+
pattern with ** so the library expands it), keep the player's games
|
|
6
|
+
once each, strip comments, variations and NAGs, and print what was
|
|
7
|
+
kept. With --out, write one PGN per game to DIR.
|
|
8
|
+
|
|
9
|
+
pgn-postmortem analyze INPUT... --out DIR [--player NAME] [--alias NAME]...
|
|
10
|
+
[--depth N | --time SECONDS] [--workers N] [--engine PATH]
|
|
11
|
+
Read the same way, then analyze with Stockfish every game that is not
|
|
12
|
+
in DIR yet, writing it to DIR with [%eval] comments.
|
|
13
|
+
|
|
14
|
+
pgn-postmortem site INPUT... --out DIR [--player NAME] [--alias NAME]... [--title TEXT]
|
|
15
|
+
[--site-key KEY] [--no-history]
|
|
16
|
+
Read the same way, keeping the analysis of games that `analyze` wrote,
|
|
17
|
+
and write a static site to DIR: one article per game and an index by
|
|
18
|
+
year. Analyzed games get notes, diagrams and a "what would you play?"
|
|
19
|
+
question at each critical moment; the others get a plain article.
|
|
20
|
+
With --player or --alias, a quiz page lists the player's own critical
|
|
21
|
+
moments, the costliest first, each linking to its question.
|
|
22
|
+
Every page carries a small script that keeps a reading history in the
|
|
23
|
+
reader's browser; --site-key sets the key it is stored under (by
|
|
24
|
+
default one derived from the title), and --no-history leaves it out.
|
|
25
|
+
|
|
26
|
+
Without --player or --alias, every game is kept.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import sys
|
|
33
|
+
|
|
34
|
+
from pgn_postmortem import __version__
|
|
35
|
+
from pgn_postmortem.analysis import DEFAULT_TIME, EngineFailure, analyze_games
|
|
36
|
+
from pgn_postmortem.collection import CollectedGame, Collection
|
|
37
|
+
from pgn_postmortem.site import build_site, check_site_key, display_name
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def add_reading_options(parser: argparse.ArgumentParser) -> None:
|
|
41
|
+
parser.add_argument("inputs", nargs="+", metavar="INPUT", help="a PGN file, a directory or a glob pattern")
|
|
42
|
+
parser.add_argument("--player", help="the player's name as it appears in White/Black")
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--alias", action="append", default=[], metavar="NAME", help="another name of the player (repeatable)"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_collection(args: argparse.Namespace, keep_analysis: bool = False) -> Collection:
|
|
49
|
+
collection = Collection.read(args.inputs, player=args.player, aliases=args.alias, keep_analysis=keep_analysis)
|
|
50
|
+
for warning in collection.report.warnings:
|
|
51
|
+
print(f"warning: {warning}", file=sys.stderr)
|
|
52
|
+
print(collection.report.summary())
|
|
53
|
+
return collection
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_read(args: argparse.Namespace) -> int:
|
|
57
|
+
collection = read_collection(args)
|
|
58
|
+
if args.out:
|
|
59
|
+
paths = collection.write(args.out)
|
|
60
|
+
kept = len(collection) - len(paths)
|
|
61
|
+
already = f"; {kept} already analyzed there, left as they are" if kept else ""
|
|
62
|
+
print(f"Wrote {len(paths)} game(s) to {args.out}{already}.")
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmd_analyze(args: argparse.Namespace) -> int:
|
|
67
|
+
collection = read_collection(args)
|
|
68
|
+
|
|
69
|
+
def progress(done: int, total: int, item: CollectedGame) -> None:
|
|
70
|
+
print(f" [{done}/{total}] {item.filename}", flush=True)
|
|
71
|
+
|
|
72
|
+
report = analyze_games(
|
|
73
|
+
collection,
|
|
74
|
+
args.out,
|
|
75
|
+
depth=args.depth,
|
|
76
|
+
time=args.time,
|
|
77
|
+
workers=args.workers,
|
|
78
|
+
engine_path=args.engine,
|
|
79
|
+
progress=progress,
|
|
80
|
+
)
|
|
81
|
+
print(f"Analyzed {report.analyzed} game(s) into {args.out}; {report.skipped} already there.")
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def site_key(value: str) -> str:
|
|
86
|
+
try:
|
|
87
|
+
return check_site_key(value)
|
|
88
|
+
except ValueError as err:
|
|
89
|
+
raise argparse.ArgumentTypeError(str(err)) from None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def cmd_site(args: argparse.Namespace) -> int:
|
|
93
|
+
collection = read_collection(args, keep_analysis=True)
|
|
94
|
+
title = args.title or (f"Games of {display_name(args.player)}" if args.player else "Games")
|
|
95
|
+
report = build_site(
|
|
96
|
+
collection,
|
|
97
|
+
args.out,
|
|
98
|
+
title=title,
|
|
99
|
+
history=args.history,
|
|
100
|
+
site_key=args.site_key,
|
|
101
|
+
player=args.player,
|
|
102
|
+
aliases=args.alias,
|
|
103
|
+
)
|
|
104
|
+
print(report.summary(args.out))
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
109
|
+
parser = argparse.ArgumentParser(
|
|
110
|
+
prog="pgn-postmortem", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
113
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
114
|
+
|
|
115
|
+
read = sub.add_parser("read", help="read and strip PGN collections")
|
|
116
|
+
add_reading_options(read)
|
|
117
|
+
read.add_argument("--out", metavar="DIR", help="write one stripped PGN per game here")
|
|
118
|
+
read.set_defaults(func=cmd_read)
|
|
119
|
+
|
|
120
|
+
analyze = sub.add_parser("analyze", help="analyze the games with Stockfish")
|
|
121
|
+
add_reading_options(analyze)
|
|
122
|
+
analyze.add_argument("--out", metavar="DIR", required=True, help="where analyzed games are written")
|
|
123
|
+
budget = analyze.add_mutually_exclusive_group()
|
|
124
|
+
budget.add_argument("--depth", type=int, help="search each position to this depth (reproducible)")
|
|
125
|
+
budget.add_argument(
|
|
126
|
+
"--time", type=float, default=DEFAULT_TIME, help=f"seconds per position (default {DEFAULT_TIME})"
|
|
127
|
+
)
|
|
128
|
+
analyze.add_argument("--workers", type=int, default=0, help="parallel Stockfish processes (default: one per CPU)")
|
|
129
|
+
analyze.add_argument("--engine", metavar="PATH", help="the Stockfish binary (default: stockfish on PATH)")
|
|
130
|
+
analyze.set_defaults(func=cmd_analyze)
|
|
131
|
+
|
|
132
|
+
site = sub.add_parser("site", help="write the static site: an article per game and an index")
|
|
133
|
+
add_reading_options(site)
|
|
134
|
+
site.add_argument("--out", metavar="DIR", required=True, help="where the site is written")
|
|
135
|
+
site.add_argument("--title", help='the site\'s title (default: "Games of <player>", or "Games")')
|
|
136
|
+
site.add_argument(
|
|
137
|
+
"--site-key",
|
|
138
|
+
type=site_key,
|
|
139
|
+
metavar="KEY",
|
|
140
|
+
help="the key the reading history is stored under in the reader's browser: 1 to 64 letters, digits, "
|
|
141
|
+
"'.', '_' or '-' (default: derived from the title)",
|
|
142
|
+
)
|
|
143
|
+
site.add_argument(
|
|
144
|
+
"--no-history",
|
|
145
|
+
dest="history",
|
|
146
|
+
action="store_false",
|
|
147
|
+
help="write the pages without the reading-history script, its section and its data- attributes",
|
|
148
|
+
)
|
|
149
|
+
site.set_defaults(func=cmd_site)
|
|
150
|
+
return parser
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def main(argv: list[str] | None = None) -> int:
|
|
154
|
+
args = build_parser().parse_args(argv)
|
|
155
|
+
try:
|
|
156
|
+
return args.func(args)
|
|
157
|
+
except (FileNotFoundError, EngineFailure) as err:
|
|
158
|
+
print(f"error: {err}", file=sys.stderr)
|
|
159
|
+
return 1
|