From 114a5e8aea9e57d477dc23b6d875badd574ef7ae Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Wed, 3 Jun 2026 13:18:47 +0100 Subject: [PATCH 01/12] feat: add get_inbook_template to bibtex formatting utilities --- doc/conf.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index 7d2f1aec9..18796ca96 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -156,6 +156,9 @@ def get_incollection_template(self, e): self.format_web_refs(e), ] + def get_inbook_template(self, e): + return self.get_incollection_template(e) + def get_inproceedings_template(self, e): return toplevel[ sentence[ From 964f3d7010820f1da209161cfd8245bd0b351bef Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Wed, 3 Jun 2026 14:49:41 +0100 Subject: [PATCH 02/12] feat: implement dynamic loading of OpenSpiel games via pygambit.catalog --- doc/catalog.rst | 19 ++++ .../openspiel.ipynb | 37 +------- src/pygambit/catalog.py | 46 +++++++++ tests/test_catalog.py | 93 +++++++++++++++++++ 4 files changed, 163 insertions(+), 32 deletions(-) diff --git a/doc/catalog.rst b/doc/catalog.rst index 2c90df4c8..8f554a5f7 100644 --- a/doc/catalog.rst +++ b/doc/catalog.rst @@ -6,4 +6,23 @@ Catalog of games Below is a complete list of games included in Gambit's catalog. Check out the :ref:`pygambit API reference ` for instructions on how to search and load these games in Python, and the :ref:`Updating the games catalog ` guide for instructions on how to contribute new games to the catalog. +.. _catalog-openspiel: + +Loading OpenSpiel games +----------------------- + +Games from the `OpenSpiel `_ library +can be loaded dynamically using the ``open_spiel/`` prefix: + +.. code-block:: python + + pygambit.catalog.load("open_spiel/matrix_rps") + pygambit.catalog.load("open_spiel/tiny_hanabi") + +This requires ``open_spiel`` to be installed (``pip install open_spiel``; +not available on Windows). The game is exported to NFG or EFG format on the fly +and loaded into Gambit. Not all OpenSpiel games can be exported; a +:class:`ValueError` is raised for games that are incompatible with either format. +See the OpenSpiel interoperability tutorial for worked examples. + .. include:: catalog_table.rst diff --git a/doc/tutorials/interoperability_tutorials/openspiel.ipynb b/doc/tutorials/interoperability_tutorials/openspiel.ipynb index fc8fe8be7..edcf7a054 100644 --- a/doc/tutorials/interoperability_tutorials/openspiel.ipynb +++ b/doc/tutorials/interoperability_tutorials/openspiel.ipynb @@ -30,14 +30,11 @@ "metadata": {}, "outputs": [], "source": [ - "from io import StringIO\n", - "\n", "import matplotlib.pyplot as plt\n", "import numpy as np\n", "import pyspiel\n", "from open_spiel.python import rl_environment\n", "from open_spiel.python.algorithms import tabular_qlearner\n", - "from open_spiel.python.algorithms.gambit import export_gambit\n", "from open_spiel.python.egt import dynamics\n", "from open_spiel.python.egt.utils import game_payoffs_array\n", "\n", @@ -150,18 +147,7 @@ "id": "045cf8dd", "metadata": {}, "source": [ - "OpenSpiel can generate an NFG representation of the game loadable in Gambit:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f5fa4e42", - "metadata": {}, - "outputs": [], - "source": [ - "nfg_matrix_rps_game = pyspiel.game_to_nfg_string(ops_matrix_rps_game)\n", - "nfg_matrix_rps_game" + "Gambit's catalog module can load games from the OpenSpiel library directly:\n" ] }, { @@ -169,7 +155,6 @@ "id": "70d1df64", "metadata": {}, "source": [ - "Now let's load the NFG in Gambit. Since Gambit's `read_nfg` function expects a file like object, we'll convert the string with `io.StringIO`.\n", "We can also add labels for the actions to make the output more interpretable:" ] }, @@ -180,7 +165,7 @@ "metadata": {}, "outputs": [], "source": [ - "gbt_matrix_rps_game = gbt.read_nfg(StringIO(nfg_matrix_rps_game))\n", + "gbt_matrix_rps_game = gbt.catalog.load(\"open_spiel/matrix_rps\")\n", "\n", "gbt_matrix_rps_game.title = \"Rock-Paper-Scissors\"\n", "\n", @@ -462,19 +447,8 @@ "source": [ "## Extensive-form games from the OpenSpiel library\n", "\n", - "For extensive-form games, OpenSpiel can export to the EFG format used by Gambit. Here we demonstrate this with **Tiny Hanabi**, loaded from the OpenSpiel [game library](https://openspiel.readthedocs.io/en/latest/games.html):" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "02a42600", - "metadata": {}, - "outputs": [], - "source": [ - "ops_hanabi_game = pyspiel.load_game(\"tiny_hanabi\")\n", - "efg_hanabi_game = export_gambit(ops_hanabi_game)\n", - "efg_hanabi_game" + "For extensive-form games, Gambit's catalog module can load games from the OpenSpiel library.\n", + "Here we demonstrate this with **Tiny Hanabi**, from the OpenSpiel [game library](https://openspiel.readthedocs.io/en/latest/games.html):\n" ] }, { @@ -482,7 +456,6 @@ "id": "fa354c9f", "metadata": {}, "source": [ - "Now let's load the EFG in Gambit.\n", "We can then compute equilibria strategies for the players as usual:" ] }, @@ -493,7 +466,7 @@ "metadata": {}, "outputs": [], "source": [ - "gbt_hanabi_game = gbt.read_efg(StringIO(efg_hanabi_game))\n", + "gbt_hanabi_game = gbt.catalog.load(\"open_spiel/tiny_hanabi\")\n", "eqm = gbt.nash.lcp_solve(gbt_hanabi_game).equilibria[0]" ] }, diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index 961c2a532..84157263a 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -1,3 +1,4 @@ +import io from importlib.resources import as_file, files from pathlib import Path from typing import Any @@ -19,6 +20,48 @@ ".efg": gbt.read_efg, } +_OPENSPIEL_PREFIX = "open_spiel/" + + +def _load_from_openspiel(game_name: str) -> gbt.Game: + """ + Load a game from the OpenSpiel library by name. + + Tries NFG export first; falls back to EFG export via the + open_spiel.python.algorithms.gambit exporter. Raises ImportError + if open_spiel is not installed, ValueError if the game cannot be + exported to either format. + """ + try: + import pyspiel + from open_spiel.python.algorithms.gambit import export_gambit + except ImportError as exc: + raise ImportError( + "open_spiel is required to load OpenSpiel games. " + "Install it with: pip install open_spiel" + ) from exc + + try: + game = pyspiel.load_game(game_name) + except Exception as exc: + raise ValueError(f"Could not load OpenSpiel game '{game_name}': {exc}") from exc + + # Try NFG first (works for normal-form games) + try: + nfg_str = pyspiel.game_to_nfg_string(game) + return gbt.read_nfg(io.StringIO(nfg_str)) + except Exception: + pass + + # Fall back to EFG export + try: + efg_str = export_gambit(game) + return gbt.read_efg(io.StringIO(efg_str)) + except Exception as exc: + raise ValueError( + f"OpenSpiel game '{game_name}' could not be exported to NFG or EFG format." + ) from exc + def load(slug: str) -> gbt.Game: """ @@ -41,6 +84,9 @@ def load(slug: str) -> gbt.Game: """ slug = str(Path(slug)).replace("\\", "/") + if slug.startswith(_OPENSPIEL_PREFIX): + return _load_from_openspiel(slug[len(_OPENSPIEL_PREFIX):]) + # Try to load from file for suffix, reader in READERS.items(): resource_path = _CATALOG_RESOURCE / f"{slug}{suffix}" diff --git a/tests/test_catalog.py b/tests/test_catalog.py index c034a3cbd..9492ae6cd 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -1,3 +1,6 @@ +import sys +from unittest.mock import MagicMock + import pandas as pd import pytest @@ -172,3 +175,93 @@ def test_catalog_games_include_descriptions(): games_with_desc = gbt.catalog.games(include_descriptions=True) assert "Description" in games_with_desc.columns assert "Download" in games_with_desc.columns + + +# --------------------------------------------------------------------------- +# OpenSpiel dynamic loading tests (all mocked; open_spiel need not be installed) +# --------------------------------------------------------------------------- + +_MOCK_NFG = gbt.Game.new_table([2, 2]).to_nfg() +_MOCK_EFG = gbt.catalog.load("bagwell1995").to_efg() + + +def _setup_pyspiel_mock( + monkeypatch, + *, + nfg_str=None, + nfg_raises=None, + efg_str=None, + efg_raises=None, + load_raises=None, +): + """Inject a fake pyspiel + open_spiel.python.algorithms.gambit into sys.modules.""" + mock_ps = MagicMock() + mock_export_fn = MagicMock() + + if load_raises is not None: + mock_ps.load_game.side_effect = load_raises + else: + mock_ps.load_game.return_value = MagicMock() + + if nfg_raises is not None: + mock_ps.game_to_nfg_string.side_effect = nfg_raises + else: + mock_ps.game_to_nfg_string.return_value = nfg_str + + if efg_raises is not None: + mock_export_fn.side_effect = efg_raises + else: + mock_export_fn.return_value = efg_str + + mock_gambit_module = MagicMock() + mock_gambit_module.export_gambit = mock_export_fn + + monkeypatch.setitem(sys.modules, "pyspiel", mock_ps) + monkeypatch.setitem(sys.modules, "open_spiel", MagicMock()) + monkeypatch.setitem(sys.modules, "open_spiel.python", MagicMock()) + monkeypatch.setitem(sys.modules, "open_spiel.python.algorithms", MagicMock()) + monkeypatch.setitem(sys.modules, "open_spiel.python.algorithms.gambit", mock_gambit_module) + return mock_ps, mock_export_fn + + +def test_openspiel_load_nfg_success(monkeypatch): + """NFG export succeeds: catalog.load returns a valid Game.""" + _setup_pyspiel_mock(monkeypatch, nfg_str=_MOCK_NFG) + game = gbt.catalog.load("open_spiel/matrix_rps") + assert isinstance(game, gbt.Game) + + +def test_openspiel_load_efg_fallback(monkeypatch): + """NFG export fails, EFG export succeeds: catalog.load returns a valid Game.""" + _setup_pyspiel_mock( + monkeypatch, + nfg_raises=RuntimeError("nfg not supported"), + efg_str=_MOCK_EFG, + ) + game = gbt.catalog.load("open_spiel/tiny_hanabi") + assert isinstance(game, gbt.Game) + + +def test_openspiel_load_import_error(monkeypatch): + """Missing open_spiel raises ImportError with a helpful message.""" + monkeypatch.setitem(sys.modules, "pyspiel", None) + with pytest.raises(ImportError, match="open_spiel"): + gbt.catalog.load("open_spiel/matrix_rps") + + +def test_openspiel_load_game_not_found(monkeypatch): + """pyspiel.load_game failure raises ValueError.""" + _setup_pyspiel_mock(monkeypatch, load_raises=RuntimeError("unknown game")) + with pytest.raises(ValueError, match="bogus_game"): + gbt.catalog.load("open_spiel/bogus_game") + + +def test_openspiel_load_export_failure(monkeypatch): + """Both NFG and EFG exports fail: raises ValueError.""" + _setup_pyspiel_mock( + monkeypatch, + nfg_raises=RuntimeError("nfg not supported"), + efg_raises=RuntimeError("efg not supported"), + ) + with pytest.raises(ValueError, match="could not be exported"): + gbt.catalog.load("open_spiel/matrix_rps") From 4684cc1892a62408b79a1907cb7936afe9329194 Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Wed, 3 Jun 2026 15:10:02 +0100 Subject: [PATCH 03/12] feat: suppress C-level stderr during OpenSpiel NFG exports to prevent unwanted console output --- src/pygambit/catalog.py | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index 84157263a..94dfc7ad3 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -1,4 +1,6 @@ +import contextlib import io +import os from importlib.resources import as_file, files from pathlib import Path from typing import Any @@ -23,6 +25,30 @@ _OPENSPIEL_PREFIX = "open_spiel/" +@contextlib.contextmanager +def _suppress_c_stderr(): + """Redirect C-level stderr (fd 2) to /dev/null for the duration of the block. + + This prevents OpenSpiel's C++ code from printing error messages (e.g. + "OpenSpiel exception: Must be a normal-form game") to the user's terminal or + notebook when we speculatively attempt an export that may not be supported. + Falls back silently if the fd-level redirect is unavailable (e.g. Windows). + """ + try: + devnull_fd = os.open(os.devnull, os.O_WRONLY) + saved_fd = os.dup(2) + os.dup2(devnull_fd, 2) + os.close(devnull_fd) + except OSError: + yield + return + try: + yield + finally: + os.dup2(saved_fd, 2) + os.close(saved_fd) + + def _load_from_openspiel(game_name: str) -> gbt.Game: """ Load a game from the OpenSpiel library by name. @@ -46,9 +72,13 @@ def _load_from_openspiel(game_name: str) -> gbt.Game: except Exception as exc: raise ValueError(f"Could not load OpenSpiel game '{game_name}': {exc}") from exc - # Try NFG first (works for normal-form games) + # Try NFG first (works for normal-form games). + # Suppress C-level stderr so OpenSpiel's C++ error message for non-NFG games + # (e.g. "OpenSpiel exception: Must be a normal-form game") is not shown when + # we fall through to the EFG path. try: - nfg_str = pyspiel.game_to_nfg_string(game) + with _suppress_c_stderr(): + nfg_str = pyspiel.game_to_nfg_string(game) return gbt.read_nfg(io.StringIO(nfg_str)) except Exception: pass From f3e111974add5b71e44c84ae618c3f603a201ae5 Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Wed, 3 Jun 2026 15:15:02 +0100 Subject: [PATCH 04/12] Update tutorial text in interoperability notebook --- .../interoperability_tutorials/openspiel.ipynb | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/doc/tutorials/interoperability_tutorials/openspiel.ipynb b/doc/tutorials/interoperability_tutorials/openspiel.ipynb index edcf7a054..eed31fa09 100644 --- a/doc/tutorials/interoperability_tutorials/openspiel.ipynb +++ b/doc/tutorials/interoperability_tutorials/openspiel.ipynb @@ -147,15 +147,7 @@ "id": "045cf8dd", "metadata": {}, "source": [ - "Gambit's catalog module can load games from the OpenSpiel library directly:\n" - ] - }, - { - "cell_type": "markdown", - "id": "70d1df64", - "metadata": {}, - "source": [ - "We can also add labels for the actions to make the output more interpretable:" + "Gambit's catalog module can load games from the OpenSpiel library directly (here we also add a title and action labels to make the output more interpretable):\n" ] }, { @@ -447,7 +439,7 @@ "source": [ "## Extensive-form games from the OpenSpiel library\n", "\n", - "For extensive-form games, Gambit's catalog module can load games from the OpenSpiel library.\n", + "We can also load extensive-form games via Gambit's catalog module.\n", "Here we demonstrate this with **Tiny Hanabi**, from the OpenSpiel [game library](https://openspiel.readthedocs.io/en/latest/games.html):\n" ] }, @@ -853,7 +845,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.5" + "version": "3.13.13" } }, "nbformat": 4, From b2fe21500e180fe9523d19892de8368804a99367 Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Wed, 3 Jun 2026 15:24:22 +0100 Subject: [PATCH 05/12] docs: reorganise catalog page by moving the table inclusion and OpenSpiel sections under dedicated headers --- doc/catalog.rst | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/doc/catalog.rst b/doc/catalog.rst index 8f554a5f7..43de13897 100644 --- a/doc/catalog.rst +++ b/doc/catalog.rst @@ -5,6 +5,12 @@ Catalog of games Below is a complete list of games included in Gambit's catalog. Check out the :ref:`pygambit API reference ` for instructions on how to search and load these games in Python, and the :ref:`Updating the games catalog ` guide for instructions on how to contribute new games to the catalog. +Games from the OpenSpiel library are also available; see :ref:`Loading OpenSpiel games `. + +Loading Gambit games +----------------------- + +.. include:: catalog_table.rst .. _catalog-openspiel: @@ -24,5 +30,3 @@ not available on Windows). The game is exported to NFG or EFG format on the fly and loaded into Gambit. Not all OpenSpiel games can be exported; a :class:`ValueError` is raised for games that are incompatible with either format. See the OpenSpiel interoperability tutorial for worked examples. - -.. include:: catalog_table.rst From c411543f8f619db2399b4c3f9c48e3bb7f78d514 Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Wed, 3 Jun 2026 15:25:36 +0100 Subject: [PATCH 06/12] Remice superfluous header and add link --- doc/catalog.rst | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/doc/catalog.rst b/doc/catalog.rst index 43de13897..6090f53d8 100644 --- a/doc/catalog.rst +++ b/doc/catalog.rst @@ -7,9 +7,6 @@ Below is a complete list of games included in Gambit's catalog. Check out the :ref:`pygambit API reference ` for instructions on how to search and load these games in Python, and the :ref:`Updating the games catalog ` guide for instructions on how to contribute new games to the catalog. Games from the OpenSpiel library are also available; see :ref:`Loading OpenSpiel games `. -Loading Gambit games ------------------------ - .. include:: catalog_table.rst .. _catalog-openspiel: @@ -29,4 +26,4 @@ This requires ``open_spiel`` to be installed (``pip install open_spiel``; not available on Windows). The game is exported to NFG or EFG format on the fly and loaded into Gambit. Not all OpenSpiel games can be exported; a :class:`ValueError` is raised for games that are incompatible with either format. -See the OpenSpiel interoperability tutorial for worked examples. +See the :doc:`OpenSpiel interoperability tutorial ` for worked examples. From cdd3ece04dc11db7476d126b9863aac4d007fc30 Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Wed, 3 Jun 2026 15:53:35 +0100 Subject: [PATCH 07/12] docs: convert OpenSpiel heading to rubric in catalog documentation --- doc/catalog.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/doc/catalog.rst b/doc/catalog.rst index 6090f53d8..3b6551a99 100644 --- a/doc/catalog.rst +++ b/doc/catalog.rst @@ -11,8 +11,7 @@ Games from the OpenSpiel library are also available; see :ref:`Loading OpenSpiel .. _catalog-openspiel: -Loading OpenSpiel games ------------------------ +.. rubric:: Loading OpenSpiel games Games from the `OpenSpiel `_ library can be loaded dynamically using the ``open_spiel/`` prefix: From f0fee0aaa39ba30d5fa040bee67ddc6324f4d77f Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Thu, 4 Jun 2026 10:27:45 +0100 Subject: [PATCH 08/12] refactor: prioritize EFG export over NFG in OpenSpiel game loading and update tests accordingly --- src/pygambit/catalog.py | 19 +++++++++---------- tests/test_catalog.py | 22 +++++++++++----------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index 94dfc7ad3..f3fb17a4c 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -72,24 +72,23 @@ def _load_from_openspiel(game_name: str) -> gbt.Game: except Exception as exc: raise ValueError(f"Could not load OpenSpiel game '{game_name}': {exc}") from exc - # Try NFG first (works for normal-form games). - # Suppress C-level stderr so OpenSpiel's C++ error message for non-NFG games - # (e.g. "OpenSpiel exception: Must be a normal-form game") is not shown when - # we fall through to the EFG path. + # Try EFG first (works for extensive-form games). + # Suppress C-level stderr so any C++ error messages for games that don't + # support EFG export are not shown when we fall through to the NFG path. try: with _suppress_c_stderr(): - nfg_str = pyspiel.game_to_nfg_string(game) - return gbt.read_nfg(io.StringIO(nfg_str)) + efg_str = export_gambit(game) + return gbt.read_efg(io.StringIO(efg_str)) except Exception: pass - # Fall back to EFG export + # Fall back to NFG export (works for normal-form games) try: - efg_str = export_gambit(game) - return gbt.read_efg(io.StringIO(efg_str)) + nfg_str = pyspiel.game_to_nfg_string(game) + return gbt.read_nfg(io.StringIO(nfg_str)) except Exception as exc: raise ValueError( - f"OpenSpiel game '{game_name}' could not be exported to NFG or EFG format." + f"OpenSpiel game '{game_name}' could not be exported to EFG or NFG format." ) from exc diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 9492ae6cd..c25bd307d 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -224,21 +224,21 @@ def _setup_pyspiel_mock( return mock_ps, mock_export_fn -def test_openspiel_load_nfg_success(monkeypatch): - """NFG export succeeds: catalog.load returns a valid Game.""" - _setup_pyspiel_mock(monkeypatch, nfg_str=_MOCK_NFG) - game = gbt.catalog.load("open_spiel/matrix_rps") +def test_openspiel_load_efg_success(monkeypatch): + """EFG export succeeds: catalog.load returns a valid Game.""" + _setup_pyspiel_mock(monkeypatch, efg_str=_MOCK_EFG) + game = gbt.catalog.load("open_spiel/tiny_hanabi") assert isinstance(game, gbt.Game) -def test_openspiel_load_efg_fallback(monkeypatch): - """NFG export fails, EFG export succeeds: catalog.load returns a valid Game.""" +def test_openspiel_load_nfg_fallback(monkeypatch): + """EFG export fails, NFG export succeeds: catalog.load returns a valid Game.""" _setup_pyspiel_mock( monkeypatch, - nfg_raises=RuntimeError("nfg not supported"), - efg_str=_MOCK_EFG, + efg_raises=RuntimeError("efg not supported"), + nfg_str=_MOCK_NFG, ) - game = gbt.catalog.load("open_spiel/tiny_hanabi") + game = gbt.catalog.load("open_spiel/matrix_rps") assert isinstance(game, gbt.Game) @@ -257,11 +257,11 @@ def test_openspiel_load_game_not_found(monkeypatch): def test_openspiel_load_export_failure(monkeypatch): - """Both NFG and EFG exports fail: raises ValueError.""" + """Both EFG and NFG exports fail: raises ValueError.""" _setup_pyspiel_mock( monkeypatch, - nfg_raises=RuntimeError("nfg not supported"), efg_raises=RuntimeError("efg not supported"), + nfg_raises=RuntimeError("nfg not supported"), ) with pytest.raises(ValueError, match="could not be exported"): gbt.catalog.load("open_spiel/matrix_rps") From dffab1baf5e07fc036382b427acae43b7e9452f9 Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Thu, 4 Jun 2026 11:03:57 +0100 Subject: [PATCH 09/12] feat: promote OpenSpiel loading to dedicated load_openspiel function with parameter support --- doc/catalog.rst | 13 +++++-- doc/pygambit.api.rst | 1 + .../openspiel.ipynb | 6 +-- src/pygambit/catalog.py | 39 +++++++++++++------ tests/test_catalog.py | 21 ++++++---- 5 files changed, 54 insertions(+), 26 deletions(-) diff --git a/doc/catalog.rst b/doc/catalog.rst index 3b6551a99..dfb0875e2 100644 --- a/doc/catalog.rst +++ b/doc/catalog.rst @@ -14,15 +14,20 @@ Games from the OpenSpiel library are also available; see :ref:`Loading OpenSpiel .. rubric:: Loading OpenSpiel games Games from the `OpenSpiel `_ library -can be loaded dynamically using the ``open_spiel/`` prefix: +can be loaded using :func:`pygambit.catalog.load_openspiel`: .. code-block:: python - pygambit.catalog.load("open_spiel/matrix_rps") - pygambit.catalog.load("open_spiel/tiny_hanabi") + pygambit.catalog.load_openspiel("matrix_rps") + pygambit.catalog.load_openspiel("tiny_hanabi") + pygambit.catalog.load_openspiel("blotto", params={"players": 2, "coins": 3, "fields": 2}) + +The ``params`` argument is forwarded directly to ``pyspiel.load_game``; see the +`OpenSpiel game list `_ for +available parameters per game. This requires ``open_spiel`` to be installed (``pip install open_spiel``; -not available on Windows). The game is exported to NFG or EFG format on the fly +not available on Windows). The game is exported to EFG or NFG format on the fly and loaded into Gambit. Not all OpenSpiel games can be exported; a :class:`ValueError` is raised for games that are incompatible with either format. See the :doc:`OpenSpiel interoperability tutorial ` for worked examples. diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 7f8eea604..5841f02be 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -338,4 +338,5 @@ Catalog of games :toctree: api/ load + load_openspiel games diff --git a/doc/tutorials/interoperability_tutorials/openspiel.ipynb b/doc/tutorials/interoperability_tutorials/openspiel.ipynb index eed31fa09..74ec53ae6 100644 --- a/doc/tutorials/interoperability_tutorials/openspiel.ipynb +++ b/doc/tutorials/interoperability_tutorials/openspiel.ipynb @@ -147,7 +147,7 @@ "id": "045cf8dd", "metadata": {}, "source": [ - "Gambit's catalog module can load games from the OpenSpiel library directly (here we also add a title and action labels to make the output more interpretable):\n" + "Gambit's catalog module can load games from the OpenSpiel library using `gbt.catalog.load_openspiel`:\n" ] }, { @@ -157,7 +157,7 @@ "metadata": {}, "outputs": [], "source": [ - "gbt_matrix_rps_game = gbt.catalog.load(\"open_spiel/matrix_rps\")\n", + "gbt_matrix_rps_game = gbt.catalog.load_openspiel(\"matrix_rps\")\n", "\n", "gbt_matrix_rps_game.title = \"Rock-Paper-Scissors\"\n", "\n", @@ -458,7 +458,7 @@ "metadata": {}, "outputs": [], "source": [ - "gbt_hanabi_game = gbt.catalog.load(\"open_spiel/tiny_hanabi\")\n", + "gbt_hanabi_game = gbt.catalog.load_openspiel(\"tiny_hanabi\")\n", "eqm = gbt.nash.lcp_solve(gbt_hanabi_game).equilibria[0]" ] }, diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index f3fb17a4c..3d568e8ee 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -22,8 +22,6 @@ ".efg": gbt.read_efg, } -_OPENSPIEL_PREFIX = "open_spiel/" - @contextlib.contextmanager def _suppress_c_stderr(): @@ -49,14 +47,34 @@ def _suppress_c_stderr(): os.close(saved_fd) -def _load_from_openspiel(game_name: str) -> gbt.Game: +def load_openspiel(game_name: str, params: dict | None = None) -> gbt.Game: """ - Load a game from the OpenSpiel library by name. + Load a game from the OpenSpiel library into Gambit. + + Parameters + ---------- + game_name : str + The short name of the OpenSpiel game (e.g. ``"matrix_rps"``, + ``"tiny_hanabi"``). Passed directly to ``pyspiel.load_game``. + params : dict, optional + Game parameters forwarded to ``pyspiel.load_game`` + (e.g. ``{"players": 2, "coins": 3, "fields": 2}`` for ``"blotto"``). + See the `OpenSpiel game list + `_ for + available parameters per game. Defaults to an empty dict. + + Returns + ------- + gbt.Game + The loaded game. - Tries NFG export first; falls back to EFG export via the - open_spiel.python.algorithms.gambit exporter. Raises ImportError - if open_spiel is not installed, ValueError if the game cannot be - exported to either format. + Raises + ------ + ImportError + If ``open_spiel`` is not installed. + ValueError + If ``pyspiel.load_game`` fails, or if the game cannot be exported + to EFG or NFG format. """ try: import pyspiel @@ -68,7 +86,7 @@ def _load_from_openspiel(game_name: str) -> gbt.Game: ) from exc try: - game = pyspiel.load_game(game_name) + game = pyspiel.load_game(game_name, params or {}) except Exception as exc: raise ValueError(f"Could not load OpenSpiel game '{game_name}': {exc}") from exc @@ -113,9 +131,6 @@ def load(slug: str) -> gbt.Game: """ slug = str(Path(slug)).replace("\\", "/") - if slug.startswith(_OPENSPIEL_PREFIX): - return _load_from_openspiel(slug[len(_OPENSPIEL_PREFIX):]) - # Try to load from file for suffix, reader in READERS.items(): resource_path = _CATALOG_RESOURCE / f"{slug}{suffix}" diff --git a/tests/test_catalog.py b/tests/test_catalog.py index c25bd307d..15a03770f 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -225,20 +225,20 @@ def _setup_pyspiel_mock( def test_openspiel_load_efg_success(monkeypatch): - """EFG export succeeds: catalog.load returns a valid Game.""" + """EFG export succeeds: load_openspiel returns a valid Game.""" _setup_pyspiel_mock(monkeypatch, efg_str=_MOCK_EFG) - game = gbt.catalog.load("open_spiel/tiny_hanabi") + game = gbt.catalog.load_openspiel("tiny_hanabi") assert isinstance(game, gbt.Game) def test_openspiel_load_nfg_fallback(monkeypatch): - """EFG export fails, NFG export succeeds: catalog.load returns a valid Game.""" + """EFG export fails, NFG export succeeds: load_openspiel returns a valid Game.""" _setup_pyspiel_mock( monkeypatch, efg_raises=RuntimeError("efg not supported"), nfg_str=_MOCK_NFG, ) - game = gbt.catalog.load("open_spiel/matrix_rps") + game = gbt.catalog.load_openspiel("matrix_rps") assert isinstance(game, gbt.Game) @@ -246,14 +246,14 @@ def test_openspiel_load_import_error(monkeypatch): """Missing open_spiel raises ImportError with a helpful message.""" monkeypatch.setitem(sys.modules, "pyspiel", None) with pytest.raises(ImportError, match="open_spiel"): - gbt.catalog.load("open_spiel/matrix_rps") + gbt.catalog.load_openspiel("matrix_rps") def test_openspiel_load_game_not_found(monkeypatch): """pyspiel.load_game failure raises ValueError.""" _setup_pyspiel_mock(monkeypatch, load_raises=RuntimeError("unknown game")) with pytest.raises(ValueError, match="bogus_game"): - gbt.catalog.load("open_spiel/bogus_game") + gbt.catalog.load_openspiel("bogus_game") def test_openspiel_load_export_failure(monkeypatch): @@ -264,4 +264,11 @@ def test_openspiel_load_export_failure(monkeypatch): nfg_raises=RuntimeError("nfg not supported"), ) with pytest.raises(ValueError, match="could not be exported"): - gbt.catalog.load("open_spiel/matrix_rps") + gbt.catalog.load_openspiel("matrix_rps") + + +def test_openspiel_load_with_params(monkeypatch): + """params dict is forwarded to pyspiel.load_game.""" + mock_ps, _ = _setup_pyspiel_mock(monkeypatch, nfg_str=_MOCK_NFG) + gbt.catalog.load_openspiel("blotto", params={"players": 2, "coins": 3, "fields": 2}) + mock_ps.load_game.assert_called_once_with("blotto", {"players": 2, "coins": 3, "fields": 2}) From b55a2b9da2d8b3f211a1f17786518bc0bf3df1ba Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 4 Jun 2026 12:22:28 +0100 Subject: [PATCH 10/12] Added ChangeLog, minor docstring tweak --- ChangeLog | 3 +++ src/pygambit/catalog.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/ChangeLog b/ChangeLog index a50e9f412..879cf50d2 100644 --- a/ChangeLog +++ b/ChangeLog @@ -2,6 +2,9 @@ ## [16.7.0] - unreleased +### Added +- Games can be materialised directly from OpenSpiel games if `pyspiel` is installed. (#917) + ### Fixed - Corrected resizing of row and column index labels in strategic form so pivoting works correctly. (#844) - Corrected incorrect output of strategic game tables to .nfg files if strategies have previously been diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index 3d568e8ee..a5fee18de 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -49,7 +49,7 @@ def _suppress_c_stderr(): def load_openspiel(game_name: str, params: dict | None = None) -> gbt.Game: """ - Load a game from the OpenSpiel library into Gambit. + Load a game from the OpenSpiel library. Parameters ---------- From 6b53e362eaf4a930e655ffa227d8d2cf4e2627f3 Mon Sep 17 00:00:00 2001 From: Ed Chalstrey Date: Thu, 4 Jun 2026 15:33:06 +0100 Subject: [PATCH 11/12] refactor: replace heuristic OpenSpiel export fallback with explicit dynamics-based selection and don't include OpenSpiel's own errors --- src/pygambit/catalog.py | 75 ++++++++++++++++----------------------- tests/test_catalog.py | 77 ++++++++++++++++++++++++++++++----------- 2 files changed, 86 insertions(+), 66 deletions(-) diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index a5fee18de..9eb03cb8c 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -1,6 +1,4 @@ -import contextlib import io -import os from importlib.resources import as_file, files from pathlib import Path from typing import Any @@ -23,30 +21,6 @@ } -@contextlib.contextmanager -def _suppress_c_stderr(): - """Redirect C-level stderr (fd 2) to /dev/null for the duration of the block. - - This prevents OpenSpiel's C++ code from printing error messages (e.g. - "OpenSpiel exception: Must be a normal-form game") to the user's terminal or - notebook when we speculatively attempt an export that may not be supported. - Falls back silently if the fd-level redirect is unavailable (e.g. Windows). - """ - try: - devnull_fd = os.open(os.devnull, os.O_WRONLY) - saved_fd = os.dup(2) - os.dup2(devnull_fd, 2) - os.close(devnull_fd) - except OSError: - yield - return - try: - yield - finally: - os.dup2(saved_fd, 2) - os.close(saved_fd) - - def load_openspiel(game_name: str, params: dict | None = None) -> gbt.Game: """ Load a game from the OpenSpiel library. @@ -73,8 +47,11 @@ def load_openspiel(game_name: str, params: dict | None = None) -> gbt.Game: ImportError If ``open_spiel`` is not installed. ValueError - If ``pyspiel.load_game`` fails, or if the game cannot be exported - to EFG or NFG format. + If the game's dynamics type is not supported for export, or if the + format exporter raises an error for this specific game. + Other exceptions from ``pyspiel.load_game`` propagate directly. + For example, ``pyspiel.SpielError`` is raised for unknown game names + or invalid/missing parameters. """ try: import pyspiel @@ -85,29 +62,37 @@ def load_openspiel(game_name: str, params: dict | None = None) -> gbt.Game: "Install it with: pip install open_spiel" ) from exc - try: - game = pyspiel.load_game(game_name, params or {}) - except Exception as exc: - raise ValueError(f"Could not load OpenSpiel game '{game_name}': {exc}") from exc + # Let pyspiel's own exceptions propagate unchanged — they already carry + # informative messages ("Unknown game '...'", "Unknown parameter '...'", etc.) + game = pyspiel.load_game(game_name, params or {}) - # Try EFG first (works for extensive-form games). - # Suppress C-level stderr so any C++ error messages for games that don't - # support EFG export are not shown when we fall through to the NFG path. - try: - with _suppress_c_stderr(): + dynamics = game.get_type().dynamics + + # OpenSpiel's SEQUENTIAL corresponds to extensive-form (tree) games in Gambit; + # SIMULTANEOUS corresponds to normal-form (strategic-form) games. + if dynamics == pyspiel.GameType.Dynamics.SEQUENTIAL: + try: efg_str = export_gambit(game) + except Exception as exc: + raise ValueError( + f"OpenSpiel game '{game_name}' could not be exported to EFG format: {exc}" + ) from exc return gbt.read_efg(io.StringIO(efg_str)) - except Exception: - pass - # Fall back to NFG export (works for normal-form games) - try: - nfg_str = pyspiel.game_to_nfg_string(game) + elif dynamics == pyspiel.GameType.Dynamics.SIMULTANEOUS: + try: + nfg_str = pyspiel.game_to_nfg_string(game) + except Exception as exc: + raise ValueError( + f"OpenSpiel game '{game_name}' could not be exported to NFG format: {exc}" + ) from exc return gbt.read_nfg(io.StringIO(nfg_str)) - except Exception as exc: + + else: raise ValueError( - f"OpenSpiel game '{game_name}' could not be exported to EFG or NFG format." - ) from exc + f"OpenSpiel game '{game_name}' has unsupported dynamics type " + f"'{dynamics}' and cannot be exported to Gambit format." + ) def load(slug: str) -> gbt.Game: diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 15a03770f..f019d4a01 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -193,15 +193,34 @@ def _setup_pyspiel_mock( efg_str=None, efg_raises=None, load_raises=None, + dynamics="sequential", ): - """Inject a fake pyspiel + open_spiel.python.algorithms.gambit into sys.modules.""" + """Inject a fake pyspiel + open_spiel.python.algorithms.gambit into sys.modules. + + ``dynamics`` controls which export path the code takes: + - ``"sequential"`` → game.get_type().dynamics == pyspiel.GameType.Dynamics.SEQUENTIAL + (OpenSpiel's term for extensive-form / tree games in Gambit) + - ``"simultaneous"`` → SIMULTANEOUS (normal-form / strategic-form games in Gambit) + - ``"other"`` → any other value, triggers the unsupported-dynamics ValueError + """ mock_ps = MagicMock() mock_export_fn = MagicMock() + mock_game = MagicMock() + + # Wire the dynamics attribute so the == comparison in load_openspiel resolves correctly. + # MagicMock attribute access is idempotent: mock_ps.GameType.Dynamics.SEQUENTIAL always + # returns the same object, so the equality check passes. + if dynamics == "sequential": + mock_game.get_type.return_value.dynamics = mock_ps.GameType.Dynamics.SEQUENTIAL + elif dynamics == "simultaneous": + mock_game.get_type.return_value.dynamics = mock_ps.GameType.Dynamics.SIMULTANEOUS + else: + mock_game.get_type.return_value.dynamics = object() # matches neither branch if load_raises is not None: mock_ps.load_game.side_effect = load_raises else: - mock_ps.load_game.return_value = MagicMock() + mock_ps.load_game.return_value = mock_game if nfg_raises is not None: mock_ps.game_to_nfg_string.side_effect = nfg_raises @@ -225,19 +244,15 @@ def _setup_pyspiel_mock( def test_openspiel_load_efg_success(monkeypatch): - """EFG export succeeds: load_openspiel returns a valid Game.""" - _setup_pyspiel_mock(monkeypatch, efg_str=_MOCK_EFG) + """Sequential (extensive-form) game: EFG export is used and returns a valid Game.""" + _setup_pyspiel_mock(monkeypatch, dynamics="sequential", efg_str=_MOCK_EFG) game = gbt.catalog.load_openspiel("tiny_hanabi") assert isinstance(game, gbt.Game) -def test_openspiel_load_nfg_fallback(monkeypatch): - """EFG export fails, NFG export succeeds: load_openspiel returns a valid Game.""" - _setup_pyspiel_mock( - monkeypatch, - efg_raises=RuntimeError("efg not supported"), - nfg_str=_MOCK_NFG, - ) +def test_openspiel_load_nfg_success(monkeypatch): + """Simultaneous (normal-form) game: NFG export is used and returns a valid Game.""" + _setup_pyspiel_mock(monkeypatch, dynamics="simultaneous", nfg_str=_MOCK_NFG) game = gbt.catalog.load_openspiel("matrix_rps") assert isinstance(game, gbt.Game) @@ -250,25 +265,45 @@ def test_openspiel_load_import_error(monkeypatch): def test_openspiel_load_game_not_found(monkeypatch): - """pyspiel.load_game failure raises ValueError.""" - _setup_pyspiel_mock(monkeypatch, load_raises=RuntimeError("unknown game")) - with pytest.raises(ValueError, match="bogus_game"): + """pyspiel.load_game errors propagate directly without wrapping.""" + _setup_pyspiel_mock(monkeypatch, load_raises=RuntimeError("Unknown game 'bogus_game'")) + with pytest.raises(RuntimeError, match="Unknown game"): gbt.catalog.load_openspiel("bogus_game") -def test_openspiel_load_export_failure(monkeypatch): - """Both EFG and NFG exports fail: raises ValueError.""" +def test_openspiel_load_efg_export_failure(monkeypatch): + """EFG export failure on a sequential game raises ValueError with format context.""" + _setup_pyspiel_mock( + monkeypatch, + dynamics="sequential", + efg_raises=RuntimeError("export error"), + ) + with pytest.raises(ValueError, match="EFG format"): + gbt.catalog.load_openspiel("tiny_hanabi") + + +def test_openspiel_load_nfg_export_failure(monkeypatch): + """NFG export failure on a simultaneous game raises ValueError with format context.""" _setup_pyspiel_mock( monkeypatch, - efg_raises=RuntimeError("efg not supported"), - nfg_raises=RuntimeError("nfg not supported"), + dynamics="simultaneous", + nfg_raises=RuntimeError("export error"), ) - with pytest.raises(ValueError, match="could not be exported"): + with pytest.raises(ValueError, match="NFG format"): gbt.catalog.load_openspiel("matrix_rps") +def test_openspiel_load_unsupported_dynamics(monkeypatch): + """A game with unsupported dynamics (e.g. MEAN_FIELD) raises ValueError.""" + _setup_pyspiel_mock(monkeypatch, dynamics="other") + with pytest.raises(ValueError, match="unsupported dynamics"): + gbt.catalog.load_openspiel("some_mfg_game") + + def test_openspiel_load_with_params(monkeypatch): - """params dict is forwarded to pyspiel.load_game.""" - mock_ps, _ = _setup_pyspiel_mock(monkeypatch, nfg_str=_MOCK_NFG) + """params dict is forwarded verbatim to pyspiel.load_game.""" + mock_ps, _ = _setup_pyspiel_mock( + monkeypatch, dynamics="simultaneous", nfg_str=_MOCK_NFG + ) gbt.catalog.load_openspiel("blotto", params={"players": 2, "coins": 3, "fields": 2}) mock_ps.load_game.assert_called_once_with("blotto", {"players": 2, "coins": 3, "fields": 2}) From 68a7857d8a4d75400c11a26751012f6af6db3caa Mon Sep 17 00:00:00 2001 From: Ted Turocy Date: Fri, 5 Jun 2026 09:18:50 +0100 Subject: [PATCH 12/12] Update catalog.py --- src/pygambit/catalog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index 9eb03cb8c..2b6aadaeb 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -35,7 +35,7 @@ def load_openspiel(game_name: str, params: dict | None = None) -> gbt.Game: (e.g. ``{"players": 2, "coins": 3, "fields": 2}`` for ``"blotto"``). See the `OpenSpiel game list `_ for - available parameters per game. Defaults to an empty dict. + available parameters per game. Returns -------