From f91e9091a720f7edaa86a3102d8d49762294bc23 Mon Sep 17 00:00:00 2001 From: drdkad Date: Sat, 9 Nov 2024 11:33:54 +0000 Subject: [PATCH] Implementing functions for reading and writing game files of specialized formats Reading * `read_efg` - reads an .efg file format * `read_nfg` - reads an .nfg file format * `read_gbt` - reads a .gbt file (XML files produced by the GUI) * `read_agg` - reads an action-graph games file format Writing * `Game.to_efg` - writes an .efg file * `Game.to_nfg` - writes an .nfg file * `Game.to_html` - writes out HTML tables * `Game.to_latex` - writes a .tex file New tests in test_io.py --- .gitignore | 1 + doc/pygambit.api.rst | 11 +- src/pygambit/gambit.pxd | 9 ++ src/pygambit/game.pxi | 248 +++++++++++++++++++++++++++++++++++++++ src/pygambit/util.h | 49 ++++++++ tests/test_games/2x2.agg | 49 ++++++++ tests/test_io.py | 91 ++++++++++++++ 7 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 tests/test_games/2x2.agg create mode 100644 tests/test_io.py diff --git a/.gitignore b/.gitignore index 3ac8183fe..ebc21507c 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ missing gambit .python-version dist +.venv diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index a9f0a6d15..b7eba64af 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -28,6 +28,11 @@ Creating, reading, and writing games .. autosummary:: :toctree: api/ + read_gbt + read_efg + read_nfg + read_agg + Game.new_tree Game.new_table Game.from_arrays @@ -36,6 +41,10 @@ Creating, reading, and writing games Game.read_game Game.parse_game Game.write + Game.to_efg + Game.to_nfg + Game.to_html + Game.to_latex Transforming game trees @@ -181,7 +190,7 @@ Player behavior Game.random_strategy_profile Game.mixed_behavior_profile Game.random_behavior_profile - Game.support_profile + Game.strategy_support_profile Representation of strategic behavior diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index f3bde61a7..96cd23062 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -379,8 +379,17 @@ cdef extern from "games/behavspt.h": cdef extern from "util.h": c_Game ReadGame(char *) except +IOError c_Game ParseGame(char *) except +IOError + c_Game ParseGbtGame(string) except +IOError + c_Game ParseEfgGame(string) except +IOError + c_Game ParseNfgGame(string) except +IOError + c_Game ParseAggGame(string) except +IOError string WriteGame(c_Game, string) except +IOError string WriteGame(c_StrategySupportProfile) except +IOError + string WriteEfgFile(c_Game) + string WriteNfgFile(c_Game) + string WriteLaTeXFile(c_Game) + string WriteHTMLFile(c_Game) + # string WriteGbtFile(c_Game) c_Rational to_rational(char *) except + diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index df16d0b1f..344532966 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -19,14 +19,144 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. # +import io import itertools import pathlib +import warnings import numpy as np import scipy.stats import pygambit.gameiter +ctypedef string (*GameWriter)(const c_Game &) except +IOError +ctypedef c_Game (*GameParser)(const string &) except +IOError + + +@cython.cfunc +def read_game(filepath_or_buffer: typing.Union[str, pathlib.Path, io.BytesIO], + parser: GameParser): + + g = cython.declare(Game) + if isinstance(filepath_or_buffer, io.BytesIO): + data = filepath_or_buffer.read() + else: + with open(filepath_or_buffer, "rb") as f: + data = f.read() + try: + g = Game.wrap(parser(data)) + except Exception as exc: + raise ValueError(f"Parse error in game file: {exc}") from None + return g + + +def read_gbt(filepath_or_buffer: typing.Union[str, pathlib.Path, io.BytesIO]) -> Game: + """Construct a game from its serialised representation in a GBT file. + + Parameters + ---------- + filepath_or_buffer : str, Path or BytesIO + The path to the file containing the game representation or file-like object + + Returns + ------- + Game + A game constructed from the representation in the file. + + Raises + ------ + IOError + If the file cannot be opened or read + ValueError + If the contents of the file are not a valid game representation. + + See Also + -------- + read_efg, read_nfg, read_agg + """ + return read_game(filepath_or_buffer, parser=ParseGbtGame) + + +def read_efg(filepath_or_buffer: typing.Union[str, pathlib.Path, io.BytesIO]) -> Game: + """Construct a game from its serialised representation in an EFG file. + + Parameters + ---------- + filepath_or_buffer : str, Path or BytesIO + The path to the file containing the game representation or file-like object + + Returns + ------- + Game + A game constructed from the representation in the file. + + Raises + ------ + IOError + If the file cannot be opened or read + ValueError + If the contents of the file are not a valid game representation. + + See Also + -------- + read_gbt, read_nfg, read_agg + """ + return read_game(filepath_or_buffer, parser=ParseEfgGame) + + +def read_nfg(filepath_or_buffer: typing.Union[str, pathlib.Path, io.BytesIO]) -> Game: + """Construct a game from its serialised representation in a NFG file. + + Parameters + ---------- + filepath_or_buffer : str, Path or BytesIO + The path to the file containing the game representation or file-like object + + Returns + ------- + Game + A game constructed from the representation in the file. + + Raises + ------ + IOError + If the file cannot be opened or read + ValueError + If the contents of the file are not a valid game representation. + + See Also + -------- + read_gbt, read_efg, read_agg + """ + return read_game(filepath_or_buffer, parser=ParseNfgGame) + + +def read_agg(filepath_or_buffer: typing.Union[str, pathlib.Path, io.BytesIO]) -> Game: + """Construct a game from its serialised representation in an AGG file. + + Parameters + ---------- + filepath_or_buffer : str, Path or BytesIO + The path to the file containing the game representation or file-like object + + Returns + ------- + Game + A game constructed from the representation in the file. + + Raises + ------ + IOError + If the file cannot be opened or read + ValueError + If the contents of the file are not a valid game representation. + + See Also + -------- + read_gbt, read_efg, read_nfg + """ + return read_game(filepath_or_buffer, parser=ParseAggGame) + @cython.cclass class GameOutcomes: @@ -446,6 +576,10 @@ class Game: def read_game(cls, filepath: typing.Union[str, pathlib.Path]) -> Game: """Construct a game from its serialised representation in a file. + .. deprecated:: 16.3.0 + Method `Game.read_game` is deprecated, use one of the respective functions instead: + ``read_gbt``, ``read_efg``, ``read_nfg``, ``read_agg`` + Parameters ---------- filepath : str or path object @@ -467,6 +601,11 @@ class Game: -------- parse_game : Constructs a game from a text string. """ + warnings.warn( + "Game.read_game() is deprecated and will be removed in 16.4. " + "Use the appropriate module-level .read_*() function instead.", + FutureWarning + ) with open(filepath, "rb") as f: data = f.read() try: @@ -1060,9 +1199,118 @@ class Game: .. versionchanged:: 16.3.0 Removed support for writing Game Theory Explorer format as the XML format is no longer supported by recent versions of GTE. + + .. deprecated:: 16.3.0 + Method Game.write is deprecated, use one of the respective methods instead: + ``Game.to_efg``, ``Game.to_nfg``, ``Game.to_html``, ``Game.to_latex`` """ + warnings.warn( + "Game.write() is deprecated and will be removed in 16.4. " + "Use the appropriate Game.to_*() function instead.", + FutureWarning + ) return WriteGame(self.game, format.encode("ascii")).decode("ascii") + @cython.cfunc + def _to_format( + self, + writer: GameWriter, + filepath_or_buffer: typing.Union[str, pathlib.Path, io.BufferedWriter, None] = None + ): + serialized_game = writer(self.game) + if filepath_or_buffer is None: + return serialized_game.decode() + if isinstance(filepath_or_buffer, io.BufferedWriter): + filepath_or_buffer.write(serialized_game) + else: + with open(filepath_or_buffer, "wb") as f: + f.write(serialized_game) + + def to_efg(self, + filepath_or_buffer: typing.Union[str, pathlib.Path, io.BufferedWriter, None] = None + ) -> typing.Union[str, None]: + """Save the game to an .efg file or return its serialized representation + + Parameters + ---------- + filepath_or_buffer : str or Path or BufferedWriter or None, default None + String, path object, or file-like object implementing a write() function. + If None, the result is returned as a string. + + Return + ------ + String representation of the game or None if the game is saved to a file + + See Also + -------- + to_nfg, to_html, to_latex + """ + return self._to_format(WriteEfgFile, filepath_or_buffer) + + def to_nfg(self, + filepath_or_buffer: typing.Union[str, pathlib.Path, io.BufferedWriter, None] = None + ) -> typing.Union[str, None]: + """Save the game to a .nfg file or return its serialized representation + + Parameters + ---------- + filepath_or_buffer : str or Path or BufferedWriter or None, default None + String, path object, or file-like object implementing a write() function. + If None, the result is returned as a string. + + Return + ------ + String representation of the game or None if the game is saved to a file + + See Also + -------- + to_efg, to_html, to_latex + """ + return self._to_format(WriteNfgFile, filepath_or_buffer) + + def to_html(self, + filepath_or_buffer: typing.Union[str, pathlib.Path, io.BufferedWriter, None] = None + ) -> typing.Union[str, None]: + """Export the game to an .html file or return its serialized representation + + Parameters + ---------- + filepath_or_buffer : str or Path or BufferedWriter or None, default None + String, path object, or file-like object implementing a write() function. + If None, the result is returned as a string. + + Return + ------ + String representation of the game or None if the game is exported to a file + + See Also + -------- + to_efg, to_nfg, to_latex + """ + return self._to_format(WriteHTMLFile, filepath_or_buffer) + + def to_latex( + self, + filepath_or_buffer: typing.Union[str, pathlib.Path, io.BufferedWriter, None] = None + ) -> typing.Union[str, None]: + """Export the game to a .tex file or return its serialized representation + + Parameters + ---------- + filepath_or_buffer : str or Path or BufferedWriter or None, default None + String, path object, or file-like object implementing a write() function. + If None, the result is returned as a string. + + Return + ------ + String representation of the game or None if the game is exported to a file + + See Also + -------- + to_efg, to_nfg, to_html + """ + return self._to_format(WriteLaTeXFile, filepath_or_buffer) + def _resolve_player(self, player: typing.Any, funcname: str, argname: str = "player") -> Player: """Resolve an attempt to reference a player of the game. diff --git a/src/pygambit/util.h b/src/pygambit/util.h index 76b350734..740faf462 100644 --- a/src/pygambit/util.h +++ b/src/pygambit/util.h @@ -50,6 +50,55 @@ Game ParseGame(char *s) return ReadGame(f); } +Game ParseGbtGame(std::string const &s) +{ + std::istringstream f(s); + return ReadGbtFile(f); +} + +Game ParseEfgGame(std::string const &s) +{ + std::istringstream f(s); + return ReadEfgFile(f); +} + +Game ParseNfgGame(std::string const &s) +{ + std::istringstream f(s); + return ReadNfgFile(f); +} + +Game ParseAggGame(std::string const &s) +{ + std::istringstream f(s); + return ReadAggFile(f); +} + +std::string WriteEfgFile(const Game &p_game) +{ + std::ostringstream f; + p_game->Write(f, "efg"); + return f.str(); +} + +std::string WriteNfgFile(const Game &p_game) +{ + std::ostringstream f; + p_game->Write(f, "nfg"); + return f.str(); +} + +std::string WriteHTMLFile(const Game &p_game) +{ + return WriteHTMLFile(p_game, p_game->GetPlayer(1), p_game->GetPlayer(2)); +} + +std::string WriteLaTeXFile(const Game &p_game) +{ + return WriteLaTeXFile(p_game, p_game->GetPlayer(1), p_game->GetPlayer(2)); +} + +/// @deprecated Deprecated in favour of WriteXXXFile std::string WriteGame(const Game &p_game, const std::string &p_format) { if (p_format == "html") { diff --git a/tests/test_games/2x2.agg b/tests/test_games/2x2.agg new file mode 100644 index 000000000..3d1ef04d6 --- /dev/null +++ b/tests/test_games/2x2.agg @@ -0,0 +1,49 @@ +#AGG +# Generated by GAMUT v1.0.1 +# Random Symmetric Action Graph Game +# Game Parameter Values: +# Random seed: 1306765487422 +# Cmd Line: -players 2 -actions 2 -g RandomSymmetricAGG -output SpecialOutput -random_params -f 2x2.agg +# Players: 2 +# Actions: 2 2 +# players: 2 +# actions: [2] +# graph: RandomGraph +# graph_params: null +# Graph Params: +# { nodes: 2, edges: 4, sym_edges: false, reflex_ok: true } +# Players: 2 +# Actions: [ 2 2 ] + +#number of players: +2 +#number of action nodes: +2 +#number of func nodes: +0 + +#sizes of action sets: +2 2 + +#action sets: +0 1 +0 1 + + +#the action graph: +2 0 1 +2 1 0 + +#the types of func nodes: +#0: sum +#1: existence +#2: highest +#3: lowest + + +#the payoffs: +#now the payoff values: one row per action node. +#For each row: first, the type of the payoff format +#Then payoffs are given in lexicographical order of the input configurations +0 35.622809717175556 -3.7188980070375948 +0 -10.180526107272556 95.1203958671928 diff --git a/tests/test_io.py b/tests/test_io.py new file mode 100644 index 000000000..8e3536ab3 --- /dev/null +++ b/tests/test_io.py @@ -0,0 +1,91 @@ +import io +import os.path +from glob import glob + +import pytest + +import pygambit as gbt + +from .games import create_2x2_zero_nfg, create_selten_horse_game_efg + + +@pytest.mark.parametrize("game_path", glob(os.path.join("tests", "test_games", "*.efg"))) +def test_read_efg(game_path): + game = gbt.read_efg(game_path) + assert isinstance(game, gbt.Game) + + +def test_read_efg_invalid(): + game_path = os.path.join("tests", "test_games", "2x2x2_nfg_with_two_pure_one_mixed_eq.nfg") + with pytest.raises(ValueError): + gbt.read_efg(game_path) + + +@pytest.mark.parametrize("game_path", glob(os.path.join("tests", "test_games", "*.nfg"))) +def test_read_nfg(game_path): + game = gbt.read_nfg(game_path) + assert isinstance(game, gbt.Game) + + +def test_read_nfg_invalid(): + game_path = os.path.join("tests", "test_games", "cent3.efg") + with pytest.raises(ValueError): + gbt.read_nfg(game_path) + + +@pytest.mark.parametrize("game_path", glob(os.path.join("tests", "test_games", "*.agg"))) +def test_read_agg(game_path): + game = gbt.read_agg(game_path) + assert isinstance(game, gbt.Game) + + +def test_read_agg_invalid(): + game_path = os.path.join("tests", "test_games", "2x2x2_nfg_with_two_pure_one_mixed_eq.nfg") + with pytest.raises(ValueError): + gbt.read_agg(game_path) + + +def test_read_gbt_invalid(): + game_path = os.path.join("tests", "test_games", "2x2x2_nfg_with_two_pure_one_mixed_eq.nfg") + with pytest.raises(ValueError): + gbt.read_gbt(game_path) + + +def test_write_efg(): + game = gbt.Game.new_tree() + serialized_game = game.to_efg() + assert serialized_game[:3] == "EFG" + + +def test_write_nfg(): + game = gbt.Game.new_table([2, 2]) + serialized_game = game.to_nfg() + assert serialized_game[:3] == "NFG" + + +def test_write_html(): + game = gbt.Game.new_table([2, 2]) + serialized_game = game.to_html() + assert isinstance(serialized_game, str) + + +def test_write_latex(): + game = gbt.Game.new_table([2, 2]) + serialized_game = game.to_latex() + assert serialized_game.startswith(r"\begin{game}") + + +def test_read_write_efg(): + efg_game = create_selten_horse_game_efg() + serialized_efg_game = efg_game.to_efg() + deserialized_efg_game = gbt.read_efg(io.BytesIO(serialized_efg_game.encode())) + double_serialized_efg_game = deserialized_efg_game.to_efg() + assert serialized_efg_game == double_serialized_efg_game + + +def test_read_write_nfg(): + nfg_game = create_2x2_zero_nfg() + serialized_nfg_game = nfg_game.to_nfg() + deserialized_nfg_game = gbt.read_nfg(io.BytesIO(serialized_nfg_game.encode())) + double_serialized_nfg_game = deserialized_nfg_game.to_nfg() + assert serialized_nfg_game == double_serialized_nfg_game