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/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 265eb682c..4bedde9b1 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 ae26e5600..e4f4dd9cc 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -19,6 +19,7 @@ # 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 @@ -28,6 +29,119 @@ import scipy.stats import pygambit.gte 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.BufferedReader], + parser: GameParser): + + g = cython.declare(Game) + g = Game() + if isinstance(filepath_or_buffer, io.BufferedReader): + data = filepath_or_buffer.read() + else: + with open(filepath_or_buffer, "rb") as f: + data = f.read() + try: + g.game = 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.BufferedReader]) -> Game: + """Construct a game from its serialised representation in a GBT file. + + Parameters + ---------- + filepath : str or path object + The path to the file containing the game representation. + + 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. + """ + return read_game(filepath_or_buffer, parser=ParseGbtGame) + + +def read_efg(filepath_or_buffer: typing.Union[str, pathlib.Path, io.BufferedReader]) -> Game: + """Construct a game from its serialised representation in a EFG file. + + Parameters + ---------- + filepath : str or path object + The path to the file containing the game representation. + + 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. + """ + return read_game(filepath_or_buffer, parser=ParseEfgGame) + + +def read_nfg(filepath_or_buffer: typing.Union[str, pathlib.Path, io.BufferedReader]) -> Game: + """Construct a game from its serialised representation in a NFG file. + + Parameters + ---------- + filepath : str or path object + The path to the file containing the game representation. + + 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. + """ + return read_game(filepath_or_buffer, parser=ParseNfgGame) + + +def read_agg(filepath_or_buffer: typing.Union[str, pathlib.Path, io.BufferedReader]) -> Game: + """Construct a game from its serialised representation in a AGG file. + + Parameters + ---------- + filepath : str or path object + The path to the file containing the game representation. + + 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. + """ + return read_game(filepath_or_buffer, parser=ParseAggGame) + @cython.cclass class GameOutcomes: @@ -1009,7 +1123,9 @@ class Game: ) def write(self, format="native") -> str: - """Produce a serialization of the game. + """Deprecated in favour of to_xxx methods. + + Produce a serialization of the game. Several output formats are supported, depending on the representation of the game. @@ -1049,6 +1165,108 @@ class Game: else: 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 + """ + 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 + """ + return self._to_format(WriteNfgFile, filepath_or_buffer) + + # def to_gbt( + # self, + # filepath_or_buffer: typing.Union[str, pathlib.Path, io.BufferedWriter, None] = None + # ) -> typing.Union[str, None]: + # """Save the game to a .gbt 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 + # """ + # return self._to_format(WriteGbtFile, 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 + """ + 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 + """ + 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..34936cf84 100644 --- a/src/pygambit/util.h +++ b/src/pygambit/util.h @@ -31,6 +31,7 @@ #include #include "gambit.h" #include "games/nash.h" +// #include "gui/gamedoc.h" using namespace std; using namespace Gambit; @@ -50,6 +51,63 @@ 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 WriteGbtFile(const Game &p_game) +// { +// std::ostringstream f; +// auto document = gbtGameDocument(p_game); +// document.SaveDocument(f); +// 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..7916a8fd2 --- /dev/null +++ b/tests/test_io.py @@ -0,0 +1,96 @@ +from html.parser import HTMLParser + +import pytest + +import pygambit as gbt + + +class MyHTMLParser(HTMLParser): + """https://stackoverflow.com/a/70214064""" + + def __init__(self): + super().__init__() + self.start_tags = list() + self.end_tags = list() + self.attributes = list() + + def is_text_html(self): + return len(self.start_tags) == len(self.end_tags) + + def handle_starttag(self, tag, attrs): + self.start_tags.append(tag) + self.attributes.append(attrs) + + def handle_endtag(self, tag): + self.end_tags.append(tag) + + def handle_data(self, data): + pass + + +def test_read_efg(): + game_path = "tests/test_games/cent3.efg" + game = gbt.read_efg(game_path) + assert isinstance(game, gbt.Game) + + +def test_read_efg_invalid(): + game_path = "tests/test_games/2x2x2_nfg_with_two_pure_one_mixed_eq.nfg" + with pytest.raises(ValueError): + gbt.read_efg(game_path) + + +def test_read_nfg(): + game_path = "tests/test_games/2x2x2_nfg_with_two_pure_one_mixed_eq.nfg" + game = gbt.read_nfg(game_path) + assert isinstance(game, gbt.Game) + + +def test_read_nfg_invalid(): + game_path = "tests/test_games/cent3.efg" + with pytest.raises(ValueError): + gbt.read_nfg(game_path) + + +def test_read_agg(): + game_path = "tests/test_games/2x2.agg" + game = gbt.read_agg(game_path) + assert isinstance(game, gbt.Game) + + +def test_read_agg_invalid(): + game_path = "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 = "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() + parser = MyHTMLParser() + parser.feed(serialized_game) + assert parser.is_text_html() + + +def test_write_latex(): + game = gbt.Game.new_table([2, 2]) + serialized_game = game.to_latex() + assert serialized_game.startswith(r"\begin{game}")