From 61a0e81b220320f118c17752cf661c0de6b32145 Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Tue, 26 May 2026 15:39:34 +0100 Subject: [PATCH 01/25] refactor: modularize catalog file management by moving the game file list to an included catalog.am file --- Makefile.am | 21 ++------------ build_support/catalog/catalog.am | 19 +++++++++++++ build_support/catalog/update.py | 47 +++++++++++++------------------- doc/developer.catalog.rst | 6 ++-- 4 files changed, 44 insertions(+), 49 deletions(-) create mode 100644 build_support/catalog/catalog.am diff --git a/Makefile.am b/Makefile.am index f07dd5034..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,24 +207,7 @@ EXTRA_DIST = \ contrib/games/yamamoto.nfg \ contrib/games/zero.nfg \ src/README.rst \ - catalog/bagwell1995.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/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..980afb135 --- /dev/null +++ b/build_support/catalog/catalog.am @@ -0,0 +1,19 @@ +CATALOG_FILES = \ + catalog/bagwell1995.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/watson2013/exercise29_6.efg \ + catalog/watson2013/fig29_1.efg diff --git a/build_support/catalog/update.py b/build_support/catalog/update.py index 0d44b3cc6..b7ad2d767 100644 --- a/build_support/catalog/update.py +++ b/build_support/catalog/update.py @@ -121,7 +121,7 @@ def generate_rst_table(df: pd.DataFrame, rst_path: Path, regenerate_images: bool def update_makefile(): - """Update the Makefile.am with all games from the catalog.""" + """Update the catalog.am with all games from the catalog.""" # Using rglob("*") to find files in all subdirectories slugs = [] @@ -139,36 +139,27 @@ def update_makefile(): 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) - - with open(MAKEFILE_AM, encoding="utf-8") as f: - updated_content = f.readlines() + am_path = Path(__file__).parent / "catalog.am" + + if am_path.exists(): + with open(am_path, encoding="utf-8") as f: + content = f.read() + else: + content = "" + + 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__": diff --git a/doc/developer.catalog.rst b/doc/developer.catalog.rst index 9ed5b51d2..8c98a7716 100644 --- a/doc/developer.catalog.rst +++ b/doc/developer.catalog.rst @@ -60,13 +60,13 @@ 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``. + 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. **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/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. + If you didn't run the update script in step 3, you should manually update the list of files in ``build_support/catalog/catalog.am`` with any new game files. From 659cadd10ee0c6a1286cc3cd75eaa22fc4bf8915 Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Tue, 26 May 2026 15:46:29 +0100 Subject: [PATCH 02/25] Step 3 is not optional --- doc/developer.catalog.rst | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/doc/developer.catalog.rst b/doc/developer.catalog.rst index 8c98a7716..726a2bac8 100644 --- a/doc/developer.catalog.rst +++ b/doc/developer.catalog.rst @@ -42,7 +42,7 @@ Currently supported representations are: pygambit.catalog.load("watson2013/exercise29_6") -3. **[Optional] Test your updates locally (and customise visuals):** +3. **Test your updates locally (and customise visuals):** Reinstall the package to pick up the new game file(s) in the ``pygambit.catalog`` module. Then use the ``update.py`` script to update Gambit's documentation & build files, as well as generating images for the new game(s). @@ -66,7 +66,3 @@ Currently supported representations are: Submit a PR according to the :ref:`usual workflow `. Ensure that any additions and changes to game files, ``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 the list of files in ``build_support/catalog/catalog.am`` with any new game files. From 66086d1e2247bccc971a5b76dbf93aaddc23721a Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Tue, 26 May 2026 15:59:56 +0100 Subject: [PATCH 03/25] Clarify docs building --- doc/developer.catalog.rst | 17 ++++++++++++++--- doc/developer.contributing.rst | 1 + 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/doc/developer.catalog.rst b/doc/developer.catalog.rst index 726a2bac8..50fd994ee 100644 --- a/doc/developer.catalog.rst +++ b/doc/developer.catalog.rst @@ -28,7 +28,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,7 +42,7 @@ Currently supported representations are: pygambit.catalog.load("watson2013/exercise29_6") -3. **Test your updates locally (and customise visuals):** +3. **Update the build files:** Reinstall the package to pick up the new game file(s) in the ``pygambit.catalog`` module. Then use the ``update.py`` script to update Gambit's documentation & build files, as well as generating images for the new game(s). @@ -62,7 +62,18 @@ Currently supported representations are: 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. **Submit a pull request to GitHub with all changes.** +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. + +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 ``build_support/catalog/catalog.am`` are included. + + .. important:: + + 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 --------------------------- From fa168435985763637eeafda9304aa594bbbb81cf Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Tue, 26 May 2026 16:43:50 +0100 Subject: [PATCH 04/25] docs: update development guide and update catalog path resolution to support editable installs --- doc/developer.catalog.rst | 10 ++++++---- src/pygambit/catalog.py | 7 ++++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/doc/developer.catalog.rst b/doc/developer.catalog.rst index 50fd994ee..6551e90ff 100644 --- a/doc/developer.catalog.rst +++ b/doc/developer.catalog.rst @@ -13,10 +13,13 @@ 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`. + To test changes locally, you'll need to have an editable developer install of `pygambit` available in your Python environment. + + .. code-block:: python + pip install -e ".[doc]" 1. **Create or edit a game file:** @@ -44,14 +47,13 @@ Currently supported representations are: 3. **Update the build files:** - Reinstall the package to pick up the new game file(s) in the ``pygambit.catalog`` module. + 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. .. code-block:: bash - pip install . python build_support/catalog/update.py --build .. note:: diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index 6b24f17eb..01f6d353b 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, From 583cc64fd33283f61a032a97afc87cd488bd3e80 Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Tue, 26 May 2026 16:49:46 +0100 Subject: [PATCH 05/25] fix indentation --- doc/developer.catalog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/developer.catalog.rst b/doc/developer.catalog.rst index 6551e90ff..fe174ba67 100644 --- a/doc/developer.catalog.rst +++ b/doc/developer.catalog.rst @@ -19,7 +19,7 @@ Currently supported representations are: To test changes locally, you'll need to have an editable developer install of `pygambit` available in your Python environment. .. code-block:: python - pip install -e ".[doc]" + pip install -e ".[doc]" 1. **Create or edit a game file:** From 43886a125d827989b18b7d066157e635989c0a57 Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Tue, 26 May 2026 16:55:39 +0100 Subject: [PATCH 06/25] docs: correct code block language hint for editable install instructions --- doc/developer.catalog.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doc/developer.catalog.rst b/doc/developer.catalog.rst index fe174ba67..217ed42aa 100644 --- a/doc/developer.catalog.rst +++ b/doc/developer.catalog.rst @@ -18,7 +18,8 @@ Currently supported representations are: 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 an editable developer install of `pygambit` available in your Python environment. - .. code-block:: python + .. code-block:: bash + pip install -e ".[doc]" 1. **Create or edit a game file:** From 9e0b74e68c7dd093739f9e4592daab49d8957901 Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Wed, 27 May 2026 09:39:16 +0100 Subject: [PATCH 07/25] docs: clarify when to use developer install in catalog dev doc --- doc/developer.catalog.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/doc/developer.catalog.rst b/doc/developer.catalog.rst index 217ed42aa..2173c943a 100644 --- a/doc/developer.catalog.rst +++ b/doc/developer.catalog.rst @@ -16,7 +16,8 @@ Currently supported representations are: .. 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 an editable developer install of `pygambit` available in your Python environment. + 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 @@ -63,7 +64,8 @@ Currently supported representations are: .. warning:: - 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``. + - 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:** From 820e982aab69b5e8f98c3e781fc54dda3d79c8f6 Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Wed, 27 May 2026 10:01:56 +0100 Subject: [PATCH 08/25] refactor: migrate game visualisation parameters to external YAML configuration file --- build_support/catalog/draw_tree_settings.yaml | 24 +++++++++++++++++++ build_support/catalog/update.py | 22 +++++++---------- doc/developer.catalog.rst | 8 ++++--- pyproject.toml | 1 + 4 files changed, 39 insertions(+), 16 deletions(-) create mode 100644 build_support/catalog/draw_tree_settings.yaml diff --git a/build_support/catalog/draw_tree_settings.yaml b/build_support/catalog/draw_tree_settings.yaml new file mode 100644 index 000000000..d969f4d11 --- /dev/null +++ b/build_support/catalog/draw_tree_settings.yaml @@ -0,0 +1,24 @@ +# 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 diff --git a/build_support/catalog/update.py b/build_support/catalog/update.py index b7ad2d767..1d0745f22 100644 --- a/build_support/catalog/update.py +++ b/build_support/catalog/update.py @@ -2,6 +2,7 @@ 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,23 +10,18 @@ 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" 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 + with open(DRAW_TREE_SETTINGS_CONFIG, encoding="utf-8") as f: + config = yaml.safe_load(f) + settings = dict(config["defaults"]) + overrides = config.get("overrides", {}) + for key in sorted(overrides, key=len): + if slug == key or slug.startswith(key + "/"): + settings.update(overrides[key]) return settings diff --git a/doc/developer.catalog.rst b/doc/developer.catalog.rst index 2173c943a..6e3a5a2ec 100644 --- a/doc/developer.catalog.rst +++ b/doc/developer.catalog.rst @@ -51,8 +51,10 @@ Currently supported representations are: 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 @@ -77,7 +79,7 @@ Currently supported representations are: 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 ``build_support/catalog/catalog.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:: diff --git a/pyproject.toml b/pyproject.toml index 601a91599..71e5f0065 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ doc = [ "open_spiel; sys_platform != 'win32'", "sphinxcontrib-tikz", "jupyter_sphinx", + "pyyaml", ] [project.urls] From 55f4ba9108410d7890e809a33f2709d3c5561565 Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Wed, 27 May 2026 10:52:25 +0100 Subject: [PATCH 09/25] feat: allow curated .ef files to override auto-generated DrawTree layouts for catalog games --- build_support/catalog/update.py | 13 +++++++++++-- doc/developer.catalog.rst | 8 ++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/build_support/catalog/update.py b/build_support/catalog/update.py index 1d0745f22..9ce1e040e 100644 --- a/build_support/catalog/update.py +++ b/build_support/catalog/update.py @@ -1,4 +1,5 @@ import argparse +import shutil from pathlib import Path import pandas as pd @@ -61,11 +62,15 @@ def generate_rst_table(df: pd.DataFrame, rst_path: Path, regenerate_images: bool 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.parent.mkdir(parents=True, exist_ok=True) + 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)) + 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") @@ -129,6 +134,10 @@ def update_makefile(): 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("*.ef")): + if resource_path.is_file() and CATALOG_DIR / "img" not in resource_path.parents: + rel_path = resource_path.relative_to(CATALOG_DIR) + slugs.append(str(rel_path)) game_files = [] for slug in slugs: diff --git a/doc/developer.catalog.rst b/doc/developer.catalog.rst index 6e3a5a2ec..deacfb6c3 100644 --- a/doc/developer.catalog.rst +++ b/doc/developer.catalog.rst @@ -39,6 +39,14 @@ Currently supported representations are: Add your new game file(s) inside the ``catalog`` dir and commit them, or edit an existing game. If there are multiple games from a particular source, place them in an appropriately named folder. + .. 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. + .. important:: The name of the game file will determine it's "slug", used by the load function of the catalog module: From f49fd5235480d8c4c14bdd038e67e0ed8b2dcec5 Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Wed, 27 May 2026 11:08:32 +0100 Subject: [PATCH 10/25] feat: update catalog image display to support curated EF files when present --- build_support/catalog/update.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/build_support/catalog/update.py b/build_support/catalog/update.py index 9ce1e040e..d20da5f18 100644 --- a/build_support/catalog/update.py +++ b/build_support/catalog/update.py @@ -107,11 +107,19 @@ def generate_rst_table(df: pd.DataFrame, rst_path: Path, regenerate_images: bool 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" - ) + curated_ef = CATALOG_DIR / f"{slug}.ef" + if curated_ef.exists(): + f.write( + f" draw_tree(" + f'"../catalog/{slug}.ef", ' + f"{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(" From f0d7463485e453e95b82377501ce589f0d849259 Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Wed, 27 May 2026 11:42:10 +0100 Subject: [PATCH 11/25] Update comments and add help to args for update.py --- build_support/catalog/update.py | 46 ++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/build_support/catalog/update.py b/build_support/catalog/update.py index d20da5f18..73841e289 100644 --- a/build_support/catalog/update.py +++ b/build_support/catalog/update.py @@ -12,6 +12,7 @@ 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: @@ -20,6 +21,8 @@ def catalog_draw_tree_settings(slug: str) -> dict: 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]) @@ -45,8 +48,13 @@ 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: + # Build the list of expected image files so we can check whether + # any are missing and need to be generated. all_exts = [] all_paths = [] if row["Format"] == "efg": @@ -64,10 +72,14 @@ def generate_rst_table(df: pd.DataFrame, rst_path: Path, regenerate_images: bool if regenerate_images or missing_any: viz_path = CATALOG_DIR / "img" / f"{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(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) @@ -110,9 +122,7 @@ def generate_rst_table(df: pd.DataFrame, rst_path: Path, regenerate_images: bool curated_ef = CATALOG_DIR / f"{slug}.ef" if curated_ef.exists(): f.write( - f" draw_tree(" - f'"../catalog/{slug}.ef", ' - f"{settings_str})\n" + f' draw_tree("../catalog/{slug}.ef", {settings_str})\n' ) else: f.write( @@ -132,7 +142,6 @@ def generate_rst_table(df: pd.DataFrame, rst_path: Path, regenerate_images: bool def update_makefile(): """Update the catalog.am with all games from the catalog.""" - # Using rglob("*") to find files in all subdirectories slugs = [] for resource_path in sorted(CATALOG_DIR.rglob("*.efg")): if resource_path.is_file(): @@ -143,6 +152,8 @@ def update_makefile(): rel_path = resource_path.relative_to(CATALOG_DIR) slugs.append(str(rel_path)) 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(str(rel_path)) @@ -176,9 +187,30 @@ def update_makefile(): 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 From 2f36d8bb322bb80a77a7d785f39887e66ddfe4de Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Wed, 27 May 2026 11:42:48 +0100 Subject: [PATCH 12/25] refactor: improve catalog game loading ensure only proper EFG and NFG files are included --- src/pygambit/catalog.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index 01f6d353b..961c2a532 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -160,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( From 2dc6efcd45616e5ce3c7ee157d2c0b50053ff2ff Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Wed, 27 May 2026 13:34:37 +0100 Subject: [PATCH 13/25] test: add suite for catalog update logic and update test dependencies --- build_support/catalog/test_update.py | 310 +++++++++++++++++++++++++++ build_support/catalog/update.py | 49 +++-- pyproject.toml | 2 + 3 files changed, 341 insertions(+), 20 deletions(-) create mode 100644 build_support/catalog/test_update.py diff --git a/build_support/catalog/test_update.py b/build_support/catalog/test_update.py new file mode 100644 index 000000000..aebe7bd0b --- /dev/null +++ b/build_support/catalog/test_update.py @@ -0,0 +1,310 @@ +"""Tests for build_support/catalog/update.py.""" + +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 + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_YAML_DEFAULTS = { + "color_scheme": "gambit", + "font_family": "sffamily", + "font_italic": True, + "shared_terminal_depth": True, + "sublevel_scaling": 0, +} + +_YAML_CONFIG = """ +defaults: + color_scheme: gambit + font_family: sffamily + font_italic: true + shared_terminal_depth: true + sublevel_scaling: 0 + +overrides: + watson2013: + sublevel_scaling: 1 + selten1975: + shared_terminal_depth: false + myerson1991/fig2_1: + action_label_position: 0.4 +""" + + +def _write_yaml(path, content=_YAML_CONFIG): + path.write_text(content, encoding="utf-8") + return path + + +def _efg_row(slug, title="Game Title", description="A description."): + return { + "Game": slug, + "Title": title, + "Description": description, + "Download": f":download:`{slug}.efg <../catalog/{slug}.efg>`", + "Format": "efg", + } + + +def _nfg_row(slug, title="NFG Title", description="NFG description."): + return { + "Game": slug, + "Title": title, + "Description": description, + "Download": f":download:`{slug}.nfg <../catalog/{slug}.nfg>`", + "Format": "nfg", + } + + +def _make_df(*rows): + return pd.DataFrame(list(rows)) + + +def _make_image_files(catalog_dir, slug, fmt="efg"): + """Create the stub image files so the existence check passes.""" + img = catalog_dir / "img" / slug + img.parent.mkdir(parents=True, exist_ok=True) + for ext in ["tex", "png", "pdf", "svg"]: + (catalog_dir / "img" / f"{slug}.{ext}").touch() + if fmt == "efg": + (catalog_dir / "img" / f"{slug}.ef").touch() + + +# --------------------------------------------------------------------------- +# catalog_draw_tree_settings +# --------------------------------------------------------------------------- + + +@pytest.mark.catalog_update +class TestCatalogDrawTreeSettings: + def test_no_override_returns_defaults(self, tmp_path, monkeypatch): + yaml_file = _write_yaml(tmp_path / "settings.yaml") + monkeypatch.setattr(update, "DRAW_TREE_SETTINGS_CONFIG", yaml_file) + result = update.catalog_draw_tree_settings("unknown/game") + assert result == _YAML_DEFAULTS + + def test_exact_slug_override_applied(self, tmp_path, monkeypatch): + yaml_file = _write_yaml(tmp_path / "settings.yaml") + monkeypatch.setattr(update, "DRAW_TREE_SETTINGS_CONFIG", yaml_file) + result = update.catalog_draw_tree_settings("myerson1991/fig2_1") + 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): + yaml_file = _write_yaml(tmp_path / "settings.yaml") + monkeypatch.setattr(update, "DRAW_TREE_SETTINGS_CONFIG", yaml_file) + result = update.catalog_draw_tree_settings("watson2013/exercise29_6") + assert result["sublevel_scaling"] == 1 + + def test_specific_key_wins_over_group(self, tmp_path, monkeypatch): + config = """ + defaults: + color_scheme: gambit + sublevel_scaling: 0 + overrides: + myerson1991: + sublevel_scaling: 1 + myerson1991/fig2_1: + 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("myerson1991/fig2_1") + assert result["sublevel_scaling"] == 2 + + def test_group_override_does_not_bleed_to_other_game(self, tmp_path, monkeypatch): + yaml_file = _write_yaml(tmp_path / "settings.yaml") + monkeypatch.setattr(update, "DRAW_TREE_SETTINGS_CONFIG", yaml_file) + result = update.catalog_draw_tree_settings("selten1975/fig1") + assert result["shared_terminal_depth"] is False + result2 = update.catalog_draw_tree_settings("watson2013/fig29_1") + assert result2["shared_terminal_depth"] is True # selten override not applied + + def test_no_overrides_section_returns_defaults(self, tmp_path, monkeypatch): + config = "defaults:\n color_scheme: gambit\n sublevel_scaling: 0\n" + yaml_file = _write_yaml(tmp_path / "settings.yaml", config) + monkeypatch.setattr(update, "DRAW_TREE_SETTINGS_CONFIG", yaml_file) + result = update.catalog_draw_tree_settings("any/game") + assert result == {"color_scheme": "gambit", "sublevel_scaling": 0} + + +# --------------------------------------------------------------------------- +# generate_rst_table +# --------------------------------------------------------------------------- + + +@pytest.mark.catalog_update +class TestGenerateRstTable: + def _no_op_generate(self, *args, **kwargs): + """Replacement for draw_tree generate_* functions that does nothing.""" + + def _mock_generates(self, monkeypatch): + 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): + self._mock_generates(monkeypatch) + catalog_dir = tmp_path / "catalog" + slug = "bagwell1995" + _make_image_files(catalog_dir, slug, "efg") + df = _make_df(_efg_row(slug, title="Bagwell 1995")) + rst_path = tmp_path / "out.rst" + update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) + rst = rst_path.read_text() + assert "Bagwell 1995" in rst + assert f'pygambit.catalog.load("{slug}")' in rst + assert f":download:`{slug}.efg" in rst + assert f":download:`{slug}.ef" in rst + + def test_nfg_row_produces_rst_with_save_to(self, tmp_path, monkeypatch): + self._mock_generates(monkeypatch) + catalog_dir = tmp_path / "catalog" + slug = "nau2004/sec3" + _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): + self._mock_generates(monkeypatch) + catalog_dir = tmp_path / "catalog" + catalog_dir.mkdir() + row = { + "Game": "bogus/game", + "Title": "Bogus", + "Description": "Has a description.", + "Download": "", + "Format": "efg_2", + } + 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 "Bogus" not in rst + + def test_row_without_description_is_skipped(self, tmp_path, monkeypatch): + self._mock_generates(monkeypatch) + catalog_dir = tmp_path / "catalog" + catalog_dir.mkdir() + df = _make_df(_efg_row("bagwell1995", description="")) + rst_path = tmp_path / "out.rst" + update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) + rst = rst_path.read_text() + assert "bagwell1995" not in rst + + def test_curated_ef_used_in_draw_tree_call(self, tmp_path, monkeypatch): + self._mock_generates(monkeypatch) + catalog_dir = tmp_path / "catalog" + slug = "selten1975/fig1" + _make_image_files(catalog_dir, slug, "efg") + # Place a curated .ef alongside the .efg + 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() + # The draw_tree() call line should reference the .ef file path directly + 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): + 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 = "bagwell1995" + _make_image_files(catalog_dir, slug, "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 == [] + + def test_images_regenerated_when_flag_set(self, tmp_path, monkeypatch): + 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 = "bagwell1995" + _make_image_files(catalog_dir, slug, "efg") + # Use a curated .ef so gbt.catalog.load is not called (avoids needing real catalog) + curated = catalog_dir / f"{slug}.ef" + 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"} + + +# --------------------------------------------------------------------------- +# update_makefile +# --------------------------------------------------------------------------- + + +@pytest.mark.catalog_update +class TestUpdateMakefile: + def test_efg_and_nfg_files_included(self, tmp_path): + (tmp_path / "foo.efg").touch() + (tmp_path / "sub").mkdir() + (tmp_path / "sub" / "bar.nfg").touch() + am = tmp_path / "catalog.am" + update.update_makefile(catalog_dir=tmp_path, am_path=am) + content = am.read_text() + assert "catalog/foo.efg" in content + assert "catalog/sub/bar.nfg" in content + + def test_curated_ef_included(self, tmp_path): + (tmp_path / "myerson1991").mkdir() + (tmp_path / "myerson1991" / "fig1.efg").touch() + (tmp_path / "myerson1991" / "fig1.ef").touch() + am = tmp_path / "catalog.am" + update.update_makefile(catalog_dir=tmp_path, am_path=am) + content = am.read_text() + assert "catalog/myerson1991/fig1.ef" in content + + def test_ef_in_img_dir_excluded(self, tmp_path): + img = tmp_path / "img" / "selten1975" + img.mkdir(parents=True) + (img / "fig1.ef").touch() + 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): + (tmp_path / "foo.efg_2").touch() + (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): + (tmp_path / "foo.efg").touch() + am = tmp_path / "catalog.am" + update.update_makefile(catalog_dir=tmp_path, am_path=am) + mtime_after_first = am.stat().st_mtime + update.update_makefile(catalog_dir=tmp_path, am_path=am) + assert am.stat().st_mtime == mtime_after_first + + def test_empty_catalog_produces_valid_am(self, tmp_path): + 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 73841e289..a6f9991bd 100644 --- a/build_support/catalog/update.py +++ b/build_support/catalog/update.py @@ -29,8 +29,14 @@ def catalog_draw_tree_settings(slug: str) -> dict: return settings -def generate_rst_table(df: pd.DataFrame, rst_path: Path, regenerate_images: bool = False): +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") @@ -58,29 +64,29 @@ def generate_rst_table(df: pd.DataFrame, rst_path: Path, regenerate_images: bool all_exts = [] all_paths = [] if row["Format"] == "efg": - ef_path = CATALOG_DIR / "img" / f"{slug}.ef" + 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" + 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}.png") + all_paths.append(catalog_dir / "img" / f"{slug}.pdf") + all_paths.append(catalog_dir / "img" / f"{slug}.svg") missing_any = not all(p.exists() for p in all_paths) if regenerate_images or missing_any: - viz_path = CATALOG_DIR / "img" / f"{slug}" + viz_path = catalog_dir / "img" / f"{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" + 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(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" + img_ef = catalog_dir / "img" / f"{slug}.ef" if not img_ef.exists() and curated_ef.exists(): shutil.copy2(curated_ef, img_ef) @@ -119,7 +125,7 @@ def generate_rst_table(df: pd.DataFrame, rst_path: Path, regenerate_images: bool if row["Format"] == "efg": settings = catalog_draw_tree_settings(slug) settings_str = ", ".join(f"{k}={v!r}" for k, v in settings.items()) - curated_ef = CATALOG_DIR / f"{slug}.ef" + curated_ef = catalog_dir / f"{slug}.ef" if curated_ef.exists(): f.write( f' draw_tree("../catalog/{slug}.ef", {settings_str})\n' @@ -139,23 +145,28 @@ def generate_rst_table(df: pd.DataFrame, rst_path: Path, regenerate_images: bool f.write(" \n") -def update_makefile(): +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" 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) + rel_path = resource_path.relative_to(catalog_dir) slugs.append(str(rel_path)) - for resource_path in sorted(CATALOG_DIR.rglob("*.nfg")): + for resource_path in sorted(catalog_dir.rglob("*.nfg")): if resource_path.is_file(): - rel_path = resource_path.relative_to(CATALOG_DIR) + rel_path = resource_path.relative_to(catalog_dir) slugs.append(str(rel_path)) - for resource_path in sorted(CATALOG_DIR.rglob("*.ef")): + 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) + if resource_path.is_file() and catalog_dir / "img" not in resource_path.parents: + rel_path = resource_path.relative_to(catalog_dir) slugs.append(str(rel_path)) game_files = [] @@ -163,8 +174,6 @@ def update_makefile(): game_files.append(f"catalog/{slug}") game_files.sort() - am_path = Path(__file__).parent / "catalog.am" - if am_path.exists(): with open(am_path, encoding="utf-8") as f: content = f.read() diff --git a/pyproject.toml b/pyproject.toml index 71e5f0065..22c8faaf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,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", @@ -115,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] From 0e16262b7624aa9b1c6e54be6763473b64aa4969 Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Wed, 27 May 2026 13:36:07 +0100 Subject: [PATCH 14/25] nicer formatting of _YAML_CONFIG indents --- build_support/catalog/test_update.py | 30 ++++++++++++++-------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/build_support/catalog/test_update.py b/build_support/catalog/test_update.py index aebe7bd0b..f4a06f686 100644 --- a/build_support/catalog/test_update.py +++ b/build_support/catalog/test_update.py @@ -21,21 +21,21 @@ } _YAML_CONFIG = """ -defaults: - color_scheme: gambit - font_family: sffamily - font_italic: true - shared_terminal_depth: true - sublevel_scaling: 0 - -overrides: - watson2013: - sublevel_scaling: 1 - selten1975: - shared_terminal_depth: false - myerson1991/fig2_1: - action_label_position: 0.4 -""" + defaults: + color_scheme: gambit + font_family: sffamily + font_italic: true + shared_terminal_depth: true + sublevel_scaling: 0 + + overrides: + watson2013: + sublevel_scaling: 1 + selten1975: + shared_terminal_depth: false + myerson1991/fig2_1: + action_label_position: 0.4 + """ def _write_yaml(path, content=_YAML_CONFIG): From 2d193e9ba042690e3fe8a894b8e8891cf040d45e Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Wed, 27 May 2026 13:58:55 +0100 Subject: [PATCH 15/25] refactor: update docstrings and comments in catalog update test suite --- build_support/catalog/test_update.py | 312 +++++++++++++++++++-------- 1 file changed, 228 insertions(+), 84 deletions(-) diff --git a/build_support/catalog/test_update.py b/build_support/catalog/test_update.py index f4a06f686..05edea358 100644 --- a/build_support/catalog/test_update.py +++ b/build_support/catalog/test_update.py @@ -1,4 +1,34 @@ -"""Tests for build_support/catalog/update.py.""" +"""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 @@ -9,9 +39,11 @@ import update # noqa: E402 # --------------------------------------------------------------------------- -# Helpers +# 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", @@ -20,30 +52,49 @@ "sublevel_scaling": 0, } -_YAML_CONFIG = """ - 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 +""") - overrides: - watson2013: - sublevel_scaling: 1 - selten1975: - shared_terminal_depth: false - myerson1991/fig2_1: - 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="Game Title", description="A description."): +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, @@ -53,7 +104,8 @@ def _efg_row(slug, title="Game Title", description="A description."): } -def _nfg_row(slug, title="NFG Title", description="NFG description."): +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, @@ -64,109 +116,169 @@ def _nfg_row(slug, title="NFG Title", description="NFG description."): 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 the stub image files so the existence check passes.""" - img = catalog_dir / "img" / slug - img.parent.mkdir(parents=True, exist_ok=True) + """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"]: - (catalog_dir / "img" / f"{slug}.{ext}").touch() + (img_dir / f"{slug}.{ext}").touch() if fmt == "efg": - (catalog_dir / "img" / f"{slug}.ef").touch() + (img_dir / f"{slug}.ef").touch() # --------------------------------------------------------------------------- -# catalog_draw_tree_settings +# 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("unknown/game") + 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("myerson1991/fig2_1") + 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) - result = update.catalog_draw_tree_settings("watson2013/exercise29_6") + # "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): - config = """ - defaults: - color_scheme: gambit - sublevel_scaling: 0 - overrides: - myerson1991: - sublevel_scaling: 1 - myerson1991/fig2_1: - sublevel_scaling: 2 - """ + """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("myerson1991/fig2_1") + 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) - result = update.catalog_draw_tree_settings("selten1975/fig1") - assert result["shared_terminal_depth"] is False - result2 = update.catalog_draw_tree_settings("watson2013/fig29_1") - assert result2["shared_terminal_depth"] is True # selten override not applied + # "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): - config = "defaults:\n color_scheme: gambit\n sublevel_scaling: 0\n" + """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("any/game") + result = update.catalog_draw_tree_settings("anygame/v1") assert result == {"color_scheme": "gambit", "sublevel_scaling": 0} # --------------------------------------------------------------------------- -# generate_rst_table +# 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): - """Replacement for draw_tree generate_* functions that does nothing.""" + """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 = "bagwell1995" + slug = "fakeauthor2000/fig1" _make_image_files(catalog_dir, slug, "efg") - df = _make_df(_efg_row(slug, title="Bagwell 1995")) + 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 "Bagwell 1995" in rst + assert "Fake Author (2000) Figure 1" in rst assert f'pygambit.catalog.load("{slug}")' in rst - assert f":download:`{slug}.efg" in rst - assert f":download:`{slug}.ef" 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 = "nau2004/sec3" + slug = "fakeauthor2001/matrix1" _make_image_files(catalog_dir, slug, "nfg") df = _make_df(_nfg_row(slug)) rst_path = tmp_path / "out.rst" @@ -175,38 +287,42 @@ def test_nfg_row_produces_rst_with_save_to(self, tmp_path, monkeypatch): 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": "bogus/game", - "Title": "Bogus", + "Game": "fakegame/v1", + "Title": "Fake Game", "Description": "Has a description.", "Download": "", - "Format": "efg_2", + "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 "Bogus" not in rst + 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("bagwell1995", description="")) + 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 "bagwell1995" not in rst + 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 = "selten1975/fig1" + slug = "fakeauthor1999/fig1" _make_image_files(catalog_dir, slug, "efg") - # Place a curated .ef alongside the .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() @@ -214,36 +330,47 @@ def test_curated_ef_used_in_draw_tree_call(self, tmp_path, monkeypatch): rst_path = tmp_path / "out.rst" update.generate_rst_table(df, rst_path, catalog_dir=catalog_dir) rst = rst_path.read_text() - # The draw_tree() call line should reference the .ef file path directly + # 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 = "bagwell1995" - _make_image_files(catalog_dir, slug, "efg") + 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 = "bagwell1995" + slug = "fakeauthor2000/fig1" _make_image_files(catalog_dir, slug, "efg") - # Use a curated .ef so gbt.catalog.load is not called (avoids needing real catalog) + # 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" @@ -252,58 +379,75 @@ def test_images_regenerated_when_flag_set(self, tmp_path, monkeypatch): # --------------------------------------------------------------------------- -# update_makefile +# 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): - (tmp_path / "foo.efg").touch() - (tmp_path / "sub").mkdir() - (tmp_path / "sub" / "bar.nfg").touch() + """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/foo.efg" in content - assert "catalog/sub/bar.nfg" in content + assert "catalog/standalone.efg" in content + assert "catalog/subfolder/matrix.nfg" in content def test_curated_ef_included(self, tmp_path): - (tmp_path / "myerson1991").mkdir() - (tmp_path / "myerson1991" / "fig1.efg").touch() - (tmp_path / "myerson1991" / "fig1.ef").touch() + """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/myerson1991/fig1.ef" in content + assert "catalog/fakegame/fig1.ef" in content def test_ef_in_img_dir_excluded(self, tmp_path): - img = tmp_path / "img" / "selten1975" + """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() + (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): - (tmp_path / "foo.efg_2").touch() - (tmp_path / "readme.txt").touch() + """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 + assert "README" not in content def test_no_write_when_content_unchanged(self, tmp_path): - (tmp_path / "foo.efg").touch() + """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 = am.stat().st_mtime + 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 + 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() From eaaf8b5a27a7921ad928b64ecefc3479bd8b4b3e Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Wed, 27 May 2026 14:17:53 +0100 Subject: [PATCH 16/25] fix: use as_posix() for cross-platform slug path consistency in catalog updates --- build_support/catalog/update.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/build_support/catalog/update.py b/build_support/catalog/update.py index a6f9991bd..b6db67758 100644 --- a/build_support/catalog/update.py +++ b/build_support/catalog/update.py @@ -157,17 +157,17 @@ def update_makefile( 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)) + 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)) + 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(str(rel_path)) + slugs.append(rel_path.as_posix()) game_files = [] for slug in slugs: From ebc8727b4ee6ce6ce137cce883955de35331b2a1 Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Wed, 27 May 2026 14:54:15 +0100 Subject: [PATCH 17/25] chore: update draw-tree dependency to version 0.9.1 in CI and ReadTheDocs configurations --- .github/workflows/python.yml | 8 ++++---- .readthedocs.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) 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 From dc9eb622935e7c842a23ebcbd51a62398a0e0b13 Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Thu, 28 May 2026 11:02:47 +0100 Subject: [PATCH 18/25] feat: enable support for multiple game layout variants in catalog via .ef file suffixing --- build_support/catalog/test_update.py | 167 +++++++++++++++++++++++++++ build_support/catalog/update.py | 151 +++++++++++++++++++----- doc/developer.catalog.rst | 20 +++- 3 files changed, 302 insertions(+), 36 deletions(-) diff --git a/build_support/catalog/test_update.py b/build_support/catalog/test_update.py index 05edea358..978e1deaf 100644 --- a/build_support/catalog/test_update.py +++ b/build_support/catalog/test_update.py @@ -226,6 +226,94 @@ def test_no_overrides_section_returns_defaults(self, tmp_path, monkeypatch): 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_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 # --------------------------------------------------------------------------- @@ -377,6 +465,85 @@ def test_images_regenerated_when_flag_set(self, tmp_path, monkeypatch): 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_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 diff --git a/build_support/catalog/update.py b/build_support/catalog/update.py index b6db67758..8c1f23b91 100644 --- a/build_support/catalog/update.py +++ b/build_support/catalog/update.py @@ -29,6 +29,39 @@ def catalog_draw_tree_settings(slug: str) -> dict: return settings +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 more than one .ef file is found alongside the game; + returns None when zero or one .ef file exists (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 + + ef_files = sorted( + p for p in game_dir.glob(f"{stem}*.ef") if p.stem == stem or p.stem.startswith(stem + "__") + ) + if len(ef_files) <= 1: + return None + + variants = [] + for ef_file in ef_files: + suffix = "" if ef_file.stem == stem else ef_file.stem[len(stem) + 2:] + label = "Default" if not suffix else suffix.replace("_", " ").title() + variant_key = slug if not suffix else 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, @@ -58,25 +91,49 @@ def generate_rst_table( if row["Format"] not in SUPPORTED_GAME_FORMATS: continue # Skip any games which lack a description - if description: - # Build the list of expected image files so we can check whether - # any are missing and need to be generated. + 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) + source = str(variant["ef_path"]) + 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(): + 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: - 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. @@ -90,33 +147,63 @@ def generate_rst_table( 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(" **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") + f.write( + f' draw_tree("../catalog/{vkey}.ef", {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") @@ -124,7 +211,7 @@ def generate_rst_table( 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()) + 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( diff --git a/doc/developer.catalog.rst b/doc/developer.catalog.rst index deacfb6c3..eb9dcfa5c 100644 --- a/doc/developer.catalog.rst +++ b/doc/developer.catalog.rst @@ -39,6 +39,14 @@ Currently supported representations are: Add your new game file(s) inside the ``catalog`` dir and commit them, or edit an existing game. If there are multiple games from a particular source, place them in an appropriately named folder. + .. important:: + + The name of the game file will determine it's "slug", used by the load function of the catalog module: + + .. code-block:: python + + pygambit.catalog.load("watson2013/exercise29_6") + .. note:: For extensive form games, you may optionally commit a curated ``.ef`` file alongside the ``.efg`` @@ -47,13 +55,17 @@ Currently supported representations are: auto-generating the layout from the ``.efg``, preserving any hand-tuned layout. Consult the `DrawTree docs `_ for the ``.ef`` format. - .. important:: + **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.: - The name of the game file will determine it's "slug", used by the load function of the catalog module: + .. code-block:: text - .. code-block:: python + catalog/example/game.ef # primary (tab label "Default") + catalog/example/game__wide.ef # additional (tab label "Wide") - pygambit.catalog.load("watson2013/exercise29_6") + 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:** From aa56574fb32be56797c9263d63ecb2cd62427155 Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Thu, 28 May 2026 11:21:30 +0100 Subject: [PATCH 19/25] feat: re-add new vonstengel2022 game files and custom draw tree settings to the catalog --- build_support/catalog/catalog.am | 7 +++++++ build_support/catalog/draw_tree_settings.yaml | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/build_support/catalog/catalog.am b/build_support/catalog/catalog.am index 980afb135..2a9095c9f 100644 --- a/build_support/catalog/catalog.am +++ b/build_support/catalog/catalog.am @@ -15,5 +15,12 @@ CATALOG_FILES = \ 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 diff --git a/build_support/catalog/draw_tree_settings.yaml b/build_support/catalog/draw_tree_settings.yaml index d969f4d11..00b42675a 100644 --- a/build_support/catalog/draw_tree_settings.yaml +++ b/build_support/catalog/draw_tree_settings.yaml @@ -22,3 +22,10 @@ overrides: 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 From 639d2866e30841469e585c4bedff58c06c5b2abc Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Fri, 29 May 2026 10:45:20 +0100 Subject: [PATCH 20/25] feat: migrate download links to dropdown --- build_support/catalog/update.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/build_support/catalog/update.py b/build_support/catalog/update.py index 8c1f23b91..d1fd6dfee 100644 --- a/build_support/catalog/update.py +++ b/build_support/catalog/update.py @@ -177,9 +177,9 @@ def generate_rst_table( download_links.append( f":download:`{slug}.{ext} <../catalog/img/{slug}.{ext}>`" ) - f.write(" **Download game and image files:**\n") - f.write(" \n") - f.write(f" {' '.join(download_links)}\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 From b51df4aed9397806fc34cb4d207720e10c6614a3 Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Mon, 1 Jun 2026 10:31:49 +0100 Subject: [PATCH 21/25] feat: support efg tab sets for games with suffix variants even when no primary .ef file exists --- build_support/catalog/test_update.py | 36 ++++++++++++++++++++ build_support/catalog/update.py | 51 ++++++++++++++++++++-------- 2 files changed, 72 insertions(+), 15 deletions(-) diff --git a/build_support/catalog/test_update.py b/build_support/catalog/test_update.py index 978e1deaf..70589e664 100644 --- a/build_support/catalog/test_update.py +++ b/build_support/catalog/test_update.py @@ -279,6 +279,21 @@ def test_two_ef_files_returns_variant_list(self, tmp_path): 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" @@ -486,6 +501,27 @@ def test_multi_variant_efg_produces_tab_set(self, tmp_path, monkeypatch): 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) diff --git a/build_support/catalog/update.py b/build_support/catalog/update.py index d1fd6dfee..96c52f042 100644 --- a/build_support/catalog/update.py +++ b/build_support/catalog/update.py @@ -33,8 +33,9 @@ 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 more than one .ef file is found alongside the game; - returns None when zero or one .ef file exists (no tabs needed). + ``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:: @@ -47,17 +48,26 @@ def catalog_ef_file_variants(slug: str, catalog_dir: Path) -> list[dict] | None: game_dir = (catalog_dir / slug).parent stem = Path(slug).name - ef_files = sorted( - p for p in game_dir.glob(f"{stem}*.ef") if p.stem == stem or p.stem.startswith(stem + "__") - ) - if len(ef_files) <= 1: + 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 = [] - for ef_file in ef_files: - suffix = "" if ef_file.stem == stem else ef_file.stem[len(stem) + 2:] - label = "Default" if not suffix else suffix.replace("_", " ").title() - variant_key = slug if not suffix else f"{slug}__{suffix}" + # 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 @@ -110,7 +120,10 @@ def generate_rst_table( 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) - source = str(variant["ef_path"]) + 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, @@ -118,7 +131,7 @@ def generate_rst_table( **catalog_draw_tree_settings(vkey), ) img_ef = catalog_dir / "img" / f"{vkey}.ef" - if not img_ef.exists(): + if not img_ef.exists() and variant["ef_path"].exists(): shutil.copy2(variant["ef_path"], img_ef) else: # Single variant @@ -198,9 +211,17 @@ def generate_rst_table( f.write(" \n") f.write(" import pygambit\n") f.write(" from draw_tree import draw_tree\n") - f.write( - f' draw_tree("../catalog/{vkey}.ef", {settings_str})\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") else: From cd9d9b3524614f03f1f2c13a6c5d143522b92bfa Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Mon, 1 Jun 2026 10:44:36 +0100 Subject: [PATCH 22/25] Add gilboa1997/fig1 to catalog.am --- build_support/catalog/catalog.am | 1 + 1 file changed, 1 insertion(+) diff --git a/build_support/catalog/catalog.am b/build_support/catalog/catalog.am index 2a9095c9f..8074da383 100644 --- a/build_support/catalog/catalog.am +++ b/build_support/catalog/catalog.am @@ -1,5 +1,6 @@ CATALOG_FILES = \ catalog/bagwell1995.efg \ + catalog/gilboa1997/fig1.efg \ catalog/gilboa1997/fig2.efg \ catalog/jakobsen2016/fig1a.efg \ catalog/jakobsen2016/fig1b.efg \ From 52ee100274fc4410ca3fb25fcfdcd573eb1c51d1 Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Mon, 1 Jun 2026 11:00:24 +0100 Subject: [PATCH 23/25] Update action_label_dist for gilboa1997 --- build_support/catalog/draw_tree_settings.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build_support/catalog/draw_tree_settings.yaml b/build_support/catalog/draw_tree_settings.yaml index 00b42675a..c71846502 100644 --- a/build_support/catalog/draw_tree_settings.yaml +++ b/build_support/catalog/draw_tree_settings.yaml @@ -29,3 +29,5 @@ overrides: sublevel_scaling: 1 vonstengelforges2008/fig9: sublevel_scaling: 0.5 + gilboa1997/fig1: + action_label_dist: 5.0 From 6517b9024ce7748a3f07b8d072a60cf7024308e0 Mon Sep 17 00:00:00 2001 From: Rahul Savani Date: Mon, 1 Jun 2026 13:46:34 +0100 Subject: [PATCH 24/25] original layout efs for figs 6 and 9 of vonstengelforges2008 --- build_support/catalog/catalog.am | 3 ++ .../fig6__Original_Layout.ef | 41 +++++++++++++++++++ .../fig9__Original_Layout.ef | 23 +++++++++++ 3 files changed, 67 insertions(+) create mode 100644 catalog/vonstengelforges2008/fig6__Original_Layout.ef create mode 100644 catalog/vonstengelforges2008/fig9__Original_Layout.ef diff --git a/build_support/catalog/catalog.am b/build_support/catalog/catalog.am index 8074da383..0fe2e1867 100644 --- a/build_support/catalog/catalog.am +++ b/build_support/catalog/catalog.am @@ -20,8 +20,11 @@ CATALOG_FILES = \ catalog/vonstengel2022/fig10.12.efg \ catalog/vonstengel2022/fig10.5.efg \ catalog/vonstengel2022/fig10.7.efg \ + catalog/vonstengelforges2008/f9-ff.ef \ 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/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 From af21819f85095243df2ad83a032d7fe175c59dc4 Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Mon, 1 Jun 2026 13:50:07 +0100 Subject: [PATCH 25/25] chore: remove f9-ff.ef from catalog build support files --- build_support/catalog/catalog.am | 1 - 1 file changed, 1 deletion(-) diff --git a/build_support/catalog/catalog.am b/build_support/catalog/catalog.am index 0fe2e1867..d65083cd9 100644 --- a/build_support/catalog/catalog.am +++ b/build_support/catalog/catalog.am @@ -20,7 +20,6 @@ CATALOG_FILES = \ catalog/vonstengel2022/fig10.12.efg \ catalog/vonstengel2022/fig10.5.efg \ catalog/vonstengel2022/fig10.7.efg \ - catalog/vonstengelforges2008/f9-ff.ef \ catalog/vonstengelforges2008/fig1.efg \ catalog/vonstengelforges2008/fig6.efg \ catalog/vonstengelforges2008/fig6__Original_Layout.ef \