diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..10013ff --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "gomill"] + path = gomill + url = https://github.com/mattheww/gomill.git diff --git a/gomill b/gomill new file mode 160000 index 0000000..e62d084 --- /dev/null +++ b/gomill @@ -0,0 +1 @@ +Subproject commit e62d084fd0f271c7c012c4b132af68f65760bed2 diff --git a/gomill/README.txt b/gomill/README.txt deleted file mode 100644 index a357aa3..0000000 --- a/gomill/README.txt +++ /dev/null @@ -1,189 +0,0 @@ -Gomill -====== - -Gomill is a suite of tools, and a Python library, for use in developing and -testing Go-playing programs. - -Updated versions of Gomill will be made available at -http://mjw.woodcraft.me.uk/gomill/ - -The documentation is distributed separately in HTML form. It can be downloaded -from the above web site, or viewed online at -http://mjw.woodcraft.me.uk/gomill/doc/ - -A Git repository containing Gomill releases (but not detailed history) is -available: - git clone http://mjw.woodcraft.me.uk/gomill/git/ -It has a web interface at http://mjw.woodcraft.me.uk/gitweb/gomill/ - - -Contents --------- - -The contents of the distribution directory (the directory containing this -README file) include: - - ringmaster -- Executable wrapper for the ringmaster program - gomill -- Python source for the gomill package - gomill_tests -- Test suite for the gomill package - docs -- ReST sources for the HTML documentation - examples -- Example scripts using the gomill library - setup.py -- Installation script - - -Requirements ------------- - -Gomill requires Python 2.5, 2.6, or 2.7. - -For Python 2.5 only, the --parallel feature requires the external -`multiprocessing` package [1]. - -Gomill is intended to run on any modern Unix-like system. - -[1] http://pypi.python.org/pypi/multiprocessing - - -Running the ringmaster ----------------------- - -The ringmaster executable in the distribution directory can be run directly -without any further installation; it will use the copy of the gomill package -in the distribution directory. - -A symbolic link to the ringmaster executable will also work, but if you move -the executable elsewhere it will not be able to find the gomill package unless -the package is installed. - - -Installation ------------- - -Installing Gomill puts the gomill package onto the Python module search path, -and the ringmaster executable onto the executable PATH. - -To install, first change to the distribution directory, then: - - - to install for the system as a whole, run (as a sufficiently privileged user) - - python setup.py install - - - - to install for the current user only (Python 2.6 or 2.7), run - - python setup.py install --user - - (in this case the ringmaster executable will be placed in ~/.local/bin.) - -Pass --dry-run to see what these will do. -See http://docs.python.org/2.7/install/ for more information. - - -Uninstallation --------------- - -To remove an installed version of Gomill, run - - python setup.py uninstall - -(This uses the Python module search path and the executable PATH to find the -files to remove; pass --dry-run to see what it will do.) - - -Running the test suite ----------------------- - -To run the testsuite against the distributed gomill package, change to the -distribution directory and run - - python -m gomill_tests.run_gomill_testsuite - - -To run the testsuite against an installed gomill package, change to the -distribution directory and run - - python test_installed_gomill.py - - -With Python versions earlier than 2.7, the unittest2 library [1] is required -to run the testsuite. - -[1] http://pypi.python.org/pypi/unittest2/ - - -Running the example scripts ---------------------------- - -To run the example scripts, it is simplest to install the gomill package -first. - -If you do not wish to do so, you can run - - export PYTHONPATH= - -so that the example scripts will be able to find the gomill package. - - -Building the HTML documentation -------------------------------- - -To build the HTML documentation, change to the distribution directory and run - - python setup.py build_sphinx - -The documentation will be generated in build/sphinx/html. - -Requirements: - - Sphinx [1] version 1.0 or later - (at least 1.0.4 recommended; tested with 1.0 and 1.1) - LaTeX [2] - dvipng [3] - -[1] http://sphinx.pocoo.org/ -[2] http://www.latex-project.org/ -[3] http://www.nongnu.org/dvipng/ - - -Licence -------- - -Gomill is copyright 2009-2012 Matthew Woodcraft - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - -Contact -------- - -Please send any bug reports, suggestions, patches, questions &c to - -Matthew Woodcraft -matthew@woodcraft.me.uk - -I'm particularly interested in hearing about any GTP engines (even buggy ones) -which don't work with the ringmaster. - - -Changelog ---------- - -See the 'Changes' page in the HTML documentation (docs/changes.rst). - - mjw 2012-08-26 diff --git a/gomill/__init__.py b/gomill/__init__.py deleted file mode 100644 index ed9d4d8..0000000 --- a/gomill/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "0.7.4" diff --git a/gomill/allplayalls.py b/gomill/allplayalls.py deleted file mode 100644 index 76019b8..0000000 --- a/gomill/allplayalls.py +++ /dev/null @@ -1,257 +0,0 @@ -"""Competitions for all-play-all tournaments.""" - -from gomill import ascii_tables -from gomill import game_jobs -from gomill import competitions -from gomill import tournaments -from gomill import tournament_results -from gomill.competitions import ( - Competition, CompetitionError, ControlFileError) -from gomill.settings import * -from gomill.utils import format_float - - -class Competitor_config(Quiet_config): - """Competitor description for use in control files.""" - # positional or keyword - positional_arguments = ('player',) - # keyword-only - keyword_arguments = () - -class Competitor_spec(object): - """Internal description of a competitor spec from the configuration file. - - Public attributes: - player -- player code - short_code -- eg 'A' or 'ZZ' - - """ - - -class Allplayall(tournaments.Tournament): - """A Tournament with matchups for all pairs of competitors. - - The game ids are like AvB_2, where A and B are the competitor short_codes - and 2 is the game number between those two competitors. - - This tournament type doesn't permit ghost matchups. - - """ - - def control_file_globals(self): - result = Competition.control_file_globals(self) - result.update({ - 'Competitor' : Competitor_config, - }) - return result - - - special_settings = [ - Setting('competitors', - interpret_sequence_of_quiet_configs( - Competitor_config, allow_simple_values=True)), - ] - - def competitor_spec_from_config(self, i, competitor_config): - """Make a Competitor_spec from a Competitor_config. - - i -- ordinal number of the competitor. - - Raises ControlFileError if there is an error in the configuration. - - Returns a Competitor_spec with all attributes set. - - """ - arguments = competitor_config.resolve_arguments() - cspec = Competitor_spec() - - if 'player' not in arguments: - raise ValueError("player not specified") - cspec.player = arguments['player'] - if cspec.player not in self.players: - raise ControlFileError("unknown player") - - def let(n): - return chr(ord('A') + n) - if i < 26: - cspec.short_code = let(i) - elif i < 26*27: - n, m = divmod(i, 26) - cspec.short_code = let(n-1) + let(m) - else: - raise ValueError("too many competitors") - return cspec - - @staticmethod - def _get_matchup_id(c1, c2): - return "%sv%s" % (c1.short_code, c2.short_code) - - def initialise_from_control_file(self, config): - Competition.initialise_from_control_file(self, config) - - matchup_settings = [ - setting for setting in competitions.game_settings - if setting.name not in ('handicap', 'handicap_style') - ] + [ - Setting('rounds', allow_none(interpret_int), default=None), - ] - try: - matchup_parameters = load_settings(matchup_settings, config) - except ValueError, e: - raise ControlFileError(str(e)) - matchup_parameters['alternating'] = True - matchup_parameters['number_of_games'] = matchup_parameters.pop('rounds') - - try: - specials = load_settings(self.special_settings, config) - except ValueError, e: - raise ControlFileError(str(e)) - - if not specials['competitors']: - raise ControlFileError("competitors: empty list") - # list of Competitor_specs - self.competitors = [] - seen_competitors = set() - for i, competitor_spec in enumerate(specials['competitors']): - try: - cspec = self.competitor_spec_from_config(i, competitor_spec) - except StandardError, e: - code = competitor_spec.get_key() - if code is None: - code = i - raise ControlFileError("competitor %s: %s" % (code, e)) - if cspec.player in seen_competitors: - raise ControlFileError("duplicate competitor: %s" - % cspec.player) - seen_competitors.add(cspec.player) - self.competitors.append(cspec) - - # map matchup_id -> Matchup - self.matchups = {} - # Matchups in order of definition - self.matchup_list = [] - for c1_i, c1 in enumerate(self.competitors): - for c2 in self.competitors[c1_i+1:]: - try: - m = self.make_matchup( - self._get_matchup_id(c1, c2), - c1.player, c2.player, - matchup_parameters) - except StandardError, e: - raise ControlFileError("%s v %s: %s" % - (c1.player, c2.player, e)) - self.matchups[m.id] = m - self.matchup_list.append(m) - - - # Can bump this to prevent people loading incompatible .status files. - status_format_version = 1 - - def get_status(self): - result = tournaments.Tournament.get_status(self) - result['competitors'] = [c.player for c in self.competitors] - return result - - def set_status(self, status): - seen_competitors = status['competitors'] - # This should mean that _check_results can never fail, but might as well - # still let it run. - if len(self.competitors) < len(seen_competitors): - raise CompetitionError( - "competitor has been removed from control file") - if ([c.player for c in self.competitors[:len(seen_competitors)]] != - seen_competitors): - raise CompetitionError( - "competitors have changed in the control file") - tournaments.Tournament.set_status(self, status) - - - def get_player_checks(self): - result = [] - matchup = self.matchup_list[0] - for competitor in self.competitors: - check = game_jobs.Player_check() - check.player = self.players[competitor.player] - check.board_size = matchup.board_size - check.komi = matchup.komi - result.append(check) - return result - - - def count_games_played(self): - """Return the total number of games completed.""" - return sum(len(l) for l in self.results.values()) - - def count_games_expected(self): - """Return the total number of games required. - - Returns None if no limit has been set. - - """ - rounds = self.matchup_list[0].number_of_games - if rounds is None: - return None - n = len(self.competitors) - return rounds * n * (n-1) // 2 - - def write_screen_report(self, out): - expected = self.count_games_expected() - if expected is not None: - print >>out, "%d/%d games played" % ( - self.count_games_played(), expected) - else: - print >>out, "%d games played" % self.count_games_played() - print >>out - - t = ascii_tables.Table(row_count=len(self.competitors)) - t.add_heading("") # player short_code - i = t.add_column(align='left') - t.set_column_values(i, (c.short_code for c in self.competitors)) - - t.add_heading("") # player code - i = t.add_column(align='left') - t.set_column_values(i, (c.player for c in self.competitors)) - - for c2_i, c2 in enumerate(self.competitors): - t.add_heading(" " + c2.short_code) - i = t.add_column(align='left') - column_values = [] - for c1_i, c1 in enumerate(self.competitors): - if c1_i == c2_i: - column_values.append("") - continue - if c1_i < c2_i: - matchup_id = self._get_matchup_id(c1, c2) - matchup = self.matchups[matchup_id] - player_x = matchup.player_1 - player_y = matchup.player_2 - else: - matchup_id = self._get_matchup_id(c2, c1) - matchup = self.matchups[matchup_id] - player_x = matchup.player_2 - player_y = matchup.player_1 - ms = tournament_results.Matchup_stats( - self.results[matchup.id], - player_x, player_y) - column_values.append( - "%s-%s" % (format_float(ms.wins_1), - format_float(ms.wins_2))) - t.set_column_values(i, column_values) - print >>out, "\n".join(t.render()) - - def write_short_report(self, out): - def p(s): - print >>out, s - p("allplayall: %s" % self.competition_code) - if self.description: - p(self.description) - p('') - self.write_screen_report(out) - p('') - self.write_matchup_reports(out) - p('') - self.write_player_descriptions(out) - p('') - - write_full_report = write_short_report - diff --git a/gomill/ascii_boards.py b/gomill/ascii_boards.py deleted file mode 100644 index 1bb79d0..0000000 --- a/gomill/ascii_boards.py +++ /dev/null @@ -1,80 +0,0 @@ -"""ASCII board representation.""" - -from gomill.common import * -from gomill import boards -from gomill.common import column_letters - -def render_grid(point_formatter, size): - """Render a board-shaped grid as a list of strings. - - point_formatter -- function (row, col) -> string of length 2. - - Returns a list of strings. - - """ - column_header_string = " ".join(column_letters[i] for i in range(size)) - result = [] - if size > 9: - rowstart = "%2d " - padding = " " - else: - rowstart = "%d " - padding = "" - for row in range(size-1, -1, -1): - result.append(rowstart % (row+1) + - " ".join(point_formatter(row, col) - for col in range(size))) - result.append(padding + " " + column_header_string) - return result - -_point_strings = { - None : " .", - 'b' : " #", - 'w' : " o", - } - -def render_board(board): - """Render a gomill Board in ascii. - - Returns a string without final newline. - - """ - def format_pt(row, col): - return _point_strings.get(board.get(row, col), " ?") - return "\n".join(render_grid(format_pt, board.side)) - -def interpret_diagram(diagram, size, board=None): - """Set up the position from a diagram. - - diagram -- board representation as from render_board() - size -- int - - Returns a Board. - - If the optional 'board' parameter is provided, it must be an empty board of - the right size; the same object will be returned. - - """ - if board is None: - board = boards.Board(size) - else: - if board.side != size: - raise ValueError("wrong board size, must be %d" % size) - if not board.is_empty(): - raise ValueError("board not empty") - lines = diagram.split("\n") - colours = {'#' : 'b', 'o' : 'w', '.' : None} - if size > 9: - extra_offset = 1 - else: - extra_offset = 0 - try: - for (row, col) in board.board_points: - colour = colours[lines[size-row-1][3*(col+1)+extra_offset]] - if colour is not None: - board.play(row, col, colour) - except Exception: - raise ValueError - return board - - diff --git a/gomill/ascii_tables.py b/gomill/ascii_tables.py deleted file mode 100644 index 0bf7590..0000000 --- a/gomill/ascii_tables.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Render tabular output. - -This is designed for screen or text-file output, using a fixed-width font. - -""" - -from collections import defaultdict - -class Column_spec(object): - """Details of a table column. - - Public attributes: - align -- 'left' or 'right' - right_padding -- int - - """ - def __init__(self, align='left', right_padding=1): - self.align = align - self.right_padding = right_padding - - def render(self, s, width): - if self.align == 'left': - s = s.ljust(width) - elif self.align == 'right': - s = s.rjust(width) - return s + " " * self.right_padding - -class Table(object): - """Render tabular output. - - Normal use: - - tbl = Table(row_count=3) - tbl.add_heading('foo') - i = tbl.add_column(align='left', right_padding=3) - tbl.set_column_values(i, ['a', 'b']) - [...] - print '\n'.join(tbl.render()) - - """ - def __init__(self, row_count=None): - self.col_count = 0 - self.row_count = row_count - self.headings = [] - self.columns = [] - self.cells = defaultdict(str) - - def set_row_count(self, row_count): - """Change the table's row count.""" - self.row_count = row_count - - def add_heading(self, heading, span=1): - """Specify a column or column group heading. - - To leave a column with no heading, pass the empty string. - - To allow a heading to cover multiple columns, pass the 'span' parameter - and don't add headings for the rest of the covered columns. - - """ - self.headings.append((heading, span)) - - def add_column(self, **kwargs): - """Add a column to the table. - - align -- 'left' (default) or 'right' - right_padding -- int (default 1) - - Returns the column id - - Right padding is the number of spaces to leave between this column and - the next. - - (The last column should have right padding 1, so that the heading can - use the full width if necessary.) - - """ - column = Column_spec(**kwargs) - self.columns.append(column) - column_id = self.col_count - self.col_count += 1 - return column_id - - def get_column(self, column_id): - """Retrieve a column object given its id. - - You can use this to change the column's attributes after adding it. - - """ - return self.columns[column_id] - - def set_column_values(self, column_id, values): - """Specify the values for a column. - - column_id -- as returned by add_column() - values -- iterable - - str() is called on the values. - - If values are not supplied for all rows, the remaining rows are left - blank. If too many values are supplied, the excess values are ignored. - - """ - for row, value in enumerate(values): - self.cells[row, column_id] = str(value) - - def render(self): - """Render the table. - - Returns a list of strings. - - Each line has no trailing whitespace. - - Lines which would be wholly blank are omitted. - - """ - def column_values(col): - return [self.cells[row, col] for row in xrange(self.row_count)] - - result = [] - - cells = self.cells - widths = [max(map(len, column_values(i))) - for i in xrange(self.col_count)] - col = 0 - heading_line = [] - for heading, span in self.headings: - # width available for the heading - width = (sum(widths[col:col+span]) + - sum(self.columns[i].right_padding - for i in range(col, col+span)) - 1) - shortfall = len(heading) - width - if shortfall > 0: - width += shortfall - # Make the leftmost column in the span wider to fit the heading - widths[col] += shortfall - heading_line.append(heading.ljust(width)) - col += span - result.append(" ".join(heading_line).rstrip()) - - for row in xrange(self.row_count): - l = [] - for col, (column, width) in enumerate(zip(self.columns, widths)): - l.append(column.render(cells[row, col], width)) - line = "".join(l).rstrip() - if line: - result.append(line) - return result - diff --git a/gomill/boards.py b/gomill/boards.py deleted file mode 100644 index 280c03a..0000000 --- a/gomill/boards.py +++ /dev/null @@ -1,250 +0,0 @@ -"""Go board representation.""" - -from gomill.common import * - - -class _Group(object): - """Represent a solidly-connected group. - - Public attributes: - colour - points - is_surrounded - - Points are coordinate pairs (row, col). - - """ - -class _Region(object): - """Represent an empty region. - - Public attributes: - points - neighbouring_colours - - Points are coordinate pairs (row, col). - - """ - def __init__(self): - self.points = set() - self.neighbouring_colours = set() - -class Board(object): - """A legal Go position. - - Supports playing stones with captures, and area scoring. - - Public attributes: - side -- board size (eg 9) - board_points -- list of coordinates of all points on the board - - Behaviour is unspecified if methods are passed out-of-range coordinates. - - """ - def __init__(self, side): - self.side = side - self.board_points = [(_row, _col) for _row in range(side) - for _col in range(side)] - self.board = [] - for row in range(side): - self.board.append([None] * side) - self._is_empty = True - - def copy(self): - """Return an independent copy of this Board.""" - b = Board(self.side) - b.board = [self.board[i][:] for i in xrange(self.side)] - b._is_empty = self._is_empty - return b - - def _make_group(self, row, col, colour): - points = set() - is_surrounded = True - to_handle = set() - to_handle.add((row, col)) - while to_handle: - point = to_handle.pop() - points.add(point) - r, c = point - for neighbour in [(r-1, c), (r+1, c), (r, c-1), (r, c+1)]: - (r1, c1) = neighbour - if not ((0 <= r1 < self.side) and (0 <= c1 < self.side)): - continue - neigh_colour = self.board[r1][c1] - if neigh_colour is None: - is_surrounded = False - elif neigh_colour == colour: - if neighbour not in points: - to_handle.add(neighbour) - group = _Group() - group.colour = colour - group.points = points - group.is_surrounded = is_surrounded - return group - - def _make_empty_region(self, row, col): - points = set() - neighbouring_colours = set() - to_handle = set() - to_handle.add((row, col)) - while to_handle: - point = to_handle.pop() - points.add(point) - r, c = point - for neighbour in [(r-1, c), (r+1, c), (r, c-1), (r, c+1)]: - (r1, c1) = neighbour - if not ((0 <= r1 < self.side) and (0 <= c1 < self.side)): - continue - neigh_colour = self.board[r1][c1] - if neigh_colour is None: - if neighbour not in points: - to_handle.add(neighbour) - else: - neighbouring_colours.add(neigh_colour) - region = _Region() - region.points = points - region.neighbouring_colours = neighbouring_colours - return region - - def _find_surrounded_groups(self): - """Find solidly-connected groups with 0 liberties. - - Returns a list of _Groups. - - """ - surrounded = [] - handled = set() - for (row, col) in self.board_points: - colour = self.board[row][col] - if colour is None: - continue - point = (row, col) - if point in handled: - continue - group = self._make_group(row, col, colour) - if group.is_surrounded: - surrounded.append(group) - handled.update(group.points) - return surrounded - - def is_empty(self): - """Say whether the board is empty.""" - return self._is_empty - - def get(self, row, col): - """Return the state of the specified point. - - Returns a colour, or None for an empty point. - - """ - return self.board[row][col] - - def play(self, row, col, colour): - """Play a move on the board. - - Raises ValueError if the specified point isn't empty. - - Performs any necessary captures. Allows self-captures. Doesn't enforce - any ko rule. - - Returns the point forbidden by simple ko, or None - - """ - if self.board[row][col] is not None: - raise ValueError - self.board[row][col] = colour - self._is_empty = False - surrounded = self._find_surrounded_groups() - simple_ko_point = None - if surrounded: - if len(surrounded) == 1: - to_capture = surrounded - if len(to_capture[0].points) == self.side*self.side: - self._is_empty = True - else: - to_capture = [group for group in surrounded - if group.colour == opponent_of(colour)] - if len(to_capture) == 1 and len(to_capture[0].points) == 1: - self_capture = [group for group in surrounded - if group.colour == colour] - if len(self_capture[0].points) == 1: - simple_ko_point = iter(to_capture[0].points).next() - for group in to_capture: - for r, c in group.points: - self.board[r][c] = None - return simple_ko_point - - def apply_setup(self, black_points, white_points, empty_points): - """Add setup stones or removals to the position. - - This is intended to support SGF AB/AW/AE commands. - - Each parameter is an iterable of coordinate pairs (row, col). - - Applies all the setup specifications, then removes any groups with no - liberties (so the resulting position is always legal). - - If the same point is specified in more than one list, the order in which - they're applied is undefined. - - Returns a boolean saying whether the position was legal as specified. - - """ - for (row, col) in black_points: - self.board[row][col] = 'b' - for (row, col) in white_points: - self.board[row][col] = 'w' - for (row, col) in empty_points: - self.board[row][col] = None - captured = self._find_surrounded_groups() - for group in captured: - for row, col in group.points: - self.board[row][col] = None - self._is_empty = True - for (row, col) in self.board_points: - if self.board[row][col] is not None: - self._is_empty = False - break - return not(captured) - - def list_occupied_points(self): - """List all nonempty points. - - Returns a list of pairs (colour, (row, col)) - - """ - result = [] - for (row, col) in self.board_points: - colour = self.board[row][col] - if colour is not None: - result.append((colour, (row, col))) - return result - - def area_score(self): - """Calculate the area score of a position. - - Assumes all stones are alive. - - Returns black score minus white score. - - Doesn't take komi into account. - - """ - scores = {'b' : 0, 'w' : 0} - handled = set() - for (row, col) in self.board_points: - colour = self.board[row][col] - if colour is not None: - scores[colour] += 1 - continue - point = (row, col) - if point in handled: - continue - region = self._make_empty_region(row, col) - region_size = len(region.points) - for colour in ('b', 'w'): - if colour in region.neighbouring_colours: - scores[colour] += region_size - handled.update(region.points) - return scores['b'] - scores['w'] - diff --git a/gomill/cem_tuners.py b/gomill/cem_tuners.py deleted file mode 100644 index 41ebfb7..0000000 --- a/gomill/cem_tuners.py +++ /dev/null @@ -1,509 +0,0 @@ -"""Competitions for parameter tuning using the cross-entropy method.""" - -from __future__ import division - -from random import gauss as random_gauss -from math import sqrt - -from gomill import compact_tracebacks -from gomill import game_jobs -from gomill import competitions -from gomill import competition_schedulers -from gomill.competitions import ( - Competition, NoGameAvailable, CompetitionError, ControlFileError, - Player_config) -from gomill.settings import * - - -def square(f): - return f * f - -class Distribution(object): - """A multi-dimensional Gaussian probability distribution. - - Instantiate with a list of pairs of floats (mean, variance) - - Public attributes: - parameters -- the list used to instantiate the distribution - - """ - def __init__(self, parameters): - self.dimension = len(parameters) - if self.dimension == 0: - raise ValueError - self.parameters = parameters - self.gaussian_params = [(mean, sqrt(variance)) - for (mean, variance) in parameters] - - def get_sample(self): - """Return a random sample from the distribution. - - Returns a list of floats - - """ - return [random_gauss(mean, stddev) - for (mean, stddev) in self.gaussian_params] - - def get_means(self): - """Return just the mean from each dimension. - - Returns a list of floats. - - """ - return [mean for (mean, stddev) in self.parameters] - - def format(self): - return " ".join("%5.2f~%4.2f" % (mean, stddev) - for (mean, stddev) in self.parameters) - - def __str__(self): - return "" % self.format() - -def update_distribution(distribution, elites, step_size): - """Update a distribution based on the given elites. - - distribution -- Distribution - elites -- list of optimiser parameter vectors - step_size -- float between 0.0 and 1.0 ('alpha') - - Returns a new distribution - - """ - n = len(elites) - new_distribution_parameters = [] - for i in range(distribution.dimension): - v = [e[i] for e in elites] - elite_mean = sum(v) / n - elite_var = sum(map(square, v)) / n - square(elite_mean) - old_mean, old_var = distribution.parameters[i] - new_mean = (elite_mean * step_size + - old_mean * (1.0 - step_size)) - new_var = (elite_var * step_size + - old_var * (1.0 - step_size)) - new_distribution_parameters.append((new_mean, new_var)) - return Distribution(new_distribution_parameters) - - -parameter_settings = [ - Setting('code', interpret_identifier), - Setting('initial_mean', interpret_float), - Setting('initial_variance', interpret_float), - Setting('transform', interpret_callable, default=float), - Setting('format', interpret_8bit_string, default=None), - ] - -class Parameter_config(Quiet_config): - """Parameter (ie, dimension) description for use in control files.""" - # positional or keyword - positional_arguments = ('code',) - # keyword-only - keyword_arguments = tuple(setting.name for setting in parameter_settings - if setting.name != 'code') - -class Parameter_spec(object): - """Internal description of a parameter spec from the configuration file. - - Public attributes: - code -- identifier - initial_mean -- float - initial_variance -- float - transform -- function float -> player parameter - format -- string for use with '%' - - """ - -class Cem_tuner(Competition): - """A Competition for parameter tuning using the cross-entropy method. - - The game ids are like 'g0#1r3', where 0 is the generation number, 1 is the - candidate number and 3 is the round number. - - """ - def __init__(self, competition_code, **kwargs): - Competition.__init__(self, competition_code, **kwargs) - self.seen_successful_game = False - - def control_file_globals(self): - result = Competition.control_file_globals(self) - result.update({ - 'Parameter' : Parameter_config, - }) - return result - - global_settings = (Competition.global_settings + - competitions.game_settings + [ - Setting('batch_size', interpret_positive_int), - Setting('samples_per_generation', interpret_positive_int), - Setting('number_of_generations', interpret_positive_int), - Setting('elite_proportion', interpret_float), - Setting('step_size', interpret_float), - ]) - - special_settings = [ - Setting('opponent', interpret_identifier), - Setting('parameters', - interpret_sequence_of_quiet_configs(Parameter_config)), - Setting('make_candidate', interpret_callable), - ] - - def parameter_spec_from_config(self, parameter_config): - """Make a Parameter_spec from a Parameter_config. - - Raises ControlFileError if there is an error in the configuration. - - Returns a Parameter_spec with all attributes set. - - """ - if not isinstance(parameter_config, Parameter_config): - raise ControlFileError("not a Parameter") - - arguments = parameter_config.resolve_arguments() - interpreted = load_settings(parameter_settings, arguments) - pspec = Parameter_spec() - for name, value in interpreted.iteritems(): - setattr(pspec, name, value) - if pspec.initial_variance < 0.0: - raise ValueError("'initial_variance': must be nonnegative") - try: - transformed = pspec.transform(pspec.initial_mean) - except Exception: - raise ValueError( - "error from transform (applied to initial_mean)\n%s" % - (compact_tracebacks.format_traceback(skip=1))) - if pspec.format is None: - pspec.format = pspec.code + ":%s" - try: - pspec.format % transformed - except Exception: - raise ControlFileError("'format': invalid format string") - return pspec - - def initialise_from_control_file(self, config): - Competition.initialise_from_control_file(self, config) - - competitions.validate_handicap( - self.handicap, self.handicap_style, self.board_size) - - if not 0.0 < self.elite_proportion < 1.0: - raise ControlFileError("elite_proportion out of range (0.0 to 1.0)") - if not 0.0 < self.step_size < 1.0: - raise ControlFileError("step_size out of range (0.0 to 1.0)") - - try: - specials = load_settings(self.special_settings, config) - except ValueError, e: - raise ControlFileError(str(e)) - - try: - self.opponent = self.players[specials['opponent']] - except KeyError: - raise ControlFileError( - "opponent: unknown player %s" % specials['opponent']) - - self.parameter_specs = [] - if not specials['parameters']: - raise ControlFileError("parameters: empty list") - seen_codes = set() - for i, parameter_spec in enumerate(specials['parameters']): - try: - pspec = self.parameter_spec_from_config(parameter_spec) - except StandardError, e: - code = parameter_spec.get_key() - if code is None: - code = i - raise ControlFileError("parameter %s: %s" % (code, e)) - if pspec.code in seen_codes: - raise ControlFileError( - "duplicate parameter code: %s" % pspec.code) - seen_codes.add(pspec.code) - self.parameter_specs.append(pspec) - - self.candidate_maker_fn = specials['make_candidate'] - - self.initial_distribution = Distribution( - [(pspec.initial_mean, pspec.initial_variance) - for pspec in self.parameter_specs]) - - - # State attributes (*: in persistent state): - # *generation -- current generation (0-based int) - # *distribution -- Distribution for current generation - # *sample_parameters -- optimiser_params - # (list indexed by candidate number) - # *wins -- number of games won - # half a point for a game with no winner - # (list indexed by candidate number) - # candidates -- Players (code attribute is the candidate code) - # (list indexed by candidate number) - # *scheduler -- Group_scheduler (group codes are candidate numbers) - # - # These are all reset for each new generation. - # - # seen_successful_game -- bool (per-run state) - - def set_clean_status(self): - self.generation = 0 - self.distribution = self.initial_distribution - self.reset_for_new_generation() - - def _set_scheduler_groups(self): - self.scheduler.set_groups( - (i, self.batch_size) for i in xrange(self.samples_per_generation) - ) - - # Can bump this to prevent people loading incompatible .status files. - status_format_version = 0 - - def get_status(self): - return { - 'generation' : self.generation, - 'distribution' : self.distribution.parameters, - 'sample_parameters' : self.sample_parameters, - 'wins' : self.wins, - 'scheduler' : self.scheduler, - } - - def set_status(self, status): - self.generation = status['generation'] - self.distribution = Distribution(status['distribution']) - self.sample_parameters = status['sample_parameters'] - self.wins = status['wins'] - self.prepare_candidates() - self.scheduler = status['scheduler'] - # Might as well notice if they changed the batch_size - self._set_scheduler_groups() - self.scheduler.rollback() - - def reset_for_new_generation(self): - get_sample = self.distribution.get_sample - self.sample_parameters = [get_sample() - for _ in xrange(self.samples_per_generation)] - self.wins = [0] * self.samples_per_generation - self.prepare_candidates() - self.scheduler = competition_schedulers.Group_scheduler() - self._set_scheduler_groups() - - def transform_parameters(self, optimiser_parameters): - l = [] - for pspec, v in zip(self.parameter_specs, optimiser_parameters): - try: - l.append(pspec.transform(v)) - except Exception: - raise CompetitionError( - "error from transform for %s\n%s" % - (pspec.code, compact_tracebacks.format_traceback(skip=1))) - return tuple(l) - - def format_engine_parameters(self, engine_parameters): - l = [] - for pspec, v in zip(self.parameter_specs, engine_parameters): - try: - s = pspec.format % v - except Exception: - s = "[%s?%s]" % (pspec.code, v) - l.append(s) - return "; ".join(l) - - def format_optimiser_parameters(self, optimiser_parameters): - return self.format_engine_parameters(self.transform_parameters( - optimiser_parameters)) - - @staticmethod - def make_candidate_code(generation, candidate_number): - return "g%d#%d" % (generation, candidate_number) - - def make_candidate(self, player_code, engine_parameters): - """Make a player using the specified engine parameters. - - Returns a game_jobs.Player. - - """ - try: - candidate_config = self.candidate_maker_fn(*engine_parameters) - except Exception: - raise CompetitionError( - "error from make_candidate()\n%s" % - compact_tracebacks.format_traceback(skip=1)) - if not isinstance(candidate_config, Player_config): - raise CompetitionError( - "make_candidate() returned %r, not Player" % - candidate_config) - try: - candidate = self.game_jobs_player_from_config( - player_code, candidate_config) - except Exception, e: - raise CompetitionError( - "bad player spec from make_candidate():\n" - "%s\nparameters were: %s" % - (e, self.format_engine_parameters(engine_parameters))) - return candidate - - def prepare_candidates(self): - """Set up the candidates array. - - This is run for each new generation, and when reloading state. - - Requires generation and sample_parameters to be already set. - - Initialises self.candidates. - - """ - self.candidates = [] - for candidate_number, optimiser_params in \ - enumerate(self.sample_parameters): - candidate_code = self.make_candidate_code( - self.generation, candidate_number) - engine_parameters = self.transform_parameters(optimiser_params) - self.candidates.append( - self.make_candidate(candidate_code, engine_parameters)) - - def finish_generation(self): - """Process a generation's results and calculate the new distribution. - - Writes a description of the generation to the history log. - - Updates self.distribution. - - """ - sorter = [(wins, candidate_number) - for (candidate_number, wins) in enumerate(self.wins)] - sorter.sort(reverse=True) - elite_count = max(1, - int(self.elite_proportion * self.samples_per_generation + 0.5)) - self.log_history("Generation %s" % self.generation) - self.log_history("Distribution\n%s" % - self.format_distribution(self.distribution)) - self.log_history(self.format_generation_results(sorter, elite_count)) - self.log_history("") - elite_samples = [self.sample_parameters[index] - for (wins, index) in sorter[:elite_count]] - self.distribution = update_distribution( - self.distribution, elite_samples, self.step_size) - - def get_player_checks(self): - engine_parameters = self.transform_parameters( - self.initial_distribution.get_sample()) - candidate = self.make_candidate('candidate', engine_parameters) - result = [] - for player in [candidate, self.opponent]: - check = game_jobs.Player_check() - check.player = player - check.board_size = self.board_size - check.komi = self.komi - result.append(check) - return result - - def get_game(self): - if self.scheduler.nothing_issued_yet(): - self.log_event("\nstarting generation %d" % self.generation) - - candidate_number, round_id = self.scheduler.issue() - if candidate_number is None: - return NoGameAvailable - - candidate = self.candidates[candidate_number] - - job = game_jobs.Game_job() - job.game_id = "%sr%d" % (candidate.code, round_id) - job.game_data = (candidate_number, candidate.code, round_id) - job.player_b = candidate - job.player_w = self.opponent - job.board_size = self.board_size - job.komi = self.komi - job.move_limit = self.move_limit - job.handicap = self.handicap - job.handicap_is_free = (self.handicap_style == 'free') - job.use_internal_scorer = (self.scorer == 'internal') - job.internal_scorer_handicap_compensation = \ - self.internal_scorer_handicap_compensation - job.sgf_event = self.competition_code - job.sgf_note = ("Candidate parameters: %s" % - self.format_optimiser_parameters( - self.sample_parameters[candidate_number])) - return job - - def process_game_result(self, response): - self.seen_successful_game = True - candidate_number, candidate_code, round_id = response.game_data - self.scheduler.fix(candidate_number, round_id) - gr = response.game_result - assert candidate_code in (gr.player_b, gr.player_w) - - # Counting jigo or no-result as half a point for the candidate - if gr.winning_player == candidate_code: - self.wins[candidate_number] += 1 - elif gr.winning_player is None: - self.wins[candidate_number] += 0.5 - - if self.scheduler.all_fixed(): - self.finish_generation() - self.generation += 1 - if self.generation != self.number_of_generations: - self.reset_for_new_generation() - - def process_game_error(self, job, previous_error_count): - ## If the very first game to return a response gives an error, halt. - ## Otherwise, retry once and halt on a second failure. - stop_competition = False - retry_game = False - if (not self.seen_successful_game) or (previous_error_count > 0): - stop_competition = True - else: - retry_game = True - return stop_competition, retry_game - - - def format_distribution(self, distribution): - """Pretty-print a distribution. - - Returns a string. - - """ - return "%s\n%s" % ( - self.format_optimiser_parameters(distribution.get_means()), - distribution.format()) - - def format_generation_results(self, ordered_samples, elite_count): - """Pretty-print the results of a single generation. - - ordered_samples -- list of pairs (wins, candidate number) - elite_count -- number of samples to mark as elite - - """ - result = [] - for i, (wins, candidate_number) in enumerate(ordered_samples): - opt_parameters = self.sample_parameters[candidate_number] - result.append( - "%s%s %s %3d" % - (self.make_candidate_code(self.generation, candidate_number), - "*" if i < elite_count else " ", - self.format_optimiser_parameters(opt_parameters), - wins)) - return "\n".join(result) - - def write_static_description(self, out): - def p(s): - print >>out, s - p("CEM tuning event: %s" % self.competition_code) - if self.description: - p(self.description) - p("board size: %s" % self.board_size) - p("komi: %s" % self.komi) - - def write_screen_report(self, out): - print >>out, "generation %d" % self.generation - print >>out - print >>out, "wins from current samples:\n%s" % self.wins - print >>out - if self.generation == self.number_of_generations: - print >>out, "final distribution:" - else: - print >>out, "distribution for generation %d:" % self.generation - print >>out, self.format_distribution(self.distribution) - - def write_short_report(self, out): - self.write_static_description(out) - self.write_screen_report(out) - - write_full_report = write_short_report - diff --git a/gomill/common.py b/gomill/common.py deleted file mode 100644 index 71a7ff8..0000000 --- a/gomill/common.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Domain-dependent utility functions for gomill. - -This module is designed to be used with 'from common import *'. - -This is for Go-specific utilities; see utils for generic utility functions. - -""" - -__all__ = ["opponent_of", "colour_name", "format_vertex", "format_vertex_list", - "move_from_vertex"] - -_opponents = {"b":"w", "w":"b"} -def opponent_of(colour): - """Return the opponent colour. - - colour -- 'b' or 'w' - - Returns 'b' or 'w'. - - """ - try: - return _opponents[colour] - except KeyError: - raise ValueError - -def colour_name(colour): - """Return the (lower-case) full name of a colour. - - colour -- 'b' or 'w' - - """ - try: - return {'b': 'black', 'w': 'white'}[colour] - except KeyError: - raise ValueError - - -column_letters = "ABCDEFGHJKLMNOPQRSTUVWXYZ" - -def format_vertex(move): - """Return coordinates as a string like 'A1', or 'pass'. - - move -- pair (row, col), or None for a pass - - The result is suitable for use directly in GTP responses. - - """ - if move is None: - return "pass" - row, col = move - if not 0 <= row < 25 or not 0 <= col < 25: - raise ValueError - return column_letters[col] + str(row+1) - -def format_vertex_list(moves): - """Return a list of coordinates as a string like 'A1,B2'.""" - return ",".join(map(format_vertex, moves)) - -def move_from_vertex(vertex, board_size): - """Interpret a string representing a vertex, as specified by GTP. - - Returns a pair of coordinates (row, col) in range(0, board_size) - - Raises ValueError with an appropriate message if 'vertex' isn't a valid GTP - vertex specification for a board of size 'board_size'. - - """ - if not 0 < board_size <= 25: - raise ValueError("board_size out of range") - try: - s = vertex.lower() - except Exception: - raise ValueError("invalid vertex") - if s == "pass": - return None - try: - col_c = s[0] - if (not "a" <= col_c <= "z") or col_c == "i": - raise ValueError - if col_c > "i": - col = ord(col_c) - ord("b") - else: - col = ord(col_c) - ord("a") - row = int(s[1:]) - 1 - if row < 0: - raise ValueError - except (IndexError, ValueError): - raise ValueError("invalid vertex: '%s'" % s) - if not (col < board_size and row < board_size): - raise ValueError("vertex is off board: '%s'" % s) - return row, col - diff --git a/gomill/compact_tracebacks.py b/gomill/compact_tracebacks.py deleted file mode 100644 index a0a5cf1..0000000 --- a/gomill/compact_tracebacks.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Compact formatting of tracebacks.""" - -import sys -import traceback - -def log_traceback_from_info(exception_type, value, tb, dst=sys.stderr, skip=0): - """Log a given exception nicely to 'dst', showing a traceback. - - dst -- writeable file-like object - skip -- number of traceback entries to omit from the top of the list - - """ - for line in traceback.format_exception_only(exception_type, value): - dst.write(line) - if (not isinstance(exception_type, str) and - issubclass(exception_type, SyntaxError)): - return - print >>dst, 'traceback (most recent call last):' - text = None - for filename, lineno, fnname, text in traceback.extract_tb(tb)[skip:]: - if fnname == "?": - fn_s = "" - else: - fn_s = "(%s)" % fnname - print >>dst, " %s:%s %s" % (filename, lineno, fn_s) - if text is not None: - print >>dst, "failing line:" - print >>dst, text - -def format_traceback_from_info(exception_type, value, tb, skip=0): - """Return a description of a given exception as a string. - - skip -- number of traceback entries to omit from the top of the list - - """ - from cStringIO import StringIO - log = StringIO() - log_traceback_from_info(exception_type, value, tb, log, skip) - return log.getvalue() - -def log_traceback(dst=sys.stderr, skip=0): - """Log the current exception nicely to 'dst'. - - dst -- writeable file-like object - skip -- number of traceback entries to omit from the top of the list - - """ - exception_type, value, tb = sys.exc_info() - log_traceback_from_info(exception_type, value, tb, dst, skip) - -def format_traceback(skip=0): - """Return a description of the current exception as a string. - - skip -- number of traceback entries to omit from the top of the list - - """ - exception_type, value, tb = sys.exc_info() - return format_traceback_from_info(exception_type, value, tb, skip) - - -def log_error_and_line_from_info(exception_type, value, tb, dst=sys.stderr): - """Log a given exception briefly to 'dst', showing line number.""" - if (not isinstance(exception_type, str) and - issubclass(exception_type, SyntaxError)): - for line in traceback.format_exception_only(exception_type, value): - dst.write(line) - else: - try: - filename, lineno, fnname, text = traceback.extract_tb(tb)[-1] - except IndexError: - pass - else: - print >>dst, "at line %s:" % lineno - for line in traceback.format_exception_only(exception_type, value): - dst.write(line) - -def format_error_and_line_from_info(exception_type, value, tb): - """Return a brief description of a given exception as a string.""" - from cStringIO import StringIO - log = StringIO() - log_error_and_line_from_info(exception_type, value, tb, log) - return log.getvalue() - -def log_error_and_line(dst=sys.stderr): - """Log the current exception briefly to 'dst'. - - dst -- writeable file-like object - - """ - exception_type, value, tb = sys.exc_info() - log_error_and_line_from_info(exception_type, value, tb, dst) - -def format_error_and_line(): - """Return a brief description of the current exception as a string.""" - exception_type, value, tb = sys.exc_info() - return format_error_and_line_from_info(exception_type, value, tb) - diff --git a/gomill/competition_schedulers.py b/gomill/competition_schedulers.py deleted file mode 100644 index 4af0f76..0000000 --- a/gomill/competition_schedulers.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Schedule games in competitions. - -These schedulers are used to keep track of the ids of games which have been -started, and which have reported their results. - -They provide a mechanism to reissue ids of games which were in progress when an -unclean shutdown occurred. - -All scheduler classes are suitable for pickling. - -""" - -class Simple_scheduler(object): - """Schedule a single sequence of games. - - The issued tokens are integers counting up from zero. - - Public attributes (treat as read-only): - issued -- int - fixed -- int - - """ - def __init__(self): - self.next_new = 0 - self.outstanding = set() - self.to_reissue = set() - self.issued = 0 - self.fixed = 0 - #self._check_consistent() - - def _check_consistent(self): - assert self.issued == \ - self.next_new - len(self.to_reissue) - assert self.fixed == \ - self.next_new - len(self.outstanding) - len(self.to_reissue) - - def __getstate__(self): - return (self.next_new, self.outstanding, self.to_reissue) - - def __setstate__(self, state): - (self.next_new, self.outstanding, self.to_reissue) = state - self.issued = self.next_new - len(self.to_reissue) - self.fixed = self.issued - len(self.outstanding) - #self._check_consistent() - - def issue(self): - """Choose the next game to start. - - Returns an integer 'token'. - - """ - if self.to_reissue: - result = min(self.to_reissue) - self.to_reissue.discard(result) - else: - result = self.next_new - self.next_new += 1 - self.outstanding.add(result) - self.issued += 1 - #self._check_consistent() - return result - - def fix(self, token): - """Note that a game's result has been reliably stored.""" - self.outstanding.remove(token) - self.fixed += 1 - #self._check_consistent() - - def rollback(self): - """Make issued-but-not-fixed tokens available again.""" - self.issued -= len(self.outstanding) - self.to_reissue.update(self.outstanding) - self.outstanding = set() - #self._check_consistent() - - -class Group_scheduler(object): - """Schedule multiple lists of games in parallel. - - This schedules for a number of _groups_, each of which may have a limit on - the number of games to play. It schedules from the group (of those which - haven't reached their limit) with the fewest issued games, with smallest - group code breaking ties. - - group codes might be ints or short strings - (any sortable, pickleable and hashable object should do). - - The issued tokens are pairs (group code, game number), with game numbers - counting up from 0 independently for each group code. - - """ - def __init__(self): - self.allocators = {} - self.limits = {} - - def __getstate__(self): - return (self.allocators, self.limits) - - def __setstate__(self, state): - (self.allocators, self.limits) = state - - def set_groups(self, group_specs): - """Set the groups to be scheduled. - - group_specs -- iterable of pairs (group code, limit) - limit -- int or None - - You can call this again after the first time. The limits will be set to - the new values. Any existing groups not in the list are forgotten. - - """ - new_allocators = {} - new_limits = {} - for group_code, limit in group_specs: - if group_code in self.allocators: - new_allocators[group_code] = self.allocators[group_code] - else: - new_allocators[group_code] = Simple_scheduler() - new_limits[group_code] = limit - self.allocators = new_allocators - self.limits = new_limits - - def issue(self): - """Choose the next game to start. - - Returns a pair (group code, game number) - - Returns (None, None) if all groups have reached their limit. - - """ - groups = [ - (group_code, allocator.issued, self.limits[group_code]) - for (group_code, allocator) in self.allocators.iteritems() - ] - available = [ - (issue_count, group_code) - for (group_code, issue_count, limit) in groups - if limit is None or issue_count < limit - ] - if not available: - return None, None - _, group_code = min(available) - return group_code, self.allocators[group_code].issue() - - def fix(self, group_code, game_number): - """Note that a game's result has been reliably stored.""" - self.allocators[group_code].fix(game_number) - - def rollback(self): - """Make issued-but-not-fixed tokens available again.""" - for allocator in self.allocators.itervalues(): - allocator.rollback() - - def nothing_issued_yet(self): - """Say whether nothing has been issued yet.""" - return all(allocator.issued == 0 - for allocator in self.allocators.itervalues()) - - def all_fixed(self): - """Check whether all groups have reached their limits. - - This returns true if all groups have limits, and each group has as many - _fixed_ tokens as its limit. - - """ - return all(allocator.fixed >= self.limits[g] - for (g, allocator) in self.allocators.iteritems()) diff --git a/gomill/competitions.py b/gomill/competitions.py deleted file mode 100644 index 8e34e18..0000000 --- a/gomill/competitions.py +++ /dev/null @@ -1,513 +0,0 @@ -"""Organise processing jobs based around playing many GTP games.""" - -import os - -from gomill import game_jobs -from gomill import gtp_controller -from gomill import handicap_layout -from gomill.settings import * - - -def log_discard(s): - pass - -NoGameAvailable = object() - -class CompetitionError(StandardError): - """Error from competition code. - - This is intended for errors from user-provided functions, but it might also - indicate a bug in tuner code. - - The ringmaster should display the error and terminate immediately. - - """ - -class ControlFileError(StandardError): - """Error interpreting the control file.""" - - -class Control_file_token(object): - def __init__(self, name): - self.name = name - def __repr__(self): - return "<%s>" % self.name - - -_player_settings = [ - Setting('command', interpret_shlex_sequence), - Setting('cwd', allow_none(interpret_8bit_string), default=None), - Setting('environ', - allow_none(interpret_map_of( - interpret_8bit_string, interpret_8bit_string)), - default=None), - Setting('is_reliable_scorer', interpret_bool, default=True), - Setting('allow_claim', interpret_bool, default=False), - Setting('gtp_aliases', - allow_none(interpret_map_of( - interpret_8bit_string, interpret_8bit_string)), - defaultmaker=dict), - Setting('startup_gtp_commands', allow_none(interpret_sequence), - defaultmaker=list), - Setting('discard_stderr', interpret_bool, default=False), - ] - -class Player_config(Quiet_config): - """Player description for use in control files.""" - # positional or keyword - positional_arguments = ('command',) - # keyword-only - keyword_arguments = tuple(setting.name for setting in _player_settings) - - -class Competition(object): - """A resumable processing job based around playing many GTP games. - - This is an abstract base class. - - """ - - def __init__(self, competition_code): - self.competition_code = competition_code - self.base_directory = None - self.event_logger = log_discard - self.history_logger = log_discard - - def control_file_globals(self): - """Specify names and values to make available to the control file. - - Returns a dict suitable for use as the control file's namespace. - - """ - return { - 'Player' : Player_config, - } - - def set_base_directory(self, pathname): - """Set the competition's base directory. - - Relative paths in the control file are interpreted relative to this - directory. - - """ - self.base_directory = pathname - - def resolve_pathname(self, pathname): - """Resolve a pathname relative to the competition's base directory. - - Accepts None, returning it. - - Applies os.expanduser to the pathname. - - Doesn't absolutise or normalise the resulting pathname. - - Raises ValueError if it can't handle the pathname. - - """ - if pathname is None: - return None - if pathname == "": - raise ValueError("empty pathname") - try: - pathname = os.path.expanduser(pathname) - except Exception: - raise ValueError("bad pathname") - try: - return os.path.join(self.base_directory, pathname) - except Exception: - raise ValueError( - "relative path supplied but base directory isn't set") - - def set_event_logger(self, logger): - """Set a callback for the event log. - - logger -- function taking a string argument - - Until this is called, event log output is silently discarded. - - """ - self.event_logger = logger - - def set_history_logger(self, logger): - """Set a callback for the history file. - - logger -- function taking a string argument - - Until this is called, event log output is silently discarded. - - """ - self.history_logger = logger - - def log_event(self, s): - """Write a message to the event log. - - The event log logs all game starts and finishes; competitions can add - lines to mark things like the start of new generations. - - A newline is added to the message. - - """ - self.event_logger(s) - - def log_history(self, s): - """Write a message to the history file. - - The history file is used to show things like game results and tuning - event intermediate status. - - A newline is added to the message. - - """ - self.history_logger(s) - - # List of Settings (subclasses can override, and should include these) - global_settings = [ - Setting('description', allow_none(interpret_as_utf8_stripped), - default=None), - ] - - def initialise_from_control_file(self, config): - """Initialise competition data from the control file. - - config -- namespace produced by the control file. - - (When resuming from saved state, this is called before set_state()). - - This processes all global_settings and sets attributes (named by the - setting names). - - It also handles the following settings and sets the corresponding - attributes: - players -- map player code -> game_jobs.Player - - Raises ControlFileError with a description if the control file has a bad - or missing value. - - """ - # This is called for all commands, so it mustn't log anything. - - # Implementations in subclasses should have their own backstop exception - # handlers, so they can at least show what part of the control file was - # being interpreted when the exception occurred. - - # We should accept that there may be unexpected exceptions, because - # control files are allowed to do things like substitute list-like - # objects for Python lists. - - try: - to_set = load_settings(self.global_settings, config) - except ValueError, e: - raise ControlFileError(str(e)) - for name, value in to_set.items(): - setattr(self, name, value) - - def interpret_pc(v): - if not isinstance(v, Player_config): - raise ValueError("not a Player") - return v - settings = [ - Setting('players', - interpret_map_of(interpret_identifier, interpret_pc)) - ] - try: - specials = load_settings(settings, config) - except ValueError, e: - raise ControlFileError(str(e)) - self.players = {} - for player_code, player_config in specials['players']: - try: - player = self.game_jobs_player_from_config( - player_code, player_config) - except Exception, e: - raise ControlFileError("player %s: %s" % (player_code, e)) - self.players[player_code] = player - - def game_jobs_player_from_config(self, code, player_config): - """Make a game_jobs.Player from a Player_config. - - Raises ControlFileError with a description if there is an error in the - configuration. - - Returns an incomplete game_jobs.Player (see get_game() for details). - - """ - arguments = player_config.resolve_arguments() - config = load_settings(_player_settings, arguments) - - player = game_jobs.Player() - player.code = code - - try: - player.cmd_args = config['command'] - if '/' in player.cmd_args[0]: - player.cmd_args[0] = self.resolve_pathname(player.cmd_args[0]) - except Exception, e: - raise ControlFileError("'command': %s" % e) - - try: - player.cwd = self.resolve_pathname(config['cwd']) - except Exception, e: - raise ControlFileError("'cwd': %s" % e) - player.environ = config['environ'] - - player.is_reliable_scorer = config['is_reliable_scorer'] - player.allow_claim = config['allow_claim'] - - player.startup_gtp_commands = [] - try: - for v in config['startup_gtp_commands']: - try: - if isinstance(v, basestring): - words = interpret_8bit_string(v).split() - else: - words = list(v) - if not all(gtp_controller.is_well_formed_gtp_word(word) - for word in words): - raise StandardError - except Exception: - raise ValueError("invalid command %s" % v) - player.startup_gtp_commands.append((words[0], words[1:])) - except ValueError, e: - raise ControlFileError("'startup_gtp_commands': %s" % e) - - player.gtp_aliases = {} - try: - for cmd1, cmd2 in config['gtp_aliases']: - if not gtp_controller.is_well_formed_gtp_word(cmd1): - raise ValueError("invalid command %s" % clean_string(cmd1)) - if not gtp_controller.is_well_formed_gtp_word(cmd2): - raise ValueError("invalid command %s" % clean_string(cmd2)) - player.gtp_aliases[cmd1] = cmd2 - except ValueError, e: - raise ControlFileError("'gtp_aliases': %s" % e) - - if config['discard_stderr']: - player.discard_stderr = True - - return player - - - def set_clean_status(self): - """Reset competition state to its initial value.""" - # This is called before logging is set up, so it mustn't log anything. - raise NotImplementedError - - def get_status(self): - """Return full state of the competition, so it can be resumed later. - - The returned result must be pickleable. - - """ - raise NotImplementedError - - def set_status(self, status): - """Reset competition state to a previously reported value. - - 'status' will be a value previously reported by get_status(). - - If the status is invalid, CompetitionError may be raised with a - description of the error, or any other exception may be raised without - a friendly description. - - """ - # This is called for the 'show' command, so it mustn't log anything. - raise NotImplementedError - - def get_player_checks(self): - """List the Player_checks for check_players() to check. - - Returns a list of game_jobs.Player_check objects. The players' - stderr_pathname attribute will be ignored. - - This is called without the competition status being set. - - """ - raise NotImplementedError - - def get_game(self): - """Return the details of the next game to play. - - Returns a game_jobs.Game_job, or NoGameAvailable. - - The following Game_job attributes are left for the ringmaster to set: - - sgf_game_name - - sgf_filename - - sgf_dirname - - void_sgf_dirname - - gtp_log_pathname - - stderr_pathname - - """ - raise NotImplementedError - - def process_game_result(self, response): - """Process the results from a completed game. - - response -- game_jobs.Game_job_result - - This may return a text description of the game result, to override the - default (it should normally include response.game_result.sgf_result). - - It's common for this method to write to the history file. - - """ - raise NotImplementedError - - def process_game_error(self, job, previous_error_count): - """Process a report that a job failed. - - job -- game_jobs.Game_job - previous_error_count -- int >= 0 - - Returns a pair of bools (stop_competition, retry_game) - - If stop_competition is True, the ringmaster will stop starting new - games. Otherwise, if retry_game is true the ringmaster will try running - the same game again. - - The job is one previously returned by get_game(). previous_error_count - is the number of times that this particular job has failed before. - - Failed jobs are ones in which there was an error more serious than one - which just causes an engine to forfeit the game. For example, the job - will fail if one of the engines fails to respond to GTP commands at all, - or (in particular) if it exits as soon as it's invoked because it - doesn't like its command-line options. - - """ - raise NotImplementedError - - def write_screen_report(self, out): - """Write a one-screen summary of current competition status. - - out -- writeable file-like object - - This is supposed to fit comfortably on one screen; it's normally - displayed continuously by the ringmaster. Aim for about 30 lines. - - It should end with a newline, but not have additional blank lines at - the end. - - This should focus on describing incomplete competitions usefully. - - """ - raise NotImplementedError - - def write_short_report(self, out): - """Write a short report of the competition status/results. - - out -- writeable file-like object - - This is used for the ringmaster's 'show' command. - - It should include the competition's description attribute. - - It should end with a newline, but not have additional blank lines at - the end. - - This should be useful for both completed and incomplete competitions. - - """ - raise NotImplementedError - - def write_full_report(self, out): - """Write a detailed report of competition status/results. - - out -- writeable file-like object - - This is used for the ringmaster's 'report' command. - - It should include the competition's description attribute. - - It should end with a newline. - - This should focus on describing completed competitions well. - - """ - raise NotImplementedError - - def get_tournament_results(self): - """Return a Tournament_results object for this competition. - - The competition status must be set before you call this. - - (The returned object is 'live', in that it will see new results as they - come in, but don't rely in this behaviour.) - - Expect this to be implemented for tournaments but not tuning events. - - This won't include results for 'ghost' matchups. - - """ - raise NotImplementedError - - -## Helper functions for settings - -def interpret_board_size(i): - i = interpret_int(i) - if i < 2: - raise ValueError("too small") - if i > 25: - raise ValueError("too large") - return i - -def validate_handicap(handicap, handicap_style, board_size): - """Check whether a handicap is allowed. - - handicap -- int or None - handicap_style -- 'free' or 'fixed' - board_size -- int - - Raises ControlFileError with a description if it isn't. - - """ - if handicap is None: - return True - if handicap < 2: - raise ControlFileError("handicap too small") - if handicap_style == 'fixed': - limit = handicap_layout.max_fixed_handicap_for_board_size(board_size) - else: - limit = handicap_layout.max_free_handicap_for_board_size(board_size) - if handicap > limit: - raise ControlFileError( - "%s handicap out of range for board size %d" % - (handicap_style, board_size)) - - -## Helper functions - -def leading_zero_template(ceiling): - """Return a template suitable for formatting numbers less than 'ceiling'. - - ceiling -- int or None - - Returns a string suitable for Python %-formatting numbers from 0 to - ceiling-1, with leading zeros so that the strings have constant length. - - If ceiling is None, there will be no leading zeros. - - That is, the result is either '%d' or '%0Nd' for some N. - - """ - if ceiling is None: - return "%d" - else: - zeros = len(str(ceiling-1)) - return "%%0%dd" % zeros - - -## Common settings - -game_settings = [ - Setting('board_size', interpret_board_size), - Setting('komi', interpret_float), - Setting('handicap', allow_none(interpret_int), default=None), - Setting('handicap_style', interpret_enum('fixed', 'free'), default='fixed'), - Setting('move_limit', interpret_positive_int, default=1000), - Setting('scorer', interpret_enum('internal', 'players'), default='players'), - Setting('internal_scorer_handicap_compensation', - interpret_enum('no', 'full', 'short'), default='full'), - ] - diff --git a/gomill/game_jobs.py b/gomill/game_jobs.py deleted file mode 100644 index 6f0face..0000000 --- a/gomill/game_jobs.py +++ /dev/null @@ -1,418 +0,0 @@ -"""Connection between GTP games and the job manager.""" - -import datetime -import os - -from gomill import gtp_controller -from gomill import gtp_games -from gomill import job_manager -from gomill import sgf -from gomill.gtp_controller import BadGtpResponse, GtpChannelError - -class Player(object): - """Player description for Game_jobs. - - required attributes: - code -- short string - cmd_args -- list of strings, as for subprocess.Popen - - optional attributes: - is_reliable_scorer -- bool (default True) - allow_claim -- bool (default False) - gtp_aliases -- map command string -> command string - startup_gtp_commands -- list of pairs (command_name, arguments) - discard_stderr -- bool (default False) - cwd -- working directory to change to (default None) - environ -- maplike of environment variables (default None) - - See gtp_controllers.Gtp_controller for an explanation of gtp_aliases. - - The startup commands will be executed before starting the game. Their - responses will be ignored, but the game will be aborted if any startup - command returns an error. - - By default, the player will be given a copy of the parent process's - environment variables; use 'environ' to add variables or replace particular - values. - - Players are suitable for pickling. - - """ - def __init__(self): - self.is_reliable_scorer = True - self.allow_claim = False - self.gtp_aliases = {} - self.startup_gtp_commands = [] - self.discard_stderr = False - self.cwd = None - self.environ = None - - def make_environ(self): - """Return environment variables to use with the player's subprocess. - - Returns a dict suitable for use with a Subprocess_gtp_channel. - - """ - environ = os.environ.copy() - if self.environ is not None: - environ.update(self.environ) - return environ - - def copy(self, code): - """Return an independent clone of the Player.""" - result = Player() - result.code = code - result.cmd_args = list(self.cmd_args) - result.is_reliable_scorer = self.is_reliable_scorer - result.allow_claim = self.allow_claim - result.gtp_aliases = dict(self.gtp_aliases) - result.startup_gtp_commands = list(self.startup_gtp_commands) - result.discard_stderr = self.discard_stderr - result.cwd = self.cwd - if self.environ is None: - result.environ = None - else: - result.environ = dict(self.environ) - return result - -class Game_job_result(object): - """Information returned after a worker process plays a game. - - Public attributes: - game_id -- short string - game_data -- arbitrary (copied from the Game_job) - game_result -- gtp_games.Game_result - warnings -- list of strings - log_entries -- list of strings - engine_names -- map player code -> string - engine_descriptions -- map player code -> string - - Game_job_results are suitable for pickling. - - """ - -class Game_job(object): - """A game to be played in a worker process. - - A Game_job is designed to be used a job object for the job manager. That is, - its public interface is the run() method. - - When the job is run, it plays a GTP game as described by its attributes, and - optionally writes an SGF file. The job result is a Game_job_result object. - - required attributes: - game_id -- short string - player_b -- Player - player_w -- Player - board_size -- int - komi -- float - move_limit -- int - - optional attributes (default None unless otherwise stated): - game_data -- arbitrary pickleable data - handicap -- int - handicap_is_free -- bool (default False) - use_internal_scorer -- bool (default True) - internal_scorer_handicap_compensation -- 'no' , 'short', or 'full' - (default 'no') - sgf_filename -- filename for the SGF file - sgf_dirname -- directory pathname for the SGF file - void_sgf_dirname -- directory pathname for the SGF file for void games - sgf_game_name -- string to show as SGF Game Name (default game_id) - sgf_event -- string to show as SGF EVent - sgf_note -- multiline string to put into SGF root comment - gtp_log_pathname -- pathname to use for the GTP log - stderr_pathname -- pathname to send players' stderr to - - The game_id will be returned in the job result, so you can tell which game - you're getting the result for. It also appears in a comment in the SGF file. - - game_data is returned in the job result. It's provided as a convenient way - to pass a small amount of information from get_job() to process_response(). - - If use_internal_scorer is False, the Players' is_reliable_scorer attributes - are used to decide which player is asked to score the game (if both are - marked as reliable, black will be tried before white). - - If sgf_dirname and sgf_filename are set, an SGF file will be written after - the game is over. - - If void_sgf_dirname and sgf_filename are set, an SGF file will be written - for void games (games which were aborted due to unhandled errors). The - leaf directory will be created if necessary. - - If gtp_log_pathname is set, all GTP messages to and from both players will - be logged (this doesn't append; any existing file will be overwritten). - - If stderr_pathname is set, the specified file will be opened in append mode - and both players' standard error streams will be sent there. Otherwise the - players' standard error streams will be left as the standard error of the - calling process. But if a player has discard_stderr=True then its standard - error is sent to os.devnull instead. - - Game_jobs are suitable for pickling. - - """ - def __init__(self): - self.handicap = None - self.handicap_is_free = False - self.sgf_filename = None - self.sgf_dirname = None - self.void_sgf_dirname = None - self.sgf_game_name = None - self.sgf_event = None - self.sgf_note = None - self.use_internal_scorer = True - self.internal_scorer_handicap_compensation = 'no' - self.game_data = None - self.gtp_log_pathname = None - self.stderr_pathname = None - - # The code here has to be happy to run in a separate process. - - def run(self, worker_id=None): - """Run the job. - - This method is called by the job manager. - - worker_id -- int or None - - Returns a Game_job_result, or raises JobFailed. - - """ - self._worker_id = worker_id - self._files_to_close = [] - try: - return self._run() - finally: - # These files are all either flushed after every write, or not - # written to at all from this process, so there shouldn't be any - # errors from close(). - for f in self._files_to_close: - try: - f.close() - except EnvironmentError: - pass - - def _start_player(self, game, colour, player, gtp_log_file): - if player.discard_stderr: - stderr_pathname = os.devnull - else: - stderr_pathname = self.stderr_pathname - if stderr_pathname is not None: - stderr = open(stderr_pathname, "a") - self._files_to_close.append(stderr) - else: - stderr = None - if player.allow_claim: - game.set_claim_allowed(colour) - env = player.make_environ() - env['GOMILL_GAME_ID'] = self.game_id - if self._worker_id is not None: - env['GOMILL_SLOT'] = str(self._worker_id) - game.set_player_subprocess( - colour, player.cmd_args, - env=env, cwd=player.cwd, stderr=stderr) - controller = game.get_controller(colour) - controller.set_gtp_aliases(player.gtp_aliases) - if gtp_log_file is not None: - controller.channel.enable_logging( - gtp_log_file, prefix="%s: " % colour) - for command, arguments in player.startup_gtp_commands: - game.send_command(colour, command, *arguments) - - def _run(self): - warnings = [] - log_entries = [] - try: - game = gtp_games.Game(self.board_size, self.komi, self.move_limit) - game.set_player_code('b', self.player_b.code) - game.set_player_code('w', self.player_w.code) - game.set_game_id(self.game_id) - except ValueError, e: - raise job_manager.JobFailed("error creating game: %s" % e) - if self.use_internal_scorer: - game.use_internal_scorer(self.internal_scorer_handicap_compensation) - else: - if self.player_b.is_reliable_scorer: - game.allow_scorer('b') - if self.player_w.is_reliable_scorer: - game.allow_scorer('w') - - if self.gtp_log_pathname is not None: - gtp_log_file = open(self.gtp_log_pathname, "w") - self._files_to_close.append(gtp_log_file) - else: - gtp_log_file = None - - try: - self._start_player(game, 'b', self.player_b, gtp_log_file) - self._start_player(game, 'w', self.player_w, gtp_log_file) - game.request_engine_descriptions() - game.ready() - if self.handicap: - try: - game.set_handicap(self.handicap, self.handicap_is_free) - except ValueError: - raise BadGtpResponse("invalid handicap") - game.run() - except (GtpChannelError, BadGtpResponse), e: - game.close_players() - msg = "aborting game due to error:\n%s" % e - self._record_void_game(game, msg) - late_error_messages = game.describe_late_errors() - if late_error_messages is not None: - msg += "\nalso:\n" + late_error_messages - raise job_manager.JobFailed(msg) - if game.result.is_forfeit: - warnings.append(game.result.detail) - game.close_players() - late_error_messages = game.describe_late_errors() - if late_error_messages: - log_entries.append(late_error_messages) - self._record_game(game) - response = Game_job_result() - response.game_id = self.game_id - response.game_result = game.result - response.warnings = warnings - response.log_entries = log_entries - response.engine_names = game.engine_names - response.engine_descriptions = game.engine_descriptions - response.game_data = self.game_data - return response - - def _write_sgf(self, pathname, sgf_string): - f = open(pathname, "w") - f.write(sgf_string) - f.close() - - def _mkdir(self, pathname): - os.mkdir(pathname) - - def _write_game_record(self, pathname, game, - game_end_message=None, result=None): - b_player = game.players['b'] - w_player = game.players['w'] - notes = [] - sgf_game = game.make_sgf(game_end_message) - root = sgf_game.get_root() - if self.sgf_game_name is not None: - root.set('GN', self.sgf_game_name) - if self.sgf_event is not None: - root.set('EV', self.sgf_event) - notes.append("Event: %s" % self.sgf_event) - notes += [ - "Game id %s" % self.game_id, - "Date %s" % datetime.datetime.now().strftime("%Y-%m-%d %H:%M"), - ] - if game.result is not None: - notes.append("Result %s" % game.result.describe()) - elif result is not None: - root.set('RE', result) - if self.sgf_note is not None: - notes.append(self.sgf_note) - if game.result is not None: - for player in [b_player, w_player]: - cpu_time = game.result.cpu_times[player] - if cpu_time is not None and cpu_time != "?": - notes.append("%s cpu time: %ss" % - (player, "%.2f" % cpu_time)) - notes += [ - "Black %s %s" % (b_player, game.engine_descriptions[b_player]), - "White %s %s" % (w_player, game.engine_descriptions[w_player]), - ] - root.set('C', "\n".join(notes)) - self._write_sgf(pathname, sgf_game.serialise()) - - def _record_game(self, game): - """Record the game in the standard sgf directory.""" - if self.sgf_dirname is None or self.sgf_filename is None: - return - pathname = os.path.join(self.sgf_dirname, self.sgf_filename) - self._write_game_record(pathname, game) - - def _record_void_game(self, game, game_end_message): - """Record the game in the void sgf directory if it had any moves.""" - if not game.moves: - return - if self.void_sgf_dirname is None or self.sgf_filename is None: - return - if not os.path.exists(self.void_sgf_dirname): - self._mkdir(self.void_sgf_dirname) - pathname = os.path.join(self.void_sgf_dirname, self.sgf_filename) - self._write_game_record(pathname, game, game_end_message, result='Void') - - -class CheckFailed(StandardError): - """Error reported by check_player()""" - -class Player_check(object): - """Information required to check a player. - - required attributes: - player -- Player - board_size -- int - komi -- float - - """ - -def check_player(player_check, discard_stderr=False): - """Do a test run of a GTP engine. - - player_check -- Player_check object - - This starts an engine subprocess, sends it some GTP commands, and ends the - process again. - - Raises CheckFailed if the player doesn't pass the checks. - - Returns a list of warning messages. - - Currently checks: - - any explicitly specified cwd exists and is a directory - - the engine subprocess starts, and replies to GTP commands - - the engine reports protocol version 2 (if it supports protocol_version) - - the engine accepts any startup_gtp_commands - - the engine accepts the specified board size and komi - - the engine accepts the 'clear_board' command - - the engine accepts 'quit' and closes down cleanly - - """ - player = player_check.player - if player.cwd is not None and not os.path.isdir(player.cwd): - raise CheckFailed("bad working directory: %s" % player.cwd) - - if discard_stderr: - stderr = open(os.devnull, "w") - else: - stderr = None - try: - env = player.make_environ() - env['GOMILL_GAME_ID'] = 'startup-check' - try: - channel = gtp_controller.Subprocess_gtp_channel( - player.cmd_args, - env=env, cwd=player.cwd, stderr=stderr) - except GtpChannelError, e: - raise GtpChannelError( - "error starting subprocess for %s:\n%s" % (player.code, e)) - controller = gtp_controller.Gtp_controller(channel, player.code) - controller.set_gtp_aliases(player.gtp_aliases) - controller.check_protocol_version() - for command, arguments in player.startup_gtp_commands: - controller.do_command(command, *arguments) - controller.do_command("boardsize", str(player_check.board_size)) - controller.do_command("clear_board") - controller.do_command("komi", str(player_check.komi)) - controller.safe_close() - except (GtpChannelError, BadGtpResponse), e: - raise CheckFailed(str(e)) - else: - return controller.retrieve_error_messages() - finally: - try: - if stderr is not None: - stderr.close() - except Exception: - pass - diff --git a/gomill/gtp_controller.py b/gomill/gtp_controller.py deleted file mode 100644 index f0bb128..0000000 --- a/gomill/gtp_controller.py +++ /dev/null @@ -1,823 +0,0 @@ -"""Go Text Protocol support (controller side). - -Based on GTP 'draft version 2' (see ). - -""" - -import errno -import os -import re -import signal -import subprocess - -from gomill.utils import * -from gomill.common import * - - -class GtpChannelError(StandardError): - """Low-level error trying to talk to a GTP engine. - - This is the base class for GtpProtocolError, GtpTransportError, - and GtpChannelClosed. It may also be raised directly. - - """ - -class GtpProtocolError(GtpChannelError): - """A GTP engine returned an ill-formed response.""" - -class GtpTransportError(GtpChannelError): - """An error from the transport underlying the GTP channel.""" - -class GtpChannelClosed(GtpChannelError): - """The (command or response) channel to a GTP engine has been closed.""" - - -class BadGtpResponse(StandardError): - """Unacceptable response from a GTP engine. - - This is usually used to indicate a GTP failure ('?') response. - - Some higher-level functions use this exception to indicate a GTP success - ('=') response which they couldn't interpret. - - Additional attributes: - gtp_command -- string (or None) - gtp_arguments -- sequence of strings (or None) - gtp_error_message -- string (or None) - - """ - def __init__(self, args, - gtp_command=None, gtp_arguments=None, gtp_error_message=None): - StandardError.__init__(self, args) - self.gtp_command = gtp_command - self.gtp_arguments = gtp_arguments - self.gtp_error_message = gtp_error_message - - -_gtp_word_characters_re = re.compile(r"\A[\x21-\x7e\x80-\xff]+\Z") -_remove_response_controls_re = re.compile(r"[\x00-\x08\x0b-\x1f\x7f]") - -def is_well_formed_gtp_word(s): - """Check whether 's' is well-formed as a single GTP word. - - In particular, this rejects unicode objects and strings containing spaces. - - """ - if not isinstance(s, str): - return False - if not _gtp_word_characters_re.search(s): - return False - return True - -class Gtp_channel(object): - """A communication channel to a GTP engine. - - public attributes: - exit_status - resource_usage - - exit_status describes the engine's exit status as an integer. It is None if - not available. The integer is in the form returned by os.wait() (in - particular, zero for successful exit, nonzero for unsuccessful). - - resource_usage describes the engine's resource usage (see - resource.getrusage() for the format). It is None if not available. - - In practice these attributes are only available for subprocess-based - channels, and only after they've been closed. - - """ - def __init__(self): - self.exit_status = None - self.resource_usage = None - self.log_dest = None - self.log_prefix = None - - def enable_logging(self, log_dest, prefix=""): - """Log all messages sent and received over the channel. - - log_dest -- writable file-like object (eg an open file) - prefix -- short string to prepend to logged lines - - """ - self.log_dest = log_dest - self.log_prefix = prefix - - def _log(self, marker, message): - """Log a message. - - marker -- string that goes before the log prefix - message -- string to log - - Swallows all errors. - - """ - try: - self.log_dest.write(marker + self.log_prefix + message + "\n") - self.log_dest.flush() - except Exception: - pass - - def send_command(self, command, arguments): - """Send a GTP command over the channel. - - command -- string - arguments -- list of strings - - May raise GtpChannelError. - - Raises ValueError if the command or an argument contains a character - forbidden in GTP. - - """ - if not is_well_formed_gtp_word(command): - raise ValueError("bad command") - for argument in arguments: - if not is_well_formed_gtp_word(argument): - raise ValueError("bad argument") - if self.log_dest is not None: - self._log(">> ", command + ("".join(" " + a for a in arguments))) - self.send_command_impl(command, arguments) - - def get_response(self): - """Read a GTP response from the channel. - - Waits indefinitely for the response. - - Returns a pair (is_failure, response) - - 'is_failure' is a bool indicating whether the engine returned a success - or a failure response. - - For a success response, 'response' is the result from the engine; for a - failure response it's the error message from the engine. - - This cleans the response according to the GTP spec, and also removes - leading and trailing whitespace. - - This means that 'response' is an 8-bit string with no trailing - whitespace. It may contain newlines, but there are no empty lines except - perhaps the first. There is no leading whitespace on the first line. - There are no other control characters. It may include 'high' characters, - in whatever encoding the engine was using. - - May raise GtpChannelError. In particular, raises GtpProtocolError if the - success/failure indicator can't be read from the engine's response. - - """ - result = self.get_response_impl() - if self.log_dest is not None: - is_error, response = result - if is_error: - response = "? " + response - else: - response = "= " + response - self._log("<< ", response.rstrip()) - return result - - # For subclasses to override: - - def close(self): - """Close the command and response channels. - - Channel implementations may use this to clean up resources associated - with the engine (eg, to terminate a subprocess). - - Raises GtpTransportError if a serious error is detected while doing this - (this is unlikely in practice). - - When it is meaningful (eg, for subprocess channels) this waits for the - engine to exit. Nonzero exit status is not considered a serious error. - - """ - pass - - def send_command_impl(self, command, arguments): - raise NotImplementedError - - def get_response_impl(self): - raise NotImplementedError - - -class Internal_gtp_channel(Gtp_channel): - """A GTP channel connected to an in-process Python GTP engine. - - Instantiate with a Gtp_engine_protocol object. - - This waits to invoke the engine's handler for each command until the - correponding response is requested. - - """ - def __init__(self, engine): - Gtp_channel.__init__(self) - self.engine = engine - self.outstanding_commands = [] - self.session_is_ended = False - - def send_command_impl(self, command, arguments): - if self.session_is_ended: - raise GtpChannelClosed("engine has ended the session") - self.outstanding_commands.append((command, arguments)) - - def get_response_impl(self): - if self.session_is_ended: - raise GtpChannelClosed("engine has ended the session") - try: - command, arguments = self.outstanding_commands.pop(0) - except IndexError: - raise GtpChannelError("no outstanding commands") - is_error, response, end_session = \ - self.engine.run_command(command, arguments) - if end_session: - self.session_is_ended = True - return is_error, response - - -class Linebased_gtp_channel(Gtp_channel): - """Generic Gtp_channel based on line-by-line communication.""" - - def __init__(self): - Gtp_channel.__init__(self) - self.is_first_response = True - - # Not using command ids; I don't see the need unless we see problems in - # practice with engines getting out of sync. - - def send_command_impl(self, command, arguments): - words = [command] + arguments - self.send_command_line(" ".join(words) + "\n") - - def get_response_impl(self): - """Obtain response according to GTP protocol. - - If we receive EOF before any data, we raise GtpChannelClosed. - - If we receive EOF otherwise, we use the data received anyway. - - The first time this is called, we check the first byte without reading - the whole line, and raise GtpProtocolError if it isn't plausibly the - start of a GTP response (strictly, if it's a control character we should - just discard it, but I think it's more useful to reject them here; in - particular, this lets us detect GMP). - - """ - lines = [] - seen_data = False - peeked_byte = None - if self.is_first_response: - self.is_first_response = False - # We read one byte first so that we don't hang if the engine never - # sends a newline (eg, it's speaking GMP). - try: - peeked_byte = self.get_response_byte() - except NotImplementedError: - pass - else: - if peeked_byte == "": - raise GtpChannelClosed( - "engine has closed the response channel") - if peeked_byte == "\x01": - raise GtpProtocolError( - "engine appears to be speaking GMP, not GTP!") - # These are the characters which could legitimately start a GTP - # response. In principle, we should be discarding other controls - # rather than treating them as errors, but it's more useful to - # report a protocol error. - if peeked_byte not in (' ', '\t', '\r', '\n', '#', '=', '?'): - raise GtpProtocolError( - "engine isn't speaking GTP: " - "first byte is %s" % repr(peeked_byte)) - if peeked_byte == "\n": - peeked_byte = None - while True: - s = self.get_response_line() - if peeked_byte: - s = peeked_byte + s - peeked_byte = None - # << All other [than HT, CR, LF] control characters must be - # discarded on input >> - # << Any occurence of a CR character must be discarded on input >> - s = _remove_response_controls_re.sub("", s) - # << Empty lines and lines with only whitespace sent by the engine - # and occuring outside a response must be ignored by the - # controller >> - if not seen_data: - if s.strip() == "": - if s.endswith("\n"): - continue - else: - break - else: - seen_data = True - if s == "\n": - break - lines.append(s) - if not s.endswith("\n"): - break - if not lines: - # Means 'EOF and empty response' - raise GtpChannelClosed("engine has closed the response channel") - first_line = lines[0] - # It's certain that first line isn't empty - if first_line[0] == "?": - is_error = True - elif first_line[0] == "=": - is_error = False - else: - raise GtpProtocolError( - "no success/failure indication from engine: " - "first line is `%s`" % first_line.rstrip()) - lines[0] = first_line[1:].lstrip(" \t") - response = "".join(lines).rstrip() - response = response.replace("\t", " ") - return is_error, response - - - # For subclasses to override: - - def send_command_line(self, command): - """Send a line of text over the channel. - - command -- string terminated by a newline. - - May raise GtpChannelClosed or GtpTransportError - - """ - raise NotImplementedError - - def get_response_line(self): - """Read a line of text from the channel. - - May raise GtpTransportError - - The result ends in a newline unless end-of-file was seen (ie, the same - protocol to indicate end-of-file as Python's readline()). - - This blocks until a line is available, or end-of-file is reached. - - """ - raise NotImplementedError - - def get_response_byte(self): - """Read a single byte from the channel. - - May raise GtpTransportError - - This blocks until a byte is available, or end-of-file is reached. - - Subclasses don't have to implement this. - - """ - raise NotImplementedError - - -def permit_sigpipe(): - signal.signal(signal.SIGPIPE, signal.SIG_DFL) - -class Subprocess_gtp_channel(Linebased_gtp_channel): - """A GTP channel to a subprocess. - - Instantiate with - command -- list of strings (as for subprocess.Popen) - stderr -- destination for standard error output (optional) - cwd -- working directory to change to (optional) - env -- new environment (optional) - Instantiation will raise GtpChannelError if the process can't be started. - - This starts the subprocess and speaks GTP over its standard input and - output. - - By default, the subprocess's standard error is left as the standard error of - the calling process. The 'stderr' parameter is interpreted as for - subprocess.Popen (but don't set it to STDOUT or PIPE). - - The 'cwd' and 'env' parameters are interpreted as for subprocess.Popen. - - Closing the channel waits for the subprocess to exit. - - """ - def __init__(self, command, stderr=None, cwd=None, env=None): - Linebased_gtp_channel.__init__(self) - try: - p = subprocess.Popen( - command, - preexec_fn=permit_sigpipe, close_fds=True, - stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=stderr, cwd=cwd, env=env) - except EnvironmentError, e: - raise GtpChannelError(str(e)) - self.subprocess = p - self.command_pipe = p.stdin - self.response_pipe = p.stdout - - def send_command_line(self, command): - try: - self.command_pipe.write(command) - self.command_pipe.flush() - except EnvironmentError, e: - if e.errno == errno.EPIPE: - raise GtpChannelClosed("engine has closed the command channel") - else: - raise GtpTransportError(str(e)) - - def get_response_line(self): - try: - return self.response_pipe.readline() - except EnvironmentError, e: - raise GtpTransportError(str(e)) - - def get_response_byte(self): - try: - return self.response_pipe.read(1) - except EnvironmentError, e: - raise GtpTransportError(str(e)) - - def close(self): - # Errors from closing pipes or wait4() are unlikely, but possible. - - # Ideally would give up waiting after a while and forcibly terminate the - # subprocess. - errors = [] - try: - self.command_pipe.close() - except EnvironmentError, e: - errors.append("error closing command pipe:\n%s" % e) - try: - self.response_pipe.close() - except EnvironmentError, e: - errors.append("error closing response pipe:\n%s" % e) - errors.append(str(e)) - try: - # We don't really care about the exit status, but we do want to be - # sure it isn't still running. - # Even if there were errors closing the pipes, it's most likely that - # the subprocesses has exited. - pid, exit_status, rusage = os.wait4(self.subprocess.pid, 0) - self.exit_status = exit_status - self.resource_usage = rusage - except EnvironmentError, e: - errors.append(str(e)) - if errors: - raise GtpTransportError("\n".join(errors)) - - -class Gtp_controller(object): - """Implementation of the controller side of the GTP protocol. - - This communicates with a single engine. It's a higher level interface than - Gtp_channel, including helper functions for the protocol-level GTP commands. - - Public attributes: - channel -- the underlying Gtp_channel - name -- the channel name (used in error messages) - channel_is_closed -- bool - channel_is_bad -- bool - - It's ok to access the underlying channel directly (eg, to enable logging). - - Instantiate with channel and name. - - """ - def __init__(self, channel, name): - self.channel = channel - self.name = str(name) - self.known_commands = {} - self.log_dest = None - self.gtp_aliases = {} - self.is_first_command = True - self.errors_seen = [] - self.channel_is_closed = False - self.channel_is_bad = False - - def do_command(self, command, *arguments): - """Send a command to the engine and return the response. - - command -- string (command name) - arguments -- strings or unicode objects - - Arguments may not contain spaces. If a command is documented as - expecting a list of vertices, each vertex must be passed as a separate - argument. - - Arguments may be unicode objects, in which case they will be sent as - utf-8. - - - Returns the result text from the engine as an 8-bit string with no - trailing whitespace. It may contain newlines, but there are no empty - lines except perhaps the first. There is no leading whitespace on the - first line. There are no other control characters. It may include 'high' - characters, in whatever encoding the engine was using. (The result text - doesn't include the leading =[id] bit.) - - If the engine returns a failure response, raises BadGtpResponse (use the - gtp_error_message attribute to retrieve the text of the response). - - This will wait indefinitely for the engine to produce the response. - - - Raises GtpChannelClosed if the engine has apparently closed its - connection. - - Raises GtpProtocolError if the engine's response is too mangled to be - returned. - - Raises GtpTransportError if there was an error from the communication - layer between the controller and the engine (which may well mean that - the engine has gone away). - - If any of these GtpChannelError variants is raised, this also marks the - channel as 'bad' (this has no effect on future do_command() calls, but - see safe_do_command() below). - - - This applies gtp_aliases (see below). Error messages (including - BadGtpResponse.gtp_command) will refer to the underlying command, not - the alias. - - """ - if self.channel_is_closed: - raise StandardError("channel is closed") - - def fix_argument(argument): - if isinstance(argument, unicode): - return argument.encode("utf-8") - else: - return argument - - fixed_command = fix_argument(command) - fixed_arguments = map(fix_argument, arguments) - translated_command = self.gtp_aliases.get(fixed_command, fixed_command) - is_first_command = self.is_first_command - self.is_first_command = False - - def format_command(): - desc = "%s" % (" ".join([translated_command] + fixed_arguments)) - if is_first_command: - return "first command (%s)" % desc - else: - return "'%s'" % desc - - try: - is_sending = True - self.channel.send_command(translated_command, fixed_arguments) - is_sending = False - is_failure, response = self.channel.get_response() - except GtpChannelError, e: - self.channel_is_bad = True - if isinstance(e, GtpTransportError): - error_label = "transport error" - elif isinstance(e, GtpProtocolError): - error_label = "GTP protocol error" - else: - error_label = "error" - if is_sending: - msg = "%s sending %s to %s:\n%s" - else: - msg = "%s reading response to %s from %s:\n%s" - e.args = (msg % (error_label, format_command(), self.name, e),) - raise - if is_failure: - raise BadGtpResponse( - "failure response from %s to %s:\n%s" % - (format_command(), self.name, response), - gtp_command=translated_command, gtp_arguments=fixed_arguments, - gtp_error_message=response) - return response - - def _known_command(self, command, do_command): - """Common implementation for known_command and safe_known_command.""" - result = self.known_commands.get(command) - if result is not None: - return result - translated_command = self.gtp_aliases.get(command, command) - try: - response = do_command("known_command", translated_command) - except BadGtpResponse: - known = False - else: - known = (response == 'true') - self.known_commands[command] = known - return known - - def known_command(self, command): - """Check whether 'command' is known by the engine. - - This sends 'known_command' the first time it's asked, then caches the - result. - - If known_command fails, returns False. - - May propagate GtpChannelError (see do_command). - - This does the right thing if gtp aliases have been set (but it doesn't - invalidate the cache if they're changed). - - """ - return self._known_command(command, self.do_command) - - def check_protocol_version(self): - """Check the engine's declared protocol version. - - Raises BadGtpResponse if the engine declares a version other than 2. - Otherwise does nothing. - - If the engine returns a GTP failure response (in particular, if - protocol_version isn't implemented), this does nothing. - - May propagate GtpChannelError (see do_command). - - """ - try: - protocol_version = self.do_command("protocol_version") - except BadGtpResponse: - return - if protocol_version != "2": - raise BadGtpResponse( - "%s reports GTP protocol version %s" % - (self.name, protocol_version)) - - def list_commands(self): - """Return the engine's declared command list. - - Returns a list of nonempty strings without leading or trailing - whitespace. Filters out strings which wouldn't be accepted as commands. - - May propagate GtpChannelError or BadGtpResponse - - """ - response = self.do_command('list_commands') - stripped = [s for s in - (t.strip() for t in response.split("\n"))] - return [s for s in stripped if is_well_formed_gtp_word(s)] - - def close(self): - """Close the communication channel to the engine. - - May propagate GtpTransportError. - - Unless you have a good reason, you should send 'quit' before closing the - connection (eg, by using safe_close() instead of close()). - - When it is meaningful (eg, for subprocess channels) this waits for the - engine to exit. Nonzero exit status is not considered an error. - - """ - if self.channel_is_closed: - raise StandardError("channel is closed") - try: - self.channel.close() - except GtpTransportError, e: - raise GtpTransportError( - "error closing %s:\n%s" % (self.name, e)) - self.channel_is_closed = True - - def safe_do_command(self, command, *arguments): - """Variant of do_command which sets low-level exceptions aside. - - If the channel is closed or marked bad, this does not attempt to send - the command, and returns None. - - If GtpChannelError is raised while running the command, it is not - propagated, but the error message is recorded; use - retrieve_error_messages to retrieve these. In this case the function - returns None. - - BadGtpResponse is raised in the same way as for do_command. - - """ - if self.channel_is_bad or self.channel_is_closed: - return None - try: - return self.do_command(command, *arguments) - except BadGtpResponse, e: - raise - except GtpChannelError, e: - self.errors_seen.append(str(e)) - return None - - def safe_known_command(self, command): - """Variant of known_command which sets low-level exceptions aside. - - If result is already cached, returns it. - - Otherwise, if the channel is closed or marked bad, returns False. - - Otherwise acts like known_command above, using safe_do_command to send - the command to the engine. - - """ - return self._known_command(command, self.safe_do_command) - - def safe_close(self): - """Close the communication channel to the engine, avoiding exceptions. - - This is safe to call even if the channel is already closed, or has had - protocol or transport errors. - - This will not propagate any exceptions; it will set them aside like - safe_do_command. - - When it is meaningful (eg, for subprocess channels) this waits for the - engine to exit. Nonzero exit status is not reported as an error. - - - This will send 'quit' to the engine if the channel is not marked as bad. - Any failure response will be set aside. - - """ - if self.channel_is_closed: - return - if not self.channel_is_bad: - try: - self.safe_do_command("quit") - except BadGtpResponse, e: - self.errors_seen.append(str(e)) - try: - self.channel.close() - except GtpTransportError, e: - self.errors_seen.append("error closing %s:\n%s" % (self.name, e)) - self.channel_is_closed = True - - def retrieve_error_messages(self): - """Return error messages which have been set aside by 'safe' commands. - - Returns a list of strings (empty if there are no such messages). - - """ - return self.errors_seen[:] - - - def set_gtp_aliases(self, aliases): - """Set GTP command aliases. - - aliases -- map public command name -> underlying command name - - In future calls to do_command, a request to send 'public command name' - will be sent to the underlying channel as the corresponding 'underlying - command name'. - - """ - self.gtp_aliases = aliases - - -def _fix_version(name, version): - """Clean up version strings.""" - version = sanitise_utf8(version) - if version.lower().startswith(name.lower()): - version = version[len(name):].lstrip() - # Some engines unfortunately include usage instructions in the version - # string (apparently for the sake of kgsGTP); try to clean this up. - if len(version) > 64: - # MoGo - a, b, c = version.partition(". Please read http:") - if b: - return a - # Pachi - a, b, c = version.partition(": I'm playing") - if b: - return a - # Other - return version.split()[0] - return version - -def describe_engine(controller, default="unknown"): - """Retrieve a description of a controller's engine via GTP. - - default -- text to use for the description if all GTP commands fail. - - This uses the 'name', 'version', and 'gomill-describe_engine' commands. - - Returns a pair of utf-8 strings (short, long): - short -- single-line form (engine name, and version if it's not too long) - long -- multi-line form (engine name, version, description) - - Attempts to clean up over-long version strings. - - May propagate GtpChannelError. - - """ - try: - name = sanitise_utf8(controller.do_command("name")) - except BadGtpResponse: - name = default - try: - version = _fix_version(name, controller.do_command("version")) - if version: - if len(version) <= 32: - short_s = name + ":" + version - else: - short_s = name - long_s = name + ":" + version - else: - long_s = short_s = name - except BadGtpResponse: - long_s = short_s = name - - if controller.known_command("gomill-describe_engine"): - try: - long_s = sanitise_utf8( - controller.do_command("gomill-describe_engine")) - except BadGtpResponse: - pass - return short_s, long_s diff --git a/gomill/gtp_engine.py b/gomill/gtp_engine.py deleted file mode 100644 index 7f73e11..0000000 --- a/gomill/gtp_engine.py +++ /dev/null @@ -1,532 +0,0 @@ -"""Go Text Protocol support (engine side). - -Based on GTP 'draft version 2' (see ), -and gnugo 3.7 as 'reference implementation'. - -""" - -import errno -import re -import sys -import os - -from gomill.common import * -from gomill.utils import isinf, isnan -from gomill import compact_tracebacks - - -class GtpError(StandardError): - """Error reported by a command handler.""" - -class GtpFatalError(GtpError): - """Fatal error reported by a command handler.""" - -class GtpQuit(Exception): - """Request to end session from a command handler.""" - - - - -### Handler support - -def interpret_boolean(arg): - """Interpret a string representing a boolean, as specified by GTP. - - Returns a Python bool. - - Raises GtpError with an appropriate message if 'arg' isn't a valid GTP - boolean specification. - - """ - try: - return {'true': True, 'false': False}[arg] - except KeyError: - raise GtpError("invalid boolean: '%s'" % arg) - -def interpret_colour(arg): - """Interpret a string representing a colour, as specified by GTP. - - Returns 'b' or 'w'. - - Raises GtpError with an appropriate message if 'arg' isn't a valid GTP - colour specification. - - """ - try: - return {'w': 'w', 'white': 'w', 'b': 'b', 'black': 'b'}[arg.lower()] - except KeyError: - raise GtpError("invalid colour: '%s'" % arg) - -def interpret_vertex(arg, board_size): - """Interpret a string representing a vertex, as specified by GTP. - - Returns a pair of coordinates (row, col) in range(0, board_size), - or None for a pass. - - Raises GtpError with an appropriate message if 'arg' isn't a valid GTP - vertex specification for a board of size 'board_size'. - - """ - try: - return move_from_vertex(arg, board_size) - except ValueError, e: - raise GtpError(str(e)) - - -_gtp_int_max = 2**31-1 - -def interpret_int(arg): - """Interpret a string representing an int, as specified by GTP. - - Returns a Python int. - - Raises GtpError with an appropriate message if 'arg' isn't a valid GTP - int specification. - - Negative numbers are returned as -1. Numbers above 2**31-1 are returned as - 2**31-1. - - """ - # I can't tell how gnugo treats negative numbers, except that it counts them - # as integers not in a suitable range for boardsize. The clipping of high - # integers is what it does for command ids. - try: - result = int(arg, 10) - except ValueError: - raise GtpError("invalid int: '%s'" % arg) - if result < 0: - result = -1 - elif result > _gtp_int_max: - result = _gtp_int_max - return result - -def interpret_float(arg): - """Interpret a string representing a float, as specified by GTP. - - Returns a Python float. - - Raises GtpError with an appropriate message if 'arg' isn't a valid GTP - float specification. - - Accepts strings accepted as a float by the platform libc; rejects - infinities and NaNs. - - """ - try: - result = float(arg) - if isinf(result) or isnan(result): - raise ValueError - except ValueError: - raise GtpError("invalid float: '%s'" % arg) - return result - -def format_gtp_boolean(b): - """Format a Python bool in GTP format.""" - if b: - return "true" - else: - return "false" - -def report_bad_arguments(): - """Raise GtpError with a suitable message for invalid arguments. - - Note that gnugo (3.7) seems to ignore extra arguments in practice; it's - supposed to be the reference implementation, so perhaps you should do the - same. - - """ - raise GtpError("invalid arguments") - - - -### Parsing - -_remove_controls_re = re.compile(r"[\x00-\x08\x0a-\x1f\x7f]") -_remove_response_controls_re = re.compile(r"[\x00-\x08\x0b-\x1f\x7f]") -_normalise_whitespace_re = re.compile(r"[\x09\x20]+") -_command_id_re = re.compile(r"^-?[0-9]+") - -def _preprocess_line(s): - """Clean up an input line and normalise whitespace.""" - s = s.partition("#")[0] - s = _remove_controls_re.sub("", s) - s = _normalise_whitespace_re.sub(" ", s) - return s - -def _clean_response(response): - """Clean up a proposed response.""" - if response is None: - return "" - if isinstance(response, unicode): - s = response.encode("utf-8") - else: - s = str(response) - s = s.rstrip() - s = s.replace("\n\n", "\n.\n") - s = _remove_response_controls_re.sub("", s) - s = s.replace("\t", " ") - return s - -def _parse_line(line): - """Parse a nonempty input line. - - Returns a tuple (command_id, command, arguments) - command_id -- string - command -- string - arguments -- list of strings - - Returns command None if the line is to be treated as empty after all. - - Behaviour in error cases is copied from gnugo 3.7. - - """ - tokens = line.split() - s = tokens[0] - command_id = None - id_match = _command_id_re.match(s) - if id_match: - command = s[id_match.end():] - if command == "": - try: - command = tokens[1] - except IndexError: - command = None - args = tokens[2:] - else: - args = tokens[1:] - command_id = id_match.group() - int_command_id = int(command_id) - if int_command_id < 0: - command_id = None - elif int_command_id > _gtp_int_max: - command_id = str(_gtp_int_max) - else: - command_id = None - command = s - args = tokens[1:] - return command_id, command, args - - -class Gtp_engine_protocol(object): - """Implementation of the engine side of the GTP protocol. - - Sample use: - e = Gtp_engine_protocol() - e.add_protocol_commands() - e.add_command('foo', foo_handler) - response, end_session = e.handle_line('foo w d5') - - - GTP commands are dispatched to _handler functions_. These can by any Python - callable. The handler function is passed a single parameter, which is a list - of strings representing the command's arguments (nonempty strings of - printable non-whitespace characters). - - The handler should return the response to sent to the controller. You can - use either None or the empty string for an empty response. If the returned - value isn't suitable to be used directly as a GTP response, it will be - 'cleaned up' so that it can be. Unicode objects will be encoded as utf-8. - - To return a failure response, the handler should raise GtpError with an - appropriate message. - - To end the session, the handler should raise GtpQuit or GtpFatalError. Any - exception message will be reported, as a success or failure response - respectively. - - If a handler raises another exception (instance of Exception), this will be - reported as 'internal error', followed by the exception description and - traceback. By default, this is not treated as a fatal error; use - set_handler_exceptions_fatal() to change this. - - """ - - def __init__(self): - self.handlers = {} - self.handler_exceptions_are_fatal = False - - def set_handler_exceptions_fatal(self, b=True): - """Treat exceptions from handlers as fatal errors.""" - self.handler_exceptions_are_fatal = bool(b) - - def add_command(self, command, handler): - """Register the handler function for a command.""" - self.handlers[command] = handler - - def add_commands(self, handlers): - """Register multiple handler functions. - - handlers -- dict command name -> handler - - """ - self.handlers.update(handlers) - - def remove_command(self, command): - """Remove a registered handler function. - - Silently does nothing if no handler was registered for the command. - - """ - try: - del self.handlers[command] - except KeyError: - pass - - def list_commands(self): - """Return a list of known commands.""" - return sorted(self.handlers) - - def _do_command(self, command, args): - try: - handler = self.handlers[command] - except KeyError: - raise GtpError("unknown command") - try: - return handler(args) - except (GtpError, GtpQuit): - raise - except Exception: - traceback = compact_tracebacks.format_traceback(skip=1) - if self.handler_exceptions_are_fatal: - raise GtpFatalError("internal error; exiting\n" + traceback) - else: - raise GtpError("internal error\n" + traceback) - - def run_command(self, command, args): - """Run the handler for a command directly. - - You can use this from Python code to interact with a GTP engine without - going via the GTP line-based syntax. - - command -- string (command name) - arguments -- list of strings (or None) - - Returns a tuple (is_error, response, end_session) - - is_error -- bool - response -- the GTP response - end_session -- bool - - The response is a string, not ending with a newline (or any other - whitespace). - - If end_session is true, the engine doesn't want to receive any more - commands. - - """ - try: - response = self._do_command(command, args) - except GtpQuit, e: - is_error = False - response = e - end_session = True - except GtpFatalError, e: - is_error = True - response = str(e) - if response == "": - response = "unspecified fatal error" - end_session = True - except GtpError, e: - is_error = True - response = str(e) - if response == "": - response = "unspecified error" - end_session = False - else: - is_error = False - end_session = False - return is_error, _clean_response(response), end_session - - def handle_line(self, line): - """Handle a line of input. - - line -- 8-bit string containing one line of input. - - The line may or may not contain the terminating newline. Any internal - newline is discarded. - - Returns a pair (response, end_session) - - response -- the GTP response to be sent to the controller - end_session -- bool - - response is normally a string containing a well-formed GTP response - (ending with '\n\n'). It may also be None, in which case nothing at all - should be sent to the controller. - - If end_session is true, the GTP session should be terminated. - - """ - normalised = _preprocess_line(line) - if normalised == "" or normalised == " ": - return None, False - command_id, command, args = _parse_line(normalised) - if command is None: - # Line with only a command id - return None, False - is_error, cleaned_response, end_session = \ - self.run_command(command, args) - if is_error: - response_code = "?" - else: - response_code = "=" - if command_id is not None: - response_prefix = response_code + command_id - else: - response_prefix = response_code - if cleaned_response == "": - response_sep = "" - else: - response_sep = " " - response = "%s%s%s\n\n" % ( - response_prefix, response_sep, cleaned_response) - return response, end_session - - def handle_known_command(self, args): - # Imitating gnugo's behaviour for bad args - try: - result = (args[0] in self.handlers) - except IndexError: - result = False - return format_gtp_boolean(result) - - def handle_list_commands(self, args): - # Gnugo ignores any arguments - return "\n".join(self.list_commands()) - - def handle_protocol_version(self, args): - # Gnugo ignores any arguments - return "2" - - def handle_quit(self, args): - # Gnugo ignores any arguments - raise GtpQuit - - def add_protocol_commands(self): - """Add the standard protocol-level commands. - - These are the commands which can be handled without reference to the - underlying engine: - known_command - list_commands - protocol_version - quit - - """ - self.add_command("known_command", self.handle_known_command) - self.add_command("list_commands", self.handle_list_commands) - self.add_command("protocol_version", self.handle_protocol_version) - self.add_command("quit", self.handle_quit) - - - -### Session loop - -class ControllerDisconnected(IOError): - """The GTP controller went away.""" - -def _run_gtp_session(engine, read, write): - while True: - try: - line = read() - except EOFError: - break - response, end_session = engine.handle_line(line) - if response is not None: - try: - write(response) - except IOError, e: - if e.errno == errno.EPIPE: - raise ControllerDisconnected(*e.args) - else: - raise - if end_session: - break - -def run_gtp_session(engine, src, dst): - """Run a GTP engine session using 'src' and 'dst' for the controller. - - engine -- Gtp_engine_protocol object - src -- readable file-like object - dst -- writeable file-like object - - Returns either when EOF is seen on src, or when the engine signals end of - session. - - If a write fails with 'broken pipe', this raises ControllerDisconnected. - - """ - def read(): - line = src.readline() - if line == "": - raise EOFError - return line - def write(s): - dst.write(s) - dst.flush() - _run_gtp_session(engine, read, write) - -def make_readline_completer(engine): - """Return a readline completer function for the specified engine.""" - commands = engine.list_commands() - def completer(text, state): - matches = [s for s in commands if s.startswith(text)] - try: - return matches[state] + " " - except IndexError: - return None - return completer - -def run_interactive_gtp_session(engine): - """Run a GTP engine session on stdin and stdout, using readline. - - engine -- Gtp_engine_protocol object - - This enables readline tab-expansion, and command history in - ~/.gomill-gtp-history (if readline is available). - - Returns either when EOF is seen on stdin, or when the engine signals end of - session. - - If stdin isn't a terminal, this is equivalent to run_gtp_session. - - If a write fails with 'broken pipe', this raises ControllerDisconnected. - - Note that this will propagate KeyboardInterrupt if the user presses ^C; - normally you'll want to handle this to avoid an ugly traceback. - - """ - # readline doesn't do anything if stdin isn't a tty, but it's simplest to - # just not import it in that case. - try: - use_readline = os.isatty(sys.stdin.fileno()) - if use_readline: - import readline - except Exception: - use_readline = False - if not use_readline: - run_gtp_session(engine, sys.stdin, sys.stdout) - return - - def write(s): - sys.stdout.write(s) - sys.stdout.flush() - - history_pathname = os.path.expanduser("~/.gomill-gtp-history") - readline.parse_and_bind("tab: complete") - old_completer = readline.get_completer() - old_delims = readline.get_completer_delims() - readline.set_completer(make_readline_completer(engine)) - readline.set_completer_delims("") - try: - readline.read_history_file(history_pathname) - except EnvironmentError: - pass - _run_gtp_session(engine, raw_input, write) - try: - readline.write_history_file(history_pathname) - except EnvironmentError: - pass - readline.set_completer(old_completer) - readline.set_completer_delims(old_delims) - diff --git a/gomill/gtp_games.py b/gomill/gtp_games.py deleted file mode 100644 index 025c6da..0000000 --- a/gomill/gtp_games.py +++ /dev/null @@ -1,817 +0,0 @@ -"""Run a game between two GTP engines.""" - -from gomill import __version__ -from gomill.utils import * -from gomill.common import * -from gomill import gtp_controller -from gomill import handicap_layout -from gomill import boards -from gomill import sgf -from gomill.gtp_controller import BadGtpResponse, GtpChannelError - -class Game_result(object): - """Description of a game result. - - Public attributes: - players -- map colour -> player code - player_b -- player code - player_w -- player code - winning_player -- player code or None - losing_player -- player code or None - winning_colour -- 'b', 'w', or None - losing_colour -- 'b', 'w', or None - is_jigo -- bool - is_forfeit -- bool - sgf_result -- string describing the game's result (for sgf RE) - detail -- additional information (string or None) - game_id -- string or None - cpu_times -- map player code -> float or None or '?'. - - Winning/losing colour and player are None for a jigo, unknown result, or - void game. - - cpu_times are user time + system time. '?' means that gomill-cpu_time gave - an error. - - Game_results are suitable for pickling. - - """ - def __init__(self, players, winning_colour): - self.players = players.copy() - self.player_b = players['b'] - self.player_w = players['w'] - self.winning_colour = winning_colour - self.winning_player = players.get(winning_colour) - self.is_jigo = False - self.is_forfeit = False - self.game_id = None - if winning_colour is None: - self.sgf_result = "?" - else: - self.sgf_result = "%s+" % winning_colour.upper() - self.detail = None - self.cpu_times = {self.player_b : None, self.player_w : None} - - def __getstate__(self): - return ( - self.player_b, - self.player_w, - self.winning_colour, - self.sgf_result, - self.detail, - self.is_forfeit, - self.game_id, - self.cpu_times, - ) - - def __setstate__(self, state): - (self.player_b, - self.player_w, - self.winning_colour, - self.sgf_result, - self.detail, - self.is_forfeit, - self.game_id, - self.cpu_times, - ) = state - self.players = {'b' : self.player_b, 'w' : self.player_w} - self.winning_player = self.players.get(self.winning_colour) - self.is_jigo = (self.sgf_result == "0") - - def set_jigo(self): - self.sgf_result = "0" - self.is_jigo = True - - @property - def losing_colour(self): - if self.winning_colour is None: - return None - return opponent_of(self.winning_colour) - - @property - def losing_player(self): - if self.winning_colour is None: - return None - return self.players.get(opponent_of(self.winning_colour)) - - def describe(self): - """Return a short human-readable description of the result.""" - if self.winning_colour is not None: - s = "%s beat %s " % (self.winning_player, self.losing_player) - else: - s = "%s vs %s " % (self.players['b'], self.players['w']) - if self.is_jigo: - s += "jigo" - else: - s += self.sgf_result - if self.detail is not None: - s += " (%s)" % self.detail - return s - - def __repr__(self): - return "" % self.describe() - - -class Game(object): - """A single game between two GTP engines. - - Instantiate with: - board_size -- int - komi -- float (default 0.0) - move_limit -- int (default 1000) - - The 'commands' values are lists of strings, as for subprocess.Popen. - - Normal use: - - game = Game(...) - game.set_player_code('b', ...) - game.set_player_code('w', ...) - game.use_internal_scorer() or game.allow_scorer(...) [optional] - game.set_move_callback...() [optional] - game.set_player_subprocess('b', ...) or set_player_controller('b', ...) - game.set_player_subprocess('w', ...) or set_player_controller('w', ...) - game.request_engine_descriptions() [optional] - game.ready() - game.set_handicap(...) [optional] - game.run() - game.close_players() - game.make_sgf() or game.write_sgf(...) [optional] - - then retrieve the Game_result and moves. - - If neither use_internal_scorer() nor allow_scorer() is called, the game - won't be scored. - - Public attributes for reading: - players -- map colour -> player code - game_id -- string or None - result -- Game_result (None before the game is complete) - moves -- list of tuples (colour, move, comment) - move is a pair (row, col), or None for a pass - player_scores -- map player code -> string or None - engine_names -- map player code -> string - engine_descriptions -- map player code -> string - - player_scores values are the response to the final_score GTP command (if the - player was asked). - - Methods which communicate with engines may raise BadGtpResponse if the - engine returns a failure response. - - Methods which communicate with engines will normally raise GtpChannelError - if there is trouble communicating with the engine. But after the game result - has been decided, they will set these errors aside; retrieve them with - describe_late_errors(). - - This enforces a simple ko rule, but no superko rule. It accepts self-capture - moves. - - """ - - def __init__(self, board_size, komi=0.0, move_limit=1000): - self.players = {'b' : 'b', 'w' : 'w'} - self.game_id = None - self.controllers = {} - self.claim_allowed = {'b' : False, 'w' : False} - self.after_move_callback = None - self.board_size = board_size - self.komi = komi - self.move_limit = move_limit - self.allowed_scorers = [] - self.internal_scorer = False - self.handicap_compensation = "no" - self.handicap = 0 - self.first_player = "b" - self.engine_names = {} - self.engine_descriptions = {} - self.moves = [] - self.player_scores = {'b' : None, 'w' : None} - self.additional_sgf_props = [] - self.late_errors = [] - self.handicap_stones = None - self.result = None - self.board = boards.Board(board_size) - self.simple_ko_point = None - - - ## Configuration methods (callable before set_player_...) - - def set_player_code(self, colour, player_code): - """Specify a player code. - - player_code -- short ascii string - - The player codes are used to identify the players in game results, sgf - files, and the error messages. - - Setting these is optional but strongly encouraged. If not explicitly - set, they will just be 'b' and 'w'. - - Raises ValueError if both players are given the same code. - - """ - s = str(player_code) - if self.players[opponent_of(colour)] == s: - raise ValueError("player codes must be distinct") - self.players[colour] = s - - def set_game_id(self, game_id): - """Specify a game id. - - game_id -- string - - The game id is reported in the game result, and used as a default game - name in the SGF file. - - If you don't set it, it will have value None. - - """ - self.game_id = str(game_id) - - def use_internal_scorer(self, handicap_compensation='no'): - """Set the scoring method to internal. - - The internal scorer uses area score, assuming all stones alive. - - handicap_compensation -- 'no' (default), 'short', or 'full'. - - If handicap_compensation is 'full', one point is deducted from Black's - score for each handicap stone; if handicap_compensation is 'short', one - point is deducted from Black's score for each handicap stone except the - first. (The number of handicap stones is taken from the parameter to - set_handicap().) - - """ - self.internal_scorer = True - if handicap_compensation not in ('no', 'short', 'full'): - raise ValueError("bad handicap_compensation value: %s" % - handicap_compensation) - self.handicap_compensation = handicap_compensation - - def allow_scorer(self, colour): - """Allow the specified player to score the game. - - If this is called for both colours, both are asked to score. - - """ - self.allowed_scorers.append(colour) - - def set_claim_allowed(self, colour, b=True): - """Allow the specified player to claim a win. - - This will have no effect if the engine doesn't implement - gomill-genmove_ex. - - """ - self.claim_allowed[colour] = bool(b) - - def set_move_callback(self, fn): - """Specify a callback function to be called after every move. - - This function is called after each move is played, including passes but - not resignations, and not moves which triggered a forfeit. - - It is passed three parameters: colour, move, board - move is a pair (row, col), or None for a pass - - Treat the board parameter as read-only. - - Exceptions raised from the callback will be propagated unchanged out of - run(). - - """ - self.after_move_callback = fn - - - ## Channel methods - - def set_player_controller(self, colour, controller, - check_protocol_version=True): - """Specify a player using a Gtp_controller. - - controller -- Gtp_controller - check_protocol_version -- bool (default True) - - By convention, the channel name should be 'player '. - - If check_protocol_version is true, rejects an engine that declares a - GTP protocol version <> 2. - - Propagates GtpChannelError if there's an error checking the protocol - version. - - """ - self.controllers[colour] = controller - if check_protocol_version: - controller.check_protocol_version() - - def set_player_subprocess(self, colour, command, - check_protocol_version=True, **kwargs): - """Specify the a player as a subprocess. - - command -- list of strings (as for subprocess.Popen) - check_protocol_version -- bool (default True) - - Additional keyword arguments are passed to the Subprocess_gtp_channel - constructor. - - If check_protocol_version is true, rejects an engine that declares a - GTP protocol version <> 2. - - Propagates GtpChannelError if there's an error creating the - subprocess or checking the protocol version. - - """ - try: - channel = gtp_controller.Subprocess_gtp_channel(command, **kwargs) - except GtpChannelError, e: - raise GtpChannelError( - "error starting subprocess for player %s:\n%s" % - (self.players[colour], e)) - controller = gtp_controller.Gtp_controller( - channel, "player %s" % self.players[colour]) - self.set_player_controller(colour, controller, check_protocol_version) - - def get_controller(self, colour): - """Return the underlying Gtp_controller for the specified engine.""" - return self.controllers[colour] - - def send_command(self, colour, command, *arguments): - """Send the specified GTP command to one of the players. - - colour -- player to talk to ('b' or 'w') - command -- gtp command name (string) - arguments -- gtp arguments (strings) - - Returns the response as a string. - - Raises BadGtpResponse if the engine returns a failure response. - - You can use this at any time between set_player_...() and - close_players(). - - """ - return self.controllers[colour].do_command(command, *arguments) - - def maybe_send_command(self, colour, command, *arguments): - """Send the specified GTP command, if supported. - - Variant of send_command(): if the command isn't supported by the - engine, or gives a failure response, returns None. - - """ - controller = self.controllers[colour] - if controller.known_command(command): - try: - result = controller.do_command(command, *arguments) - except BadGtpResponse: - result = None - else: - result = None - return result - - def known_command(self, colour, command): - """Check whether the specified GTP command is supported.""" - return self.controllers[colour].known_command(command) - - def close_players(self): - """Close both controllers (if they're open). - - Retrieves the late errors for describe_late_errors(). - - If cpu times are not already set in the game result, sets them from the - CPU usage of the engine subprocesses. - - """ - for colour in ("b", "w"): - controller = self.controllers.get(colour) - if controller is None: - continue - controller.safe_close() - self.late_errors += controller.retrieve_error_messages() - self.update_cpu_times_from_channels() - - - ## High-level methods - - def request_engine_descriptions(self): - """Obtain the engines' name, version, and description by GTP. - - After you have called this, you can retrieve the results from the - engine_names and engine_descriptions attributes. - - If this has been called, other methods will use the engine name and/or - description when appropriate (ie, call this if you want proper engine - names to appear in the SGF file). - - """ - for colour in "b", "w": - controller = self.controllers[colour] - player = self.players[colour] - short_s, long_s = gtp_controller.describe_engine(controller, player) - self.engine_names[player] = short_s - self.engine_descriptions[player] = long_s - - def ready(self): - """Reset the engines' GTP game state (board size, contents, komi).""" - for colour in "b", "w": - controller = self.controllers[colour] - controller.do_command("boardsize", str(self.board_size)) - controller.do_command("clear_board") - controller.do_command("komi", str(self.komi)) - - def set_handicap(self, handicap, is_free): - """Initialise the board position for a handicap. - - Raises ValueError if the number of stones isn't valid (see GTP spec). - - Raises BadGtpResponse if there's an invalid respone to - place_free_handicap or fixed_handicap. - - """ - if is_free: - max_points = handicap_layout.max_free_handicap_for_board_size( - self.board_size) - if not 2 <= handicap <= max_points: - raise ValueError - vertices = self.send_command( - "b", "place_free_handicap", str(handicap)) - try: - points = [move_from_vertex(vt, self.board_size) - for vt in vertices.split(" ")] - if None in points: - raise ValueError("response included 'pass'") - if len(set(points)) < len(points): - raise ValueError("duplicate point") - except ValueError, e: - raise BadGtpResponse( - "invalid response from place_free_handicap command " - "to %s: %s" % (self.players["b"], e)) - vertices = [format_vertex(point) for point in points] - self.send_command("w", "set_free_handicap", *vertices) - else: - # May propagate ValueError - points = handicap_layout.handicap_points(handicap, self.board_size) - for colour in "b", "w": - vertices = self.send_command( - colour, "fixed_handicap", str(handicap)) - try: - seen_points = [move_from_vertex(vt, self.board_size) - for vt in vertices.split(" ")] - if set(seen_points) != set(points): - raise ValueError - except ValueError: - raise BadGtpResponse( - "bad response from fixed_handicap command " - "to %s: %s" % (self.players[colour], vertices)) - self.board.apply_setup(points, [], []) - self.handicap = handicap - self.additional_sgf_props.append(('HA', handicap)) - self.handicap_stones = points - self.first_player = "w" - - def _forfeit_to(self, winner, msg): - self.winner = winner - self.forfeited = True - self.forfeit_reason = msg - - def _play_move(self, colour): - opponent = opponent_of(colour) - if (self.claim_allowed[colour] and - self.known_command(colour, "gomill-genmove_ex")): - genmove_command = ["gomill-genmove_ex", colour, "claim"] - may_claim = True - else: - genmove_command = ["genmove", colour] - may_claim = False - try: - move_s = self.send_command(colour, *genmove_command).lower() - except BadGtpResponse, e: - self._forfeit_to(opponent, str(e)) - return - if move_s == "resign": - self.winner = opponent - self.seen_resignation = True - return - if may_claim and move_s == "claim": - self.winner = colour - self.seen_claim = True - return - try: - move = move_from_vertex(move_s, self.board_size) - except ValueError: - self._forfeit_to(opponent, "%s attempted ill-formed move %s" % ( - self.players[colour], move_s)) - return - comment = self.maybe_send_command(colour, "gomill-explain_last_move") - comment = sanitise_utf8(comment) - if comment == "": - comment = None - if move is not None: - self.pass_count = 0 - if move == self.simple_ko_point: - self._forfeit_to( - opponent, "%s attempted move to ko-forbidden point %s" % ( - self.players[colour], move_s)) - return - row, col = move - try: - self.simple_ko_point = self.board.play(row, col, colour) - except ValueError: - self._forfeit_to( - opponent, "%s attempted move to occupied point %s" % ( - self.players[colour], move_s)) - return - else: - self.pass_count += 1 - self.simple_ko_point = None - try: - self.send_command(opponent, "play", colour, move_s) - except BadGtpResponse, e: - if e.gtp_error_message == "illegal move": - # we assume the move really was illegal, so 'colour' should lose - self._forfeit_to(opponent, "%s claims move %s is illegal" % ( - self.players[opponent], move_s)) - else: - self._forfeit_to(colour, str(e)) - return - self.moves.append((colour, move, comment)) - if self.after_move_callback: - self.after_move_callback(colour, move, self.board) - - def run(self): - """Run a complete game between the two players. - - Sets self.moves and self.result. - - Sets CPU times in the game result if available via GTP. - - """ - self.pass_count = 0 - self.winner = None - self.margin = None - self.scorers_disagreed = False - self.seen_resignation = False - self.seen_claim = False - self.forfeited = False - self.hit_move_limit = False - self.forfeit_reason = None - self.passed_out = False - player = self.first_player - move_count = 0 - while move_count < self.move_limit: - self._play_move(player) - if self.pass_count == 2: - self.passed_out = True - self.winner, self.margin, self.scorers_disagreed = \ - self._score_game() - break - if self.winner is not None: - break - player = opponent_of(player) - move_count += 1 - else: - self.hit_move_limit = True - self.calculate_result() - self.calculate_cpu_times() - - def fake_run(self, winner): - """Set state variables as if the game had been run (for testing). - - You don't need to use set_player_{subprocess,controller} to call this. - - winner -- 'b' or 'w' - - """ - self.winner = winner - self.seen_resignation = False - self.seen_claim = False - self.forfeited = False - self.hit_move_limit = False - self.forfeit_reason = None - self.passed_out = True - self.margin = True - self.scorers_disagreed = False - self.calculate_result() - - def _score_game(self): - is_disagreement = False - if self.internal_scorer: - score = self.board.area_score() - self.komi - if self.handicap: - if self.handicap_compensation == "full": - score -= self.handicap - elif self.handicap_compensation == "short": - score -= (self.handicap - 1) - if score > 0: - winner = "b" - margin = score - elif score < 0: - winner = "w" - margin = -score - else: - winner = None - margin = 0 - else: - winners = [] - margins = [] - for colour in self.allowed_scorers: - final_score = self.maybe_send_command(colour, "final_score") - if final_score is None: - continue - self.player_scores[colour] = final_score - final_score = final_score.upper() - if final_score == "0": - winners.append(None) - margins.append(0) - continue - if final_score.startswith("B+"): - winners.append("b") - elif final_score.startswith("W+"): - winners.append("w") - else: - continue - try: - margin = float(final_score[2:]) - if margin <= 0: - margin = None - except ValueError: - margin = None - margins.append(margin) - if len(set(winners)) == 1: - winner = winners[0] - if len(set(margins)) == 1: - margin = margins[0] - else: - margin = None - else: - if len(set(winners)) > 1: - is_disagreement = True - winner = None - margin = None - return winner, margin, is_disagreement - - def calculate_result(self): - """Set self.result. - - You shouldn't normally call this directly. - - """ - result = Game_result(self.players, self.winner) - result.game_id = self.game_id - if self.hit_move_limit: - result.sgf_result = "Void" - result.detail = "hit move limit" - elif self.seen_resignation: - result.sgf_result += "R" - elif self.seen_claim: - # Leave SGF result in form 'B+' - result.detail = "claim" - elif self.forfeited: - result.sgf_result += "F" - result.is_forfeit = True - result.detail = "forfeit: %s" % self.forfeit_reason - else: - assert self.passed_out - if self.winner is None: - if self.margin == 0: - result.set_jigo() - elif self.scorers_disagreed: - result.detail = "players disagreed" - else: - result.detail = "no score reported" - elif self.margin is not None: - result.sgf_result += format_float(self.margin) - else: - # Players returned something like 'B+?', - # or disagreed about the margin - # Leave SGF result in form 'B+' - result.detail = "unknown margin" - self.result = result - - def calculate_cpu_times(self): - """Set CPU times in self.result. - - You shouldn't normally call this directly. - - """ - # The ugliness with cpu_time '?' is to avoid using the cpu time reported - # by channel close() for engines which claim to support gomill-cpu_time - # but give an error. - for colour in ('b', 'w'): - cpu_time = None - controller = self.controllers[colour] - if controller.safe_known_command('gomill-cpu_time'): - try: - s = controller.safe_do_command('gomill-cpu_time') - cpu_time = float(s) - except (BadGtpResponse, ValueError, TypeError): - cpu_time = "?" - self.result.cpu_times[self.players[colour]] = cpu_time - - def update_cpu_times_from_channels(self): - """Set CPU times in self.result from the channel resource usage. - - There's normally no need to call this directly: close_players() will do - it. - - Has no effect if CPU times have already been set. - - """ - for colour in ('b', 'w'): - controller = self.controllers.get(colour) - if controller is None: - continue - ru = controller.channel.resource_usage - if (ru is not None and self.result is not None and - self.result.cpu_times[self.players[colour]] is None): - self.result.cpu_times[self.players[colour]] = \ - ru.ru_utime + ru.ru_stime - - def describe_late_errors(self): - """Retrieve the late error messages. - - Returns a string, or None if there were no late errors. - - This is only available after close_players() has been called. - - The late errors are low-level errors which occurred after the game - result was decided and so were set asied. In particular, they include - any errors from closing (including failure responses from the final - 'quit' command) - - """ - if not self.late_errors: - return None - return "\n".join(self.late_errors) - - def describe_scoring(self): - """Return a multiline string describing the game's scoring.""" - if self.result is None: - return "" - def normalise_score(s): - s = s.upper() - if s.endswith(".0"): - s = s[:-2] - return s - l = [self.result.describe()] - sgf_result = self.result.sgf_result - score_b = self.player_scores['b'] - score_w = self.player_scores['w'] - if ((score_b is not None and normalise_score(score_b) != sgf_result) or - (score_w is not None and normalise_score(score_w) != sgf_result)): - for colour, score in (('b', score_b), ('w', score_w)): - if score is not None: - l.append("%s final_score: %s" % - (self.players[colour], score)) - return "\n".join(l) - - def make_sgf(self, game_end_message=None): - """Return an SGF description of the game. - - Returns an Sgf_game object. - - game_end_message -- optional string to put in the final comment. - - If game_end_message is specified, it appears before the text describing - 'late errors'. - - """ - sgf_game = sgf.Sgf_game(self.board_size) - root = sgf_game.get_root() - root.set('KM', self.komi) - root.set('AP', ("gomill", __version__)) - for prop, value in self.additional_sgf_props: - root.set(prop, value) - sgf_game.set_date() - if self.engine_names: - root.set('PB', self.engine_names[self.players['b']]) - root.set('PW', self.engine_names[self.players['w']]) - if self.game_id: - root.set('GN', self.game_id) - if self.handicap_stones: - root.set_setup_stones(black=self.handicap_stones, white=[]) - for colour, move, comment in self.moves: - node = sgf_game.extend_main_sequence() - node.set_move(colour, move) - if comment is not None: - node.set("C", comment) - last_node = sgf_game.get_last_node() - if self.result is not None: - root.set('RE', self.result.sgf_result) - last_node.add_comment_text(self.describe_scoring()) - if game_end_message is not None: - last_node.add_comment_text(game_end_message) - late_error_messages = self.describe_late_errors() - if late_error_messages is not None: - last_node.add_comment_text(late_error_messages) - return sgf_game - - def write_sgf(self, pathname, game_end_message=None): - """Write an SGF description of the game to the specified pathname.""" - sgf_game = self.make_sgf(game_end_message) - f = open(pathname, "w") - f.write(sgf_game.serialise()) - f.close() - diff --git a/gomill/gtp_proxy.py b/gomill/gtp_proxy.py deleted file mode 100644 index 002fae8..0000000 --- a/gomill/gtp_proxy.py +++ /dev/null @@ -1,262 +0,0 @@ -"""Support for implementing proxy GTP engines. - -That is, engines which implement some or all of their commands by sending them -on to another engine (the _back end_). - -""" - -from gomill import gtp_controller -from gomill import gtp_engine -from gomill.gtp_controller import ( - BadGtpResponse, GtpChannelError, GtpChannelClosed) -from gomill.gtp_engine import GtpError, GtpQuit, GtpFatalError - - -class BackEndError(StandardError): - """Difficulty communicating with the back end. - - Public attributes: - cause -- Exception instance of an underlying exception (or None) - - """ - def __init__(self, args, cause=None): - StandardError.__init__(self, args) - self.cause = cause - -class Gtp_proxy(object): - """Manager for a GTP proxy engine. - - Public attributes: - engine -- Gtp_engine_protocol - controller -- Gtp_controller - - The 'engine' attribute is the proxy engine. Initially it supports all the - commands reported by the back end's 'list_commands'. You can add commands to - it in the usual way; new commands will override any commands with the same - names in the back end. - - The proxy engine also supports the following commands: - gomill-passthrough [args] ... - Run a command on the back end (use this to get at overridden commands, - or commands which don't appear in list_commands) - - If the proxy subprocess exits, this will be reported (as a transport error) - when the next command is sent. If you're using handle_command, it will - apropriately turn this into a fatal error. - - Sample use: - proxy = gtp_proxy.Gtp_proxy() - proxy.set_back_end_subprocess([, , ...]) - proxy.engine.add_command(...) - try: - proxy.run() - except KeyboardInterrupt: - sys.exit(1) - - The default 'quit' handler passes 'quit' on the back end and raises - GtpQuit. - - If you add a handler which you expect to cause the back end to exit (eg, by - sending it 'quit'), you should have call expect_back_end_exit() (and usually - also raise GtpQuit). - - If you want to hide one of the underlying commands, or don't want one of the - additional commands, just use engine.remove_command(). - - """ - def __init__(self): - self.controller = None - self.engine = None - - def _back_end_is_set(self): - return self.controller is not None - - def _make_back_end_handlers(self): - result = {} - for command in self.back_end_commands: - def handler(args, _command=command): - return self.handle_command(_command, args) - result[command] = handler - return result - - def _make_engine(self): - self.engine = gtp_engine.Gtp_engine_protocol() - self.engine.add_commands(self._make_back_end_handlers()) - self.engine.add_protocol_commands() - self.engine.add_commands({ - 'quit' : self.handle_quit, - 'gomill-passthrough' : self.handle_passthrough, - }) - - def set_back_end_controller(self, controller): - """Specify the back end using a Gtp_controller. - - controller -- Gtp_controller - - Raises BackEndError if it can't communicate with the back end. - - By convention, the controller's channel name should be "back end". - - """ - if self._back_end_is_set(): - raise StandardError("back end already set") - self.controller = controller - try: - self.back_end_commands = controller.list_commands() - except (GtpChannelError, BadGtpResponse), e: - raise BackEndError(str(e), cause=e) - self._make_engine() - - def set_back_end_subprocess(self, command, **kwargs): - """Specify the back end as a subprocess. - - command -- list of strings (as for subprocess.Popen) - - Additional keyword arguments are passed to the Subprocess_gtp_channel - constructor. - - Raises BackEndError if it can't communicate with the back end. - - """ - try: - channel = gtp_controller.Subprocess_gtp_channel(command, **kwargs) - except GtpChannelError, e: - # Probably means exec failure - raise BackEndError("can't launch back end command\n%s" % e, cause=e) - controller = gtp_controller.Gtp_controller(channel, "back end") - self.set_back_end_controller(controller) - - def close(self): - """Close the channel to the back end. - - It's safe to call this at any time after set_back_end_... (including - after receiving a BackEndError). - - It's not strictly necessary to call this if you're going to exit from - the parent process anyway, as that will naturally close the command - channel. But some engines don't behave well if you don't send 'quit', - so it's safest to close the proxy explicitly. - - This will send 'quit' if low-level errors have not previously been seen - on the channel, unless expect_back_end_exit() has been called. - - Errors (including failure responses to 'quit') are reported by raising - BackEndError. - - """ - if self.controller is None: - return - self.controller.safe_close() - late_errors = self.controller.retrieve_error_messages() - if late_errors: - raise BackEndError("\n".join(late_errors)) - - def run(self): - """Run a GTP session on stdin and stdout, using the proxy engine. - - This is provided for convenience; it's also ok to use the proxy engine - directly. - - Returns either when EOF is seen on stdin, or when a handler (such as the - default 'quit' handler) raises GtpQuit. - - Closes the channel to the back end before it returns. When it is - meaningful (eg, for subprocess channels) this waits for the back end to - exit. - - Propagates ControllerDisconnected if a pipe connected to stdout goes - away. - - """ - gtp_engine.run_interactive_gtp_session(self.engine) - self.close() - - def pass_command(self, command, args): - """Pass a command to the back end, and return its response. - - The response (or failure response) is unchanged, except for whitespace - normalisation. - - This passes the command to the back end even if it isn't included in the - back end's list_commands output; the back end will presumably return an - 'unknown command' error. - - Failure responses from the back end are reported by raising - BadGtpResponse. - - Low-level (ie, transport or protocol) errors are reported by raising - BackEndError. - - """ - if not self._back_end_is_set(): - raise StandardError("back end isn't set") - try: - return self.controller.do_command(command, *args) - except GtpChannelError, e: - raise BackEndError(str(e), cause=e) - - def handle_command(self, command, args): - """Run a command on the back end, from inside a GTP handler. - - This is a variant of pass_command, intended to be used directly in a - command handler. - - Failure responses from the back end are reported by raising GtpError. - - Low-level (ie, transport or protocol) errors are reported by raising - GtpFatalError. - - """ - try: - return self.pass_command(command, args) - except BadGtpResponse, e: - raise GtpError(e.gtp_error_message) - except BackEndError, e: - raise GtpFatalError(str(e)) - - def back_end_has_command(self, command): - """Say whether the back end supports the specified command. - - This uses known_command, not list_commands. It caches the results. - - Low-level (ie, transport or protocol) errors are reported by raising - BackEndError. - - """ - if not self._back_end_is_set(): - raise StandardError("back end isn't set") - try: - return self.controller.known_command(command) - except GtpChannelError, e: - raise BackEndError(str(e), cause=e) - - def expect_back_end_exit(self): - """Mark that the back end is expected to have exited. - - Call this from any handler which you expect to cause the back end to - exit (eg, by sending it 'quit'). - - """ - self.controller.channel_is_bad = True - - def handle_quit(self, args): - # Ignores GtpChannelClosed - try: - result = self.pass_command("quit", []) - except BackEndError, e: - if isinstance(e.cause, GtpChannelClosed): - result = "" - else: - raise GtpFatalError(str(e)) - except BadGtpResponse, e: - self.expect_back_end_exit() - raise GtpFatalError(e.gtp_error_message) - self.expect_back_end_exit() - raise GtpQuit(result) - - def handle_passthrough(self, args): - try: - command = args[0] - except IndexError: - gtp_engine.report_bad_arguments() - return self.handle_command(command, args[1:]) diff --git a/gomill/gtp_states.py b/gomill/gtp_states.py deleted file mode 100644 index 1fc92d7..0000000 --- a/gomill/gtp_states.py +++ /dev/null @@ -1,651 +0,0 @@ -"""Stateful GTP engine.""" - -from __future__ import with_statement - -from gomill import __version__ -from gomill.common import * -from gomill import ascii_boards -from gomill import boards -from gomill import gtp_engine -from gomill import handicap_layout -from gomill import sgf -from gomill import sgf_grammar -from gomill import sgf_moves -from gomill.gtp_engine import GtpError - - -class History_move(object): - """Information about a move (for move_history). - - Public attributes: - colour - move -- (row, col), or None for a pass - comments -- multiline string, or None - cookie - - comments are used by gomill-savesgf. - - The cookie attribute stores an arbitrary value which was provided by the - move generator when the move was played. The cookie attribute of a move - which did not come from the move generator is None. - - This is a way for a move generator to maintain state across moves, without - becoming confused by 'undo' &c. It's not intended for storing large amounts - of data. - - """ - def __init__(self, colour, move, comments=None, cookie=None): - self.colour = colour - self.move = move - self.comments = comments - self.cookie = cookie - - def is_pass(self): - return (self.move is None) - - -class Game_state(object): - """Data passed to a move generator. - - Public attributes: - size -- int - board -- boards.Board - komi -- float - history_base -- boards.Board - move_history -- list of History_move objects - ko_point -- (row, col) or None - handicap -- int >= 2 or None - for_regression -- bool - time_settings -- tuple (m, b, s), or None - time_remaining -- int (seconds), or None - canadian_stones_remaining -- int or None - - 'board' represents the current board position. - - history_base represents a (possibly) earlier board position; move_history - lists the moves leading to 'board' from that position. - - Normally, history_base will be an empty board, or else be the position after - the placement of handicap stones; but if the loadsgf command has been used - it may be the position given by setup stones in the SGF file. - - The get_last_move() and get_last_move_and_cookie() functions below are - provided to help interpret move history. - - - ko_point is the point forbidden by the simple ko rule. This is provided for - convenience for engines which don't want to deduce it from the move history. - To handle superko properly, engines will have to use the move history. - - - 'handicap' is provided in case the engine wants to modify its behaviour in - handicap games; it can safely be ignored. Any handicap stones will be - present in history_base. - - - for_regression is true if the command was 'reg_genmove'; engines which care - should use a fixed seed in this case. - - - time_settings describes the time limits for the game; time_remaining - describes the current situation. - - time_settings values m, b, s are main time (in seconds), 'Canadian byo-yomi' - time (in seconds), and 'Canadian byo-yomi' stones; see GTP spec 4.2 (which - describes what 0 values mean). time_settings None means the information - isn't available. - - time_remaining None means the game isn't timed. canadian_stones_remaining - None means we're in main time. - - The most important information for the move generator is in time_remaining; - time_settings lets it know whether it's going to get overtime as well. It's - possible for time_remaining to be available but not time_settings (if the - controller doesn't send time_settings). - - """ - -class Move_generator_result(object): - """Return value from a move generator. - - Public attributes: - resign -- bool - pass_move -- bool - move -- (row, col), or None - claim -- bool (for gomill-genmove_ex claim) - comments -- multiline string, or None - cookie -- arbitrary value - - Exactly one of the first three attributes should be set to a nondefault - value. The other attributes can be safely left at their defaults. - - If claim is true, either 'move' or 'pass_move' must still be set. - - comments are used by gomill-explain_last_move and gomill-savesgf. - - See History_move for an explanation of the cookie attribute. It has the - value None if not explicitly set. - - """ - def __init__(self): - self.resign = False - self.pass_move = False - self.move = None - self.claim = False - self.comments = None - self.cookie = None - - -class Gtp_state(object): - """Manage the stateful part of the GTP engine protocol. - - This supports implementing a GTP engine using a stateless move generator. - - Sample use: - gtp_state = Gtp_state(...) - engine = Gtp_engine_protocol() - engine.add_commands(gtp_state.get_handlers()) - - A Gtp_state maintains the following state: - board configuration - move history - komi - simple ko ban - - - Instantiate with a _move generator function_ and a list of acceptable board - sizes (default 19 only). - - The move generator function is called to handle genmove. It is passed - arguments (game_state, colour to play). It should return a - Move_generator_result. It must not modify data passed in the game_state. - - If the move generator returns an occupied point, Gtp_state will report a GTP - error. Gtp_state does not enforce any ko rule. It permits self-captures. - - """ - - def __init__(self, move_generator, acceptable_sizes=None): - self.komi = 0.0 - self.time_settings = None - self.time_status = { - 'b' : (None, None), - 'w' : (None, None), - } - self.move_generator = move_generator - if acceptable_sizes is None: - self.acceptable_sizes = set((19,)) - self.board_size = 19 - else: - self.acceptable_sizes = set(acceptable_sizes) - self.board_size = min(self.acceptable_sizes) - self.reset() - - def reset(self): - self.board = boards.Board(self.board_size) - # None, or a small integer - self.handicap = None - self.simple_ko_point = None - # Player that any simple_ko_point is banned for - self.simple_ko_player = None - self.history_base = boards.Board(self.board_size) - # list of History_move objects - self.move_history = [] - - def set_history_base(self, board): - """Change the history base to a new position. - - Takes ownership of 'board'. - - Clears the move history. - - """ - self.history_base = board - self.move_history = [] - - def reset_to_moves(self, history_moves): - """Reset to history base and play the specified moves. - - history_moves -- list of History_move objects. - - 'history_moves' becomes the new move history. Takes ownership of - 'history_moves'. - - Raises ValueError if there is an invalid move in the list. - - """ - self.board = self.history_base.copy() - simple_ko_point = None - simple_ko_player = None - for history_move in history_moves: - if history_move.is_pass(): - self.simple_ko_point = None - continue - row, col = history_move.move - # Propagates ValueError if the move is bad - simple_ko_point = self.board.play(row, col, history_move.colour) - simple_ko_player = opponent_of(history_move.colour) - self.simple_ko_point = simple_ko_point - self.simple_ko_player = simple_ko_player - self.move_history = history_moves - - def set_komi(self, f): - max_komi = 625.0 - if f < -max_komi: - f = -max_komi - elif f > max_komi: - f = max_komi - self.komi = f - - def handle_boardsize(self, args): - try: - size = gtp_engine.interpret_int(args[0]) - except IndexError: - gtp_engine.report_bad_arguments() - if size not in self.acceptable_sizes: - raise GtpError("unacceptable size") - self.board_size = size - self.reset() - - def handle_clear_board(self, args): - self.reset() - - def handle_komi(self, args): - try: - f = gtp_engine.interpret_float(args[0]) - except IndexError: - gtp_engine.report_bad_arguments() - self.set_komi(f) - - def handle_fixed_handicap(self, args): - try: - number_of_stones = gtp_engine.interpret_int(args[0]) - except IndexError: - gtp_engine.report_bad_arguments() - if not self.board.is_empty(): - raise GtpError("board not empty") - try: - points = handicap_layout.handicap_points( - number_of_stones, self.board_size) - except ValueError: - raise GtpError("invalid number of stones") - for row, col in points: - self.board.play(row, col, 'b') - self.simple_ko_point = None - self.handicap = number_of_stones - self.set_history_base(self.board.copy()) - return " ".join(format_vertex((row, col)) - for (row, col) in points) - - def handle_set_free_handicap(self, args): - max_points = handicap_layout.max_free_handicap_for_board_size( - self.board_size) - if not 2 <= len(args) <= max_points: - raise GtpError("invalid number of stones") - if not self.board.is_empty(): - raise GtpError("board not empty") - try: - for vertex_s in args: - move = gtp_engine.interpret_vertex(vertex_s, self.board_size) - if move is None: - raise GtpError("'pass' not permitted") - row, col = move - try: - self.board.play(row, col, 'b') - except ValueError: - raise GtpError("engine error: %s is occupied" % vertex_s) - except Exception: - self.reset() - raise - self.set_history_base(self.board.copy()) - self.handicap = len(args) - self.simple_ko_point = None - - def _choose_free_handicap_moves(self, number_of_stones): - i = min(number_of_stones, - handicap_layout.max_fixed_handicap_for_board_size( - self.board_size)) - return handicap_layout.handicap_points(i, self.board_size) - - def handle_place_free_handicap(self, args): - try: - number_of_stones = gtp_engine.interpret_int(args[0]) - except IndexError: - gtp_engine.report_bad_arguments() - max_points = handicap_layout.max_free_handicap_for_board_size( - self.board_size) - if not 2 <= number_of_stones <= max_points: - raise GtpError("invalid number of stones") - if not self.board.is_empty(): - raise GtpError("board not empty") - if number_of_stones == max_points: - number_of_stones = max_points - 1 - moves = self._choose_free_handicap_moves(number_of_stones) - try: - try: - if len(moves) > number_of_stones: - raise ValueError - for row, col in moves: - self.board.play(row, col, 'b') - except (ValueError, TypeError): - raise GtpError("invalid result from move generator: %s" - % format_vertex_list(moves)) - except Exception: - self.reset() - raise - self.simple_ko_point = None - self.handicap = number_of_stones - self.set_history_base(self.board.copy()) - return " ".join(format_vertex((row, col)) - for (row, col) in moves) - - def handle_play(self, args): - try: - colour_s, vertex_s = args[:2] - except ValueError: - gtp_engine.report_bad_arguments() - colour = gtp_engine.interpret_colour(colour_s) - move = gtp_engine.interpret_vertex(vertex_s, self.board_size) - if move is None: - self.simple_ko_point = None - self.move_history.append(History_move(colour, None)) - return - row, col = move - try: - self.simple_ko_point = self.board.play(row, col, colour) - self.simple_ko_player = opponent_of(colour) - except ValueError: - raise GtpError("illegal move") - self.move_history.append(History_move(colour, move)) - - def handle_showboard(self, args): - return "\n%s\n" % ascii_boards.render_board(self.board) - - def _handle_genmove(self, args, for_regression=False, allow_claim=False): - """Common implementation for genmove commands.""" - try: - colour = gtp_engine.interpret_colour(args[0]) - except IndexError: - gtp_engine.report_bad_arguments() - game_state = Game_state() - game_state.size = self.board_size - game_state.board = self.board - game_state.history_base = self.history_base - game_state.move_history = self.move_history - game_state.komi = self.komi - game_state.for_regression = for_regression - if self.simple_ko_point is not None and self.simple_ko_player == colour: - game_state.ko_point = self.simple_ko_point - else: - game_state.ko_point = None - game_state.handicap = self.handicap - game_state.time_settings = self.time_settings - game_state.time_remaining, game_state.canadian_stones_remaining = \ - self.time_status[colour] - generated = self.move_generator(game_state, colour) - if allow_claim and generated.claim: - return 'claim' - if generated.resign: - return 'resign' - if generated.pass_move: - if not for_regression: - self.move_history.append(History_move( - colour, None, generated.comments, generated.cookie)) - return 'pass' - row, col = generated.move - vertex = format_vertex((row, col)) - if not for_regression: - try: - self.simple_ko_point = self.board.play(row, col, colour) - self.simple_ko_player = opponent_of(colour) - except ValueError: - raise GtpError("engine error: tried to play %s" % vertex) - self.move_history.append( - History_move(colour, generated.move, - generated.comments, generated.cookie)) - return vertex - - def handle_genmove(self, args): - return self._handle_genmove(args) - - def handle_genmove_ex(self, args): - if not args: - return "claim" - allow_claim = False - for arg in args[1:]: - if arg == 'claim': - allow_claim = True - return self._handle_genmove(args[:1], allow_claim=allow_claim) - - def handle_reg_genmove(self, args): - return self._handle_genmove(args, for_regression=True) - - def handle_undo(self, args): - if not self.move_history: - raise GtpError("cannot undo") - try: - self.reset_to_moves(self.move_history[:-1]) - except ValueError: - raise GtpError("corrupt history") - - def _load_file(self, pathname): - """Read the specified file and return its contents as a string. - - Subclasses can override this to change how loadsgf interprets filenames. - - May raise EnvironmentError. - - """ - with open(pathname) as f: - return f.read() - - def handle_loadsgf(self, args): - try: - pathname = args[0] - except IndexError: - gtp_engine.report_bad_arguments() - if len(args) > 1: - move_number = gtp_engine.interpret_int(args[1]) - else: - move_number = None - # The GTP spec mandates the "cannot load file" error message, so we - # can't be more helpful. - try: - s = self._load_file(pathname) - except EnvironmentError: - raise GtpError("cannot load file") - try: - sgf_game = sgf.Sgf_game.from_string(s) - except ValueError: - raise GtpError("cannot load file") - new_size = sgf_game.get_size() - if new_size not in self.acceptable_sizes: - raise GtpError("unacceptable size") - self.board_size = new_size - try: - komi = sgf_game.get_komi() - except ValueError: - raise GtpError("bad komi") - try: - handicap = sgf_game.get_handicap() - except ValueError: - # Handicap isn't important, so soldier on - handicap = None - try: - sgf_board, plays = sgf_moves.get_setup_and_moves(sgf_game) - except ValueError, e: - raise GtpError(str(e)) - history_moves = [History_move(colour, move) - for (colour, move) in plays] - if move_number is None: - new_move_history = history_moves - else: - # gtp spec says we want the "position before move_number" - move_number = max(0, move_number-1) - new_move_history = history_moves[:move_number] - old_history_base = self.history_base - old_move_history = self.move_history - try: - self.set_history_base(sgf_board) - self.reset_to_moves(new_move_history) - except ValueError: - try: - self.set_history_base(old_history_base) - self.reset_to_moves(old_move_history) - except ValueError: - raise GtpError("bad move in file and corrupt history") - raise GtpError("bad move in file") - self.set_komi(komi) - self.handicap = handicap - - def handle_time_left(self, args): - # colour time stones - try: - colour = gtp_engine.interpret_colour(args[0]) - time_remaining = gtp_engine.interpret_int(args[1]) - stones_remaining = gtp_engine.interpret_int(args[2]) - except IndexError: - gtp_engine.report_bad_arguments() - if stones_remaining == 0: - stones_remaining = None - self.time_status[colour] = (time_remaining, stones_remaining) - - def handle_time_settings(self, args): - try: - main_time = gtp_engine.interpret_int(args[0]) - canadian_time = gtp_engine.interpret_int(args[1]) - canadian_stones = gtp_engine.interpret_int(args[2]) - except IndexError: - gtp_engine.report_bad_arguments() - self.time_settings = (main_time, canadian_time, canadian_stones) - - def handle_explain_last_move(self, args): - try: - return self.move_history[-1].comments - except IndexError: - return None - - def _save_file(self, pathname, contents): - """Write a string to the specified file. - - Subclasses can override this to change how gomill-savesgf interprets - filenames. - - May raise EnvironmentError. - - """ - with open(pathname, "w") as f: - f.write(contents) - - def handle_savesgf(self, args): - try: - pathname = args[0] - except IndexError: - gtp_engine.report_bad_arguments() - - sgf_game = sgf.Sgf_game(self.board_size) - root = sgf_game.get_root() - root.set('KM', self.komi) - root.set('AP', ("gomill", __version__)) - sgf_game.set_date() - if self.handicap is not None: - root.set('HA', self.handicap) - for arg in args[1:]: - try: - identifier, value = arg.split("=", 1) - if not identifier.isalpha(): - raise ValueError - identifier = identifier.upper() - value = value.replace("\\_", " ").replace("\\\\", "\\") - except Exception: - gtp_engine.report_bad_arguments() - root.set_raw(identifier, sgf_grammar.escape_text(value)) - sgf_moves.set_initial_position(sgf_game, self.history_base) - for history_move in self.move_history: - node = sgf_game.extend_main_sequence() - node.set_move(history_move.colour, history_move.move) - if history_move.comments is not None: - node.set("C", history_move.comments) - sgf_moves.indicate_first_player(sgf_game) - try: - self._save_file(pathname, sgf_game.serialise()) - except EnvironmentError, e: - raise GtpError("error writing file: %s" % e) - - - def get_handlers(self): - return {'boardsize' : self.handle_boardsize, - 'clear_board' : self.handle_clear_board, - 'komi' : self.handle_komi, - 'fixed_handicap' : self.handle_fixed_handicap, - 'set_free_handicap' : self.handle_set_free_handicap, - 'place_free_handicap' : self.handle_place_free_handicap, - 'play' : self.handle_play, - 'genmove' : self.handle_genmove, - 'gomill-genmove_ex' : self.handle_genmove_ex, - 'reg_genmove' : self.handle_reg_genmove, - 'undo' : self.handle_undo, - 'showboard' : self.handle_showboard, - 'loadsgf' : self.handle_loadsgf, - 'gomill-explain_last_move' : self.handle_explain_last_move, - 'gomill-savesgf' : self.handle_savesgf, - } - - def get_time_handlers(self): - """Return handlers for time-related commands. - - These are separated out so that engines which don't support time - handling can avoid advertising time support. - - """ - return {'time_left' : self.handle_time_left, - 'time_settings' : self.handle_time_settings, - } - - -def get_last_move(history_moves, player): - """Get the last move from the move history, checking it's by the opponent. - - This is a convenience function for use by move generators. - - history_moves -- list of History_move objects - player -- player to play current move ('b' or 'w') - - Returns a pair (move_is_available, move) - where move is (row, col), or None for a pass. - - If the last move is unknown, or it wasn't by the opponent, move_is_available - is False and move is None. - - """ - if not history_moves: - return False, None - if history_moves[-1].colour != opponent_of(player): - return False, None - return True, history_moves[-1].move - -def get_last_move_and_cookie(history_moves, player): - """Interpret recent move history. - - This is a convenience function for use by move generators. - - This is a variant of get_last_move, which also returns the last-but-one - move's cookie if available. - - Returns a tuple (move_is_available, opponent's move, cookie) - - move_is_available has the same meaning as for get_last_move(). - - If move_is_available is false, or if the next-to-last move is unavailable or - wasn't by the current player, cookie is None. - - """ - move_is_available, opponents_move = get_last_move(history_moves, player) - if (move_is_available and len(history_moves) > 1 and - history_moves[-2].colour == player): - cookie = history_moves[-2].cookie - else: - cookie = None - return move_is_available, opponents_move, cookie - - diff --git a/gomill/handicap_layout.py b/gomill/handicap_layout.py deleted file mode 100644 index 36910a4..0000000 --- a/gomill/handicap_layout.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Standard layout of fixed handicap stones. - -This follows the rules from the GTP spec. - -""" - -def max_free_handicap_for_board_size(board_size): - """Return the maximum number of stones for place_free_handicap command.""" - return board_size * board_size - 1 - -def max_fixed_handicap_for_board_size(board_size): - """Return the maximum number of stones for fixed_handicap command.""" - if board_size <= 7: - return 0 - if board_size > 25: - raise ValueError - if board_size % 2 == 0 or board_size == 7: - return 4 - else: - return 9 - -handicap_pattern = [ - ['00', '22'], - ['00', '22', '20'], - ['00', '22', '20', '02'], - ['00', '22', '20', '02', '11'], - ['00', '22', '20', '02', '10', '12'], - ['00', '22', '20', '02', '10', '12', '11'], - ['00', '22', '20', '02', '10', '12', '01', '21'], - ['00', '22', '20', '02', '10', '12', '01', '21', '11'], -] - -def handicap_points(number_of_stones, board_size): - """Return the handicap points for a given number of stones and board size. - - Returns a list of pairs (row, col), length 'number_of_stones'. - - Raises ValueError if there isn't a placement pattern for the specified - number of handicap stones and board size. - - """ - if number_of_stones > max_fixed_handicap_for_board_size(board_size): - raise ValueError - if number_of_stones < 2: - raise ValueError - if board_size < 13: - altitude = 2 - else: - altitude = 3 - pos = {'0' : altitude, - '1' : (board_size - 1) / 2, - '2' : board_size - altitude - 1} - return [(pos[s[0]], pos[s[1]]) - for s in handicap_pattern[number_of_stones-2]] diff --git a/gomill/job_manager.py b/gomill/job_manager.py deleted file mode 100644 index 967aa01..0000000 --- a/gomill/job_manager.py +++ /dev/null @@ -1,218 +0,0 @@ -"""Job system supporting multiprocessing.""" - -import sys - -from gomill import compact_tracebacks - -multiprocessing = None - -NoJobAvailable = object() - -class JobFailed(StandardError): - """Error reported by a job.""" - -class JobSourceError(StandardError): - """Error from a job source object.""" - -class JobError(object): - """Error from a job.""" - def __init__(self, job, msg): - self.job = job - self.msg = msg - -def _initialise_multiprocessing(): - global multiprocessing - if multiprocessing is not None: - return - try: - import multiprocessing - except ImportError: - multiprocessing = None - -class Worker_finish_signal(object): - pass -worker_finish_signal = Worker_finish_signal() - -def worker_run_jobs(job_queue, response_queue, worker_id): - try: - #pid = os.getpid() - #sys.stderr.write("worker %d starting\n" % pid) - while True: - job = job_queue.get() - #sys.stderr.write("worker %d: %s\n" % (pid, repr(job))) - if isinstance(job, Worker_finish_signal): - break - try: - response = job.run(worker_id) - except JobFailed, e: - response = JobError(job, str(e)) - sys.exc_clear() - del e - except Exception: - response = JobError( - job, compact_tracebacks.format_traceback(skip=1)) - sys.exc_clear() - response_queue.put(response) - #sys.stderr.write("worker %d finishing\n" % pid) - response_queue.cancel_join_thread() - # Unfortunately, there will be places in the child that this doesn't cover. - # But it will avoid the ugly traceback in most cases. - except KeyboardInterrupt: - sys.exit(3) - -class Job_manager(object): - def __init__(self): - self.passed_exceptions = [] - - def pass_exception(self, cls): - self.passed_exceptions.append(cls) - -class Multiprocessing_job_manager(Job_manager): - def __init__(self, number_of_workers): - Job_manager.__init__(self) - _initialise_multiprocessing() - if multiprocessing is None: - raise StandardError("multiprocessing not available") - if not 1 <= number_of_workers < 1024: - raise ValueError - self.number_of_workers = number_of_workers - - def start_workers(self): - self.job_queue = multiprocessing.Queue() - self.response_queue = multiprocessing.Queue() - self.workers = [] - for i in range(self.number_of_workers): - worker = multiprocessing.Process( - target=worker_run_jobs, - args=(self.job_queue, self.response_queue, i)) - self.workers.append(worker) - for worker in self.workers: - worker.start() - - def run_jobs(self, job_source): - active_jobs = 0 - while True: - if active_jobs < self.number_of_workers: - try: - job = job_source.get_job() - except Exception, e: - for cls in self.passed_exceptions: - if isinstance(e, cls): - raise - raise JobSourceError( - "error from get_job()\n%s" % - compact_tracebacks.format_traceback(skip=1)) - if job is not NoJobAvailable: - #sys.stderr.write("MGR: sending %s\n" % repr(job)) - self.job_queue.put(job) - active_jobs += 1 - continue - if active_jobs == 0: - break - - response = self.response_queue.get() - if isinstance(response, JobError): - try: - job_source.process_error_response( - response.job, response.msg) - except Exception, e: - for cls in self.passed_exceptions: - if isinstance(e, cls): - raise - raise JobSourceError( - "error from process_error_response()\n%s" % - compact_tracebacks.format_traceback(skip=1)) - else: - try: - job_source.process_response(response) - except Exception, e: - for cls in self.passed_exceptions: - if isinstance(e, cls): - raise - raise JobSourceError( - "error from process_response()\n%s" % - compact_tracebacks.format_traceback(skip=1)) - active_jobs -= 1 - #sys.stderr.write("MGR: received response %s\n" % repr(response)) - - def finish(self): - for _ in range(self.number_of_workers): - self.job_queue.put(worker_finish_signal) - for worker in self.workers: - worker.join() - self.job_queue = None - self.response_queue = None - -class In_process_job_manager(Job_manager): - def start_workers(self): - pass - - def run_jobs(self, job_source): - while True: - try: - job = job_source.get_job() - except Exception, e: - for cls in self.passed_exceptions: - if isinstance(e, cls): - raise - raise JobSourceError( - "error from get_job()\n%s" % - compact_tracebacks.format_traceback(skip=1)) - if job is NoJobAvailable: - break - try: - response = job.run(None) - except Exception, e: - if isinstance(e, JobFailed): - msg = str(e) - else: - msg = compact_tracebacks.format_traceback(skip=1) - try: - job_source.process_error_response(job, msg) - except Exception, e: - for cls in self.passed_exceptions: - if isinstance(e, cls): - raise - raise JobSourceError( - "error from process_error_response()\n%s" % - compact_tracebacks.format_traceback(skip=1)) - else: - try: - job_source.process_response(response) - except Exception, e: - for cls in self.passed_exceptions: - if isinstance(e, cls): - raise - raise JobSourceError( - "error from process_response()\n%s" % - compact_tracebacks.format_traceback(skip=1)) - - def finish(self): - pass - -def run_jobs(job_source, max_workers=None, allow_mp=True, - passed_exceptions=None): - if allow_mp: - _initialise_multiprocessing() - if multiprocessing is None: - allow_mp = False - if allow_mp: - if max_workers is None: - max_workers = multiprocessing.cpu_count() - job_manager = Multiprocessing_job_manager(max_workers) - else: - job_manager = In_process_job_manager() - if passed_exceptions: - for cls in passed_exceptions: - job_manager.pass_exception(cls) - job_manager.start_workers() - try: - job_manager.run_jobs(job_source) - except Exception: - try: - job_manager.finish() - except Exception, e2: - print >>sys.stderr, "Error closing down workers:\n%s" % e2 - raise - job_manager.finish() - diff --git a/gomill/mcts_tuners.py b/gomill/mcts_tuners.py deleted file mode 100644 index dc1e4c9..0000000 --- a/gomill/mcts_tuners.py +++ /dev/null @@ -1,848 +0,0 @@ -"""Competitions for parameter tuning using Monte-carlo tree search.""" - -from __future__ import division - -import operator -import random -from heapq import nlargest -from math import exp, log, sqrt - -from gomill import compact_tracebacks -from gomill import game_jobs -from gomill import competitions -from gomill import competition_schedulers -from gomill.competitions import ( - Competition, NoGameAvailable, CompetitionError, ControlFileError, - Player_config) -from gomill.settings import * - - -class Node(object): - """A MCTS node. - - Public attributes: - children -- list of Nodes, or None for unexpanded - wins - visits - value -- wins / visits - rsqrt_visits -- 1 / sqrt(visits) - - """ - def count_tree_size(self): - if self.children is None: - return 1 - return sum(child.count_tree_size() for child in self.children) + 1 - - def recalculate(self): - """Update value and rsqrt_visits from changed wins and visits.""" - self.value = self.wins / self.visits - self.rsqrt_visits = sqrt(1/self.visits) - - def __getstate__(self): - return (self.children, self.wins, self.visits) - - def __setstate__(self, state): - self.children, self.wins, self.visits = state - self.recalculate() - - __slots__ = ( - 'children', - 'wins', - 'visits', - 'value', - 'rsqrt_visits', - ) - - def __repr__(self): - return "" % (self.value, repr(self.children)) - - -class Tree(object): - """A tree of MCTS nodes representing N-dimensional parameter space. - - Parameters (available as read-only attributes): - splits -- subdivisions of each dimension - (list of integers, one per dimension) - max_depth -- number of generations below the root - initial_visits -- visit count for newly-created nodes - initial_wins -- win count for newly-created nodes - exploration_coefficient -- constant for UCT formula (float) - - Public attributes: - root -- Node - dimensions -- number of dimensions in the parameter space - - All changing state is in the tree of Node objects started at 'root'. - - References to 'optimiser_parameters' below mean a sequence of length - 'dimensions', whose values are floats in the range 0.0..1.0 representing - a point in this space. - - Each node in the tree represents an N-cuboid of parameter space. Each - expanded node has prod(splits) children, tiling its cuboid. - - (The splits are the same in each generation.) - - Instantiate with: - all parameters listed above - parameter_formatter -- function optimiser_parameters -> string - - """ - def __init__(self, splits, max_depth, - exploration_coefficient, - initial_visits, initial_wins, - parameter_formatter): - self.splits = splits - self.dimensions = len(splits) - self.branching_factor = reduce(operator.mul, splits) - self.max_depth = max_depth - self.exploration_coefficient = exploration_coefficient - self.initial_visits = initial_visits - self.initial_wins = initial_wins - self._initial_value = initial_wins / initial_visits - self._initial_rsqrt_visits = 1/sqrt(initial_visits) - self.format_parameters = parameter_formatter - - # map child index -> coordinate vector - # coordinate vector -- tuple length 'dimensions' with values in - # range(splits[d]) - # The first dimension changes most slowly. - self._cube_coordinates = [] - for child_index in xrange(self.branching_factor): - v = [] - i = child_index - for split in reversed(splits): - i, coord = divmod(i, split) - v.append(coord) - v.reverse() - self._cube_coordinates.append(tuple(v)) - - def new_root(self): - """Initialise the tree with an expanded root node.""" - self.node_count = 1 # For description only - self.root = Node() - self.root.children = None - self.root.wins = self.initial_wins - self.root.visits = self.initial_visits - self.root.value = self.initial_wins / self.initial_visits - self.root.rsqrt_visits = self._initial_rsqrt_visits - self.expand(self.root) - - def set_root(self, node): - """Use the specified node as the tree's root. - - This is used when restoring serialised state. - - Raises ValueError if the node doesn't have the expected number of - children. - - """ - if not node.children or len(node.children) != self.branching_factor: - raise ValueError - self.root = node - self.node_count = node.count_tree_size() - - def expand(self, node): - """Add children to the specified node.""" - assert node.children is None - node.children = [] - child_count = self.branching_factor - for _ in xrange(child_count): - child = Node() - child.children = None - child.wins = self.initial_wins - child.visits = self.initial_visits - child.value = self._initial_value - child.rsqrt_visits = self._initial_rsqrt_visits - node.children.append(child) - self.node_count += child_count - - def is_ripe(self, node): - """Say whether a node has been visted enough times to be expanded.""" - return node.visits != self.initial_visits - - def parameters_for_path(self, choice_path): - """Retrieve the point in parameter space given by a node. - - choice_path -- sequence of child indices - - Returns optimiser_parameters representing the centre of the region - of parameter space represented by the node of interest. - - choice_path must represent a path from the root to the node of interest. - - """ - lo = [0.0] * self.dimensions - breadths = [1.0] * self.dimensions - for child_index in choice_path: - cube_pos = self._cube_coordinates[child_index] - breadths = [f/split for (f, split) in zip(breadths, self.splits)] - for d, coord in enumerate(cube_pos): - lo[d] += breadths[d] * coord - return [f + .5*breadth for (f, breadth) in zip(lo, breadths)] - - def retrieve_best_parameters(self): - """Find the parameters with the most promising simulation results. - - Returns optimiser_parameters - - This walks the tree from the root, at each point choosing the node with - most wins, and returns the parameters corresponding to the leaf node. - - """ - simulation = self.retrieve_best_parameter_simulation() - return simulation.get_parameters() - - def retrieve_best_parameter_simulation(self): - """Return the Greedy_simulation used for retrieve_best_parameters.""" - simulation = Greedy_simulation(self) - simulation.walk() - return simulation - - def get_test_parameters(self): - """Return a 'typical' optimiser_parameters.""" - return self.parameters_for_path([0]) - - def describe_choice(self, choice): - """Return a string describing a child's coordinates in its parent.""" - return str(self._cube_coordinates[choice]).replace(" ", "") - - def describe(self): - """Return a text description of the current state of the tree. - - This currently dumps the full tree to depth 2. - - """ - - def describe_node(node, choice_path): - parameters = self.format_parameters( - self.parameters_for_path(choice_path)) - choice_s = self.describe_choice(choice_path[-1]) - return "%s %s %.3f %3d" % ( - choice_s, parameters, node.value, - node.visits - self.initial_visits) - - root = self.root - wins = root.wins - self.initial_wins - visits = root.visits - self.initial_visits - try: - win_rate = "%.3f" % (wins/visits) - except ZeroDivisionError: - win_rate = "--" - result = [ - "%d nodes" % self.node_count, - "Win rate %d/%d = %s" % (wins, visits, win_rate) - ] - - for choice, node in enumerate(self.root.children): - result.append(" " + describe_node(node, [choice])) - if node.children is None: - continue - for choice2, node2 in enumerate(node.children): - result.append(" " + describe_node(node2, [choice, choice2])) - return "\n".join(result) - - def summarise(self, out, summary_spec): - """Write a summary of the most-visited parts of the tree. - - out -- writeable file-like object - summary_spec -- list of ints - - summary_spec says how many nodes to describe at each depth of the tree - (so to show only direct children of the root, pass a list of length 1). - - """ - def p(s): - print >>out, s - - def describe_node(node, choice_path): - parameters = self.format_parameters( - self.parameters_for_path(choice_path)) - choice_s = " ".join(map(self.describe_choice, choice_path)) - return "%s %-40s %.3f %3d" % ( - choice_s, parameters, node.value, - node.visits - self.initial_visits) - - def most_visits((child_index, node)): - return node.visits - - last_generation = [([], self.root)] - for i, n in enumerate(summary_spec): - depth = i + 1 - p("most visited at depth %s" % (depth)) - - this_generation = [] - for path, node in last_generation: - if node.children is not None: - this_generation += [ - (path + [child_index], child) - for (child_index, child) in enumerate(node.children)] - - for path, node in sorted( - nlargest(n, this_generation, key=most_visits)): - p(describe_node(node, path)) - last_generation = this_generation - p("") - - -class Simulation(object): - """A single monte-carlo simulation. - - Instantiate with the Tree the simulation will run in. - - Use the methods in the following order: - run() - get_parameters() - update_stats(b) - describe() - - """ - def __init__(self, tree): - self.tree = tree - # list of Nodes - self.node_path = [] - # corresponding list of child indices - self.choice_path = [] - # bool - self.candidate_won = None - - def _choose_action(self, node): - """Choose the best action from the specified node. - - Returns a pair (child index, node) - - """ - uct_numerator = (self.tree.exploration_coefficient * - sqrt(log(node.visits))) - def urgency((i, child)): - return child.value + uct_numerator * child.rsqrt_visits - start = random.randrange(len(node.children)) - children = list(enumerate(node.children)) - return max(children[start:] + children[:start], key=urgency) - - def walk(self): - """Choose a node sequence, without expansion.""" - node = self.tree.root - while node.children is not None: - choice, node = self._choose_action(node) - self.node_path.append(node) - self.choice_path.append(choice) - - def run(self): - """Choose the node sequence for this simulation. - - This walks down from the root, using _choose_action() at each level, - until it reaches a leaf; if the leaf has already been visited, this - expands it and chooses one more action. - - """ - self.walk() - node = self.node_path[-1] - if (len(self.node_path) < self.tree.max_depth and - self.tree.is_ripe(node)): - self.tree.expand(node) - choice, child = self._choose_action(node) - self.node_path.append(child) - self.choice_path.append(choice) - - def get_parameters(self): - """Retrieve the parameters corresponding to the simulation's leaf node. - - Returns optimiser_parameters - - """ - return self.tree.parameters_for_path(self.choice_path) - - def update_stats(self, candidate_won): - """Update the tree's node statistics with the simulation's results. - - This updates visits (and wins, if appropriate) for each node in the - simulation's node sequence. - - """ - self.candidate_won = candidate_won - for node in self.node_path: - node.visits += 1 - if candidate_won: - node.wins += 1 - node.recalculate() - self.tree.root.visits += 1 - if candidate_won: - self.tree.root.wins += 1 # For description only - self.tree.root.recalculate() - - def describe_steps(self): - """Return a text description of the simulation's node sequence.""" - return " ".join(map(self.tree.describe_choice, self.choice_path)) - - def describe(self): - """Return a one-line-ish text description of the simulation.""" - result = "%s [%s]" % ( - self.tree.format_parameters(self.get_parameters()), - self.describe_steps()) - if self.candidate_won is not None: - result += (" lost", " won")[self.candidate_won] - return result - - def describe_briefly(self): - """Return a shorter description of the simulation.""" - return "%s %s" % (self.tree.format_parameters(self.get_parameters()), - ("lost", "won")[self.candidate_won]) - -class Greedy_simulation(Simulation): - """Variant of simulation that chooses the node with most wins. - - This is used to pick the 'best' parameters from the current state of the - tree. - - """ - def _choose_action(self, node): - def wins((i, node)): - return node.wins - return max(enumerate(node.children), key=wins) - - -parameter_settings = [ - Setting('code', interpret_identifier), - Setting('scale', interpret_callable), - Setting('split', interpret_positive_int), - Setting('format', interpret_8bit_string, default=None), - ] - -class Parameter_config(Quiet_config): - """Parameter (ie, dimension) description for use in control files.""" - # positional or keyword - positional_arguments = ('code',) - # keyword-only - keyword_arguments = tuple(setting.name for setting in parameter_settings - if setting.name != 'code') - -class Parameter_spec(object): - """Internal description of a parameter spec from the configuration file. - - Public attributes: - code -- identifier - split -- integer - scale -- function float(0.0..1.0) -> player parameter - format -- string for use with '%' - - """ - -class Scale_fn(object): - """Callable implementing a scale function. - - Scale_fn classes are used to provide a convenient way to describe scale - functions in the control file (LINEAR, LOG, ...). - - """ - -class Linear_scale_fn(Scale_fn): - """Linear scale function. - - Instantiate with - lower_bound -- float - upper_bound -- float - integer -- bool (means 'round result to nearest integer') - - """ - def __init__(self, lower_bound, upper_bound, integer=False): - self.lower_bound = float(lower_bound) - self.upper_bound = float(upper_bound) - self.range = float(upper_bound - lower_bound) - self.integer = bool(integer) - - def __call__(self, f): - result = (f * self.range) + self.lower_bound - if self.integer: - result = int(result+.5) - return result - -class Log_scale_fn(Scale_fn): - """Log scale function. - - Instantiate with - lower_bound -- float - upper_bound -- float - integer -- bool (means 'round result to nearest integer') - - """ - def __init__(self, lower_bound, upper_bound, integer=False): - if lower_bound == 0.0: - raise ValueError("lower bound is zero") - self.rate = log(upper_bound / lower_bound) - self.lower_bound = lower_bound - self.integer = bool(integer) - - def __call__(self, f): - result = exp(self.rate*f) * self.lower_bound - if self.integer: - result = int(result+.5) - return result - -class Explicit_scale_fn(Scale_fn): - """Scale function that returns elements from a list. - - Instantiate with the list of values to use. - - Normally use this with 'split' equal to the length of the list - (more generally, split**max_depth equal to the length of the list). - - """ - def __init__(self, values): - if not values: - raise ValueError("empty value list") - self.values = tuple(values) - self.n = len(values) - - def __call__(self, f): - return self.values[int(self.n * f)] - - -class LINEAR(Config_proxy): - underlying = Linear_scale_fn - -class LOG(Config_proxy): - underlying = Log_scale_fn - -class EXPLICIT(Config_proxy): - underlying = Explicit_scale_fn - - -class Mcts_tuner(Competition): - """A Competition for parameter tuning using the Monte-carlo tree search. - - The game ids are strings containing integers starting from zero. - - """ - def __init__(self, competition_code, **kwargs): - Competition.__init__(self, competition_code, **kwargs) - self.outstanding_simulations = {} - self.halt_on_next_failure = True - - def control_file_globals(self): - result = Competition.control_file_globals(self) - result.update({ - 'Parameter' : Parameter_config, - 'LINEAR' : LINEAR, - 'LOG' : LOG, - 'EXPLICIT' : EXPLICIT, - }) - return result - - global_settings = (Competition.global_settings + - competitions.game_settings + [ - Setting('number_of_games', allow_none(interpret_int), default=None), - Setting('candidate_colour', interpret_colour), - Setting('log_tree_to_history_period', - allow_none(interpret_positive_int), default=None), - Setting('summary_spec', interpret_sequence_of(interpret_int), - default=(30,)), - Setting('number_of_running_simulations_to_show', interpret_int, - default=12), - ]) - - special_settings = [ - Setting('opponent', interpret_identifier), - Setting('parameters', - interpret_sequence_of_quiet_configs(Parameter_config)), - Setting('make_candidate', interpret_callable), - ] - - # These are used to instantiate Tree; they don't turn into Mcts_tuner - # attributes. - tree_settings = [ - Setting('max_depth', interpret_positive_int, default=1), - Setting('exploration_coefficient', interpret_float), - Setting('initial_visits', interpret_positive_int), - Setting('initial_wins', interpret_positive_int), - ] - - def parameter_spec_from_config(self, parameter_config): - """Make a Parameter_spec from a Parameter_config. - - Raises ControlFileError if there is an error in the configuration. - - Returns a Parameter_spec with all attributes set. - - """ - arguments = parameter_config.resolve_arguments() - interpreted = load_settings(parameter_settings, arguments) - pspec = Parameter_spec() - for name, value in interpreted.iteritems(): - setattr(pspec, name, value) - optimiser_param = 1.0/(pspec.split*2) - try: - scaled = pspec.scale(optimiser_param) - except Exception: - raise ValueError( - "error from scale (applied to %s)\n%s" % - (optimiser_param, compact_tracebacks.format_traceback(skip=1))) - if pspec.format is None: - pspec.format = pspec.code + ":%s" - try: - pspec.format % scaled - except Exception: - raise ControlFileError("'format': invalid format string") - return pspec - - def initialise_from_control_file(self, config): - Competition.initialise_from_control_file(self, config) - - if self.komi == int(self.komi): - raise ControlFileError("komi: must be fractional to prevent jigos") - - competitions.validate_handicap( - self.handicap, self.handicap_style, self.board_size) - - try: - specials = load_settings(self.special_settings, config) - except ValueError, e: - raise ControlFileError(str(e)) - - try: - self.opponent = self.players[specials['opponent']] - except KeyError: - raise ControlFileError( - "opponent: unknown player %s" % specials['opponent']) - - self.parameter_specs = [] - if not specials['parameters']: - raise ControlFileError("parameters: empty list") - seen_codes = set() - for i, parameter_spec in enumerate(specials['parameters']): - try: - pspec = self.parameter_spec_from_config(parameter_spec) - except StandardError, e: - code = parameter_spec.get_key() - if code is None: - code = i - raise ControlFileError("parameter %s: %s" % (code, e)) - if pspec.code in seen_codes: - raise ControlFileError( - "duplicate parameter code: %s" % pspec.code) - seen_codes.add(pspec.code) - self.parameter_specs.append(pspec) - - self.candidate_maker_fn = specials['make_candidate'] - - try: - tree_arguments = load_settings(self.tree_settings, config) - except ValueError, e: - raise ControlFileError(str(e)) - self.tree = Tree(splits=[pspec.split for pspec in self.parameter_specs], - parameter_formatter=self.format_optimiser_parameters, - **tree_arguments) - - - # State attributes (*: in persistent state): - # *scheduler -- Simple_scheduler - # *tree -- Tree (root node is persisted) - # outstanding_simulations -- map game_number -> Simulation - # halt_on_next_failure -- bool - # *opponent_description -- string (or None) - - def set_clean_status(self): - self.scheduler = competition_schedulers.Simple_scheduler() - self.tree.new_root() - self.opponent_description = None - - # Can bump this to prevent people loading incompatible .status files. - status_format_version = 0 - - def get_status(self): - # path0 is stored for consistency check - return { - 'scheduler' : self.scheduler, - 'tree_root' : self.tree.root, - 'opponent_description' : self.opponent_description, - 'path0' : self.scale_parameters(self.tree.parameters_for_path([0])), - } - - def set_status(self, status): - root = status['tree_root'] - try: - self.tree.set_root(root) - except ValueError: - raise CompetitionError( - "status file is inconsistent with control file") - expected_path0 = self.scale_parameters( - self.tree.parameters_for_path([0])) - if status['path0'] != expected_path0: - raise CompetitionError( - "status file is inconsistent with control file") - self.scheduler = status['scheduler'] - self.scheduler.rollback() - self.opponent_description = status['opponent_description'] - - def scale_parameters(self, optimiser_parameters): - l = [] - for pspec, v in zip(self.parameter_specs, optimiser_parameters): - try: - l.append(pspec.scale(v)) - except Exception: - raise CompetitionError( - "error from scale for %s\n%s" % - (pspec.code, compact_tracebacks.format_traceback(skip=1))) - return tuple(l) - - def format_engine_parameters(self, engine_parameters): - l = [] - for pspec, v in zip(self.parameter_specs, engine_parameters): - try: - s = pspec.format % v - except Exception: - s = "[%s?%s]" % (pspec.code, v) - l.append(s) - return "; ".join(l) - - def format_optimiser_parameters(self, optimiser_parameters): - return self.format_engine_parameters(self.scale_parameters( - optimiser_parameters)) - - def make_candidate(self, player_code, engine_parameters): - """Make a player using the specified engine parameters. - - Returns a game_jobs.Player. - - """ - try: - candidate_config = self.candidate_maker_fn(*engine_parameters) - except Exception: - raise CompetitionError( - "error from make_candidate()\n%s" % - compact_tracebacks.format_traceback(skip=1)) - if not isinstance(candidate_config, Player_config): - raise CompetitionError( - "make_candidate() returned %r, not Player" % - candidate_config) - try: - candidate = self.game_jobs_player_from_config( - player_code, candidate_config) - except Exception, e: - raise CompetitionError( - "bad player spec from make_candidate():\n" - "%s\nparameters were: %s" % - (e, self.format_engine_parameters(engine_parameters))) - return candidate - - def get_player_checks(self): - test_parameters = self.tree.get_test_parameters() - engine_parameters = self.scale_parameters(test_parameters) - candidate = self.make_candidate('candidate', engine_parameters) - result = [] - for player in [candidate, self.opponent]: - check = game_jobs.Player_check() - check.player = player - check.board_size = self.board_size - check.komi = self.komi - result.append(check) - return result - - def get_game(self): - if (self.number_of_games is not None and - self.scheduler.issued >= self.number_of_games): - return NoGameAvailable - game_number = self.scheduler.issue() - - simulation = Simulation(self.tree) - simulation.run() - optimiser_parameters = simulation.get_parameters() - engine_parameters = self.scale_parameters(optimiser_parameters) - candidate = self.make_candidate("#%d" % game_number, engine_parameters) - self.outstanding_simulations[game_number] = simulation - - job = game_jobs.Game_job() - job.game_id = str(game_number) - job.game_data = game_number - if self.candidate_colour == 'b': - job.player_b = candidate - job.player_w = self.opponent - else: - job.player_b = self.opponent - job.player_w = candidate - job.board_size = self.board_size - job.komi = self.komi - job.move_limit = self.move_limit - job.handicap = self.handicap - job.handicap_is_free = (self.handicap_style == 'free') - job.use_internal_scorer = (self.scorer == 'internal') - job.internal_scorer_handicap_compensation = \ - self.internal_scorer_handicap_compensation - job.sgf_event = self.competition_code - job.sgf_note = ("Candidate parameters: %s" % - self.format_engine_parameters(engine_parameters)) - return job - - def process_game_result(self, response): - self.halt_on_next_failure = False - self.opponent_description = response.engine_descriptions[ - self.opponent.code] - game_number = response.game_data - self.scheduler.fix(game_number) - # Counting no-result as loss for the candidate - candidate_won = ( - response.game_result.winning_colour == self.candidate_colour) - simulation = self.outstanding_simulations.pop(game_number) - simulation.update_stats(candidate_won) - self.log_history(simulation.describe()) - if (self.log_tree_to_history_period is not None and - self.scheduler.fixed % self.log_tree_to_history_period == 0): - self.log_history(self.tree.describe()) - return "%s %s" % (simulation.describe(), - response.game_result.sgf_result) - - def process_game_error(self, job, previous_error_count): - ## If the very first game to return a response gives an error, halt. - ## If two games in a row give an error, halt. - ## Otherwise, forget about the failed game - stop_competition = False - retry_game = False - game_number = job.game_data - del self.outstanding_simulations[game_number] - self.scheduler.fix(game_number) - if self.halt_on_next_failure: - stop_competition = True - else: - self.halt_on_next_failure = True - return stop_competition, retry_game - - def write_static_description(self, out): - def p(s): - print >>out, s - p("MCTS tuning event: %s" % self.competition_code) - if self.description: - p(self.description) - p("board size: %s" % self.board_size) - p("komi: %s" % self.komi) - - def _write_main_report(self, out): - games_played = self.scheduler.fixed - if self.number_of_games is None: - print >>out, "%d games played" % games_played - else: - print >>out, "%d/%d games played" % ( - games_played, self.number_of_games) - print >>out - best_simulation = self.tree.retrieve_best_parameter_simulation() - print >>out, "Best parameters: %s" % best_simulation.describe() - print >>out - self.tree.summarise(out, self.summary_spec) - - def write_screen_report(self, out): - self._write_main_report(out) - if self.outstanding_simulations: - print >>out, "In progress:" - to_show = sorted(self.outstanding_simulations.iteritems())\ - [:self.number_of_running_simulations_to_show] - for game_id, simulation in to_show: - print >>out, "game %s: %s" % (game_id, simulation.describe()) - - def write_short_report(self, out): - self.write_static_description(out) - self._write_main_report(out) - if self.opponent_description: - print >>out, "opponent: %s" % self.opponent_description - print >>out - - write_full_report = write_short_report - diff --git a/gomill/playoffs.py b/gomill/playoffs.py deleted file mode 100644 index bfbbd82..0000000 --- a/gomill/playoffs.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Competitions made up of repeated matchups between specified players.""" - -from gomill import game_jobs -from gomill import competitions -from gomill import tournaments -from gomill.competitions import (Competition, ControlFileError) -from gomill.settings import * - - -class Matchup_config(Quiet_config): - """Matchup description for use in control files.""" - # positional or keyword - positional_arguments = ('player_1', 'player_2') - # keyword-only - keyword_arguments = ( - ('id', 'name') + - tuple(setting.name for setting in tournaments.matchup_settings)) - - -class Playoff(tournaments.Tournament): - """A Tournament with explicitly listed matchups. - - The game ids are like '0_2', where 0 is the matchup id and 2 is the game - number within the matchup. - - """ - - def control_file_globals(self): - result = Competition.control_file_globals(self) - result.update({ - 'Matchup' : Matchup_config, - }) - return result - - - special_settings = [ - Setting('matchups', - interpret_sequence_of_quiet_configs(Matchup_config)), - ] - - def matchup_from_config(self, matchup_number, - matchup_config, matchup_defaults): - """Make a Matchup from a Matchup_config. - - This does the following checks and fixups before calling make_matchup(): - - Checks that the player_1 and player_2 parameters exist, and that the - player codes are present in self.players. - - Validates all the matchup_config arguments, and merges them with the - defaults. - - If player_1 and player_2 are the same, takes the following actions: - - sets player_2 to #2 - - if it doesn't already exist, creates #2 as a clone of - player_1 and adds it to self.players - - """ - matchup_id = str(matchup_number) - try: - arguments = matchup_config.resolve_arguments() - if 'id' in arguments: - try: - matchup_id = interpret_identifier(arguments['id']) - except ValueError, e: - raise ValueError("'id': %s" % e) - try: - player_1 = arguments['player_1'] - player_2 = arguments['player_2'] - except KeyError: - raise ControlFileError("not enough arguments") - if player_1 not in self.players: - raise ControlFileError("unknown player %s" % player_1) - if player_2 not in self.players: - raise ControlFileError("unknown player %s" % player_2) - # If both players are the same, make a clone. - if player_1 == player_2: - player_2 += "#2" - if player_2 not in self.players: - self.players[player_2] = \ - self.players[player_1].copy(player_2) - interpreted = load_settings( - tournaments.matchup_settings, arguments, - apply_defaults=False, allow_missing=True) - matchup_name = arguments.get('name') - if matchup_name is not None: - try: - matchup_name = interpret_as_utf8(matchup_name) - except ValueError, e: - raise ValueError("'name': %s" % e) - parameters = matchup_defaults.copy() - parameters.update(interpreted) - return self.make_matchup( - matchup_id, player_1, player_2, - parameters, matchup_name) - except StandardError, e: - raise ControlFileError("matchup %s: %s" % (matchup_id, e)) - - - def initialise_from_control_file(self, config): - Competition.initialise_from_control_file(self, config) - - try: - matchup_defaults = load_settings( - tournaments.matchup_settings, config, allow_missing=True) - except ValueError, e: - raise ControlFileError(str(e)) - - # Check default handicap settings when possible, for friendlier error - # reporting (would be caught in the matchup anyway). - if 'board_size' in matchup_defaults: - try: - competitions.validate_handicap( - matchup_defaults['handicap'], - matchup_defaults['handicap_style'], - matchup_defaults['board_size']) - except ControlFileError, e: - raise ControlFileError("default %s" % e) - - try: - specials = load_settings(self.special_settings, config) - except ValueError, e: - raise ControlFileError(str(e)) - - # map matchup_id -> Matchup - self.matchups = {} - # Matchups in order of definition - self.matchup_list = [] - if not specials['matchups']: - raise ControlFileError("matchups: empty list") - - for i, matchup_config in enumerate(specials['matchups']): - m = self.matchup_from_config(i, matchup_config, matchup_defaults) - if m.id in self.matchups: - raise ControlFileError("duplicate matchup id '%s'" % m.id) - self.matchups[m.id] = m - self.matchup_list.append(m) - - - # Can bump this to prevent people loading incompatible .status files. - status_format_version = 1 - - def get_player_checks(self): - # For board size and komi, we check the values from the first matchup - # the player appears in. - used_players = {} - for m in reversed(self.matchup_list): - if m.number_of_games == 0: - continue - used_players[m.player_1] = m - used_players[m.player_2] = m - result = [] - for code, matchup in sorted(used_players.iteritems()): - check = game_jobs.Player_check() - check.player = self.players[code] - check.board_size = matchup.board_size - check.komi = matchup.komi - result.append(check) - return result - - - def write_screen_report(self, out): - self.write_matchup_reports(out) - - def write_short_report(self, out): - def p(s): - print >>out, s - p("playoff: %s" % self.competition_code) - if self.description: - p(self.description) - p('') - self.write_screen_report(out) - self.write_ghost_matchup_reports(out) - p('') - self.write_player_descriptions(out) - p('') - - write_full_report = write_short_report - diff --git a/gomill/ringmaster_command_line.py b/gomill/ringmaster_command_line.py deleted file mode 100644 index d963e54..0000000 --- a/gomill/ringmaster_command_line.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Command-line interface to the ringmaster.""" - -import os -import sys -from optparse import OptionParser - -from gomill import compact_tracebacks -from gomill.ringmasters import ( - Ringmaster, RingmasterError, RingmasterInternalError) - - -# Action functions return the desired exit status; implicit return is fine to -# indicate a successful exit. - -def do_run(ringmaster, options): - if not options.quiet: - print "running startup checks on all players" - if not ringmaster.check_players(discard_stderr=True): - print "(use the 'check' command to see stderr output)" - return 1 - if options.log_gtp: - ringmaster.enable_gtp_logging() - if options.quiet: - ringmaster.set_display_mode('quiet') - if ringmaster.status_file_exists(): - ringmaster.load_status() - else: - ringmaster.set_clean_status() - if options.parallel is not None: - ringmaster.set_parallel_worker_count(options.parallel) - ringmaster.run(options.max_games) - ringmaster.report() - -def do_stop(ringmaster, options): - ringmaster.write_command("stop") - -def do_show(ringmaster, options): - if not ringmaster.status_file_exists(): - raise RingmasterError("no status file") - ringmaster.load_status() - ringmaster.print_status_report() - -def do_report(ringmaster, options): - if not ringmaster.status_file_exists(): - raise RingmasterError("no status file") - ringmaster.load_status() - ringmaster.report() - -def do_reset(ringmaster, options): - ringmaster.delete_state_and_output() - -def do_check(ringmaster, options): - if not ringmaster.check_players(discard_stderr=False): - return 1 - -def do_debugstatus(ringmaster, options): - ringmaster.print_status() - -_actions = { - "run" : do_run, - "stop" : do_stop, - "show" : do_show, - "report" : do_report, - "reset" : do_reset, - "check" : do_check, - "debugstatus" : do_debugstatus, - } - - -def run(argv, ringmaster_class): - usage = ("%prog [options] [command]\n\n" - "commands: run (default), stop, show, report, reset, check") - parser = OptionParser(usage=usage, prog="ringmaster", - version=ringmaster_class.public_version) - parser.add_option("--max-games", "-g", type="int", - help="maximum number of games to play in this run") - parser.add_option("--parallel", "-j", type="int", - help="number of worker processes") - parser.add_option("--quiet", "-q", action="store_true", - help="be silent except for warnings and errors") - parser.add_option("--log-gtp", action="store_true", - help="write GTP logs") - (options, args) = parser.parse_args(argv) - if len(args) == 0: - parser.error("no control file specified") - if len(args) > 2: - parser.error("too many arguments") - if len(args) == 1: - command = "run" - else: - command = args[1] - try: - action = _actions[command] - except KeyError: - parser.error("no such command: %s" % command) - ctl_pathname = args[0] - try: - if not os.path.exists(ctl_pathname): - raise RingmasterError("control file %s not found" % ctl_pathname) - ringmaster = ringmaster_class(ctl_pathname) - exit_status = action(ringmaster, options) - except RingmasterError, e: - print >>sys.stderr, "ringmaster:", e - exit_status = 1 - except KeyboardInterrupt: - exit_status = 3 - except RingmasterInternalError, e: - print >>sys.stderr, "ringmaster: internal error" - print >>sys.stderr, e - exit_status = 4 - except: - print >>sys.stderr, "ringmaster: internal error" - compact_tracebacks.log_traceback() - exit_status = 4 - sys.exit(exit_status) - -def main(): - run(sys.argv[1:], Ringmaster) - -if __name__ == "__main__": - main() - diff --git a/gomill/ringmaster_presenters.py b/gomill/ringmaster_presenters.py deleted file mode 100644 index 4d0aa24..0000000 --- a/gomill/ringmaster_presenters.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Live display for ringmasters.""" - -import os -import subprocess -import sys -from cStringIO import StringIO - - -class Presenter(object): - """Abstract base class for presenters. - - This accepts messages on four _channels_, with codes - warnings - status - screen_report - results - - Warnings are always displayed immediately. - - Some presenters will delay display of other channels until refresh() is - called; some will display them immediately. - - """ - - # If this is true, ringmaster needn't bother doing the work to prepare most - # of the display. - shows_warnings_only = False - - def clear(self, channel): - """Clear the contents of the specified channel.""" - raise NotImplementedError - - def say(self, channel, s): - """Add a message to the specified channel. - - channel -- channel code - s -- string to display (no trailing newline) - - """ - raise NotImplementedError - - def refresh(self): - """Re-render the current screen. - - This typically displays the full status and screen_report, and the most - recent warnings and results. - - """ - raise NotImplementedError - - def get_stream(self, channel): - """Return a file-like object wired up to the specified channel. - - When the object is closed, the text written it is sent to the channel - (except that any trailing newline is removed). - - """ - return _Channel_writer(self, channel) - -class _Channel_writer(object): - """Support for get_stream() implementation.""" - def __init__(self, parent, channel): - self.parent = parent - self.channel = channel - self.stringio = StringIO() - - def write(self, s): - self.stringio.write(s) - - def close(self): - s = self.stringio.getvalue() - if s.endswith("\n"): - s = s[:-1] - self.parent.say(self.channel, s) - self.stringio.close() - - -class Quiet_presenter(Presenter): - """Presenter which shows only warnings. - - Warnings go to stderr. - - """ - shows_warnings_only = True - - def clear(self, channel): - pass - - def say(self, channel, s): - if channel == 'warnings': - print >>sys.stderr, s - - def refresh(self): - pass - - -class Box(object): - """Description of screen layout for the clearing presenter.""" - def __init__(self, name, heading, limit): - self.name = name - self.heading = heading - self.limit = limit - self.contents = [] - - def layout(self): - return "\n".join(self.contents[-self.limit:]) - -class Clearing_presenter(Presenter): - """Low-tech full-screen presenter. - - This shows all channels. - - """ - shows_warnings_only = False - - # warnings has to be last, so we can add to it immediately - box_specs = ( - ('status', None, 999), - ('screen_report', None, 999), - ('results', "Results", 6), - ('warnings', "Warnings", 4), - ) - - def __init__(self): - self.boxes = {} - self.box_list = [] - for t in self.box_specs: - box = Box(*t) - self.boxes[box.name] = box - self.box_list.append(box) - self.clear_method = None - - def clear(self, channel): - self.boxes[channel].contents = [] - - def say(self, channel, s): - self.boxes[channel].contents.append(s) - # 'warnings' box heading might be missing, but never mind. - if channel == 'warnings': - print s - - def refresh(self): - self.clear_screen() - for box in self.box_list: - if not box.contents: - continue - if box.heading: - print "= %s = " % box.heading - print box.layout() - if box.name != 'warnings': - print - - def screen_height(self): - """Return the current terminal height, or best guess.""" - return os.environ.get("LINES", 80) - - def clear_screen(self): - """Try to clear the terminal screen (if stdout is a terminal).""" - if self.clear_method is None: - try: - isatty = os.isatty(sys.stdout.fileno()) - except Exception: - isatty = False - if isatty: - self.clear_method = "clear" - else: - self.clear_method = "delimiter" - - if self.clear_method == "clear": - try: - retcode = subprocess.call("clear") - except Exception: - retcode = 1 - if retcode != 0: - self.clear_method = "newlines" - if self.clear_method == "newlines": - print "\n" * (self.screen_height()+1) - elif self.clear_method == "delimiter": - print 78 * "-" - diff --git a/gomill/ringmasters.py b/gomill/ringmasters.py deleted file mode 100644 index 9fec06a..0000000 --- a/gomill/ringmasters.py +++ /dev/null @@ -1,769 +0,0 @@ -"""Run competitions using GTP.""" - -from __future__ import division, with_statement - -import cPickle as pickle -import datetime -import errno -import os -import re -import shutil -import sys - -try: - import fcntl -except ImportError: - fcntl = None - -from gomill import compact_tracebacks -from gomill import game_jobs -from gomill import job_manager -from gomill import ringmaster_presenters -from gomill import terminal_input -from gomill.settings import * -from gomill.competitions import ( - NoGameAvailable, CompetitionError, ControlFileError) - -def interpret_python(source, provided_globals, display_filename): - """Interpret Python code from a unicode string. - - source -- unicode object - provided_globals -- dict - display_filename -- filename to use in exceptions - - The string is executed with a copy of provided_globals as the global and - local namespace. Returns that namespace. - - The source string must not have an encoding declaration (SyntaxError will be - raised if it does). - - Propagates exceptions. - - """ - result = provided_globals.copy() - code = compile(source, display_filename, 'exec', - division.compiler_flag, True) - exec code in result - return result - -class RingmasterError(StandardError): - """Error reported by a Ringmaster.""" - -class RingmasterInternalError(StandardError): - """Error reported by a Ringmaster which indicates a bug.""" - - -class Ringmaster(object): - """Manage a competition as described by a control file. - - Most methods can raise RingmasterError. - - Instantiate with the pathname of the control file. The control file is read - and interpreted at instantiation time (and errors are reported at that - point). - - Ringmaster objects are used as a job source for the job manager. - - """ - # Can bump this to prevent people loading incompatible .status files. - status_format_version = 0 - - # For --version command - public_version = "gomill ringmaster v0.7.4" - - def __init__(self, control_pathname): - """Instantiate and initialise a Ringmaster. - - Reads the control file. - - Creates the Competition and initialises it from the control file. - - """ - self.display_mode = 'clearing' - self.worker_count = None - self.max_games_this_run = None - self.presenter = None - self.terminal_reader = None - self.stopping = False - self.stopping_reason = None - # Map game_id -> int - self.game_error_counts = {} - self.write_gtp_logs = False - - self.control_pathname = control_pathname - self.base_directory, control_filename = os.path.split(control_pathname) - self.competition_code, ext = os.path.splitext(control_filename) - if ext in (".log", ".status", ".cmd", ".hist", - ".report", ".games", ".void", ".gtplogs"): - raise RingmasterError("forbidden control file extension: %s" % ext) - stem = os.path.join(self.base_directory, self.competition_code) - self.log_pathname = stem + ".log" - self.status_pathname = stem + ".status" - self.command_pathname = stem + ".cmd" - self.history_pathname = stem + ".hist" - self.report_pathname = stem + ".report" - self.sgf_dir_pathname = stem + ".games" - self.void_dir_pathname = stem + ".void" - self.gtplog_dir_pathname = stem + ".gtplogs" - - self.status_is_loaded = False - try: - self._load_control_file() - except ControlFileError, e: - raise RingmasterError("error in control file:\n%s" % e) - - - def _read_control_file(self): - """Return the contents of the control file as an 8-bit string.""" - try: - with open(self.control_pathname) as f: - return f.read() - except EnvironmentError, e: - raise RingmasterError("failed to read control file:\n%s" % e) - - def _load_control_file(self): - """Main implementation for __init__.""" - - control_s = self._read_control_file() - - try: - self.competition_type = self._parse_competition_type(control_s) - except ValueError, e: - raise ControlFileError("can't find competition_type") - - try: - competition_class = self._get_competition_class( - self.competition_type) - except ValueError: - raise ControlFileError( - "unknown competition type: %s" % self.competition_type) - self.competition = competition_class(self.competition_code) - self.competition.set_base_directory(self.base_directory) - - try: - control_u = control_s.decode("utf-8") - except UnicodeDecodeError: - raise ControlFileError("file is not encoded in utf-8") - - try: - config = interpret_python( - control_u, self.competition.control_file_globals(), - display_filename=self.control_pathname) - except KeyboardInterrupt: - raise - except ControlFileError, e: - raise - except: - raise ControlFileError(compact_tracebacks.format_error_and_line()) - - if config.get("competition_type") != self.competition_type: - raise ControlFileError("competition_type improperly specified") - - try: - self._initialise_from_control_file(config) - except ControlFileError: - raise - except Exception, e: - raise RingmasterError("unhandled error in control file:\n%s" % - compact_tracebacks.format_traceback(skip=1)) - - try: - self.competition.initialise_from_control_file(config) - except ControlFileError: - raise - except Exception, e: - raise RingmasterError("unhandled error in control file:\n%s" % - compact_tracebacks.format_traceback(skip=1)) - - @staticmethod - def _parse_competition_type(source): - """Find the compitition_type definition in the control file. - - source -- string - - Requires the competition_type line to be the first non-comment line, and - to be a simple assignment of a string literal. - - Raises ValueError if it can't find the competition_type line, or if the - value isn't 'identifier-like'. - - """ - for line in source.split("\n"): - s = line.lstrip() - if not s or s.startswith("#"): - continue - break - else: - raise ValueError - # May propagate ValueError - m = re.match(r"competition_type\s*=\s*(['\"])([_a-zA-Z0-9]+)(['\"])$", - line) - if not m: - raise ValueError - if m.group(1) != m.group(3): - raise ValueError - return m.group(2) - - - @staticmethod - def _get_competition_class(competition_type): - """Find the competition class. - - competition_type -- string - - Returns a Competition subclass. - - Raises ValueError if the competition type is unknown. - - """ - if competition_type == "playoff": - from gomill import playoffs - return playoffs.Playoff - elif competition_type == "allplayall": - from gomill import allplayalls - return allplayalls.Allplayall - elif competition_type == "ce_tuner": - from gomill import cem_tuners - return cem_tuners.Cem_tuner - elif competition_type == "mc_tuner": - from gomill import mcts_tuners - return mcts_tuners.Mcts_tuner - else: - raise ValueError - - def _open_files(self): - """Open the log files and ensure that output directories exist. - - If flock is available, this takes out an exclusive lock on the log file. - If this lock is unavailable, it raises RingmasterError. - - Also removes the command file if it exists. - - """ - try: - self.logfile = open(self.log_pathname, "a") - except EnvironmentError, e: - raise RingmasterError("failed to open log file:\n%s" % e) - - if fcntl is not None: - try: - fcntl.flock(self.logfile, fcntl.LOCK_EX|fcntl.LOCK_NB) - except IOError, e: - if e.errno in (errno.EACCES, errno.EAGAIN): - raise RingmasterError("competition is already running") - except Exception: - pass - - try: - if os.path.exists(self.command_pathname): - os.remove(self.command_pathname) - except EnvironmentError, e: - raise RingmasterError("error removing existing .cmd file:\n%s" % e) - - try: - self.historyfile = open(self.history_pathname, "a") - except EnvironmentError, e: - raise RingmasterError("failed to open history file:\n%s" % e) - - if self.record_games: - try: - if not os.path.exists(self.sgf_dir_pathname): - os.mkdir(self.sgf_dir_pathname) - except EnvironmentError: - raise RingmasterError("failed to create SGF directory:\n%s" % e) - - if self.write_gtp_logs: - try: - if not os.path.exists(self.gtplog_dir_pathname): - os.mkdir(self.gtplog_dir_pathname) - except EnvironmentError: - raise RingmasterError( - "failed to create GTP log directory:\n%s" % e) - - def _close_files(self): - """Close the log files.""" - try: - self.logfile.close() - except EnvironmentError, e: - raise RingmasterError("error closing log file:\n%s" % e) - try: - self.historyfile.close() - except EnvironmentError, e: - raise RingmasterError("error closing history file:\n%s" % e) - - ringmaster_settings = [ - Setting('record_games', interpret_bool, True), - Setting('stderr_to_log', interpret_bool, True), - ] - - def _initialise_from_control_file(self, config): - """Interpret the parts of the control file which belong to Ringmaster. - - Sets attributes from ringmaster_settings. - - """ - try: - to_set = load_settings(self.ringmaster_settings, config) - except ValueError, e: - raise ControlFileError(str(e)) - for name, value in to_set.items(): - setattr(self, name, value) - - def enable_gtp_logging(self, b=True): - self.write_gtp_logs = b - - def set_parallel_worker_count(self, n): - self.worker_count = n - - def log(self, s): - print >>self.logfile, s - self.logfile.flush() - - def warn(self, s): - """Log a message and say it on the 'warnings' channel.""" - self.log(s) - self.presenter.say('warnings', s) - - def say(self, channel, s): - """Say a message on the specified channel.""" - self.presenter.say(channel, s) - - def log_history(self, s): - print >>self.historyfile, s - self.historyfile.flush() - - _presenter_classes = { - 'clearing' : ringmaster_presenters.Clearing_presenter, - 'quiet' : ringmaster_presenters.Quiet_presenter, - } - - def set_display_mode(self, presenter_code): - """Specify the presenter to use during run().""" - if presenter_code not in self._presenter_classes: - raise RingmasterError("unknown presenter type: %s" % presenter_code) - self.display_mode = presenter_code - - def _initialise_presenter(self): - self.presenter = self._presenter_classes[self.display_mode]() - - def _initialise_terminal_reader(self): - self.terminal_reader = terminal_input.Terminal_reader() - self.terminal_reader.initialise() - - def get_sgf_filename(self, game_id): - """Return the sgf filename given a game id.""" - return "%s.sgf" % game_id - - def get_sgf_pathname(self, game_id): - """Return the sgf pathname given a game id.""" - return os.path.join(self.sgf_dir_pathname, - self.get_sgf_filename(game_id)) - - - # State attributes (*: in persistent state): - # * void_game_count -- int - # * comp -- from Competition.get_status() - # games_in_progress -- dict game_id -> Game_job - # games_to_replay -- dict game_id -> Game_job - - def _write_status(self, value): - """Write the pickled contents of the persistent state file.""" - f = open(self.status_pathname + ".new", "wb") - pickle.dump(value, f, protocol=-1) - f.close() - os.rename(self.status_pathname + ".new", self.status_pathname) - - def write_status(self): - """Write the persistent state file.""" - competition_status = self.competition.get_status() - status = { - 'void_game_count' : self.void_game_count, - 'comp_vn' : self.competition.status_format_version, - 'comp' : competition_status, - } - try: - self._write_status((self.status_format_version, status)) - except EnvironmentError, e: - raise RingmasterError("error writing persistent state:\n%s" % e) - - def _load_status(self): - """Return the unpickled contents of the persistent state file.""" - with open(self.status_pathname, "rb") as f: - return pickle.load(f) - - def load_status(self): - """Read the persistent state file and load the state it contains.""" - try: - status_format_version, status = self._load_status() - if (status_format_version != self.status_format_version or - status['comp_vn'] != self.competition.status_format_version): - raise StandardError - self.void_game_count = status['void_game_count'] - self.games_in_progress = {} - self.games_to_replay = {} - competition_status = status['comp'] - except pickle.UnpicklingError: - raise RingmasterError("corrupt status file") - except EnvironmentError, e: - raise RingmasterError("error loading status file:\n%s" % e) - except KeyError, e: - raise RingmasterError("incompatible status file: missing %s" % e) - except Exception, e: - # Probably an exception from __setstate__ somewhere - raise RingmasterError("incompatible status file") - try: - self.competition.set_status(competition_status) - except CompetitionError, e: - raise RingmasterError("error loading competition state: %s" % e) - except KeyError, e: - raise RingmasterError( - "error loading competition state: missing %s" % e) - except Exception, e: - raise RingmasterError("error loading competition state:\n%s" % - compact_tracebacks.format_traceback(skip=1)) - self.status_is_loaded = True - - def set_clean_status(self): - """Reset persistent state to the initial values.""" - self.void_game_count = 0 - self.games_in_progress = {} - self.games_to_replay = {} - try: - self.competition.set_clean_status() - except CompetitionError, e: - raise RingmasterError(e) - self.status_is_loaded = True - - def status_file_exists(self): - """Check whether the persistent state file exists.""" - return os.path.exists(self.status_pathname) - - def print_status(self): - """Print the contents of the persistent state file, for debugging.""" - from pprint import pprint - status_format_version, status = self._load_status() - print "status_format_version:", status_format_version - pprint(status) - - def write_command(self, command): - """Write a command to the command file. - - command -- short string - - Overwrites the command file if it already exists. - - """ - # Short enough that I think we can get aw - try: - f = open(self.command_pathname, "w") - f.write(command) - f.close() - except EnvironmentError, e: - raise RingmasterError("error writing command file:\n%s" % e) - - def get_tournament_results(self): - """Provide access to the tournament's results. - - Returns a Tournament_results object. - - Raises RingmasterError if the competition state isn't loaded, or if the - competition isn't a tournament. - - """ - if not self.status_is_loaded: - raise RingmasterError("status is not loaded") - try: - return self.competition.get_tournament_results() - except NotImplementedError: - raise RingmasterError("competition is not a tournament") - - def report(self): - """Write the full competition report to the report file.""" - f = open(self.report_pathname, "w") - self.competition.write_full_report(f) - f.close() - - def print_status_report(self): - """Write current competition status to standard output. - - This is for the 'show' command. - - """ - self.competition.write_short_report(sys.stdout) - - def _halt_competition(self, reason): - """Make the competition stop submitting new games. - - reason -- message for the log and the status box. - - """ - self.stopping = True - self.stopping_reason = reason - self.log("halting competition: %s" % reason) - - def _update_display(self): - """Redisplay the 'live' competition description. - - Does nothing in quiet mode. - - """ - if self.presenter.shows_warnings_only: - return - def p(s): - self.say('status', s) - self.presenter.clear('status') - if self.stopping: - if self.worker_count is None or not self.games_in_progress: - p("halting: %s" % self.stopping_reason) - else: - p("waiting for workers to finish: %s" % - self.stopping_reason) - if self.games_in_progress: - if self.worker_count is None: - gms = "game" - else: - gms = "%d games" % len(self.games_in_progress) - p("%s in progress: %s" % - (gms, " ".join(sorted(self.games_in_progress)))) - if not self.stopping: - if self.max_games_this_run is not None: - p("will start at most %d more games in this run" % - self.max_games_this_run) - if self.terminal_reader.is_enabled(): - p("(Ctrl-X to halt gracefully)") - - self.presenter.clear('screen_report') - sr = self.presenter.get_stream('screen_report') - if self.void_game_count > 0: - print >>sr, "%d void games; see log file." % self.void_game_count - self.competition.write_screen_report(sr) - sr.close() - - self.presenter.refresh() - - def _prepare_job(self, job): - """Finish off a Game_job provided by the Competition. - - job -- incomplete Game_job, as returned by Competition.get_game() - - """ - job.sgf_game_name = "%s %s" % (self.competition_code, job.game_id) - if self.record_games: - job.sgf_filename = self.get_sgf_filename(job.game_id) - job.sgf_dirname = self.sgf_dir_pathname - job.void_sgf_dirname = self.void_dir_pathname - if self.write_gtp_logs: - job.gtp_log_pathname = os.path.join( - self.gtplog_dir_pathname, "%s.log" % job.game_id) - if self.stderr_to_log: - job.stderr_pathname = self.log_pathname - - def get_job(self): - """Job supply function for the job manager.""" - job = self._get_job() - self._update_display() - return job - - def _get_job(self): - """Main implementation of get_job().""" - - if self.stopping: - return job_manager.NoJobAvailable - - if self.terminal_reader.stop_was_requested(): - self._halt_competition("stop instruction received from terminal") - if self.presenter.shows_warnings_only: - self.terminal_reader.acknowledge() - return job_manager.NoJobAvailable - - try: - if os.path.exists(self.command_pathname): - command = open(self.command_pathname).read() - if command == "stop": - self._halt_competition("stop command received") - try: - os.remove(self.command_pathname) - except EnvironmentError, e: - self.warn("error removing .cmd file:\n%s" % e) - return job_manager.NoJobAvailable - except EnvironmentError, e: - self.warn("error reading .cmd file:\n%s" % e) - if self.max_games_this_run is not None: - if self.max_games_this_run == 0: - self._halt_competition("max-games reached for this run") - return job_manager.NoJobAvailable - self.max_games_this_run -= 1 - - if self.games_to_replay: - _, job = self.games_to_replay.popitem() - else: - job = self.competition.get_game() - if job is NoGameAvailable: - return job_manager.NoJobAvailable - if job.game_id in self.games_in_progress: - raise RingmasterInternalError( - "duplicate game id: %s" % job.game_id) - self._prepare_job(job) - self.games_in_progress[job.game_id] = job - start_msg = "starting game %s: %s (b) vs %s (w)" % ( - job.game_id, job.player_b.code, job.player_w.code) - self.log(start_msg) - - return job - - def process_response(self, response): - """Job response function for the job manager.""" - # We log before processing the result, in case there's an error from the - # competition code. - self.log("response from game %s" % response.game_id) - for warning in response.warnings: - self.warn(warning) - for log_entry in response.log_entries: - self.log(log_entry) - result_description = self.competition.process_game_result(response) - del self.games_in_progress[response.game_id] - self.write_status() - if result_description is None: - result_description = response.game_result.describe() - self.say('results', "game %s: %s" % ( - response.game_id, result_description)) - - def process_error_response(self, job, message): - """Job error response function for the job manager.""" - self.warn("game %s -- %s" % ( - job.game_id, message)) - self.void_game_count += 1 - previous_error_count = self.game_error_counts.get(job.game_id, 0) - stop_competition, retry_game = \ - self.competition.process_game_error(job, previous_error_count) - if retry_game and not stop_competition: - self.games_to_replay[job.game_id] = \ - self.games_in_progress.pop(job.game_id) - self.game_error_counts[job.game_id] = previous_error_count + 1 - else: - del self.games_in_progress[job.game_id] - if previous_error_count != 0: - del self.game_error_counts[job.game_id] - self.write_status() - if stop_competition and not self.stopping: - # No need to log: _halt competition will do so - self.say('warnings', "halting run due to void games") - self._halt_competition("too many void games") - - def run(self, max_games=None): - """Run the competition. - - max_games -- int or None (maximum games to start in this run) - - Returns when max_games have been played in this run, when the - Competition is over, or when a 'stop' command is received via the - command file. - - """ - def now(): - return datetime.datetime.now().strftime("%Y-%m-%d %H:%M") - - def log_games_in_progress(): - try: - msg = "games in progress were: %s" % ( - " ".join(sorted(self.games_in_progress))) - except Exception: - pass - self.log(msg) - - self._open_files() - self.competition.set_event_logger(self.log) - self.competition.set_history_logger(self.log_history) - - self._initialise_presenter() - self._initialise_terminal_reader() - - allow_mp = (self.worker_count is not None) - self.log("run started at %s with max_games %s" % (now(), max_games)) - if allow_mp: - self.log("using %d worker processes" % self.worker_count) - self.max_games_this_run = max_games - self._update_display() - try: - job_manager.run_jobs( - job_source=self, - allow_mp=allow_mp, max_workers=self.worker_count, - passed_exceptions=[RingmasterError, CompetitionError, - RingmasterInternalError]) - except KeyboardInterrupt: - self.log("run interrupted at %s" % now()) - log_games_in_progress() - raise - except (RingmasterError, CompetitionError), e: - self.log("run finished with error at %s\n%s" % (now(), e)) - log_games_in_progress() - raise RingmasterError(e) - except (job_manager.JobSourceError, RingmasterInternalError), e: - self.log("run finished with internal error at %s\n%s" % (now(), e)) - log_games_in_progress() - raise RingmasterInternalError(e) - except: - self.log("run finished with internal error at %s" % now()) - self.log(compact_tracebacks.format_traceback()) - log_games_in_progress() - raise - self.log("run finished at %s" % now()) - self._close_files() - - def delete_state_and_output(self): - """Delete all files generated by this competition. - - Deletes the persistent state file, game records, log files, and reports. - - """ - for pathname in [ - self.log_pathname, - self.status_pathname, - self.command_pathname, - self.history_pathname, - self.report_pathname, - ]: - if os.path.exists(pathname): - try: - os.remove(pathname) - except EnvironmentError, e: - print >>sys.stderr, e - for pathname in [ - self.sgf_dir_pathname, - self.void_dir_pathname, - self.gtplog_dir_pathname, - ]: - if os.path.exists(pathname): - try: - shutil.rmtree(pathname) - except EnvironmentError, e: - print >>sys.stderr, e - - def check_players(self, discard_stderr=False): - """Check that the engines required for the competition will run. - - If an engine fails, prints a description of the problem and returns - False without continuing to check. - - Otherwise returns True. - - """ - try: - to_check = self.competition.get_player_checks() - except CompetitionError, e: - raise RingmasterError(e) - for check in to_check: - if not discard_stderr: - print "checking player %s" % check.player.code - try: - msgs = game_jobs.check_player(check, discard_stderr) - except game_jobs.CheckFailed, e: - print "player %s failed startup check:\n%s" % ( - check.player.code, e) - return False - else: - if not discard_stderr: - for msg in msgs: - print msg - return True - diff --git a/gomill/settings.py b/gomill/settings.py deleted file mode 100644 index 9595be0..0000000 --- a/gomill/settings.py +++ /dev/null @@ -1,436 +0,0 @@ -"""Support for describing configurable values.""" - -import re -import shlex - -__all__ = ['Setting', 'allow_none', 'load_settings', - 'Config_proxy', 'Quiet_config', - 'interpret_any', 'interpret_bool', - 'interpret_int', 'interpret_positive_int', 'interpret_float', - 'interpret_8bit_string', 'interpret_identifier', - 'interpret_as_utf8', 'interpret_as_utf8_stripped', - 'interpret_colour', 'interpret_enum', 'interpret_callable', - 'interpret_shlex_sequence', - 'interpret_sequence', 'interpret_sequence_of', - 'interpret_sequence_of_quiet_configs', - 'interpret_map', 'interpret_map_of', - 'clean_string', - ] - -def interpret_any(v): - return v - -def interpret_bool(b): - if b is not True and b is not False: - raise ValueError("invalid True/False value") - return b - -def interpret_int(i): - if not isinstance(i, int) or isinstance(i, long): - raise ValueError("invalid integer") - return i - -def interpret_positive_int(i): - if not isinstance(i, int) or isinstance(i, long): - raise ValueError("invalid integer") - if i <= 0: - raise ValueError("must be positive integer") - return i - -def interpret_float(f): - if isinstance(f, float): - return f - if isinstance(f, int) or isinstance(f, long): - return float(f) - raise ValueError("invalid float") - -def interpret_8bit_string(s): - if isinstance(s, str): - result = s - elif isinstance(s, unicode): - try: - result = s.encode("ascii") - except UnicodeEncodeError: - raise ValueError("non-ascii character in unicode string") - else: - raise ValueError("not a string") - if '\0' in s: - raise ValueError("contains NUL") - return result - -def interpret_as_utf8(s): - if isinstance(s, str): - try: - s.decode("utf-8") - except UnicodeDecodeError: - raise ValueError("not a valid utf-8 string") - return s - if isinstance(s, unicode): - return s.encode("utf-8") - if s is None: - return "" - raise ValueError("invalid string") - -def interpret_as_utf8_stripped(s): - return interpret_as_utf8(s).strip() - - -def clean_string(s): - return re.sub(r"[\x00-\x1f\x7f-\x9f]", "?", s) - -# NB, tuners use '#' in player codes -_identifier_re = re.compile(r"\A[-!$%&*+-.:;<=>?^_~a-zA-Z0-9]*\Z") - -def interpret_identifier(s): - if isinstance(s, unicode): - try: - s = s.encode("ascii") - except UnicodeEncodeError: - raise ValueError( - "contains forbidden character: %s" % - clean_string(s.encode("ascii", "replace"))) - elif not isinstance(s, str): - raise ValueError("not a string") - if not s: - raise ValueError("empty string") - if not _identifier_re.search(s): - raise ValueError("contains forbidden character: %s" % clean_string(s)) - return s - -_colour_dict = { - 'b' : 'b', - 'black' : 'b', - 'w' : 'w', - 'white' : 'w', - } - -def interpret_colour(s): - if isinstance(s, basestring): - try: - return _colour_dict[s.lower()] - except KeyError: - pass - raise ValueError("invalid colour") - -def interpret_enum(*values): - def interpreter(value): - if value not in values: - raise ValueError("unknown value") - return value - return interpreter - -def interpret_callable(c): - if not callable(c): - raise ValueError("invalid callable") - return c - -def interpret_shlex_sequence(v): - """Interpret a sequence of 'shlex' tokens. - - If v is a string, calls shlex.split() on it. - - Otherwise, treats it as a list of strings. - - Rejects empty sequences. - - """ - if isinstance(v, basestring): - result = shlex.split(interpret_8bit_string(v)) - else: - try: - l = interpret_sequence(v) - except ValueError: - raise ValueError("not a string or a sequence") - try: - result = [interpret_8bit_string(s) for s in l] - except ValueError, e: - raise ValueError("element %s" % e) - if not result: - raise ValueError("empty") - return result - - -def interpret_sequence(l): - """Interpret a list-like object. - - Accepts any iterable and returns a list. - - """ - try: - l = list(l) - except Exception: - raise ValueError("not a sequence") - return l - -def interpret_sequence_of(item_interpreter): - """Make an interpreter for list-like objects. - - The interpreter behaves like interpret_list, and additionally calls - item_interpreter for each list item. - - """ - def interpreter(value): - l = interpret_sequence(value) - for i, v in enumerate(l): - try: - l[i] = item_interpreter(v) - except ValueError, e: - raise ValueError("item %s: %s" % (i, e)) - return l - return interpreter - -def interpret_sequence_of_quiet_configs(cls, allow_simple_values=False): - """Make an interpreter for sequences of a given Quiet_config. - - If 'allow_simple_values' is true, any value which isn't an instance of 'cls' - will be used (as a single positional parameter) to instantiate a 'cls' - instance. - - """ - def interpret(v): - if not isinstance(v, cls): - if allow_simple_values: - v = cls(v) - else: - raise ValueError("not a %s" % cls.get_type_name()) - return v - return interpret_sequence_of(interpret) - -def interpret_map(m): - """Interpret a map-like object. - - Accepts anything that dict() accepts for its first argument. - - Returns a list of pairs (key, value). - - """ - try: - d = dict(m) - except Exception: - raise ValueError("not a map") - return d.items() - -def interpret_map_of(key_interpreter, value_interpreter): - """Make an interpreter for map-like objects. - - The interpreter behaves like interpret_map, and additionally calls - key_interpreter for each key and value_interpreter for each value. - - Sorts the result by key. - - """ - def interpreter(m): - result = [] - for key, value in interpret_map(m): - try: - new_key = key_interpreter(key) - except ValueError, e: - raise ValueError("bad key: %s" % e) - try: - new_value = value_interpreter(value) - except ValueError, e: - # we assume validated keys are fit to print - raise ValueError("bad value for '%s': %s" % (new_key, e)) - result.append((new_key, new_value)) - # We assume validated items are suitable for sorting - return sorted(result) - return interpreter - -def allow_none(fn): - """Make a new interpreter from an existing one, which maps None to None.""" - def sub(v): - if v is None: - return None - return fn(v) - return sub - -_nodefault = object() - -class Setting(object): - """Describe a single setting. - - Instantiate with: - setting name - interpreter function - optionally: - default value, or - defaultmaker -- callable creating the default value - - """ - def __init__(self, name, interpreter, - default=_nodefault, defaultmaker=None): - self.name = name - self.interpreter = interpreter - self.default = default - self.defaultmaker = defaultmaker - - def get_default(self): - """Return the default value for this setting. - - Raises KeyError if there isn't one. - - """ - if self.default is not _nodefault: - return self.default - if self.defaultmaker is not None: - return self.defaultmaker() - raise KeyError - - def interpret(self, value): - """Validate the value and normalise if necessary. - - Returns the normalised value (usually unchanged). - - Raises ValueError with a description if the value is invalid. - - """ - try: - return self.interpreter(value) - except ValueError, e: - raise ValueError("'%s': %s" % (self.name, e)) - -def load_settings(settings, config, apply_defaults=True, allow_missing=False): - """Read settings values from configuration. - - settings -- list of Settings - config -- dict containing the values to be read - apply_defaults -- bool (default true) - allow_missing -- bool (default false) - - Returns a dict: setting name -> interpreted value - - Handling of values which aren't present in 'config': - - if apply_defaults is true, the setting's default is substituted - - if apply_defaults is false or the setting has no default: - - if allow_missing is true, omits the setting from the returned dict - - if allow_missing is false, raises ValueError - - Resolves Config_proxy objects (see below) - - Raises ValueError with a description if a value can't be interpreted. - - """ - result = {} - for setting in settings: - try: - try: - v = config[setting.name] - if isinstance(v, Config_proxy): - try: - v = v.resolve() - except ValueError, e: - raise ValueError("'%s': %s" % (setting.name, e)) - # May propagate ValueError - v = setting.interpret(v) - except KeyError: - if apply_defaults: - v = setting.get_default() - else: - raise - except KeyError: - if allow_missing: - continue - else: - raise ValueError("'%s' not specified" % setting.name) - result[setting.name] = v - return result - -class Config_proxy(object): - """Class proxy for use in control files. - - To use this, define a subclass, giving it the following class attribute: - underlying -- the underlying class - - Then in the control file, the proxy can be used anywhere which will be - interpreted using the settings mechanism. An instance of the underlying - class will be created by load_settings and then passed to the interpret - function as usual. - - Any errors from the underlying class's __init__ will be raised as ValueError - from load_settings(). - - """ - def __init__(self, *args, **kwargs): - self.args = args - self.kwargs = kwargs - - def resolve(self): - try: - return self.underlying(*self.args, **self.kwargs) - except Exception, e: - raise ValueError("invalid parameters for %s:\n%s" % - (self.__class__.__name__, e)) - - -class Quiet_config(object): - """Configuration object for use in control files. - - At instantiation time, this just records its arguments, so they can be - validated later. - - """ - # These may be specified as positional or keyword - positional_arguments = () - # These are keyword-only - keyword_arguments = () - # Used by interpret_sequence_of_quiet_configs - type_name = None - - def __init__(self, *args, **kwargs): - self.args = args - self.kwargs = kwargs - - @classmethod - def get_type_name(cls): - """Return a name for the config type, for use in error messages.""" - if cls.type_name is not None: - return cls.type_name - return cls.__name__.partition("_config")[0] - - def resolve_arguments(self): - """Combine positional and keyword arguments. - - Returns a dict: argument name -> value - - Raises ValueError if the arguments are invalid. - - Checks for: - - too many positional arguments - - unknown keyword arguments - - argument specified as both positional and keyword - - Unspecified arguments (either positional or keyword) are not considered - errors; they're just not included in the result. - - """ - result = {} - if len(self.args) > len(self.positional_arguments): - raise ValueError("too many positional arguments") - for name, val in zip(self.positional_arguments, self.args): - result[name] = val - allowed = set(self.positional_arguments + self.keyword_arguments) - for name, val in sorted(self.kwargs.iteritems()): - if name not in allowed: - raise ValueError("unknown argument '%s'" % name) - if name in result: - raise ValueError( - "%s specified as both positional and keyword argument" % - name) - result[name] = val - return result - - def get_key(self): - """Retrieve the first positional argument, if possible. - - Does the right thing if it was specified as a keyword argument. - - Returns None if there isn't one. - - """ - try: - if self.args: - return self.args[0] - return self.kwargs[self.positional_arguments[0]] - except LookupError: - return None - diff --git a/gomill/sgf.py b/gomill/sgf.py deleted file mode 100644 index a55d40f..0000000 --- a/gomill/sgf.py +++ /dev/null @@ -1,806 +0,0 @@ -"""Represent SGF games. - -This is intended for use with SGF FF[4]; see http://www.red-bean.com/sgf/ - -""" - -import datetime - -from gomill import sgf_grammar -from gomill import sgf_properties - - -class Node(object): - """An SGF node. - - Instantiate with a raw property map (see sgf_grammar) and an - sgf_properties.Presenter. - - A Node doesn't belong to a particular game (cf Tree_node below), but it - knows its board size (in order to interpret move values) and the encoding - to use for the raw property strings. - - Changing the SZ property isn't allowed. - - """ - def __init__(self, property_map, presenter): - # Map identifier (PropIdent) -> nonempty list of raw values - self._property_map = property_map - self._presenter = presenter - - def get_size(self): - """Return the board size used to interpret property values.""" - return self._presenter.size - - def get_encoding(self): - """Return the encoding used for raw property values. - - Returns a string (a valid Python codec name, eg "UTF-8"). - - """ - return self._presenter.encoding - - def get_presenter(self): - """Return the node's sgf_properties.Presenter.""" - return self._presenter - - def has_property(self, identifier): - """Check whether the node has the specified property.""" - return identifier in self._property_map - - def properties(self): - """Find the properties defined for the node. - - Returns a list of property identifiers, in unspecified order. - - """ - return self._property_map.keys() - - def get_raw_list(self, identifier): - """Return the raw values of the specified property. - - Returns a nonempty list of 8-bit strings, in the raw property encoding. - - The strings contain the exact bytes that go between the square brackets - (without interpreting escapes or performing any whitespace conversion). - - Raises KeyError if there was no property with the given identifier. - - (If the property is an empty elist, this returns a list containing a - single empty string.) - - """ - return self._property_map[identifier] - - def get_raw(self, identifier): - """Return a single raw value of the specified property. - - Returns an 8-bit string, in the raw property encoding. - - The string contains the exact bytes that go between the square brackets - (without interpreting escapes or performing any whitespace conversion). - - Raises KeyError if there was no property with the given identifier. - - If the property has multiple values, this returns the first (if the - value is an empty elist, this returns an empty string). - - """ - return self._property_map[identifier][0] - - def get_raw_property_map(self): - """Return the raw values of all properties as a dict. - - Returns a dict mapping property identifiers to lists of raw values - (see get_raw_list()). - - Returns the same dict each time it's called. - - Treat the returned dict as read-only. - - """ - return self._property_map - - - def _set_raw_list(self, identifier, values): - if identifier == "SZ" and values != [str(self._presenter.size)]: - raise ValueError("changing size is not permitted") - self._property_map[identifier] = values - - def unset(self, identifier): - """Remove the specified property. - - Raises KeyError if the property isn't currently present. - - """ - if identifier == "SZ" and self._presenter.size != 19: - raise ValueError("changing size is not permitted") - del self._property_map[identifier] - - - def set_raw_list(self, identifier, values): - """Set the raw values of the specified property. - - identifier -- ascii string passing is_valid_property_identifier() - values -- nonempty iterable of 8-bit strings in the raw property - encoding - - The values specify the exact bytes to appear between the square - brackets in the SGF file; you must perform any necessary escaping - first. - - (To specify an empty elist, pass a list containing a single empty - string.) - - """ - if not sgf_grammar.is_valid_property_identifier(identifier): - raise ValueError("ill-formed property identifier") - values = list(values) - if not values: - raise ValueError("empty property list") - for value in values: - if not sgf_grammar.is_valid_property_value(value): - raise ValueError("ill-formed raw property value") - self._set_raw_list(identifier, values) - - def set_raw(self, identifier, value): - """Set the specified property to a single raw value. - - identifier -- ascii string passing is_valid_property_identifier() - value -- 8-bit string in the raw property encoding - - The value specifies the exact bytes to appear between the square - brackets in the SGF file; you must perform any necessary escaping - first. - - """ - if not sgf_grammar.is_valid_property_identifier(identifier): - raise ValueError("ill-formed property identifier") - if not sgf_grammar.is_valid_property_value(value): - raise ValueError("ill-formed raw property value") - self._set_raw_list(identifier, [value]) - - - def get(self, identifier): - """Return the interpreted value of the specified property. - - Returns the value as a suitable Python representation. - - Raises KeyError if the node does not have a property with the given - identifier. - - Raises ValueError if it cannot interpret the value. - - See sgf_properties.Presenter.interpret() for details. - - """ - return self._presenter.interpret( - identifier, self._property_map[identifier]) - - def set(self, identifier, value): - """Set the value of the specified property. - - identifier -- ascii string passing is_valid_property_identifier() - value -- new property value (in its Python representation) - - For properties with value type 'none', use value True. - - Raises ValueError if it cannot represent the value. - - See sgf_properties.Presenter.serialise() for details. - - """ - self._set_raw_list( - identifier, self._presenter.serialise(identifier, value)) - - def get_raw_move(self): - """Return the raw value of the move from a node. - - Returns a pair (colour, raw value) - - colour is 'b' or 'w'. - - Returns None, None if the node contains no B or W property. - - """ - values = self._property_map.get("B") - if values is not None: - colour = "b" - else: - values = self._property_map.get("W") - if values is not None: - colour = "w" - else: - return None, None - return colour, values[0] - - def get_move(self): - """Retrieve the move from a node. - - Returns a pair (colour, move) - - colour is 'b' or 'w'. - - move is (row, col), or None for a pass. - - Returns None, None if the node contains no B or W property. - - """ - colour, raw = self.get_raw_move() - if colour is None: - return None, None - return (colour, - sgf_properties.interpret_go_point(raw, self._presenter.size)) - - def get_setup_stones(self): - """Retrieve Add Black / Add White / Add Empty properties from a node. - - Returns a tuple (black_points, white_points, empty_points) - - Each value is a set of pairs (row, col). - - """ - try: - bp = self.get("AB") - except KeyError: - bp = set() - try: - wp = self.get("AW") - except KeyError: - wp = set() - try: - ep = self.get("AE") - except KeyError: - ep = set() - return bp, wp, ep - - def has_setup_stones(self): - """Check whether the node has any AB/AW/AE properties.""" - d = self._property_map - return ("AB" in d or "AW" in d or "AE" in d) - - def set_move(self, colour, move): - """Set the B or W property. - - colour -- 'b' or 'w'. - move -- (row, col), or None for a pass. - - Replaces any existing B or W property in the node. - - """ - if colour not in ('b', 'w'): - raise ValueError - if 'B' in self._property_map: - del self._property_map['B'] - if 'W' in self._property_map: - del self._property_map['W'] - self.set(colour.upper(), move) - - def set_setup_stones(self, black, white, empty=None): - """Set Add Black / Add White / Add Empty properties. - - black, white, empty -- list or set of pairs (row, col) - - Removes any existing AB/AW/AE properties from the node. - - """ - if 'AB' in self._property_map: - del self._property_map['AB'] - if 'AW' in self._property_map: - del self._property_map['AW'] - if 'AE' in self._property_map: - del self._property_map['AE'] - if black: - self.set('AB', black) - if white: - self.set('AW', white) - if empty: - self.set('AE', empty) - - def add_comment_text(self, text): - """Add or extend the node's comment. - - If the node doesn't have a C property, adds one with the specified - text. - - Otherwise, adds the specified text to the existing C property value - (with two newlines in front). - - """ - if self.has_property('C'): - self.set('C', self.get('C') + "\n\n" + text) - else: - self.set('C', text) - - def __str__(self): - def format_property(ident, values): - return ident + "".join("[%s]" % s for s in values) - return "\n".join( - format_property(ident, values) - for (ident, values) in sorted(self._property_map.items())) \ - + "\n" - - -class Tree_node(Node): - """A node embedded in an SGF game. - - A Tree_node is a Node that also knows its position within an Sgf_game. - - Do not instantiate directly; retrieve from an Sgf_game or another Tree_node. - - A Tree_node is a list-like container of its children: it can be indexed, - sliced, and iterated over like a list, and supports index(). - - A Tree_node with no children is treated as having truth value false. - - Public attributes (treat as read-only): - owner -- the node's Sgf_game - parent -- the nodes's parent Tree_node (None for the root node) - - """ - def __init__(self, parent, properties): - self.owner = parent.owner - self.parent = parent - self._children = [] - Node.__init__(self, properties, parent._presenter) - - def _add_child(self, node): - self._children.append(node) - - def __len__(self): - return len(self._children) - - def __getitem__(self, key): - return self._children[key] - - def index(self, child): - return self._children.index(child) - - def new_child(self, index=None): - """Create a new Tree_node and add it as this node's last child. - - If 'index' is specified, the new node is inserted in the child list at - the specified index instead (behaves like list.insert). - - Returns the new node. - - """ - child = Tree_node(self, {}) - if index is None: - self._children.append(child) - else: - self._children.insert(index, child) - return child - - def delete(self): - """Remove this node from its parent.""" - if self.parent is None: - raise ValueError("can't remove the root node") - self.parent._children.remove(self) - - def reparent(self, new_parent, index=None): - """Move this node to a new place in the tree. - - new_parent -- Tree_node from the same game. - - Raises ValueError if the new parent is this node or one of its - descendants. - - If 'index' is specified, the node is inserted in the new parent's child - list at the specified index (behaves like list.insert); otherwise it's - placed at the end. - - """ - if new_parent.owner != self.owner: - raise ValueError("new parent doesn't belong to the same game") - n = new_parent - while True: - if n == self: - raise ValueError("would create a loop") - n = n.parent - if n is None: - break - # self.parent is not None because moving the root would create a loop. - self.parent._children.remove(self) - self.parent = new_parent - if index is None: - new_parent._children.append(self) - else: - new_parent._children.insert(index, self) - - def find(self, identifier): - """Find the nearest ancestor-or-self containing the specified property. - - Returns a Tree_node, or None if there is no such node. - - """ - node = self - while node is not None: - if node.has_property(identifier): - return node - node = node.parent - return None - - def find_property(self, identifier): - """Return the value of a property, defined at this node or an ancestor. - - This is intended for use with properties of type 'game-info', and with - properties with the 'inherit' attribute. - - This returns the interpreted value, in the same way as get(). - - It searches up the tree, in the same way as find(). - - Raises KeyError if no node defining the property is found. - - """ - node = self.find(identifier) - if node is None: - raise KeyError - return node.get(identifier) - -class _Root_tree_node(Tree_node): - """Variant of Tree_node used for a game root.""" - def __init__(self, property_map, owner): - self.owner = owner - self.parent = None - self._children = [] - Node.__init__(self, property_map, owner.presenter) - -class _Unexpanded_root_tree_node(_Root_tree_node): - """Variant of _Root_tree_node used with 'loaded' Sgf_games.""" - def __init__(self, owner, coarse_tree): - _Root_tree_node.__init__(self, coarse_tree.sequence[0], owner) - self._coarse_tree = coarse_tree - - def _expand(self): - sgf_grammar.make_tree( - self._coarse_tree, self, Tree_node, Tree_node._add_child) - delattr(self, '_coarse_tree') - self.__class__ = _Root_tree_node - - def __len__(self): - self._expand() - return self.__len__() - - def __getitem__(self, key): - self._expand() - return self.__getitem__(key) - - def index(self, child): - self._expand() - return self.index(child) - - def new_child(self, index=None): - self._expand() - return self.new_child(index) - - def _main_sequence_iter(self): - presenter = self._presenter - for properties in sgf_grammar.main_sequence_iter(self._coarse_tree): - yield Node(properties, presenter) - - -class Sgf_game(object): - """An SGF game tree. - - The complete game tree is represented using Tree_nodes. The various methods - which return Tree_nodes will always return the same object for the same - node. - - Instantiate with - size -- int (board size), in range 1 to 26 - encoding -- the raw property encoding (default "UTF-8") - - 'encoding' must be a valid Python codec name. - - The following root node properties are set for newly-created games: - FF[4] - GM[1] - SZ[size] - CA[encoding] - - Changing FF and GM is permitted (but this library will carry on using the - FF[4] and GM[1] rules). Changing SZ is not permitted (unless the change - leaves the effective value unchanged). Changing CA is permitted; this - controls the encoding used by serialise(). - - """ - def __new__(cls, size, encoding="UTF-8", *args, **kwargs): - # To complete initialisation after this, you need to set 'root'. - if not 1 <= size <= 26: - raise ValueError("size out of range: %s" % size) - game = super(Sgf_game, cls).__new__(cls) - game.size = size - game.presenter = sgf_properties.Presenter(size, encoding) - return game - - def __init__(self, *args, **kwargs): - self.root = _Root_tree_node({}, self) - self.root.set_raw('FF', "4") - self.root.set_raw('GM', "1") - self.root.set_raw('SZ', str(self.size)) - # Read the encoding back so we get the normalised form - self.root.set_raw('CA', self.presenter.encoding) - - @classmethod - def from_coarse_game_tree(cls, coarse_game, override_encoding=None): - """Alternative constructor: create an Sgf_game from the parser output. - - coarse_game -- Coarse_game_tree - override_encoding -- encoding name, eg "UTF-8" (optional) - - The nodes' property maps (as returned by get_raw_property_map()) will - be the same dictionary objects as the ones from the Coarse_game_tree. - - The board size and raw property encoding are taken from the SZ and CA - properties in the root node (defaulting to 19 and "ISO-8859-1", - respectively). - - If override_encoding is specified, the source data is assumed to be in - the specified encoding (no matter what the CA property says), and the - CA property is set to match. - - """ - try: - size_s = coarse_game.sequence[0]['SZ'][0] - except KeyError: - size = 19 - else: - try: - size = int(size_s) - except ValueError: - raise ValueError("bad SZ property: %s" % size_s) - if override_encoding is None: - try: - encoding = coarse_game.sequence[0]['CA'][0] - except KeyError: - encoding = "ISO-8859-1" - else: - encoding = override_encoding - game = cls.__new__(cls, size, encoding) - game.root = _Unexpanded_root_tree_node(game, coarse_game) - if override_encoding is not None: - game.root.set_raw("CA", game.presenter.encoding) - return game - - @classmethod - def from_string(cls, s, override_encoding=None): - """Alternative constructor: read a single Sgf_game from a string. - - s -- 8-bit string - - Raises ValueError if it can't parse the string. See parse_sgf_game() - for details. - - See from_coarse_game_tree for details of size and encoding handling. - - """ - coarse_game = sgf_grammar.parse_sgf_game(s) - return cls.from_coarse_game_tree(coarse_game, override_encoding) - - def serialise(self, wrap=79): - """Serialise the SGF data as a string. - - wrap -- int (default 79), or None - - Returns an 8-bit string, in the encoding specified by the CA property - in the root node (defaulting to "ISO-8859-1"). - - If the raw property encoding and the target encoding match (which is - the usual case), the raw property values are included unchanged in the - output (even if they are improperly encoded.) - - Otherwise, if any raw property value is improperly encoded, - UnicodeDecodeError is raised, and if any property value can't be - represented in the target encoding, UnicodeEncodeError is raised. - - If the target encoding doesn't identify a Python codec, ValueError is - raised. Behaviour is unspecified if the target encoding isn't - ASCII-compatible (eg, UTF-16). - - If 'wrap' is not None, makes some effort to keep output lines no longer - than 'wrap'. - - """ - try: - encoding = self.get_charset() - except ValueError: - raise ValueError("unsupported charset: %s" % - self.root.get_raw_list("CA")) - coarse_tree = sgf_grammar.make_coarse_game_tree( - self.root, lambda node:node, Node.get_raw_property_map) - serialised = sgf_grammar.serialise_game_tree(coarse_tree, wrap) - if encoding == self.root.get_encoding(): - return serialised - else: - return serialised.decode(self.root.get_encoding()).encode(encoding) - - - def get_property_presenter(self): - """Return the property presenter. - - Returns an sgf_properties.Presenter. - - This can be used to customise how property values are interpreted and - serialised. - - """ - return self.presenter - - def get_root(self): - """Return the root node (as a Tree_node).""" - return self.root - - def get_last_node(self): - """Return the last node in the 'leftmost' variation (as a Tree_node).""" - node = self.root - while node: - node = node[0] - return node - - def get_main_sequence(self): - """Return the 'leftmost' variation. - - Returns a list of Tree_nodes, from the root to a leaf. - - """ - node = self.root - result = [node] - while node: - node = node[0] - result.append(node) - return result - - def get_main_sequence_below(self, node): - """Return the 'leftmost' variation below the specified node. - - node -- Tree_node - - Returns a list of Tree_nodes, from the first child of 'node' to a leaf. - - """ - if node.owner is not self: - raise ValueError("node doesn't belong to this game") - result = [] - while node: - node = node[0] - result.append(node) - return result - - def get_sequence_above(self, node): - """Return the partial variation leading to the specified node. - - node -- Tree_node - - Returns a list of Tree_nodes, from the root to the parent of 'node'. - - """ - if node.owner is not self: - raise ValueError("node doesn't belong to this game") - result = [] - while node.parent is not None: - node = node.parent - result.append(node) - result.reverse() - return result - - def main_sequence_iter(self): - """Provide the 'leftmost' variation as an iterator. - - Returns an iterator providing Node instances, from the root to a leaf. - - The Node instances may or may not be Tree_nodes. - - It's OK to use these Node instances to modify properties: even if they - are not the same objects as returned by the main tree navigation - methods, they share the underlying property maps. - - If you know the game has no variations, or you're only interested in - the 'leftmost' variation, you can use this function to retrieve the - nodes without building the entire game tree. - - """ - if isinstance(self.root, _Unexpanded_root_tree_node): - return self.root._main_sequence_iter() - return iter(self.get_main_sequence()) - - def extend_main_sequence(self): - """Create a new Tree_node and add to the 'leftmost' variation. - - Returns the new node. - - """ - return self.get_last_node().new_child() - - def get_size(self): - """Return the board size as an integer.""" - return self.size - - def get_charset(self): - """Return the effective value of the CA root property. - - This applies the default, and returns the normalised form. - - Raises ValueError if the CA property doesn't identify a Python codec. - - """ - try: - s = self.root.get("CA") - except KeyError: - return "ISO-8859-1" - try: - return sgf_properties.normalise_charset_name(s) - except LookupError: - raise ValueError("no codec available for CA %s" % s) - - def get_komi(self): - """Return the komi as a float. - - Returns 0.0 if the KM property isn't present in the root node. - - Raises ValueError if the KM property is malformed. - - """ - try: - return self.root.get("KM") - except KeyError: - return 0.0 - - def get_handicap(self): - """Return the number of handicap stones as a small integer. - - Returns None if the HA property isn't present, or has (illegal) value - zero. - - Raises ValueError if the HA property is otherwise malformed. - - """ - try: - handicap = self.root.get("HA") - except KeyError: - return None - if handicap == 0: - handicap = None - elif handicap == 1: - raise ValueError - return handicap - - def get_player_name(self, colour): - """Return the name of the specified player. - - Returns None if there is no corresponding 'PB' or 'PW' property. - - """ - try: - return self.root.get({'b' : 'PB', 'w' : 'PW'}[colour]) - except KeyError: - return None - - def get_winner(self): - """Return the colour of the winning player. - - Returns None if there is no RE property, or if neither player won. - - """ - try: - colour = self.root.get("RE")[0].lower() - except LookupError: - return None - if colour not in ("b", "w"): - return None - return colour - - def set_date(self, date=None): - """Set the DT property to a single date. - - date -- datetime.date (defaults to today) - - (SGF allows dates to be rather more complicated than this, so there's - no corresponding get_date() method.) - - """ - if date is None: - date = datetime.date.today() - self.root.set('DT', date.strftime("%Y-%m-%d")) - diff --git a/gomill/sgf_grammar.py b/gomill/sgf_grammar.py deleted file mode 100644 index a54c99a..0000000 --- a/gomill/sgf_grammar.py +++ /dev/null @@ -1,513 +0,0 @@ -"""Parse and serialise SGF data. - -This is intended for use with SGF FF[4]; see http://www.red-bean.com/sgf/ - -Nothing in this module is Go-specific. - -This module is encoding-agnostic: it works with 8-bit strings in an arbitrary -'ascii-compatible' encoding. - - -In the documentation below, a _property map_ is a dict mapping a PropIdent to a -nonempty list of raw property values. - -A raw property value is an 8-bit string containing a PropValue without its -enclosing brackets, but with backslashes and line endings left untouched. - -So a property map's keys should pass is_valid_property_identifier(), and its -values should pass is_valid_property_value(). - -""" - -import re -import string - - -_propident_re = re.compile(r"\A[A-Z]{1,8}\Z") -_propvalue_re = re.compile(r"\A [^\\\]]* (?: \\. [^\\\]]* )* \Z", - re.VERBOSE | re.DOTALL) -_find_start_re = re.compile(r"\(\s*;") -_tokenise_re = re.compile(r""" -\s* -(?: - \[ (?P [^\\\]]* (?: \\. [^\\\]]* )* ) \] # PropValue - | - (?P [A-Za-z]{1,12} ) # PropIdent - | - (?P [;()] ) # delimiter -) -""", re.VERBOSE | re.DOTALL) - - -def is_valid_property_identifier(s): - """Check whether 's' is a well-formed PropIdent. - - s -- 8-bit string - - This accepts the same values as the tokeniser. - - Details: - - it doesn't permit lower-case letters (these are allowed in some ancient - SGF variants) - - it accepts at most 8 letters (there is no limit in the spec; no standard - property has more than 2) - - """ - return bool(_propident_re.search(s)) - -def is_valid_property_value(s): - """Check whether 's' is a well-formed PropValue. - - s -- 8-bit string - - This accepts the same values as the tokeniser: any string that doesn't - contain an unescaped ] or end with an unescaped \ . - - """ - return bool(_propvalue_re.search(s)) - -def tokenise(s, start_position=0): - """Tokenise a string containing SGF data. - - s -- 8-bit string - start_position -- index into 's' - - Skips leading junk. - - Returns a list of pairs of strings (token type, contents), and also the - index in 's' of the start of the unprocessed 'tail'. - - token types and contents: - I -- PropIdent: upper-case letters - V -- PropValue: raw value, without the enclosing brackets - D -- delimiter: ';', '(', or ')' - - Stops when it has seen as many closing parens as open ones, at the end of - the string, or when it first finds something it can't tokenise. - - The first two tokens are always '(' and ';' (otherwise it won't find the - start of the content). - - """ - result = [] - m = _find_start_re.search(s, start_position) - if not m: - return [], 0 - i = m.start() - depth = 0 - while True: - m = _tokenise_re.match(s, i) - if not m: - break - group = m.lastgroup - token = m.group(m.lastindex) - result.append((group, token)) - i = m.end() - if group == 'D': - if token == '(': - depth += 1 - elif token == ')': - depth -= 1 - if depth == 0: - break - return result, i - -class Coarse_game_tree(object): - """An SGF GameTree. - - This is a direct representation of the SGF parse tree. It's 'coarse' in the - sense that the objects in the tree structure represent node sequences, not - individual nodes. - - Public attributes - sequence -- nonempty list of property maps - children -- list of Coarse_game_trees - - The sequence represents the nodes before the variations. - - """ - def __init__(self): - self.sequence = [] # must be at least one node - self.children = [] # may be empty - -def _parse_sgf_game(s, start_position): - """Common implementation for parse_sgf_game and parse_sgf_games.""" - tokens, end_position = tokenise(s, start_position) - if not tokens: - return None, None - stack = [] - game_tree = None - sequence = None - properties = None - index = 0 - try: - while True: - token_type, token = tokens[index] - index += 1 - if token_type == 'V': - raise ValueError("unexpected value") - if token_type == 'D': - if token == ';': - if sequence is None: - raise ValueError("unexpected node") - properties = {} - sequence.append(properties) - else: - if sequence is not None: - if not sequence: - raise ValueError("empty sequence") - game_tree.sequence = sequence - sequence = None - if token == '(': - stack.append(game_tree) - game_tree = Coarse_game_tree() - sequence = [] - else: - # token == ')' - variation = game_tree - game_tree = stack.pop() - if game_tree is None: - break - game_tree.children.append(variation) - properties = None - else: - # token_type == 'I' - prop_ident = token - prop_values = [] - while True: - token_type, token = tokens[index] - if token_type != 'V': - break - index += 1 - prop_values.append(token) - if not prop_values: - raise ValueError("property with no values") - try: - if prop_ident in properties: - properties[prop_ident] += prop_values - else: - properties[prop_ident] = prop_values - except TypeError: - raise ValueError("property value outside a node") - except IndexError: - raise ValueError("unexpected end of SGF data") - assert index == len(tokens) - return variation, end_position - -def parse_sgf_game(s): - """Read a single SGF game from a string, returning the parse tree. - - s -- 8-bit string - - Returns a Coarse_game_tree. - - Applies the rules for FF[4]. - - Raises ValueError if can't parse the string. - - If a property appears more than once in a node (which is not permitted by - the spec), treats it the same as a single property with multiple values. - - - Identifies the start of the SGF content by looking for '(;' (with possible - whitespace between); ignores everything preceding that. Ignores everything - following the first game. - - """ - game_tree, _ = _parse_sgf_game(s, 0) - if game_tree is None: - raise ValueError("no SGF data found") - return game_tree - -def parse_sgf_collection(s): - """Read an SGF game collection, returning the parse trees. - - s -- 8-bit string - - Returns a nonempty list of Coarse_game_trees. - - Raises ValueError if no games were found in the string. - - Raises ValueError if there is an error parsing a game. See - parse_sgf_game() for details. - - - Ignores non-SGF data before the first game, between games, and after the - final game. Identifies the start of each game in the same way as - parse_sgf_game(). - - """ - position = 0 - result = [] - while True: - try: - game_tree, position = _parse_sgf_game(s, position) - except ValueError, e: - raise ValueError("error parsing game %d: %s" % (len(result), e)) - if game_tree is None: - break - result.append(game_tree) - if not result: - raise ValueError("no SGF data found") - return result - - -def block_format(pieces, width=79): - """Concatenate strings, adding newlines. - - pieces -- iterable of strings - width -- int (default 79) - - Returns "".join(pieces), with added newlines between pieces as necessary to - avoid lines longer than 'width'. - - Leaves newlines inside 'pieces' untouched, and ignores them in its width - calculation. If a single piece is longer than 'width', it will become a - single long line in the output. - - """ - lines = [] - line = "" - for s in pieces: - if len(line) + len(s) > width: - lines.append(line) - line = "" - line += s - if line: - lines.append(line) - return "\n".join(lines) - -def serialise_game_tree(game_tree, wrap=79): - """Serialise an SGF game as a string. - - game_tree -- Coarse_game_tree - wrap -- int (default 79), or None - - Returns an 8-bit string, ending with a newline. - - If 'wrap' is not None, makes some effort to keep output lines no longer - than 'wrap'. - - """ - l = [] - to_serialise = [game_tree] - while to_serialise: - game_tree = to_serialise.pop() - if game_tree is None: - l.append(")") - continue - l.append("(") - for properties in game_tree.sequence: - l.append(";") - # Force FF to the front, largely to work around a Quarry bug which - # makes it ignore the first few bytes of the file. - for prop_ident, prop_values in sorted( - properties.iteritems(), - key=lambda (ident, _,): (-(ident=="FF"), ident)): - # Make a single string for each property, to get prettier - # block_format output. - m = [prop_ident] - for value in prop_values: - m.append("[%s]" % value) - l.append("".join(m)) - to_serialise.append(None) - to_serialise.extend(reversed(game_tree.children)) - l.append("\n") - if wrap is None: - return "".join(l) - else: - return block_format(l, wrap) - - -def make_tree(game_tree, root, node_builder, node_adder): - """Construct a node tree from a Coarse_game_tree. - - game_tree -- Coarse_game_tree - root -- node - node_builder -- function taking parameters (parent node, property map) - returning a node - node_adder -- function taking a pair (parent node, child node) - - Builds a tree of nodes corresponding to this GameTree, calling - node_builder() to make new nodes and node_adder() to add child nodes to - their parent. - - Makes no further assumptions about the node type. - - """ - to_build = [(root, game_tree, 0)] - while to_build: - node, game_tree, index = to_build.pop() - if index < len(game_tree.sequence) - 1: - child = node_builder(node, game_tree.sequence[index+1]) - node_adder(node, child) - to_build.append((child, game_tree, index+1)) - else: - node._children = [] - for child_tree in game_tree.children: - child = node_builder(node, child_tree.sequence[0]) - node_adder(node, child) - to_build.append((child, child_tree, 0)) - -def make_coarse_game_tree(root, get_children, get_properties): - """Construct a Coarse_game_tree from a node tree. - - root -- node - get_children -- function taking a node, returning a sequence of nodes - get_properties -- function taking a node, returning a property map - - Returns a Coarse_game_tree. - - Walks the node tree based at 'root' using get_children(), and uses - get_properties() to extract the raw properties. - - Makes no further assumptions about the node type. - - Doesn't check that the property maps have well-formed keys and values. - - """ - result = Coarse_game_tree() - to_serialise = [(result, root)] - while to_serialise: - game_tree, node = to_serialise.pop() - while True: - game_tree.sequence.append(get_properties(node)) - children = get_children(node) - if len(children) != 1: - break - node = children[0] - for child in children: - child_tree = Coarse_game_tree() - game_tree.children.append(child_tree) - to_serialise.append((child_tree, child)) - return result - - -def main_sequence_iter(game_tree): - """Provide the 'leftmost' complete sequence of a Coarse_game_tree. - - game_tree -- Coarse_game_tree - - Returns an iterable of property maps. - - If the game has no variations, this provides the complete game. Otherwise, - it chooses the first variation each time it has a choice. - - """ - while True: - for properties in game_tree.sequence: - yield properties - if not game_tree.children: - break - game_tree = game_tree.children[0] - - -_split_compose_re = re.compile( - r"( (?: [^\\:] | \\. )* ) :", - re.VERBOSE | re.DOTALL) - -def parse_compose(s): - """Split the parts of an SGF Compose value. - - If the value is a well-formed Compose, returns a pair of strings. - - If it isn't (ie, there is no delimiter), returns the complete string and - None. - - Interprets backslash escapes in order to find the delimiter, but leaves - backslash escapes unchanged in the returned strings. - - """ - m = _split_compose_re.match(s) - if not m: - return s, None - return m.group(1), s[m.end():] - -def compose(s1, s2): - """Construct a value of Compose value type. - - s1, s2 -- serialised form of a property value - - (This is only needed if the type of the first value permits colons.) - - """ - return s1.replace(":", "\\:") + ":" + s2 - - -_newline_re = re.compile(r"\n\r|\r\n|\n|\r") -_whitespace_table = string.maketrans("\t\f\v", " ") -_chunk_re = re.compile(r" [^\n\\]+ | [\n\\] ", re.VERBOSE) - -def simpletext_value(s): - """Convert a raw SimpleText property value to the string it represents. - - Returns an 8-bit string, in the encoding of the original SGF string. - - This interprets escape characters, and does whitespace mapping: - - - backslash followed by linebreak (LF, CR, LFCR, or CRLF) disappears - - any other linebreak is replaced by a space - - any other whitespace character is replaced by a space - - other backslashes disappear (but double-backslash -> single-backslash) - - """ - s = _newline_re.sub("\n", s) - s = s.translate(_whitespace_table) - is_escaped = False - result = [] - for chunk in _chunk_re.findall(s): - if is_escaped: - if chunk != "\n": - result.append(chunk) - is_escaped = False - elif chunk == "\\": - is_escaped = True - elif chunk == "\n": - result.append(" ") - else: - result.append(chunk) - return "".join(result) - -def text_value(s): - """Convert a raw Text property value to the string it represents. - - Returns an 8-bit string, in the encoding of the original SGF string. - - This interprets escape characters, and does whitespace mapping: - - - linebreak (LF, CR, LFCR, or CRLF) is converted to \n - - any other whitespace character is replaced by a space - - backslash followed by linebreak disappears - - other backslashes disappear (but double-backslash -> single-backslash) - - """ - s = _newline_re.sub("\n", s) - s = s.translate(_whitespace_table) - is_escaped = False - result = [] - for chunk in _chunk_re.findall(s): - if is_escaped: - if chunk != "\n": - result.append(chunk) - is_escaped = False - elif chunk == "\\": - is_escaped = True - else: - result.append(chunk) - return "".join(result) - -def escape_text(s): - """Convert a string to a raw Text property value that represents it. - - s -- 8-bit string, in the desired output encoding. - - Returns an 8-bit string which passes is_valid_property_value(). - - Normally text_value(escape_text(s)) == s, but there are the following - exceptions: - - all linebreaks are are normalised to \n - - whitespace other than line breaks is converted to a single space - - """ - return s.replace("\\", "\\\\").replace("]", "\\]") - diff --git a/gomill/sgf_moves.py b/gomill/sgf_moves.py deleted file mode 100644 index 26e3802..0000000 --- a/gomill/sgf_moves.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Higher-level processing of moves and positions from SGF games.""" - -from gomill import boards -from gomill import sgf_properties - - -def get_setup_and_moves(sgf_game, board=None): - """Return the initial setup and the following moves from an Sgf_game. - - Returns a pair (board, plays) - - board -- boards.Board - plays -- list of pairs (colour, move) - moves are (row, col), or None for a pass. - - The board represents the position described by AB and/or AW properties - in the root node. - - The moves are from the game's 'leftmost' variation. - - Raises ValueError if this position isn't legal. - - Raises ValueError if there are any AB/AW/AE properties after the root - node. - - Doesn't check whether the moves are legal. - - If the optional 'board' parameter is provided, it must be an empty board of - the right size; the same object will be returned. - - """ - size = sgf_game.get_size() - if board is None: - board = boards.Board(size) - else: - if board.side != size: - raise ValueError("wrong board size, must be %d" % size) - if not board.is_empty(): - raise ValueError("board not empty") - root = sgf_game.get_root() - nodes = sgf_game.main_sequence_iter() - ab, aw, ae = root.get_setup_stones() - if ab or aw: - is_legal = board.apply_setup(ab, aw, ae) - if not is_legal: - raise ValueError("setup position not legal") - colour, raw = root.get_raw_move() - if colour is not None: - raise ValueError("mixed setup and moves in root node") - nodes.next() - moves = [] - for node in nodes: - if node.has_setup_stones(): - raise ValueError("setup properties after the root node") - colour, raw = node.get_raw_move() - if colour is not None: - moves.append((colour, sgf_properties.interpret_go_point(raw, size))) - return board, moves - -def set_initial_position(sgf_game, board): - """Add setup stones to an Sgf_game reflecting a board position. - - sgf_game -- Sgf_game - board -- boards.Board - - Replaces any existing setup stones in the Sgf_game's root node. - - """ - stones = {'b' : set(), 'w' : set()} - for (colour, point) in board.list_occupied_points(): - stones[colour].add(point) - sgf_game.get_root().set_setup_stones(stones['b'], stones['w']) - -def indicate_first_player(sgf_game): - """Add a PL property to the root node if appropriate. - - Looks at the first child of the root to see who the first player is, and - sets PL it isn't the expected player (ie, black normally, but white if - there is a handicap), or if there are non-handicap setup stones. - - """ - root = sgf_game.get_root() - first_player, move = root[0].get_move() - if first_player is None: - return - has_handicap = root.has_property("HA") - if root.has_property("AW"): - specify_pl = True - elif root.has_property("AB") and not has_handicap: - specify_pl = True - elif not has_handicap and first_player == 'w': - specify_pl = True - elif has_handicap and first_player == 'b': - specify_pl = True - else: - specify_pl = False - if specify_pl: - root.set('PL', first_player) - diff --git a/gomill/sgf_properties.py b/gomill/sgf_properties.py deleted file mode 100644 index 9fc085f..0000000 --- a/gomill/sgf_properties.py +++ /dev/null @@ -1,730 +0,0 @@ -"""Interpret SGF property values. - -This is intended for use with SGF FF[4]; see http://www.red-bean.com/sgf/ - -This supports all general properties and Go-specific properties, but not -properties for other games. Point, Move and Stone values are interpreted as Go -points. - -""" - -import codecs - -from gomill import sgf_grammar -from gomill.utils import isinf, isnan - -def normalise_charset_name(s): - """Convert an encoding name to the form implied in the SGF spec. - - In particular, normalises to 'ISO-8859-1' and 'UTF-8'. - - Raises LookupError if the encoding name isn't known to Python. - - """ - return (codecs.lookup(s).name.replace("_", "-").upper() - .replace("ISO8859", "ISO-8859")) - - -def interpret_go_point(s, size): - """Convert a raw SGF Go Point, Move, or Stone value to coordinates. - - s -- 8-bit string - size -- board size (int) - - Returns a pair (row, col), or None for a pass. - - Raises ValueError if the string is malformed or the coordinates are out of - range. - - Only supports board sizes up to 26. - - The returned coordinates are in the GTP coordinate system (as in the rest - of gomill), where (0, 0) is the lower left. - - """ - if s == "" or (s == "tt" and size <= 19): - return None - # May propagate ValueError - col_s, row_s = s - col = ord(col_s) - 97 # 97 == ord("a") - row = size - ord(row_s) + 96 - if not ((0 <= col < size) and (0 <= row < size)): - raise ValueError - return row, col - -def serialise_go_point(move, size): - """Serialise a Go Point, Move, or Stone value. - - move -- pair (row, col), or None for a pass - - Returns an 8-bit string. - - Only supports board sizes up to 26. - - The move coordinates are in the GTP coordinate system (as in the rest of - gomill), where (0, 0) is the lower left. - - """ - if not 1 <= size <= 26: - raise ValueError - if move is None: - # Prefer 'tt' where possible, for the sake of older code - if size <= 19: - return "tt" - else: - return "" - row, col = move - if not ((0 <= col < size) and (0 <= row < size)): - raise ValueError - col_s = "abcdefghijklmnopqrstuvwxy"[col] - row_s = "abcdefghijklmnopqrstuvwxy"[size - row - 1] - return col_s + row_s - - -class _Context(object): - def __init__(self, size, encoding): - self.size = size - self.encoding = encoding - -def interpret_none(s, context=None): - """Convert a raw None value to a boolean. - - That is, unconditionally returns True. - - """ - return True - -def serialise_none(b, context=None): - """Serialise a None value. - - Ignores its parameter. - - """ - return "" - - -def interpret_number(s, context=None): - """Convert a raw Number value to the integer it represents. - - This is a little more lenient than the SGF spec: it permits leading and - trailing spaces, and spaces between the sign and the numerals. - - """ - return int(s, 10) - -def serialise_number(i, context=None): - """Serialise a Number value. - - i -- integer - - """ - return "%d" % i - - -def interpret_real(s, context=None): - """Convert a raw Real value to the float it represents. - - This is more lenient than the SGF spec: it accepts strings accepted as a - float by the platform libc. It rejects infinities and NaNs. - - """ - result = float(s) - if isinf(result): - raise ValueError("infinite") - if isnan(result): - raise ValueError("not a number") - return result - -def serialise_real(f, context=None): - """Serialise a Real value. - - f -- real number (int or float) - - If the absolute value is too small to conveniently express as a decimal, - returns "0" (this currently happens if abs(f) is less than 0.0001). - - """ - f = float(f) - try: - i = int(f) - except OverflowError: - # infinity - raise ValueError - if f == i: - # avoid trailing '.0'; also avoid scientific notation for large numbers - return str(i) - s = repr(f) - if 'e-' in s: - return "0" - return s - - -def interpret_double(s, context=None): - """Convert a raw Double value to an integer. - - Returns 1 or 2 (unknown values are treated as 1). - - """ - if s.strip() == "2": - return 2 - else: - return 1 - -def serialise_double(i, context=None): - """Serialise a Double value. - - i -- integer (1 or 2) - - (unknown values are treated as 1) - - """ - if i == 2: - return "2" - return "1" - - -def interpret_colour(s, context=None): - """Convert a raw Color value to a gomill colour. - - Returns 'b' or 'w'. - - """ - colour = s.lower() - if colour not in ('b', 'w'): - raise ValueError - return colour - -def serialise_colour(colour, context=None): - """Serialise a Colour value. - - colour -- 'b' or 'w' - - """ - if colour not in ('b', 'w'): - raise ValueError - return colour.upper() - - -def _transcode(s, encoding): - """Common implementation for interpret_text and interpret_simpletext.""" - # If encoding is UTF-8, we don't need to transcode, but we still want to - # report an error if it's not properly encoded. - u = s.decode(encoding) - if encoding == "UTF-8": - return s - else: - return u.encode("utf-8") - -def interpret_simpletext(s, context): - """Convert a raw SimpleText value to a string. - - See sgf_grammar.simpletext_value() for details. - - s -- raw value - - Returns an 8-bit utf-8 string. - - """ - return _transcode(sgf_grammar.simpletext_value(s), context.encoding) - -def serialise_simpletext(s, context): - """Serialise a SimpleText value. - - See sgf_grammar.escape_text() for details. - - s -- 8-bit utf-8 string - - """ - if context.encoding != "UTF-8": - s = s.decode("utf-8").encode(context.encoding) - return sgf_grammar.escape_text(s) - - -def interpret_text(s, context): - """Convert a raw Text value to a string. - - See sgf_grammar.text_value() for details. - - s -- raw value - - Returns an 8-bit utf-8 string. - - """ - return _transcode(sgf_grammar.text_value(s), context.encoding) - -def serialise_text(s, context): - """Serialise a Text value. - - See sgf_grammar.escape_text() for details. - - s -- 8-bit utf-8 string - - """ - if context.encoding != "UTF-8": - s = s.decode("utf-8").encode(context.encoding) - return sgf_grammar.escape_text(s) - - - -def interpret_point(s, context): - """Convert a raw SGF Point or Stone value to coordinates. - - See interpret_go_point() above for details. - - Returns a pair (row, col). - - """ - result = interpret_go_point(s, context.size) - if result is None: - raise ValueError - return result - -def serialise_point(point, context): - """Serialise a Point or Stone value. - - point -- pair (row, col) - - See serialise_go_point() above for details. - - """ - if point is None: - raise ValueError - return serialise_go_point(point, context.size) - - -def interpret_move(s, context): - """Convert a raw SGF Move value to coordinates. - - See interpret_go_point() above for details. - - Returns a pair (row, col), or None for a pass. - - """ - return interpret_go_point(s, context.size) - -def serialise_move(move, context): - """Serialise a Move value. - - move -- pair (row, col), or None for a pass - - See serialise_go_point() above for details. - - """ - return serialise_go_point(move, context.size) - - -def interpret_point_list(values, context): - """Convert a raw SGF list of Points to a set of coordinates. - - values -- list of strings - - Returns a set of pairs (row, col). - - If 'values' is empty, returns an empty set. - - This interprets compressed point lists. - - Doesn't complain if there is overlap, or if a single point is specified as - a 1x1 rectangle. - - Raises ValueError if the data is otherwise malformed. - - """ - result = set() - for s in values: - # No need to use parse_compose(), as \: would always be an error. - p1, is_rectangle, p2 = s.partition(":") - if is_rectangle: - top, left = interpret_point(p1, context) - bottom, right = interpret_point(p2, context) - if not (bottom <= top and left <= right): - raise ValueError - for row in xrange(bottom, top+1): - for col in xrange(left, right+1): - result.add((row, col)) - else: - pt = interpret_point(p1, context) - result.add(pt) - return result - -def serialise_point_list(points, context): - """Serialise a list of Points, Moves, or Stones. - - points -- iterable of pairs (row, col) - - Returns a list of strings. - - If 'points' is empty, returns an empty list. - - Doesn't produce a compressed point list. - - """ - result = [serialise_point(point, context) for point in points] - result.sort() - return result - - -def interpret_AP(s, context): - """Interpret an AP (application) property value. - - Returns a pair of strings (name, version number) - - Permits the version number to be missing (which is forbidden by the SGF - spec), in which case the second returned value is an empty string. - - """ - application, version = sgf_grammar.parse_compose(s) - if version is None: - version = "" - return (interpret_simpletext(application, context), - interpret_simpletext(version, context)) - -def serialise_AP(value, context): - """Serialise an AP (application) property value. - - value -- pair (application, version) - application -- string - version -- string - - Note this takes a single parameter (which is a pair). - - """ - application, version = value - return sgf_grammar.compose(serialise_simpletext(application, context), - serialise_simpletext(version, context)) - - -def interpret_ARLN_list(values, context): - """Interpret an AR (arrow) or LN (line) property value. - - Returns a list of pairs (point, point), where point is a pair (row, col) - - """ - result = [] - for s in values: - p1, p2 = sgf_grammar.parse_compose(s) - result.append((interpret_point(p1, context), - interpret_point(p2, context))) - return result - -def serialise_ARLN_list(values, context): - """Serialise an AR (arrow) or LN (line) property value. - - values -- list of pairs (point, point), where point is a pair (row, col) - - """ - return ["%s:%s" % (serialise_point(p1, context), - serialise_point(p2, context)) - for p1, p2 in values] - - -def interpret_FG(s, context): - """Interpret an FG (figure) property value. - - Returns a pair (flags, string), or None. - - flags is an integer; see http://www.red-bean.com/sgf/properties.html#FG - - """ - if s == "": - return None - flags, name = sgf_grammar.parse_compose(s) - return int(flags), interpret_simpletext(name, context) - -def serialise_FG(value, context): - """Serialise an FG (figure) property value. - - value -- pair (flags, name), or None - flags -- int - name -- string - - Use serialise_FG(None) to produce an empty value. - - """ - if value is None: - return "" - flags, name = value - return "%d:%s" % (flags, serialise_simpletext(name, context)) - - -def interpret_LB_list(values, context): - """Interpret an LB (label) property value. - - Returns a list of pairs ((row, col), string). - - """ - result = [] - for s in values: - point, label = sgf_grammar.parse_compose(s) - result.append((interpret_point(point, context), - interpret_simpletext(label, context))) - return result - -def serialise_LB_list(values, context): - """Serialise an LB (label) property value. - - values -- list of pairs ((row, col), string) - - """ - return ["%s:%s" % (serialise_point(point, context), - serialise_simpletext(text, context)) - for point, text in values] - - -class Property_type(object): - """Description of a property type.""" - def __init__(self, interpreter, serialiser, uses_list, - allows_empty_list=False): - self.interpreter = interpreter - self.serialiser = serialiser - self.uses_list = bool(uses_list) - self.allows_empty_list = bool(allows_empty_list) - -def _make_property_type(type_name, allows_empty_list=False): - return Property_type( - globals()["interpret_" + type_name], - globals()["serialise_" + type_name], - uses_list=(type_name.endswith("_list")), - allows_empty_list=allows_empty_list) - -_property_types_by_name = { - 'none' : _make_property_type('none'), - 'number' : _make_property_type('number'), - 'real' : _make_property_type('real'), - 'double' : _make_property_type('double'), - 'colour' : _make_property_type('colour'), - 'simpletext' : _make_property_type('simpletext'), - 'text' : _make_property_type('text'), - 'point' : _make_property_type('point'), - 'move' : _make_property_type('move'), - 'point_list' : _make_property_type('point_list'), - 'point_elist' : _make_property_type('point_list', allows_empty_list=True), - 'stone_list' : _make_property_type('point_list'), - 'AP' : _make_property_type('AP'), - 'ARLN_list' : _make_property_type('ARLN_list'), - 'FG' : _make_property_type('FG'), - 'LB_list' : _make_property_type('LB_list'), -} - -P = _property_types_by_name - -_property_types_by_ident = { - 'AB' : P['stone_list'], # setup Add Black - 'AE' : P['point_list'], # setup Add Empty - 'AN' : P['simpletext'], # game-info Annotation - 'AP' : P['AP'], # root Application - 'AR' : P['ARLN_list'], # - Arrow - 'AW' : P['stone_list'], # setup Add White - 'B' : P['move'], # move Black - 'BL' : P['real'], # move Black time left - 'BM' : P['double'], # move Bad move - 'BR' : P['simpletext'], # game-info Black rank - 'BT' : P['simpletext'], # game-info Black team - 'C' : P['text'], # - Comment - 'CA' : P['simpletext'], # root Charset - 'CP' : P['simpletext'], # game-info Copyright - 'CR' : P['point_list'], # - Circle - 'DD' : P['point_elist'], # - [inherit] Dim points - 'DM' : P['double'], # - Even position - 'DO' : P['none'], # move Doubtful - 'DT' : P['simpletext'], # game-info Date - 'EV' : P['simpletext'], # game-info Event - 'FF' : P['number'], # root Fileformat - 'FG' : P['FG'], # - Figure - 'GB' : P['double'], # - Good for Black - 'GC' : P['text'], # game-info Game comment - 'GM' : P['number'], # root Game - 'GN' : P['simpletext'], # game-info Game name - 'GW' : P['double'], # - Good for White - 'HA' : P['number'], # game-info Handicap - 'HO' : P['double'], # - Hotspot - 'IT' : P['none'], # move Interesting - 'KM' : P['real'], # game-info Komi - 'KO' : P['none'], # move Ko - 'LB' : P['LB_list'], # - Label - 'LN' : P['ARLN_list'], # - Line - 'MA' : P['point_list'], # - Mark - 'MN' : P['number'], # move set move number - 'N' : P['simpletext'], # - Nodename - 'OB' : P['number'], # move OtStones Black - 'ON' : P['simpletext'], # game-info Opening - 'OT' : P['simpletext'], # game-info Overtime - 'OW' : P['number'], # move OtStones White - 'PB' : P['simpletext'], # game-info Player Black - 'PC' : P['simpletext'], # game-info Place - 'PL' : P['colour'], # setup Player to play - 'PM' : P['number'], # - [inherit] Print move mode - 'PW' : P['simpletext'], # game-info Player White - 'RE' : P['simpletext'], # game-info Result - 'RO' : P['simpletext'], # game-info Round - 'RU' : P['simpletext'], # game-info Rules - 'SL' : P['point_list'], # - Selected - 'SO' : P['simpletext'], # game-info Source - 'SQ' : P['point_list'], # - Square - 'ST' : P['number'], # root Style - 'SZ' : P['number'], # root Size - 'TB' : P['point_elist'], # - Territory Black - 'TE' : P['double'], # move Tesuji - 'TM' : P['real'], # game-info Timelimit - 'TR' : P['point_list'], # - Triangle - 'TW' : P['point_elist'], # - Territory White - 'UC' : P['double'], # - Unclear pos - 'US' : P['simpletext'], # game-info User - 'V' : P['real'], # - Value - 'VW' : P['point_elist'], # - [inherit] View - 'W' : P['move'], # move White - 'WL' : P['real'], # move White time left - 'WR' : P['simpletext'], # game-info White rank - 'WT' : P['simpletext'], # game-info White team -} -_text_property_type = P['text'] - -del P - - -class Presenter(_Context): - """Convert property values between Python and SGF-string representations. - - Instantiate with: - size -- board size (int) - encoding -- encoding for the SGF strings - - Public attributes (treat as read-only): - size -- int - encoding -- string (normalised form) - - See the _property_types_by_ident table above for a list of properties - initially known, and their types. - - Initially, treats unknown (private) properties as if they had type Text. - - """ - - def __init__(self, size, encoding): - try: - encoding = normalise_charset_name(encoding) - except LookupError: - raise ValueError("unknown encoding: %s" % encoding) - _Context.__init__(self, size, encoding) - self.property_types_by_ident = _property_types_by_ident.copy() - self.default_property_type = _text_property_type - - def get_property_type(self, identifier): - """Return the Property_type for the specified PropIdent. - - Rasies KeyError if the property is unknown. - - """ - return self.property_types_by_ident[identifier] - - def register_property(self, identifier, property_type): - """Specify the Property_type for a PropIdent.""" - self.property_types_by_ident[identifier] = property_type - - def deregister_property(self, identifier): - """Forget the type for the specified PropIdent.""" - del self.property_types_by_ident[identifier] - - def set_private_property_type(self, property_type): - """Specify the Property_type to use for unknown properties. - - Pass property_type = None to make unknown properties raise an error. - - """ - self.default_property_type = property_type - - def _get_effective_property_type(self, identifier): - try: - return self.property_types_by_ident[identifier] - except KeyError: - result = self.default_property_type - if result is None: - raise ValueError("unknown property") - return result - - def interpret_as_type(self, property_type, raw_values): - """Variant of interpret() for explicitly specified type. - - property_type -- Property_type - - """ - if not raw_values: - raise ValueError("no raw values") - if property_type.uses_list: - if raw_values == [""]: - raw = [] - else: - raw = raw_values - else: - if len(raw_values) > 1: - raise ValueError("multiple values") - raw = raw_values[0] - return property_type.interpreter(raw, self) - - def interpret(self, identifier, raw_values): - """Return a Python representation of a property value. - - identifier -- PropIdent - raw_values -- nonempty list of 8-bit strings in the presenter's encoding - - See the interpret_... functions above for details of how values are - represented as Python types. - - Raises ValueError if it cannot interpret the value. - - Note that in some cases the interpret_... functions accept values which - are not strictly permitted by the specification. - - elist handling: if the property's value type is a list type and - 'raw_values' is a list containing a single empty string, passes an - empty list to the interpret_... function (that is, this function treats - all lists like elists). - - Doesn't enforce range restrictions on values with type Number. - - """ - return self.interpret_as_type( - self._get_effective_property_type(identifier), raw_values) - - def serialise_as_type(self, property_type, value): - """Variant of serialise() for explicitly specified type. - - property_type -- Property_type - - """ - serialised = property_type.serialiser(value, self) - if property_type.uses_list: - if serialised == []: - if property_type.allows_empty_list: - return [""] - else: - raise ValueError("empty list") - return serialised - else: - return [serialised] - - def serialise(self, identifier, value): - """Serialise a Python representation of a property value. - - identifier -- PropIdent - value -- corresponding Python value - - Returns a nonempty list of 8-bit strings in the presenter's encoding, - suitable for use as raw PropValues. - - See the serialise_... functions above for details of the acceptable - values for each type. - - elist handling: if the property's value type is an elist type and the - serialise_... function returns an empty list, this returns a list - containing a single empty string. - - Raises ValueError if it cannot serialise the value. - - In general, the serialise_... functions try not to produce an invalid - result, but do not try to prevent garbage input happening to produce a - valid result. - - """ - return self.serialise_as_type( - self._get_effective_property_type(identifier), value) diff --git a/gomill/terminal_input.py b/gomill/terminal_input.py deleted file mode 100644 index 1ee0641..0000000 --- a/gomill/terminal_input.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Support for non-blocking terminal input.""" - -import os - -try: - import termios -except ImportError: - termios = None - -class Terminal_reader(object): - """Check for input on the controlling terminal.""" - - def __init__(self): - self.enabled = True - self.tty = None - - def is_enabled(self): - return self.enabled - - def disable(self): - self.enabled = False - - def initialise(self): - if not self.enabled: - return - if termios is None: - self.enabled = False - return - try: - self.tty = open("/dev/tty", "w+") - os.tcgetpgrp(self.tty.fileno()) - self.clean_tcattr = termios.tcgetattr(self.tty) - iflag, oflag, cflag, lflag, ispeed, ospeed, cc = self.clean_tcattr - new_lflag = lflag & (0xffffffff ^ termios.ICANON) - new_cc = cc[:] - new_cc[termios.VMIN] = 0 - self.cbreak_tcattr = [ - iflag, oflag, cflag, new_lflag, ispeed, ospeed, new_cc] - except Exception: - self.enabled = False - return - - def close(self): - if self.tty is not None: - self.tty.close() - self.tty = None - - def stop_was_requested(self): - """Check whether a 'keyboard stop' instruction has been sent. - - Returns true if ^X has been sent on the controlling terminal. - - Consumes all available input on /dev/tty. - - """ - if not self.enabled: - return False - # Don't try to read the terminal if we're in the background. - # There's a race here, if we're backgrounded just after this check, but - # I don't see a clean way to avoid it. - if os.tcgetpgrp(self.tty.fileno()) != os.getpid(): - return False - try: - termios.tcsetattr(self.tty, termios.TCSANOW, self.cbreak_tcattr) - except EnvironmentError: - return False - try: - seen_ctrl_x = False - while True: - c = os.read(self.tty.fileno(), 1) - if not c: - break - if c == "\x18": - seen_ctrl_x = True - except EnvironmentError: - seen_ctrl_x = False - finally: - termios.tcsetattr(self.tty, termios.TCSANOW, self.clean_tcattr) - return seen_ctrl_x - - def acknowledge(self): - """Leave an acknowledgement on the controlling terminal.""" - self.tty.write("\rCtrl-X received; halting\n") diff --git a/gomill/tournament_results.py b/gomill/tournament_results.py deleted file mode 100644 index 82d5718..0000000 --- a/gomill/tournament_results.py +++ /dev/null @@ -1,311 +0,0 @@ -"""Retrieving and reporting on tournament results.""" - -from __future__ import division - -from gomill import ascii_tables -from gomill.utils import format_float, format_percent -from gomill.common import colour_name - -class Matchup_description(object): - """Description of a matchup (pairing of two players). - - Public attributes: - id -- matchup id (very short string) - player_1 -- player code (identifier-like string) - player_2 -- player code (identifier-like string) - name -- string (eg 'xxx v yyy') - board_size -- int - komi -- float - alternating -- bool - handicap -- int or None - handicap_style -- 'fixed' or 'free' - move_limit -- int - scorer -- 'internal' or 'players' - number_of_games -- int or None - - If alternating is False, player_1 plays black and player_2 plays white; - otherwise they alternate. - - player_1 and player_2 are always different. - - """ - def describe_details(self): - """Return a text description of game settings. - - This covers the most important game settings which can't be observed - in the results table (board size, handicap, and komi). - - """ - s = "board size: %s " % self.board_size - if self.handicap is not None: - s += "handicap: %s (%s) " % ( - self.handicap, self.handicap_style) - s += "komi: %s" % self.komi - return s - - -class Tournament_results(object): - """Provide access to results of a single tournament. - - The tournament results are catalogued in terms of 'matchups', with each - matchup corresponding to a series of games which have the same players and - settings. Each matchup has an id, which is a short string. - - """ - def __init__(self, matchup_list, results): - self.matchup_list = matchup_list - self.results = results - self.matchups = dict((m.id, m) for m in matchup_list) - - def get_matchup_ids(self): - """Return a list of all matchup ids, in definition order.""" - return [m.id for m in self.matchup_list] - - def get_matchup(self, matchup_id): - """Describe the matchup with the specified id. - - Returns a Matchup_description (which should be treated as read-only). - - """ - return self.matchups[matchup_id] - - def get_matchups(self): - """Return a map matchup id -> Matchup_description.""" - return self.matchups.copy() - - def get_matchup_results(self, matchup_id): - """Return the results for the specified matchup. - - Returns a list of gtp_games.Game_results (in unspecified order). - - The Game_results all have game_id set. - - """ - return self.results[matchup_id][:] - - def get_matchup_stats(self, matchup_id): - """Return statistics for the specified matchup. - - Returns a Matchup_stats object. - - """ - matchup = self.matchups[matchup_id] - ms = Matchup_stats(self.results[matchup_id], - matchup.player_1, matchup.player_2) - ms.calculate_colour_breakdown() - ms.calculate_time_stats() - return ms - - -class Matchup_stats(object): - """Result statistics for games between a pair of players. - - Instantiate with - results -- list of gtp_games.Game_results - player_1 -- player code - player_2 -- player code - The game results should all be for games between player_1 and player_2. - - Public attributes: - player_1 -- player code - player_2 -- player code - total -- int (number of games) - wins_1 -- float (score) - wins_2 -- float (score) - forfeits_1 -- int (number of games) - forfeits_2 -- int (number of games) - unknown -- int (number of games) - - scores are multiples of 0.5 (as there may be jigos). - - """ - def __init__(self, results, player_1, player_2): - self._results = results - self.player_1 = player_1 - self.player_2 = player_2 - - self.total = len(results) - - js = self._jigo_score = 0.5 * sum(r.is_jigo for r in results) - self.unknown = sum(r.winning_player is None and not r.is_jigo - for r in results) - - self.wins_1 = sum(r.winning_player == player_1 for r in results) + js - self.wins_2 = sum(r.winning_player == player_2 for r in results) + js - - self.forfeits_1 = sum(r.winning_player == player_2 and r.is_forfeit - for r in results) - self.forfeits_2 = sum(r.winning_player == player_1 and r.is_forfeit - for r in results) - - def calculate_colour_breakdown(self): - """Calculate futher statistics, broken down by colour played. - - Sets the following additional attributes: - - played_1b -- int (number of games) - played_1w -- int (number of games) - played_2b -- int (number of games) - played_y2 -- int (number of games) - alternating -- bool - when alternating is true => - wins_b -- float (score) - wins_w -- float (score) - wins_1b -- float (score) - wins_1w -- float (score) - wins_2b -- float (score) - wins_2w -- float (score) - else => - colour_1 -- 'b' or 'w' - colour_2 -- 'b' or 'w' - - """ - results = self._results - player_1 = self.player_1 - player_2 = self.player_2 - js = self._jigo_score - - self.played_1b = sum(r.player_b == player_1 for r in results) - self.played_1w = sum(r.player_w == player_1 for r in results) - self.played_2b = sum(r.player_b == player_2 for r in results) - self.played_y2 = sum(r.player_w == player_2 for r in results) - - if self.played_1w == 0 and self.played_2b == 0: - self.alternating = False - self.colour_1 = 'b' - self.colour_2 = 'w' - elif self.played_1b == 0 and self.played_y2 == 0: - self.alternating = False - self.colour_1 = 'w' - self.colour_2 = 'b' - else: - self.alternating = True - self.wins_b = sum(r.winning_colour == 'b' for r in results) + js - self.wins_w = sum(r.winning_colour == 'w' for r in results) + js - self.wins_1b = sum( - r.winning_player == player_1 and r.winning_colour == 'b' - for r in results) + js - self.wins_1w = sum( - r.winning_player == player_1 and r.winning_colour == 'w' - for r in results) + js - self.wins_2b = sum( - r.winning_player == player_2 and r.winning_colour == 'b' - for r in results) + js - self.wins_2w = sum( - r.winning_player == player_2 and r.winning_colour == 'w' - for r in results) + js - - def calculate_time_stats(self): - """Calculate CPU time statistics. - - average_time_1 -- float or None - average_time_2 -- float or None - - """ - player_1 = self.player_1 - player_2 = self.player_2 - times_1 = [r.cpu_times[player_1] for r in self._results] - known_times_1 = [t for t in times_1 if t is not None and t != '?'] - times_2 = [r.cpu_times[player_2] for r in self._results] - known_times_2 = [t for t in times_2 if t is not None and t != '?'] - if known_times_1: - self.average_time_1 = sum(known_times_1) / len(known_times_1) - else: - self.average_time_1 = None - if known_times_2: - self.average_time_2 = sum(known_times_2) / len(known_times_2) - else: - self.average_time_2 = None - - -def make_matchup_stats_table(ms): - """Produce an ascii table showing matchup statistics. - - ms -- Matchup_stats (with all statistics set) - - returns an ascii_tables.Table - - """ - ff = format_float - pct = format_percent - - t = ascii_tables.Table(row_count=3) - t.add_heading("") # player name - i = t.add_column(align='left', right_padding=3) - t.set_column_values(i, [ms.player_1, ms.player_2]) - - t.add_heading("wins") - i = t.add_column(align='right') - t.set_column_values(i, [ff(ms.wins_1), ff(ms.wins_2)]) - - t.add_heading("") # overall pct - i = t.add_column(align='right') - t.set_column_values(i, [pct(ms.wins_1, ms.total), - pct(ms.wins_2, ms.total)]) - - if ms.alternating: - t.columns[i].right_padding = 7 - t.add_heading("black", span=2) - i = t.add_column(align='left') - t.set_column_values(i, [ff(ms.wins_1b), ff(ms.wins_2b), ff(ms.wins_b)]) - i = t.add_column(align='right', right_padding=5) - t.set_column_values(i, [pct(ms.wins_1b, ms.played_1b), - pct(ms.wins_2b, ms.played_2b), - pct(ms.wins_b, ms.total)]) - - t.add_heading("white", span=2) - i = t.add_column(align='left') - t.set_column_values(i, [ff(ms.wins_1w), ff(ms.wins_2w), ff(ms.wins_w)]) - i = t.add_column(align='right', right_padding=3) - t.set_column_values(i, [pct(ms.wins_1w, ms.played_1w), - pct(ms.wins_2w, ms.played_y2), - pct(ms.wins_w, ms.total)]) - else: - t.columns[i].right_padding = 3 - t.add_heading("") - i = t.add_column(align='left') - t.set_column_values(i, ["(%s)" % colour_name(ms.colour_1), - "(%s)" % colour_name(ms.colour_2)]) - - if ms.forfeits_1 or ms.forfeits_2: - t.add_heading("forfeits") - i = t.add_column(align='right') - t.set_column_values(i, [ms.forfeits_1, ms.forfeits_2]) - - if ms.average_time_1 or ms.average_time_2: - if ms.average_time_1 is not None: - avg_time_1_s = "%7.2f" % ms.average_time_1 - else: - avg_time_1_s = " ----" - if ms.average_time_2 is not None: - avg_time_2_s = "%7.2f" % ms.average_time_2 - else: - avg_time_2_s = " ----" - t.add_heading("avg cpu") - i = t.add_column(align='right', right_padding=2) - t.set_column_values(i, [avg_time_1_s, avg_time_2_s]) - - return t - -def write_matchup_summary(out, matchup, ms): - """Write a summary block for the specified matchup to 'out'. - - matchup -- Matchup_description - ms -- Matchup_stats (with all statistics set) - - """ - def p(s): - print >>out, s - - if matchup.number_of_games is None: - played_s = "%d" % ms.total - else: - played_s = "%d/%d" % (ms.total, matchup.number_of_games) - p("%s (%s games)" % (matchup.name, played_s)) - if ms.unknown > 0: - p("unknown results: %d %s" % - (ms.unknown, format_percent(ms.unknown, ms.total))) - - p(matchup.describe_details()) - p("\n".join(make_matchup_stats_table(ms).render())) - diff --git a/gomill/tournaments.py b/gomill/tournaments.py deleted file mode 100644 index 7b63a65..0000000 --- a/gomill/tournaments.py +++ /dev/null @@ -1,317 +0,0 @@ -"""Common code for all tournament types.""" - -from collections import defaultdict - -from gomill import game_jobs -from gomill import competition_schedulers -from gomill import tournament_results -from gomill import competitions -from gomill.competitions import ( - Competition, NoGameAvailable, CompetitionError, ControlFileError) -from gomill.settings import * -from gomill.utils import format_percent - -# These all appear as Matchup_description attributes -matchup_settings = competitions.game_settings + [ - Setting('alternating', interpret_bool, default=False), - Setting('number_of_games', allow_none(interpret_int), default=None), - ] - - -class Matchup(tournament_results.Matchup_description): - """Internal description of a matchup from the configuration file. - - See tournament_results.Matchup_description for main public attributes. - - Additional attributes: - event_description -- string to show as sgf event - - Instantiate with - matchup_id -- identifier - player_1 -- player code - player_2 -- player code - parameters -- dict matchup setting name -> value - name -- utf-8 string, or None - event_code -- identifier - - If a matchup setting is missing from 'parameters', the setting's default - value is used (or, if there is no default value, ValueError is raised). - - 'event_code' is used for the sgf event description (combined with 'name' - if available). - - Instantiation raises ControlFileError if the handicap settings aren't - permitted. - - """ - def __init__(self, matchup_id, player_1, player_2, parameters, - name, event_code): - self.id = matchup_id - self.player_1 = player_1 - self.player_2 = player_2 - - for setting in matchup_settings: - try: - v = parameters[setting.name] - except KeyError: - try: - v = setting.get_default() - except KeyError: - raise ValueError("'%s' not specified" % setting.name) - setattr(self, setting.name, v) - - competitions.validate_handicap( - self.handicap, self.handicap_style, self.board_size) - - if name is None: - name = "%s v %s" % (self.player_1, self.player_2) - event_description = event_code - else: - event_description = "%s (%s)" % (event_code, name) - self.name = name - self.event_description = event_description - self._game_id_template = ("%s_" + - competitions.leading_zero_template(self.number_of_games)) - - def make_game_id(self, game_number): - return self._game_id_template % (self.id, game_number) - - -class Ghost_matchup(object): - """Dummy Matchup object for matchups which have gone from the control file. - - This is used if the matchup appears in results. - - It has to be a good enough imitation to keep write_matchup_summary() happy. - - """ - def __init__(self, matchup_id, player_1, player_2): - self.id = matchup_id - self.player_1 = player_1 - self.player_2 = player_2 - self.name = "%s v %s" % (player_1, player_2) - self.number_of_games = None - - def describe_details(self): - return "?? (missing from control file)" - - -class Tournament(Competition): - """A Competition based on a number of matchups. - - """ - def __init__(self, competition_code, **kwargs): - Competition.__init__(self, competition_code, **kwargs) - self.working_matchups = set() - self.probationary_matchups = set() - - def make_matchup(self, matchup_id, player_1, player_2, parameters, - name=None): - """Make a Matchup from the various parameters. - - Raises ControlFileError if any required parameters are missing. - - See Matchup.__init__ for details. - - """ - try: - return Matchup(matchup_id, player_1, player_2, parameters, name, - event_code=self.competition_code) - except ValueError, e: - raise ControlFileError(str(e)) - - - # State attributes (*: in persistent state): - # *results -- map matchup id -> list of Game_results - # *scheduler -- Group_scheduler (group codes are matchup ids) - # *engine_names -- map player code -> string - # *engine_descriptions -- map player code -> string - # working_matchups -- set of matchup ids - # (matchups which have successfully completed a game in this run) - # probationary_matchups -- set of matchup ids - # (matchups which failed to complete their last game) - # ghost_matchups -- map matchup id -> Ghost_matchup - # (matchups which have been removed from the control file) - - def _check_results(self): - """Check that the current results are consistent with the control file. - - This is run when reloading state. - - Raises CompetitionError if they're not. - - (In general, control file changes are permitted. The only thing we - reject is results for a currently-defined matchup whose players aren't - correct.) - - """ - # We guarantee that results for a given matchup always have consistent - # players, so we need only check the first result. - for matchup in self.matchup_list: - results = self.results[matchup.id] - if not results: - continue - result = results[0] - seen_players = sorted(result.players.itervalues()) - expected_players = sorted((matchup.player_1, matchup.player_2)) - if seen_players != expected_players: - raise CompetitionError( - "existing results for matchup %s " - "are inconsistent with control file:\n" - "result players are %s;\n" - "control file players are %s" % - (matchup.id, - ",".join(seen_players), ",".join(expected_players))) - - def _set_ghost_matchups(self): - self.ghost_matchups = {} - live = set(self.matchups) - for matchup_id, results in self.results.iteritems(): - if matchup_id in live: - continue - result = results[0] - # player_1 and player_2 might not be the right way round, but it - # doesn't matter. - self.ghost_matchups[matchup_id] = Ghost_matchup( - matchup_id, result.player_b, result.player_w) - - def _set_scheduler_groups(self): - self.scheduler.set_groups( - [(m.id, m.number_of_games) for m in self.matchup_list] + - [(id, 0) for id in self.ghost_matchups]) - - def set_clean_status(self): - self.results = defaultdict(list) - self.engine_names = {} - self.engine_descriptions = {} - self.scheduler = competition_schedulers.Group_scheduler() - self.ghost_matchups = {} - self._set_scheduler_groups() - - def get_status(self): - return { - 'results' : self.results, - 'scheduler' : self.scheduler, - 'engine_names' : self.engine_names, - 'engine_descriptions' : self.engine_descriptions, - } - - def set_status(self, status): - self.results = status['results'] - self._check_results() - self._set_ghost_matchups() - self.scheduler = status['scheduler'] - self._set_scheduler_groups() - self.scheduler.rollback() - self.engine_names = status['engine_names'] - self.engine_descriptions = status['engine_descriptions'] - - - def get_game(self): - matchup_id, game_number = self.scheduler.issue() - if matchup_id is None: - return NoGameAvailable - matchup = self.matchups[matchup_id] - if matchup.alternating and (game_number % 2): - player_b, player_w = matchup.player_2, matchup.player_1 - else: - player_b, player_w = matchup.player_1, matchup.player_2 - game_id = matchup.make_game_id(game_number) - - job = game_jobs.Game_job() - job.game_id = game_id - job.game_data = (matchup_id, game_number) - job.player_b = self.players[player_b] - job.player_w = self.players[player_w] - job.board_size = matchup.board_size - job.komi = matchup.komi - job.move_limit = matchup.move_limit - job.handicap = matchup.handicap - job.handicap_is_free = (matchup.handicap_style == 'free') - job.use_internal_scorer = (matchup.scorer == 'internal') - job.internal_scorer_handicap_compensation = \ - matchup.internal_scorer_handicap_compensation - job.sgf_event = matchup.event_description - return job - - def process_game_result(self, response): - self.engine_names.update(response.engine_names) - self.engine_descriptions.update(response.engine_descriptions) - matchup_id, game_number = response.game_data - game_id = response.game_id - self.working_matchups.add(matchup_id) - self.probationary_matchups.discard(matchup_id) - self.scheduler.fix(matchup_id, game_number) - self.results[matchup_id].append(response.game_result) - self.log_history("%7s %s" % (game_id, response.game_result.describe())) - - def process_game_error(self, job, previous_error_count): - # ignoring previous_error_count, as we can consider all jobs for the - # same matchup to be equivalent. - stop_competition = False - retry_game = False - matchup_id, game_data = job.game_data - if (matchup_id not in self.working_matchups or - matchup_id in self.probationary_matchups): - stop_competition = True - else: - self.probationary_matchups.add(matchup_id) - retry_game = True - return stop_competition, retry_game - - def write_matchup_report(self, out, matchup, results): - """Write the summary block for the specified matchup to 'out' - - results -- nonempty list of Game_results - - """ - # The control file might have changed since the results were recorded. - # We are guaranteed that the player codes correspond, but nothing else. - - # We use the current matchup to describe 'background' information, as - # that isn't available any other way, but we look to the results where - # we can. - - ms = tournament_results.Matchup_stats( - results, matchup.player_1, matchup.player_2) - ms.calculate_colour_breakdown() - ms.calculate_time_stats() - tournament_results.write_matchup_summary(out, matchup, ms) - - def write_matchup_reports(self, out): - """Write summary blocks for all live matchups to 'out'. - - This doesn't include ghost matchups, or matchups with no games. - - """ - first = True - for matchup in self.matchup_list: - results = self.results[matchup.id] - if not results: - continue - if first: - first = False - else: - print >>out - self.write_matchup_report(out, matchup, results) - - def write_ghost_matchup_reports(self, out): - """Write summary blocks for all ghost matchups to 'out'. - - (This may produce no output. Starts with a blank line otherwise.) - - """ - for matchup_id, matchup in sorted(self.ghost_matchups.iteritems()): - print >>out - results = self.results[matchup_id] - self.write_matchup_report(out, matchup, results) - - def write_player_descriptions(self, out): - """Write descriptions of all players to 'out'.""" - for code, description in sorted(self.engine_descriptions.items()): - print >>out, ("player %s: %s" % (code, description)) - - def get_tournament_results(self): - return tournament_results.Tournament_results( - self.matchup_list, self.results) - diff --git a/gomill/utils.py b/gomill/utils.py deleted file mode 100644 index 93598ad..0000000 --- a/gomill/utils.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Domain-independent utility functions for gomill. - -This module is designed to be used with 'from utils import *'. - -This is for generic utilities; see common for Go-specific utility functions. - -""" - -from __future__ import division - -__all__ = ["format_float", "format_percent", "sanitise_utf8", "isinf", "isnan"] - -def format_float(f): - """Format a Python float in a friendly way. - - This is intended for values like komi or win counts, which will be either - integers or half-integers. - - """ - if f == int(f): - return str(int(f)) - else: - return str(f) - -def format_percent(n, baseline): - """Format a ratio as a percentage (showing two decimal places). - - Returns a string. - - Accepts baseline zero and returns '??' or '--'. - - """ - if baseline == 0: - if n == 0: - return "--" - else: - return "??" - return "%.2f%%" % (100 * n/baseline) - - -def sanitise_utf8(s): - """Ensure an 8-bit string is utf-8. - - s -- 8-bit string (or None) - - Returns the sanitised string. If the string was already valid utf-8, returns - the same object. - - This replaces bad characters with ascii question marks (I don't want to use - a unicode replacement character, because if this function is doing anything - then it's likely that there's a non-unicode setup involved somewhere, so it - probably wouldn't be helpful). - - """ - if s is None: - return None - try: - s.decode("utf-8") - except UnicodeDecodeError: - return (s.decode("utf-8", 'replace') - .replace(u"\ufffd", u"?") - .encode("utf-8")) - else: - return s - -try: - from math import isinf, isnan -except ImportError: - # Python < 2.6 - def isinf(f): - return (f == float("1e500") or f == float("-1e500")) - def isnan(f): - return (f != f) - diff --git a/r2csv.py b/r2csv.py index 688ff4a..d0ca6af 100644 --- a/r2csv.py +++ b/r2csv.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +import sys +sys.path.append('./gomill') from gomill import sgf_moves from toolbox import * diff --git a/toolbox.py b/toolbox.py index b05e0d2..ea7c2ff 100644 --- a/toolbox.py +++ b/toolbox.py @@ -145,13 +145,14 @@ def ij2sgf(m): except: raise GRPException("Cannot convert grid coordinates "+str(m)+" to SGF coordinates!") +import sys +sys.path.append('./gomill') from gomill import sgf, sgf_moves from Tkinter import * #from Tix import Tk, NoteBook from Tkconstants import * -import sys import os import urllib2