diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index f380bff25..a31e9152f 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -30,7 +30,7 @@ jobs: cd dist sdist=$(ls pygambit-*.tar.gz) pip install -v "${sdist}[test,doc]" - pip install "draw-tree @ git+https://github.com/gambitproject/draw_tree.git@v0.9.0" + pip install "draw-tree @ git+https://github.com/gambitproject/draw_tree.git@v0.9.1" - name: Run tests run: pytest --run-tutorials @@ -53,7 +53,7 @@ jobs: - name: Build extension run: | python -m pip install -v .[test,doc] - pip install "draw-tree @ git+https://github.com/gambitproject/draw_tree.git@v0.9.0" + pip install "draw-tree @ git+https://github.com/gambitproject/draw_tree.git@v0.9.1" - name: Run tests run: pytest --run-tutorials @@ -76,7 +76,7 @@ jobs: - name: Build extension run: | python -m pip install -v .[test,doc] - pip install "draw-tree @ git+https://github.com/gambitproject/draw_tree.git@v0.9.0" + pip install "draw-tree @ git+https://github.com/gambitproject/draw_tree.git@v0.9.1" - name: Run tests run: pytest --run-tutorials @@ -99,6 +99,6 @@ jobs: - name: Build extension run: | python -m pip install -v .[test,doc] - pip install "draw-tree @ git+https://github.com/gambitproject/draw_tree.git@v0.9.0" + pip install "draw-tree @ git+https://github.com/gambitproject/draw_tree.git@v0.9.1" - name: Run tests run: pytest --run-tutorials diff --git a/.readthedocs.yml b/.readthedocs.yml index 16e06bb4c..d06f69e2b 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -17,7 +17,7 @@ build: - pdf2svg jobs: post_install: - - pip install "draw-tree @ git+https://github.com/gambitproject/draw_tree.git@v0.9.0" + - pip install "draw-tree @ git+https://github.com/gambitproject/draw_tree.git@v0.9.1" # Create RST for catalog table in docs - $READTHEDOCS_VIRTUALENV_PATH/bin/python build_support/catalog/update.py diff --git a/Makefile.am b/Makefile.am index 0df98d38c..425dbb66a 100644 --- a/Makefile.am +++ b/Makefile.am @@ -22,6 +22,8 @@ ACLOCAL_AMFLAGS = -I m4 +include build_support/catalog/catalog.am + EXTRA_DIST = \ build_support/msw/gambit.wxs.in \ build_support/osx/Info.plist.in \ @@ -205,32 +207,7 @@ EXTRA_DIST = \ contrib/games/yamamoto.nfg \ contrib/games/zero.nfg \ src/README.rst \ - catalog/bagwell1995.efg \ - catalog/gilboa1997/fig1.efg \ - catalog/gilboa1997/fig2.efg \ - catalog/jakobsen2016/fig1a.efg \ - catalog/jakobsen2016/fig1b.efg \ - catalog/jakobsen2016/fig1c.efg \ - catalog/jakobsen2016/fig3.efg \ - catalog/myerson1991/fig2_1.efg \ - catalog/myerson1991/fig4_2.efg \ - catalog/nau2004/sec3.nfg \ - catalog/nau2004/sec4.nfg \ - catalog/nau2004/sec5.nfg \ - catalog/nau2004/sec6.nfg \ - catalog/reiley2008/fig1.efg \ - catalog/selten1975/fig1.efg \ - catalog/selten1975/fig2.efg \ - catalog/selten1975/fig3.efg \ - catalog/vonstengel2022/fig10.1.efg \ - catalog/vonstengel2022/fig10.12.efg \ - catalog/vonstengel2022/fig10.5.efg \ - catalog/vonstengel2022/fig10.7.efg \ - catalog/vonstengelforges2008/fig1.efg \ - catalog/vonstengelforges2008/fig6.efg \ - catalog/vonstengelforges2008/fig9.efg \ - catalog/watson2013/exercise29_6.efg \ - catalog/watson2013/fig29_1.efg + $(CATALOG_FILES) core_SOURCES = \ src/core/core.h \ diff --git a/build_support/catalog/catalog.am b/build_support/catalog/catalog.am new file mode 100644 index 000000000..d65083cd9 --- /dev/null +++ b/build_support/catalog/catalog.am @@ -0,0 +1,29 @@ +CATALOG_FILES = \ + catalog/bagwell1995.efg \ + catalog/gilboa1997/fig1.efg \ + catalog/gilboa1997/fig2.efg \ + catalog/jakobsen2016/fig1a.efg \ + catalog/jakobsen2016/fig1b.efg \ + catalog/jakobsen2016/fig1c.efg \ + catalog/jakobsen2016/fig3.efg \ + catalog/myerson1991/fig2_1.efg \ + catalog/myerson1991/fig4_2.efg \ + catalog/nau2004/sec3.nfg \ + catalog/nau2004/sec4.nfg \ + catalog/nau2004/sec5.nfg \ + catalog/nau2004/sec6.nfg \ + catalog/reiley2008/fig1.efg \ + catalog/selten1975/fig1.efg \ + catalog/selten1975/fig2.efg \ + catalog/selten1975/fig3.efg \ + catalog/vonstengel2022/fig10.1.efg \ + catalog/vonstengel2022/fig10.12.efg \ + catalog/vonstengel2022/fig10.5.efg \ + catalog/vonstengel2022/fig10.7.efg \ + catalog/vonstengelforges2008/fig1.efg \ + catalog/vonstengelforges2008/fig6.efg \ + catalog/vonstengelforges2008/fig6__Original_Layout.ef \ + catalog/vonstengelforges2008/fig9.efg \ + catalog/vonstengelforges2008/fig9__Original_Layout.ef \ + catalog/watson2013/exercise29_6.efg \ + catalog/watson2013/fig29_1.efg diff --git a/build_support/catalog/draw_tree_settings.yaml b/build_support/catalog/draw_tree_settings.yaml new file mode 100644 index 000000000..c71846502 --- /dev/null +++ b/build_support/catalog/draw_tree_settings.yaml @@ -0,0 +1,33 @@ +# Default draw_tree settings applied to all catalog games. +defaults: + color_scheme: gambit + font_family: sffamily + font_italic: true + shared_terminal_depth: true + sublevel_scaling: 0 + +# Per-game overrides. Keys are slug prefixes or exact slugs. +# For a given game, all matching keys are applied shortest-first, +# so more specific entries (longer keys) override more general ones. +# A key matches if slug == key or slug starts with key + "/". +# Consult https://www.gambit-project.org/draw_tree/ for available settings. +overrides: + bagwell1995: + sublevel_scaling: 1 + watson2013: + sublevel_scaling: 1 + selten1975: + shared_terminal_depth: false + myerson1991/fig2_1: + action_label_position: 0.4 + reiley2008/fig1: + action_label_position: 0.4 + vonstengel2022/fig10.1: + sublevel_scaling: 0.75 + shared_terminal_depth: false + vonstengelforges2008/fig1: + sublevel_scaling: 1 + vonstengelforges2008/fig9: + sublevel_scaling: 0.5 + gilboa1997/fig1: + action_label_dist: 5.0 diff --git a/build_support/catalog/test_update.py b/build_support/catalog/test_update.py new file mode 100644 index 000000000..70589e664 --- /dev/null +++ b/build_support/catalog/test_update.py @@ -0,0 +1,657 @@ +"""Tests for build_support/catalog/update.py. + +All catalog slugs used here are clearly fictional (e.g. ``"testgroup2000/fig1"``) +and do not correspond to any game in the real catalog. This is intentional: the +tests construct their own temporary catalog directories and DataFrame rows so they +are completely isolated from the actual catalog on disk. + +Monkeypatching strategy +----------------------- +``update.py`` depends on three external resources that are replaced in tests: + +1. ``DRAW_TREE_SETTINGS_CONFIG`` (a ``Path``) — swapped for a tmp YAML file so + ``catalog_draw_tree_settings`` reads controlled config without touching the + real ``draw_tree_settings.yaml``. ``monkeypatch.setattr(update, + "DRAW_TREE_SETTINGS_CONFIG", yaml_file)`` replaces the module-level path for + the duration of a single test and restores it automatically on teardown. + +2. ``generate_tex`` / ``generate_png`` / ``generate_pdf`` / ``generate_svg`` + (functions imported from ``draw_tree``) — replaced with no-ops or + call-tracking lambdas. This lets us test RST-generation logic without + actually invoking LaTeX, and lets us assert whether image + generation was triggered at all. + +3. ``catalog_dir`` (an argument to ``generate_rst_table`` and + ``update_makefile``) — both functions accept an optional ``catalog_dir`` + kwarg that defaults to the real ``CATALOG_DIR``. Tests pass a + ``tmp_path``-based directory instead, keeping all file I/O inside pytest's + temporary directory and avoiding any reads from or writes to the repo. +""" + +import textwrap + +import pytest + +pytest.importorskip("draw_tree") # update.py imports draw_tree at module level +pytest.importorskip("yaml") + +import pandas as pd # noqa: E402 +import update # noqa: E402 + +# --------------------------------------------------------------------------- +# Module-level test fixtures +# --------------------------------------------------------------------------- + +# The expected dict produced by _YAML_CONFIG with no slug-specific override. +# Tests that expect defaults-only results compare against this constant. +_YAML_DEFAULTS = { + "color_scheme": "gambit", + "font_family": "sffamily", + "font_italic": True, + "shared_terminal_depth": True, + "sublevel_scaling": 0, +} + +# A self-contained draw_tree_settings YAML config used by settings tests. +# Slugs are entirely fictional: +# "testgroup2000" – group-level prefix covering testgroup2000/* +# "othergroup1999" – group-level prefix covering othergroup1999/* +# "testgroup2000/fig2" – game-specific entry that overrides the group above +_YAML_CONFIG = textwrap.dedent("""\ + defaults: + color_scheme: gambit + font_family: sffamily + font_italic: true + shared_terminal_depth: true + sublevel_scaling: 0 + + overrides: + testgroup2000: + sublevel_scaling: 1 + othergroup1999: + shared_terminal_depth: false + testgroup2000/fig2: + action_label_position: 0.4 +""") + + +# --------------------------------------------------------------------------- +# Helper functions +# --------------------------------------------------------------------------- + + +def _write_yaml(path, content=_YAML_CONFIG): + """Write *content* to *path* and return *path*. + + Used to create a temporary draw_tree_settings YAML file that can be + pointed at via ``monkeypatch.setattr(update, "DRAW_TREE_SETTINGS_CONFIG", + path)`` without touching the real config file. + """ + path.write_text(content, encoding="utf-8") + return path + + +def _efg_row(slug, title="Test EFG Game", description="A description."): + """Return a dict representing one row of the DataFrame produced by + ``gbt.catalog.games(include_descriptions=True)`` for an extensive-form game. + """ + return { + "Game": slug, + "Title": title, + "Description": description, + "Download": f":download:`{slug}.efg <../catalog/{slug}.efg>`", + "Format": "efg", + } + + +def _nfg_row(slug, title="Test NFG Game", description="A description."): + """Return a dict representing one row of the DataFrame for a normal-form game.""" + return { + "Game": slug, + "Title": title, + "Description": description, + "Download": f":download:`{slug}.nfg <../catalog/{slug}.nfg>`", + "Format": "nfg", + } + + +def _make_df(*rows): + """Build a DataFrame from one or more row dicts as ``generate_rst_table`` expects.""" + return pd.DataFrame(list(rows)) + + +def _make_image_files(catalog_dir, slug, fmt="efg"): + """Create stub image files under *catalog_dir*/img/ for *slug*. + + ``generate_rst_table`` checks that all expected image files exist before + deciding whether to regenerate them. Touching empty files satisfies that + check without requiring real draw_tree output, so tests that are not + specifically about image generation can use this helper to set up the + pre-existing-images state. + + For EFG games the ``.ef`` intermediate file is also created, since it + appears in the existence check and the download links. + """ + img_dir = catalog_dir / "img" + img_dir.mkdir(parents=True, exist_ok=True) + slug_path = img_dir / slug + slug_path.parent.mkdir(parents=True, exist_ok=True) + for ext in ["tex", "png", "pdf", "svg"]: + (img_dir / f"{slug}.{ext}").touch() + if fmt == "efg": + (img_dir / f"{slug}.ef").touch() + + +# --------------------------------------------------------------------------- +# Tests for catalog_draw_tree_settings +# --------------------------------------------------------------------------- + + +@pytest.mark.catalog_update +class TestCatalogDrawTreeSettings: + """Unit tests for ``catalog_draw_tree_settings(slug) -> dict``. + + Each test writes a temporary YAML config and redirects the module-level + ``DRAW_TREE_SETTINGS_CONFIG`` path to it via ``monkeypatch.setattr``. + This means the real ``draw_tree_settings.yaml`` is never read or modified. + """ + + def test_no_override_returns_defaults(self, tmp_path, monkeypatch): + """A slug with no matching entry in ``overrides`` returns the defaults verbatim.""" + yaml_file = _write_yaml(tmp_path / "settings.yaml") + monkeypatch.setattr(update, "DRAW_TREE_SETTINGS_CONFIG", yaml_file) + result = update.catalog_draw_tree_settings("unknowngame/v1") + assert result == _YAML_DEFAULTS + + def test_exact_slug_override_applied(self, tmp_path, monkeypatch): + """A key in ``overrides`` that exactly matches the slug is merged into defaults.""" + yaml_file = _write_yaml(tmp_path / "settings.yaml") + monkeypatch.setattr(update, "DRAW_TREE_SETTINGS_CONFIG", yaml_file) + result = update.catalog_draw_tree_settings("testgroup2000/fig2") + assert result["action_label_position"] == pytest.approx(0.4) + assert result["color_scheme"] == "gambit" # defaults still present + + def test_prefix_slug_override_applied(self, tmp_path, monkeypatch): + """A group-level key (e.g. ``"testgroup2000"``) matches any slug that starts with it.""" + yaml_file = _write_yaml(tmp_path / "settings.yaml") + monkeypatch.setattr(update, "DRAW_TREE_SETTINGS_CONFIG", yaml_file) + # "testgroup2000/fig1" is not listed explicitly; it matches the group prefix + result = update.catalog_draw_tree_settings("testgroup2000/fig1") + assert result["sublevel_scaling"] == 1 + + def test_specific_key_wins_over_group(self, tmp_path, monkeypatch): + """When both a group key and a more specific key match, the specific key wins. + + The config has ``testgroup2000`` (sets sublevel_scaling=1) and + ``testgroup2000/fig2`` (sets sublevel_scaling=2). The game + ``testgroup2000/fig2`` matches both, but the longer/specific key is + applied last, so sublevel_scaling should be 2. + """ + config = textwrap.dedent("""\ + defaults: + color_scheme: gambit + sublevel_scaling: 0 + overrides: + testgroup2000: + sublevel_scaling: 1 + testgroup2000/fig2: + sublevel_scaling: 2 + """) + yaml_file = _write_yaml(tmp_path / "settings.yaml", config) + monkeypatch.setattr(update, "DRAW_TREE_SETTINGS_CONFIG", yaml_file) + result = update.catalog_draw_tree_settings("testgroup2000/fig2") + assert result["sublevel_scaling"] == 2 + + def test_group_override_does_not_bleed_to_other_game(self, tmp_path, monkeypatch): + """A group-level override applies only to games whose slug starts with that prefix.""" + yaml_file = _write_yaml(tmp_path / "settings.yaml") + monkeypatch.setattr(update, "DRAW_TREE_SETTINGS_CONFIG", yaml_file) + # "othergroup1999" override sets shared_terminal_depth = False + result_other = update.catalog_draw_tree_settings("othergroup1999/fig1") + assert result_other["shared_terminal_depth"] is False + # "testgroup2000" has a different override; shared_terminal_depth should be True (default) + result_test = update.catalog_draw_tree_settings("testgroup2000/fig1") + assert result_test["shared_terminal_depth"] is True + + def test_no_overrides_section_returns_defaults(self, tmp_path, monkeypatch): + """A config with no ``overrides`` key at all returns only the defaults.""" + config = textwrap.dedent("""\ + defaults: + color_scheme: gambit + sublevel_scaling: 0 + """) + yaml_file = _write_yaml(tmp_path / "settings.yaml", config) + monkeypatch.setattr(update, "DRAW_TREE_SETTINGS_CONFIG", yaml_file) + result = update.catalog_draw_tree_settings("anygame/v1") + assert result == {"color_scheme": "gambit", "sublevel_scaling": 0} + + +# --------------------------------------------------------------------------- +# Tests for catalog_ef_file_variants +# --------------------------------------------------------------------------- + + +@pytest.mark.catalog_update +class TestCatalogEfFileVariants: + """Tests for ``catalog_ef_file_variants(slug, catalog_dir) -> list[dict] | None``. + + The function scans the directory for the game slug inside ``catalog_dir`` + for ``.ef`` files matching the naming convention:: + + {stem}.ef primary variant + {stem}__{suffix}.ef additional variant + + All slugs use the fictional prefix ``fakevariant2000`` to make it clear + these tests do not depend on the real catalog contents. + """ + + def _game_dir(self, catalog_dir, slug): + """Create and return the directory that would contain the game's files.""" + game_dir = (catalog_dir / slug).parent + game_dir.mkdir(parents=True, exist_ok=True) + return game_dir + + def test_no_ef_files_returns_none(self, tmp_path): + """No .ef files in the game directory → returns None (no tab-set needed).""" + catalog_dir = tmp_path / "catalog" + slug = "fakevariant2000/fig1" + self._game_dir(catalog_dir, slug) + assert update.catalog_ef_file_variants(slug, catalog_dir) is None + + def test_single_ef_file_returns_none(self, tmp_path): + """A single curated .ef file → returns None (single image, no tabs needed).""" + catalog_dir = tmp_path / "catalog" + slug = "fakevariant2000/fig1" + game_dir = self._game_dir(catalog_dir, slug) + (game_dir / "fig1.ef").touch() + assert update.catalog_ef_file_variants(slug, catalog_dir) is None + + def test_two_ef_files_returns_variant_list(self, tmp_path): + """Two .ef files → 2-item list with correct label, ef_path, and variant_key.""" + catalog_dir = tmp_path / "catalog" + slug = "fakevariant2000/fig1" + game_dir = self._game_dir(catalog_dir, slug) + (game_dir / "fig1.ef").touch() + (game_dir / "fig1__wide.ef").touch() + result = update.catalog_ef_file_variants(slug, catalog_dir) + assert result is not None + assert len(result) == 2 + assert {v["label"] for v in result} == {"Default", "Wide"} + assert {v["variant_key"] for v in result} == {slug, f"{slug}__wide"} + + def test_variant_without_primary_ef_returns_variants(self, tmp_path): + """A variant .ef file exists but no primary .ef file exists -> + returns variants list including Default. + """ + catalog_dir = tmp_path / "catalog" + slug = "fakevariant2000/fig1" + game_dir = self._game_dir(catalog_dir, slug) + (game_dir / "fig1__wide.ef").touch() + result = update.catalog_ef_file_variants(slug, catalog_dir) + assert result is not None + assert len(result) == 2 + assert {v["label"] for v in result} == {"Default", "Wide"} + assert {v["variant_key"] for v in result} == {slug, f"{slug}__wide"} + assert not (game_dir / "fig1.ef").exists() + + def test_label_derived_from_filename_suffix(self, tmp_path): + """The tab label is the suffix after ``__``, title-cased.""" + catalog_dir = tmp_path / "catalog" + slug = "fakevariant2000/fig1" + game_dir = self._game_dir(catalog_dir, slug) + (game_dir / "fig1.ef").touch() + (game_dir / "fig1__compact.ef").touch() + result = update.catalog_ef_file_variants(slug, catalog_dir) + assert {v["label"] for v in result} == {"Default", "Compact"} + + def test_multi_word_suffix_title_cased(self, tmp_path): + """Underscores in the suffix become spaces: ``fig1__very_wide.ef`` → "Very Wide".""" + catalog_dir = tmp_path / "catalog" + slug = "fakevariant2000/fig1" + game_dir = self._game_dir(catalog_dir, slug) + (game_dir / "fig1.ef").touch() + (game_dir / "fig1__very_wide.ef").touch() + result = update.catalog_ef_file_variants(slug, catalog_dir) + assert "Very Wide" in {v["label"] for v in result} + + def test_file_without_double_underscore_excluded(self, tmp_path): + """A file whose stem is ``{stem}extra`` (no ``__``) is not treated as a variant. + + Only files matching exactly ``{stem}.ef`` or ``{stem}__*.ef`` are counted. + If the non-conforming file is the only candidate alongside the base, the + function still returns None (only one conforming file found). + """ + catalog_dir = tmp_path / "catalog" + slug = "fakevariant2000/fig1" + game_dir = self._game_dir(catalog_dir, slug) + (game_dir / "fig1.ef").touch() + (game_dir / "fig1extra.ef").touch() # no __ separator — must be ignored + assert update.catalog_ef_file_variants(slug, catalog_dir) is None + + +# --------------------------------------------------------------------------- +# Tests for generate_rst_table +# --------------------------------------------------------------------------- + + +@pytest.mark.catalog_update +class TestGenerateRstTable: + """Tests for ``generate_rst_table(df, rst_path, ...)``. + + Image generation (generate_tex / generate_png / etc.) is mocked out so + that tests can run without LaTeX installed and without reading + from the real catalog directory. + + ``_mock_generates`` uses ``monkeypatch.setattr`` to replace each of the + four draw_tree generate functions in the ``update`` module's namespace with + a no-op. Because the replacement is scoped to the test, the originals are + automatically restored afterward. + + Tests that need to verify *whether* generation was triggered replace the + functions with lambdas that append to a ``calls`` list instead. + + All catalog directories and RST output files are created inside ``tmp_path`` + (pytest's per-test temporary directory) so nothing is written to the repo. + """ + + def _no_op_generate(self, *args, **kwargs): + """Stand-in for draw_tree generate_* functions; does nothing.""" + + def _mock_generates(self, monkeypatch): + """Replace all four draw_tree image-generation functions with no-ops.""" + for name in ["generate_tex", "generate_png", "generate_pdf", "generate_svg"]: + monkeypatch.setattr(update, name, self._no_op_generate) + + def test_efg_row_produces_rst_with_slug_and_title(self, tmp_path, monkeypatch): + """An EFG game row appears in the RST with its title, load call, and download links.""" + self._mock_generates(monkeypatch) + catalog_dir = tmp_path / "catalog" + slug = "fakeauthor2000/fig1" + _make_image_files(catalog_dir, slug, "efg") + df = _make_df(_efg_row(slug, title="Fake Author (2000) Figure 1")) + rst_path = tmp_path / "out.rst" + update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) + rst = rst_path.read_text() + assert "Fake Author (2000) Figure 1" in rst + assert f'pygambit.catalog.load("{slug}")' in rst + assert f":download:`{slug}.efg" in rst # source game file download link + assert f":download:`{slug}.ef" in rst # draw_tree intermediate file download link + + def test_nfg_row_produces_rst_with_save_to(self, tmp_path, monkeypatch): + """An NFG game row uses the ``save_to`` form of the draw_tree call (no .ef involved).""" + self._mock_generates(monkeypatch) + catalog_dir = tmp_path / "catalog" + slug = "fakeauthor2001/matrix1" + _make_image_files(catalog_dir, slug, "nfg") + df = _make_df(_nfg_row(slug)) + rst_path = tmp_path / "out.rst" + update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) + rst = rst_path.read_text() + assert f'save_to="../catalog/img/{slug}.png"' in rst + + def test_unknown_format_row_is_skipped(self, tmp_path, monkeypatch): + """A row whose Format is not 'efg' or 'nfg' is silently omitted from the RST.""" + self._mock_generates(monkeypatch) + catalog_dir = tmp_path / "catalog" + catalog_dir.mkdir() + row = { + "Game": "fakegame/v1", + "Title": "Fake Game", + "Description": "Has a description.", + "Download": "", + "Format": "efg_2", # not in SUPPORTED_GAME_FORMATS + } + df = _make_df(row) + rst_path = tmp_path / "out.rst" + update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) + rst = rst_path.read_text() + assert "Fake Game" not in rst + + def test_row_without_description_is_skipped(self, tmp_path, monkeypatch): + """A game with an empty description is not included in the RST output.""" + self._mock_generates(monkeypatch) + catalog_dir = tmp_path / "catalog" + catalog_dir.mkdir() + df = _make_df(_efg_row("fakeauthor2000/fig1", description="")) + rst_path = tmp_path / "out.rst" + update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) + rst = rst_path.read_text() + assert "fakeauthor2000/fig1" not in rst + + def test_curated_ef_used_in_draw_tree_call(self, tmp_path, monkeypatch): + """When a curated .ef file exists alongside the .efg, the RST draw_tree call + references the .ef path directly rather than ``pygambit.catalog.load``.""" + self._mock_generates(monkeypatch) + catalog_dir = tmp_path / "catalog" + slug = "fakeauthor1999/fig1" + _make_image_files(catalog_dir, slug, "efg") + # Place a curated .ef file alongside the game — this is what update.py checks for + curated = catalog_dir / f"{slug}.ef" + curated.parent.mkdir(parents=True, exist_ok=True) + curated.touch() + df = _make_df(_efg_row(slug)) + rst_path = tmp_path / "out.rst" + update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) + rst = rst_path.read_text() + # Find the draw_tree( call line in the jupyter-execute block + draw_tree_call = next(line for line in rst.splitlines() if "draw_tree(" in line) + assert f'"../catalog/{slug}.ef"' in draw_tree_call + assert "catalog.load" not in draw_tree_call + + def test_images_not_regenerated_when_all_exist(self, tmp_path, monkeypatch): + """If all expected image files are already present and ``regenerate_images`` is + False, none of the draw_tree generate functions are called.""" + calls = [] + # Replace generate_* with lambdas that record invocations + monkeypatch.setattr(update, "generate_tex", lambda *a, **k: calls.append("tex")) + monkeypatch.setattr(update, "generate_png", lambda *a, **k: calls.append("png")) + monkeypatch.setattr(update, "generate_pdf", lambda *a, **k: calls.append("pdf")) + monkeypatch.setattr(update, "generate_svg", lambda *a, **k: calls.append("svg")) + catalog_dir = tmp_path / "catalog" + slug = "fakeauthor2000/fig1" + _make_image_files(catalog_dir, slug, "efg") # all images already exist + df = _make_df(_efg_row(slug)) + rst_path = tmp_path / "out.rst" + update.generate_rst_table(df, rst_path, regenerate_images=False, catalog_dir=catalog_dir) + assert calls == [] + + def test_images_regenerated_when_flag_set(self, tmp_path, monkeypatch): + """When ``regenerate_images=True``, all four generate functions are called even + if the image files already exist. + + A curated .ef file is placed in the catalog dir so ``update.py`` uses it + as the draw_tree source rather than calling ``gbt.catalog.load``, which + would require the real catalog to be present. + """ + calls = [] + monkeypatch.setattr(update, "generate_tex", lambda *a, **k: calls.append("tex")) + monkeypatch.setattr(update, "generate_png", lambda *a, **k: calls.append("png")) + monkeypatch.setattr(update, "generate_pdf", lambda *a, **k: calls.append("pdf")) + monkeypatch.setattr(update, "generate_svg", lambda *a, **k: calls.append("svg")) + catalog_dir = tmp_path / "catalog" + slug = "fakeauthor2000/fig1" + _make_image_files(catalog_dir, slug, "efg") + # Place a curated .ef file alongside the game — this is what update.py checks for + curated = catalog_dir / f"{slug}.ef" + curated.parent.mkdir(parents=True, exist_ok=True) + curated.touch() + df = _make_df(_efg_row(slug)) + rst_path = tmp_path / "out.rst" + update.generate_rst_table(df, rst_path, regenerate_images=True, catalog_dir=catalog_dir) + assert set(calls) == {"tex", "png", "pdf", "svg"} + + def test_multi_variant_efg_produces_tab_set(self, tmp_path, monkeypatch): + """Two curated .ef files alongside a game trigger a ``tab-set`` in the RST. + + The RST should contain ``.. tab-set::`` and one ``.. tab-item::`` per + variant, with labels derived from the filename suffixes. + """ + self._mock_generates(monkeypatch) + catalog_dir = tmp_path / "catalog" + slug = "fakevariant2001/fig1" + game_dir = catalog_dir / "fakevariant2001" + game_dir.mkdir(parents=True) + (game_dir / "fig1.ef").touch() + (game_dir / "fig1__wide.ef").touch() + df = _make_df(_efg_row(slug)) + rst_path = tmp_path / "out.rst" + update.generate_rst_table(df, rst_path, regenerate_images=True, catalog_dir=catalog_dir) + rst = rst_path.read_text() + assert ".. tab-set::" in rst + assert ".. tab-item:: Default" in rst + assert ".. tab-item:: Wide" in rst + + def test_multi_variant_efg_without_primary_ef_produces_tab_set(self, tmp_path, monkeypatch): + """A variant .ef file without a primary .ef file triggers a tab-set in the RST + containing both a Default and the custom variant, calling catalog.load for the Default. + """ + self._mock_generates(monkeypatch) + monkeypatch.setattr(update.gbt.catalog, "load", lambda slug: "dummy_game") + catalog_dir = tmp_path / "catalog" + slug = "fakevariant2001/fig1" + game_dir = catalog_dir / "fakevariant2001" + game_dir.mkdir(parents=True) + (game_dir / "fig1__wide.ef").touch() + df = _make_df(_efg_row(slug)) + rst_path = tmp_path / "out.rst" + update.generate_rst_table(df, rst_path, regenerate_images=True, catalog_dir=catalog_dir) + rst = rst_path.read_text() + assert ".. tab-set::" in rst + assert ".. tab-item:: Default" in rst + assert ".. tab-item:: Wide" in rst + assert 'draw_tree(pygambit.catalog.load("fakevariant2001/fig1")' in rst + assert 'draw_tree("../catalog/fakevariant2001/fig1__wide.ef"' in rst + + def test_single_variant_efg_produces_no_tab_set(self, tmp_path, monkeypatch): + """A single curated .ef file (or no .ef file) does not produce a ``tab-set``.""" + self._mock_generates(monkeypatch) + catalog_dir = tmp_path / "catalog" + slug = "fakevariant2001/fig1" + _make_image_files(catalog_dir, slug, "efg") + game_dir = catalog_dir / "fakevariant2001" + game_dir.mkdir(parents=True, exist_ok=True) + (game_dir / "fig1.ef").touch() # only one .ef — no tabs + df = _make_df(_efg_row(slug)) + rst_path = tmp_path / "out.rst" + update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) + rst = rst_path.read_text() + assert ".. tab-set::" not in rst + + def test_per_variant_images_generated(self, tmp_path, monkeypatch): + """When multiple .ef variants exist, image generation is called once per variant. + + Two variants × four generate functions = eight total calls. + """ + calls = [] + monkeypatch.setattr(update, "generate_tex", lambda *a, **k: calls.append("tex")) + monkeypatch.setattr(update, "generate_png", lambda *a, **k: calls.append("png")) + monkeypatch.setattr(update, "generate_pdf", lambda *a, **k: calls.append("pdf")) + monkeypatch.setattr(update, "generate_svg", lambda *a, **k: calls.append("svg")) + catalog_dir = tmp_path / "catalog" + slug = "fakevariant2001/fig1" + game_dir = catalog_dir / "fakevariant2001" + game_dir.mkdir(parents=True) + (game_dir / "fig1.ef").touch() + (game_dir / "fig1__wide.ef").touch() + df = _make_df(_efg_row(slug)) + rst_path = tmp_path / "out.rst" + update.generate_rst_table(df, rst_path, regenerate_images=True, catalog_dir=catalog_dir) + assert len(calls) == 8 # 4 functions × 2 variants + + def test_per_variant_images_not_regenerated_when_all_exist(self, tmp_path, monkeypatch): + """If all variant image files already exist and ``regenerate_images`` is False, + generate functions are not called.""" + calls = [] + monkeypatch.setattr(update, "generate_tex", lambda *a, **k: calls.append("tex")) + monkeypatch.setattr(update, "generate_png", lambda *a, **k: calls.append("png")) + monkeypatch.setattr(update, "generate_pdf", lambda *a, **k: calls.append("pdf")) + monkeypatch.setattr(update, "generate_svg", lambda *a, **k: calls.append("svg")) + catalog_dir = tmp_path / "catalog" + slug = "fakevariant2001/fig1" + game_dir = catalog_dir / "fakevariant2001" + game_dir.mkdir(parents=True) + (game_dir / "fig1.ef").touch() + (game_dir / "fig1__wide.ef").touch() + # Pre-create all image files for both variants so nothing needs regenerating + for vkey in [slug, f"{slug}__wide"]: + _make_image_files(catalog_dir, vkey, "efg") + df = _make_df(_efg_row(slug)) + rst_path = tmp_path / "out.rst" + update.generate_rst_table(df, rst_path, regenerate_images=False, catalog_dir=catalog_dir) + assert calls == [] + + +# --------------------------------------------------------------------------- +# Tests for update_makefile +# --------------------------------------------------------------------------- + + +@pytest.mark.catalog_update +class TestUpdateMakefile: + """Tests for ``update_makefile(catalog_dir, am_path)``. + + Both arguments are injected via ``tmp_path`` so the real catalog directory + and the real ``catalog.am`` file are never read or written. + """ + + def test_efg_and_nfg_files_included(self, tmp_path): + """Game files with .efg and .nfg extensions appear in the generated catalog.am.""" + (tmp_path / "standalone.efg").touch() + (tmp_path / "subfolder").mkdir() + (tmp_path / "subfolder" / "matrix.nfg").touch() + am = tmp_path / "catalog.am" + update.update_makefile(catalog_dir=tmp_path, am_path=am) + content = am.read_text() + assert "catalog/standalone.efg" in content + assert "catalog/subfolder/matrix.nfg" in content + + def test_curated_ef_included(self, tmp_path): + """A curated .ef file committed alongside a game file appears in catalog.am.""" + (tmp_path / "fakegame").mkdir() + (tmp_path / "fakegame" / "fig1.efg").touch() + (tmp_path / "fakegame" / "fig1.ef").touch() # curated layout file + am = tmp_path / "catalog.am" + update.update_makefile(catalog_dir=tmp_path, am_path=am) + content = am.read_text() + assert "catalog/fakegame/fig1.ef" in content + + def test_ef_in_img_dir_excluded(self, tmp_path): + """Generated .ef files under the img/ subdirectory are excluded from catalog.am.""" + img = tmp_path / "img" / "fakegame" + img.mkdir(parents=True) + (img / "fig1.ef").touch() # generated artifact — should not be distributed + am = tmp_path / "catalog.am" + update.update_makefile(catalog_dir=tmp_path, am_path=am) + content = am.read_text() + assert "img" not in content + + def test_non_game_file_excluded(self, tmp_path): + """Files with non-game extensions (e.g. .efg_2, .txt) are not included.""" + (tmp_path / "fakegame.efg_2").touch() # hidden/renamed file + (tmp_path / "README.txt").touch() + am = tmp_path / "catalog.am" + update.update_makefile(catalog_dir=tmp_path, am_path=am) + content = am.read_text() + assert "efg_2" not in content + assert "README" not in content + + def test_no_write_when_content_unchanged(self, tmp_path): + """If catalog.am already contains the correct content, it is not rewritten. + + The mtime of the file is captured after the first write and compared + after the second call. If the file were overwritten, the mtime would + change; if the content-equality check works correctly, it stays the same. + """ + (tmp_path / "standalone.efg").touch() + am = tmp_path / "catalog.am" + update.update_makefile(catalog_dir=tmp_path, am_path=am) + mtime_after_first_write = am.stat().st_mtime + update.update_makefile(catalog_dir=tmp_path, am_path=am) + assert am.stat().st_mtime == mtime_after_first_write + + def test_empty_catalog_produces_valid_am(self, tmp_path): + """An empty catalog directory produces a catalog.am with a valid (empty) CATALOG_FILES.""" + am = tmp_path / "catalog.am" + update.update_makefile(catalog_dir=tmp_path, am_path=am) + content = am.read_text() + assert content.startswith("CATALOG_FILES =") diff --git a/build_support/catalog/update.py b/build_support/catalog/update.py index 0b6c03074..96c52f042 100644 --- a/build_support/catalog/update.py +++ b/build_support/catalog/update.py @@ -1,7 +1,9 @@ import argparse +import shutil from pathlib import Path import pandas as pd +import yaml from draw_tree import generate_pdf, generate_png, generate_svg, generate_tex import pygambit as gbt @@ -9,35 +11,75 @@ CATALOG_RST_TABLE = Path(__file__).parent.parent.parent / "doc" / "catalog_table.rst" CATALOG_DIR = Path(__file__).parent.parent.parent / "catalog" MAKEFILE_AM = Path(__file__).parent.parent.parent / "Makefile.am" +DRAW_TREE_SETTINGS_CONFIG = Path(__file__).parent / "draw_tree_settings.yaml" +SUPPORTED_GAME_FORMATS = {"efg", "nfg"} def catalog_draw_tree_settings(slug: str) -> dict: """Return the draw_tree settings for a given catalog slug.""" - settings = { - "color_scheme": "gambit", - "font_family": "sffamily", - "font_italic": True, - "shared_terminal_depth": True, - "sublevel_scaling": 0, - } - if slug == "bagwell1995" or "watson2013" in slug: - settings["sublevel_scaling"] = 1 - elif slug == "myerson1991/fig2_1" or slug == "reiley2008/fig1": - settings["action_label_position"] = 0.4 - elif "selten1975" in slug: - settings["shared_terminal_depth"] = False - elif slug == "vonstengel2022/fig10.1": - settings["sublevel_scaling"] = 0.75 - settings["shared_terminal_depth"] = False - elif slug == "vonstengelforges2008/fig1": - settings["sublevel_scaling"] = 1 - elif slug == "vonstengelforges2008/fig9": - settings["sublevel_scaling"] = 0.5 + with open(DRAW_TREE_SETTINGS_CONFIG, encoding="utf-8") as f: + config = yaml.safe_load(f) + settings = dict(config["defaults"]) + overrides = config.get("overrides", {}) + # Apply overrides shortest-key-first so that more specific (longer) entries + # such as "myerson1991/fig2_1" win over group-level entries like "myerson1991". + for key in sorted(overrides, key=len): + if slug == key or slug.startswith(key + "/"): + settings.update(overrides[key]) return settings -def generate_rst_table(df: pd.DataFrame, rst_path: Path, regenerate_images: bool = False): +def catalog_ef_file_variants(slug: str, catalog_dir: Path) -> list[dict] | None: + """Scan catalog_dir for multiple curated .ef files for a game slug. + + Returns a list of dicts with keys ``label``, ``ef_path``, and + ``variant_key`` when multiple .ef variants are detected alongside the game; + returns None when zero or one .ef file exists and no suffix variants are present + (no tabs needed). + + File-naming convention:: + + catalog/{slug}.ef primary variant → label "Default" + catalog/{slug}__{suffix}.ef additional variant → label derived from suffix + + The suffix part (after ``__``) is title-cased with underscores replaced by + spaces, e.g. ``fig1__very_wide.ef`` → label "Very Wide". + """ + game_dir = (catalog_dir / slug).parent + stem = Path(slug).name + + primary_ef = game_dir / f"{stem}.ef" + additional_efs = sorted(game_dir.glob(f"{stem}__*.ef")) + + # If there are no additional/suffix variants, it's not a multi-variant game. + if not additional_efs: + return None + + variants = [] + # Add the primary/default variant + variants.append({ + "label": "Default", + "ef_path": primary_ef, + "variant_key": slug, + }) + + # Add the additional suffix variants + for ef_file in additional_efs: + suffix = ef_file.stem[len(stem) + 2:] + label = suffix.replace("_", " ").title() + variant_key = f"{slug}__{suffix}" + variants.append({"label": label, "ef_path": ef_file, "variant_key": variant_key}) + return variants + + +def generate_rst_table( + df: pd.DataFrame, + rst_path: Path, + regenerate_images: bool = False, + catalog_dir: Path | None = None, +): """Generate RST output with a list-table for games.""" + catalog_dir = catalog_dir or CATALOG_DIR with open(rst_path, "w", encoding="utf-8") as f: # TOC linking to both sections f.write(".. contents::\n") @@ -55,56 +97,134 @@ def generate_rst_table(df: pd.DataFrame, rst_path: Path, regenerate_images: bool slug = row["Game"] title = str(row.get("Title", "")).strip() description = str(row.get("Description", "")).strip() + # Skip rows with unrecognised formats (defensive guard). + if row["Format"] not in SUPPORTED_GAME_FORMATS: + continue # Skip any games which lack a description - if description: + if not description: + continue + + # Detect whether this EFG has multiple curated .ef layout variants. + ef_variants = ( + catalog_ef_file_variants(slug, catalog_dir) if row["Format"] == "efg" else None + ) + + if ef_variants: + # Multi-variant: generate one image set per variant + _variant_img_exts = ["ef", "tex", "png", "pdf", "svg"] + for variant in ef_variants: + vkey = variant["variant_key"] + variant_paths = [ + catalog_dir / "img" / f"{vkey}.{ext}" for ext in _variant_img_exts + ] + if regenerate_images or not all(p.exists() for p in variant_paths): + viz_path = catalog_dir / "img" / vkey + viz_path.parent.mkdir(parents=True, exist_ok=True) + if variant["ef_path"].exists(): + source = str(variant["ef_path"]) + else: + source = gbt.catalog.load(slug) + for func in [generate_tex, generate_png, generate_pdf, generate_svg]: + func( + source, + save_to=str(viz_path), + **catalog_draw_tree_settings(vkey), + ) + img_ef = catalog_dir / "img" / f"{vkey}.ef" + if not img_ef.exists() and variant["ef_path"].exists(): + shutil.copy2(variant["ef_path"], img_ef) + else: + # Single variant all_exts = [] all_paths = [] if row["Format"] == "efg": - ef_path = CATALOG_DIR / "img" / f"{slug}.ef" all_exts.append("ef") - all_paths.append(ef_path) - all_exts = all_exts + ["tex", "png", "pdf", "svg"] - tex_path = CATALOG_DIR / "img" / f"{slug}.tex" - all_paths.append(tex_path) - all_paths.append(CATALOG_DIR / "img" / f"{slug}.png") - all_paths.append(CATALOG_DIR / "img" / f"{slug}.pdf") - all_paths.append(CATALOG_DIR / "img" / f"{slug}.svg") + all_paths.append(catalog_dir / "img" / f"{slug}.ef") + all_exts += ["tex", "png", "pdf", "svg"] + for ext in ["tex", "png", "pdf", "svg"]: + all_paths.append(catalog_dir / "img" / f"{slug}.{ext}") missing_any = not all(p.exists() for p in all_paths) if regenerate_images or missing_any: - g = gbt.catalog.load(slug) - viz_path = CATALOG_DIR / "img" / f"{slug}" + viz_path = catalog_dir / "img" / slug viz_path.parent.mkdir(parents=True, exist_ok=True) + # Use a committed curated .ef file if present; otherwise derive + # the layout automatically from the game object. + curated_ef = catalog_dir / f"{slug}.ef" + source = str(curated_ef) if curated_ef.exists() else gbt.catalog.load(slug) for func in [generate_tex, generate_png, generate_pdf, generate_svg]: - func(g, save_to=str(viz_path), **catalog_draw_tree_settings(slug)) + func(source, save_to=str(viz_path), **catalog_draw_tree_settings(slug)) + # DrawTree may not write catalog/img/{slug}.ef when its input is + # already an .ef file, so copy it if the img copy is still absent. + img_ef = catalog_dir / "img" / f"{slug}.ef" + if not img_ef.exists() and curated_ef.exists(): + shutil.copy2(curated_ef, img_ef) - # Main dropdown - f.write(f" * - .. dropdown:: {title}\n") - f.write(" :open:\n") - f.write(" \n") - for line in description.splitlines(): - f.write(f" {line}\n") + # Main dropdown + f.write(f" * - .. dropdown:: {title}\n") + f.write(" :open:\n") + f.write(" \n") + for line in description.splitlines(): + f.write(f" {line}\n") - f.write(" \n") - f.write(" **Load in PyGambit:**\n") - f.write(" \n") - f.write(" .. code-block:: python\n") - f.write(" \n") - f.write(f' pygambit.catalog.load("{slug}")\n') - f.write(" \n") + f.write(" \n") + f.write(" **Load in PyGambit:**\n") + f.write(" \n") + f.write(" .. code-block:: python\n") + f.write(" \n") + f.write(f' pygambit.catalog.load("{slug}")\n') + f.write(" \n") - # Download links - download_links = [row["Download"]] + # Download links + download_links = [row["Download"]] + if ef_variants: + _variant_img_exts = ["ef", "tex", "png", "pdf", "svg"] + for variant in ef_variants: + vkey = variant["variant_key"] + for ext in _variant_img_exts: + download_links.append( + f":download:`{vkey}.{ext} <../catalog/img/{vkey}.{ext}>`" + ) + else: for ext in all_exts: download_links.append( f":download:`{slug}.{ext} <../catalog/img/{slug}.{ext}>`" ) - f.write(" **Download game and image files:**\n") + f.write(" .. dropdown:: Download game and image files\n") + f.write(" \n") + f.write(f" {' '.join(download_links)}\n") + f.write(" \n") + + # Draw image — tab-set for multiple variants, single block otherwise + if ef_variants: + f.write(" .. tab-set::\n") f.write(" \n") - f.write(f" {' '.join(download_links)}\n") + for variant in ef_variants: + label = variant["label"] + vkey = variant["variant_key"] + settings = catalog_draw_tree_settings(vkey) + settings_str = ", ".join(f"{k}={val!r}" for k, val in settings.items()) + f.write(f" .. tab-item:: {label}\n") + f.write(" \n") + f.write(" .. jupyter-execute::\n") + f.write(" :hide-code:\n") + f.write(" \n") + f.write(" import pygambit\n") + f.write(" from draw_tree import draw_tree\n") + if variant["ef_path"].exists(): + f.write( + " draw_tree(" + f'"../catalog/{vkey}.ef", {settings_str})\n' + ) + else: + f.write( + f" draw_tree(" + f'pygambit.catalog.load("{slug}"), ' + f"{settings_str})\n" + ) + f.write(" \n") f.write(" \n") - - # Draw image + else: f.write(" .. jupyter-execute::\n") f.write(" :hide-code:\n") f.write(" \n") @@ -112,12 +232,18 @@ def generate_rst_table(df: pd.DataFrame, rst_path: Path, regenerate_images: bool f.write(" from draw_tree import draw_tree\n") if row["Format"] == "efg": settings = catalog_draw_tree_settings(slug) - settings_str = ", ".join(f"{k}={v!r}" for k, v in settings.items()) - f.write( - f" draw_tree(" - f'pygambit.catalog.load("{slug}"), ' - f"{settings_str})\n" - ) + settings_str = ", ".join(f"{k}={val!r}" for k, val in settings.items()) + curated_ef = catalog_dir / f"{slug}.ef" + if curated_ef.exists(): + f.write( + f' draw_tree("../catalog/{slug}.ef", {settings_str})\n' + ) + else: + f.write( + f" draw_tree(" + f'pygambit.catalog.load("{slug}"), ' + f"{settings_str})\n" + ) elif row["Format"] == "nfg": f.write( f" draw_tree(" @@ -127,61 +253,81 @@ def generate_rst_table(df: pd.DataFrame, rst_path: Path, regenerate_images: bool f.write(" \n") -def update_makefile(): - """Update the Makefile.am with all games from the catalog.""" +def update_makefile( + catalog_dir: Path | None = None, + am_path: Path | None = None, +): + """Update the catalog.am with all games from the catalog.""" + catalog_dir = catalog_dir or CATALOG_DIR + am_path = am_path or Path(__file__).parent / "catalog.am" - # Using rglob("*") to find files in all subdirectories slugs = [] - for resource_path in sorted(CATALOG_DIR.rglob("*.efg")): + for resource_path in sorted(catalog_dir.rglob("*.efg")): if resource_path.is_file(): - rel_path = resource_path.relative_to(CATALOG_DIR) - slugs.append(str(rel_path)) - for resource_path in sorted(CATALOG_DIR.rglob("*.nfg")): + rel_path = resource_path.relative_to(catalog_dir) + slugs.append(rel_path.as_posix()) + for resource_path in sorted(catalog_dir.rglob("*.nfg")): if resource_path.is_file(): - rel_path = resource_path.relative_to(CATALOG_DIR) - slugs.append(str(rel_path)) + rel_path = resource_path.relative_to(catalog_dir) + slugs.append(rel_path.as_posix()) + for resource_path in sorted(catalog_dir.rglob("*.ef")): + # Exclude the generated .ef files under catalog/img/; only curated + # .ef files committed alongside game files should be distributed. + if resource_path.is_file() and catalog_dir / "img" not in resource_path.parents: + rel_path = resource_path.relative_to(catalog_dir) + slugs.append(rel_path.as_posix()) game_files = [] for slug in slugs: game_files.append(f"catalog/{slug}") game_files.sort() - with open(MAKEFILE_AM, encoding="utf-8") as f: - content = f.readlines() - - with open(MAKEFILE_AM, "w", encoding="utf-8") as f: - in_gamefiles_section = False - for line in content: - # Add to the EXTRA_DIST after the README.rst line - if line.startswith(" src/README.rst \\"): - in_gamefiles_section = True - f.write(" src/README.rst \\\n") - for gf in game_files: - if gf == game_files[-1]: - f.write(f"\t{gf}\n") - else: - f.write(f"\t{gf} \\\n") - f.write("\n") - elif in_gamefiles_section: - if line.strip() == "": - in_gamefiles_section = False - continue # Skip old gamefiles lines - else: - f.write(line) + if am_path.exists(): + with open(am_path, encoding="utf-8") as f: + content = f.read() + else: + content = "" - with open(MAKEFILE_AM, encoding="utf-8") as f: - updated_content = f.readlines() + updated_content = "CATALOG_FILES = \\\n" + for gf in game_files: + if gf == game_files[-1]: + updated_content += f"\t{gf}\n" + else: + updated_content += f"\t{gf} \\\n" if content != updated_content: - print(f"Updated {str(MAKEFILE_AM)}") + with open(am_path, "w", encoding="utf-8") as f: + f.write(updated_content) + print(f"Updated {str(am_path)}") else: - print(f"No changes to add to {str(MAKEFILE_AM)}") + print(f"No changes to add to {str(am_path)}") if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument("--build", action="store_true") - parser.add_argument("--regenerate-images", action="store_true") + parser = argparse.ArgumentParser( + description=( + "Update Gambit catalog documentation and build files. " + "Always regenerates doc/catalog_table.rst from the current catalog. " + "Run from the repo root or build_support/catalog/." + ) + ) + parser.add_argument( + "--build", + action="store_true", + help=( + "Also update build_support/catalog/catalog.am with the current list of " + "catalog game files. Required after adding or removing games." + ), + ) + parser.add_argument( + "--regenerate-images", + action="store_true", + help=( + "Force regeneration of all game visualisation images (PNG, PDF, SVG, TeX), " + "even if they already exist. Use this to pick up changes to game files or " + "draw_tree_settings.yaml." + ), + ) args = parser.parse_args() # Create RST list-table used by doc/catalog.rst diff --git a/catalog/vonstengelforges2008/fig6__Original_Layout.ef b/catalog/vonstengelforges2008/fig6__Original_Layout.ef new file mode 100644 index 000000000..34da71a32 --- /dev/null +++ b/catalog/vonstengelforges2008/fig6__Original_Layout.ef @@ -0,0 +1,41 @@ +player 1 name 1 +player 2 name 2 +level 0 node 1 player 2 +level 1.5 node 1 xshift -a=3.5 from 0,1 move a +level 1.5 node 2 xshift a from 0,1 move b +iset 1.5,1 1.5,2 player 1 +% level 4 node 1 xshift -b=1.6 from 1.5,1 move L +level 2.75 node 1 xshift -b=1.6 from 1.5,1 move L +level 4 node 2 xshift 2b from 1.5,1 move R + +level 4 node 3 xshift -s=1 from 2.75,1 move S payoffs 0 +level 4 node 1 xshift 1.2 from 2.75,1 move T +% iset k +iset 4,1 4,2 player 2 + +level 5 node 1 xshift -c=.6 from 4,1 move c payoffs 0 +level 5 node 2 xshift c from 4,1 move d payoffs 0 +level 5 node 3 xshift -c from 4,2 move c payoffs 0 +level 6 node 1 xshift 2c from 4,2 move d +level 6 node 2 xshift 2r=.5 from 1.5,2 move R +iset 6,1 6,2 player 1 + +level 7 node 1 xshift -u=.6 from 6,1 move U payoffs 0 +level 7 node 2 xshift u from 6,1 move V payoffs 0 +level 7 node 3 xshift -u from 6,2 move U payoffs 0 + +level 8 node 1 xshift -3r from 1.5,2 move L +level 8 node 2 xshift 2u from 6,2 move V +iset 8,1 8,2 player 2 + +% level 4 node 4 xshift 1.02 from 1.5,2 move R +% level 6 node 4 xshift 1.02 from 4,4 move V + +level 10 node 1 xshift -7 from 8,1 move e +level 10 node 2 xshift f=0.7 from 8,1 move f payoffs 0 +level 10 node 3 xshift -f from 8,2 move e payoffs 0 +level 10 node 4 xshift f from 8,2 move f payoffs 0 +iset 2.75,1 10,1 player 1 + +level 11 node 1 xshift -s from 10,1 move S payoffs 0 +level 11 node 2 xshift s from 10,1 move T payoffs 0 diff --git a/catalog/vonstengelforges2008/fig9__Original_Layout.ef b/catalog/vonstengelforges2008/fig9__Original_Layout.ef new file mode 100644 index 000000000..d99f6df04 --- /dev/null +++ b/catalog/vonstengelforges2008/fig9__Original_Layout.ef @@ -0,0 +1,23 @@ +player 1 name 1 +player 2 name 2 +level 0 node 1 player 0 +level 2 node 1 player 2 xshift -a=3.5 from 0,1 move \frac{1}{3} +level 2 node 2 player 2 xshift -0.2 from 0,1 move \frac{1}{3} +level 2 node 3 player 2 xshift a from 0,1 move \frac{1}{3} +level 4 node 1 xshift 0 from 2,1 move x +level 4 node 2 xshift -b=.7 from 2,2 move -x +level 8 node 1 xshift 3b from 2,2 move y +level 6 node 1 xshift -c=.65 from 4,1 move x payoffs 1 1 +level 6 node 2 xshift c from 4,1 move -x payoffs 0 0 +level 6 node 3 xshift -c from 4,2 move x payoffs 0 0 +level 6 node 4 xshift c from 4,2 move -x payoffs 1 1 +level 10 node 1 xshift -c from 8,1 move y payoffs 1 1 +level 10 node 2 xshift c from 8,1 move -y payoffs 0 0 +level 4 node 3 xshift -b from 2,3 move -x +level 8 node 2 xshift 3b from 2,3 move -y +level 6 node 5 xshift -c from 4,3 move x payoffs 0 0 +level 6 node 6 xshift c from 4,3 move -x payoffs 1 1 +level 10 node 3 xshift -c from 8,2 move y payoffs 0 0 +level 10 node 4 xshift c from 8,2 move -y payoffs 1 1 +iset 8,1 8,2 player 1 +iset 4,1 4,2 4,3 player 1 diff --git a/doc/developer.catalog.rst b/doc/developer.catalog.rst index 9ed5b51d2..eb9dcfa5c 100644 --- a/doc/developer.catalog.rst +++ b/doc/developer.catalog.rst @@ -13,10 +13,15 @@ Currently supported representations are: - `.efg` for extensive form games - `.nfg` for normal form games -.. note:: +.. important:: When updating the catalog, changes can be viewed by inspecting the documentation build generated by a pull request. - To test changes locally, you'll need to have a developer install of `pygambit` available in your Python environment, see :ref:`build-python`. + You can also test changes locally. It can be helpful when doing so to perform an editable install of `pygambit` available in your Python environment. + Note that for general edits to the codebase non-editable :ref:`developer install ` is better to pick up changes in the C++ code as well as Python. + + .. code-block:: bash + + pip install -e ".[doc]" 1. **Create or edit a game file:** @@ -28,7 +33,7 @@ Currently supported representations are: If no bibliography entry exists, you should add one by editing `doc/biblio.rst`. -2. **Commit the changes:** +2. **Add the game(s) to the repo:** Create a new branch in the ``gambit`` repo. Add your new game file(s) inside the ``catalog`` dir and commit them, or edit an existing game. @@ -42,16 +47,37 @@ Currently supported representations are: pygambit.catalog.load("watson2013/exercise29_6") -3. **[Optional] Test your updates locally (and customise visuals):** + .. note:: + + For extensive form games, you may optionally commit a curated ``.ef`` file alongside the ``.efg`` + (e.g. ``catalog/source/game.ef``). + When present, ``update.py`` will use this file directly as input to DrawTree instead of + auto-generating the layout from the ``.efg``, preserving any hand-tuned layout. + Consult the `DrawTree docs `_ for the ``.ef`` format. + + **Layout variants:** To display multiple layout variants on the catalog page (rendered as + clickable tabs), commit additional ``.ef`` files using the ``{slug}__{label}.ef`` naming + convention (double underscore separator), e.g.: - Reinstall the package to pick up the new game file(s) in the ``pygambit.catalog`` module. + .. code-block:: text + + catalog/example/game.ef # primary (tab label "Default") + catalog/example/game__wide.ef # additional (tab label "Wide") + + The label shown on each tab is derived automatically from the filename suffix: underscores + are replaced by spaces and the result is title-cased (e.g. ``__very_wide`` → "Very Wide"). + +3. **Update the build files:** + + Ensure you have installed the package in editable mode to automatically pick up the new game file(s) in the ``pygambit.catalog`` module without reinstalling each time. Then use the ``update.py`` script to update Gambit's documentation & build files, as well as generating images for the new game(s). - If you want, you can first edit the ``catalog_draw_tree_settings`` in ``build_support/catalog/update.py`` to change the default visualisation parameters for your game(s). - Consult the `DrawTree docs `_ for the available options. + If you want to customise the visualisation parameters for your game(s), edit ``build_support/catalog/draw_tree_settings.yaml``. + Add an entry under ``overrides`` keyed by your game's exact slug, or by a shared prefix (e.g. the author-year folder name) to apply settings to all games from that source. + More specific entries (longer keys) take precedence over shorter ones. + Consult the `DrawTree docs `_ for available settings. .. code-block:: bash - pip install . python build_support/catalog/update.py --build .. note:: @@ -60,13 +86,21 @@ Currently supported representations are: .. warning:: - Running the script with the ``--build`` flag updates ``Makefile.am``. If you moved games that were previously in ``contrib/games`` you'll need to also manually remove those files from ``EXTRA_DIST``. + - If haven't done an editable install of ``pygambit`` in your python environment, you'll need to re-install it before running the update script to include new games in the catalog module. + - Running the script with the ``--build`` flag updates ``build_support/catalog/catalog.am``, which is included in ``Makefile.am``. If you moved games that were previously in ``contrib/games`` you'll need to also manually remove those files from ``EXTRA_DIST`` in ``Makefile.am``. + +4. **[Optional] Test your updates to the documentation locally:** + + The previous step will (re)build your local copy of the Gambit Catalog RST page used by the documentation. + You should then build the docs in the :ref:`usual way `. + Open the catalog page at ``doc/_build/html/catalog.html`` to view your changes. + Iterate steps 2-4 as required. -4. **Submit a pull request to GitHub with all changes.** +5. **Submit a pull request to GitHub with all changes.** Submit a PR according to the :ref:`usual workflow `. - Ensure that any additions and changes to game files, ``build_support/catalog/update.py`` and ``Makefile.am`` are included. + Ensure that any additions and changes to game files, ``build_support/catalog/draw_tree_settings.yaml``, ``build_support/catalog/update.py`` and ``build_support/catalog/catalog.am`` are included. .. important:: - If you didn't run the update script in step 3, you should manually update ``EXTRA_DIST`` in ``Makefile.am`` with any new game files. + Even if you already checked the local docs build, ensure the Catalog page on the ReadTheDocs preview build on the pull request looks right. diff --git a/doc/developer.contributing.rst b/doc/developer.contributing.rst index e890faba2..ab8851e37 100644 --- a/doc/developer.contributing.rst +++ b/doc/developer.contributing.rst @@ -176,6 +176,7 @@ Tests can be added to the test suite by creating new test files in the ``tests`` Tests should be written using the `pytest` framework. Refer to existing test files for examples of how to write tests or see the `pytest documentation `_ for more information. +.. _editing-docs: Editing this documentation --------------------------- diff --git a/pyproject.toml b/pyproject.toml index 601a91599..22c8faaf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ doc = [ "open_spiel; sys_platform != 'win32'", "sphinxcontrib-tikz", "jupyter_sphinx", + "pyyaml", ] [project.urls] @@ -93,6 +94,7 @@ max-line-length = 99 [tool.pytest.ini_options] addopts = "--strict-markers" +pythonpath = ["build_support/catalog"] markers = [ "nash_enumpure_strategy: tests of enumpure_solve in pure strategies", "nash_enumpure_agent: tests of enumpure_solve in pure behaviors", @@ -114,6 +116,7 @@ markers = [ "qre_logit_branch: tests of logit_solve_branch", "nash: all tests of Nash equilibrium solvers", "slow: all time-consuming tests", + "catalog_update: tests of build_support/catalog/update.py", ] [tool.setuptools] diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index 6b24f17eb..961c2a532 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -7,7 +7,12 @@ import pygambit as gbt # Use the full string path to where the catalog data are placed in the package -_CATALOG_RESOURCE = files("pygambit")/"catalog_data" +_CATALOG_RESOURCE = files("pygambit") / "catalog_data" +# This ensures that catalog files are included in editable installs too +if not _CATALOG_RESOURCE.is_dir(): + _repo_catalog = Path(__file__).parent.parent.parent / "catalog" + if _repo_catalog.is_dir(): + _CATALOG_RESOURCE = _repo_catalog READERS = { ".nfg": gbt.read_nfg, @@ -155,20 +160,23 @@ def append_record( record["Format"] = ext records.append(record) - # Add all the games stored as EFG/NFG files - for resource_path in sorted(_CATALOG_RESOURCE.rglob("*")): - reader = READERS.get(resource_path.suffix) - - if reader is not None and resource_path.is_file(): - # Calculate the path relative to the root resource - # and remove the suffix to get the "slug" - rel_path = resource_path.relative_to(_CATALOG_RESOURCE) - slug = rel_path.with_suffix("").as_posix() - - with as_file(resource_path) as path: - game = reader(str(path)) - if check_filters(game): - append_record(slug, game) + # Add all the games stored as EFG/NFG files. + # Collect paths matching each supported extension, sort together to preserve + # a consistent alphabetical order, then load using the known reader. + for resource_path in sorted( + path + for suffix in READERS + for path in _CATALOG_RESOURCE.rglob(f"*{suffix}") + if path.is_file() + ): + reader = READERS[resource_path.suffix] + rel_path = resource_path.relative_to(_CATALOG_RESOURCE) + slug = rel_path.with_suffix("").as_posix() + + with as_file(resource_path) as path: + game = reader(str(path)) + if check_filters(game): + append_record(slug, game) if include_descriptions: return pd.DataFrame.from_records(