├── .gitignore ├── MANIFEST.in ├── README.rst ├── README.sdist.txt ├── build_docs ├── doc ├── _static │ └── sgfmill.css_t ├── _templates │ └── wholetoc.html ├── ascii_boards.rst ├── boards.rst ├── changes.rst ├── common.rst ├── conf.py ├── contact.rst ├── encoding.rst ├── examples.rst ├── glossary.rst ├── index.rst ├── install.rst ├── intro.rst ├── licence.rst ├── parsing.rst ├── porting.rst ├── properties.rst ├── property_types.rst ├── python-inv.txt ├── sgf.rst ├── sgf_board_interface.rst ├── sgf_games.rst ├── sgf_moves.rst ├── sgfmill_package.rst └── tree_nodes.rst ├── examples ├── show_sgf.py └── split_sgf_collection.py ├── release_sgfmill.py ├── run_sgfmill_testsuite ├── setup.py ├── sgfmill ├── __init__.py ├── ascii_boards.py ├── boards.py ├── common.py ├── sgf.py ├── sgf_board_interface.py ├── sgf_grammar.py ├── sgf_moves.py └── sgf_properties.py ├── sgfmill_tests ├── __init__.py ├── board_test_data.py ├── board_tests.py ├── common_tests.py ├── run_sgfmill_testsuite.py ├── sgf_grammar_tests.py ├── sgf_moves_tests.py ├── sgf_properties_tests.py ├── sgf_tests.py ├── sgfmill_test_support.py └── test_framework.py └── test_installed_sgfmill.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | /doc/_build 3 | /release_sgfmill.conf 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft examples 2 | prune examples/__pycache__ 3 | graft sgfmill_tests 4 | prune sgfmill_tests/__pycache__ 5 | graft doc 6 | prune doc/_build 7 | include build_docs 8 | include run_sgfmill_testsuite 9 | include test_installed_sgfmill.py 10 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Sgfmill 3 | ======= 4 | 5 | Sgfmill is a Python library for reading and writing Go game records using 6 | Smart Game Format (SGF). 7 | 8 | Full documentation and contact information is available from the `home page`__. 9 | 10 | .. __: http://mjw.woodcraft.me.uk/sgfmill/ 11 | 12 | 13 | Requirements 14 | ------------ 15 | 16 | Sgfmill requires Python 3.2 or later. There are no other requirements. 17 | 18 | This is a Python 3 version of the SGF code from the Python 2 Gomill__ project. 19 | If you need Python 2 support, please use Gomill instead. 20 | 21 | .. __: https://mjw.woodcraft.me.uk/gomill/ 22 | 23 | 24 | Building the documentation 25 | -------------------------- 26 | 27 | To build the HTML documentation yourself:: 28 | 29 | ./build_docs 30 | 31 | The documentation will be generated in ``doc/_build/html``. 32 | 33 | Requirements: 34 | 35 | - Sphinx__ version 1.0 or later (tested with 1.4) 36 | 37 | .. __: http://sphinx.pocoo.org/ 38 | 39 | -------------------------------------------------------------------------------- /README.sdist.txt: -------------------------------------------------------------------------------- 1 | Sgfmill 2 | ======= 3 | 4 | Sgfmill is a Python library for reading and writing Go game records using 5 | Smart Game Format (SGF). 6 | 7 | Sgfmill's home page is http://mjw.woodcraft.me.uk/sgfmill/ 8 | 9 | There is also a Github repository at https://github.com/mattheww/sgfmill 10 | 11 | 12 | Requirements 13 | ------------ 14 | 15 | Sgfmill requires Python 3.2 or later. There are no other requirements. 16 | 17 | This is a Python 3 version of the SGF code from the Python 2 Gomill project 18 | . If you need Python 2 support, please 19 | use Gomill instead. 20 | 21 | 22 | Installation 23 | ------------ 24 | 25 | Installing Sgfmill puts the sgfmill package onto the Python module search path. 26 | 27 | To install from the source distribution: 28 | 29 | python3 setup.py bdist_wheel 30 | pip3 install --user dist/sgfmill-*.whl 31 | 32 | To uninstall: 33 | 34 | pip3 uninstall sgfmill 35 | 36 | 37 | Running the test suite 38 | ---------------------- 39 | 40 | To run the testsuite against the distributed sgfmill package, change to 41 | the distribution directory and run 42 | 43 | ./run_sgfmill_testsuite 44 | 45 | 46 | To run the testsuite against an installed sgfmill package, change to the 47 | distribution directory and run 48 | 49 | python3 test_installed_sgfmill.py 50 | 51 | 52 | Running the example scripts 53 | --------------------------- 54 | 55 | To run the example scripts, it is simplest to install the sgfmill package 56 | first. 57 | 58 | If you do not wish to do so, you can run 59 | 60 | export PYTHONPATH= 61 | 62 | so that the example scripts will be able to find the sgfmill package. 63 | 64 | 65 | Building the documentation 66 | -------------------------- 67 | 68 | To build the documentation, change to the distribution directory and run 69 | 70 | ./build_docs 71 | 72 | The documentation will be generated in doc/_build/html. 73 | 74 | Requirements: 75 | 76 | Sphinx [1] version 1.0 or later (tested with 1.4) 77 | 78 | [1] http://sphinx.pocoo.org/ 79 | 80 | 81 | Licence 82 | ------- 83 | 84 | Sgfmill is copyright 2009-2018 Matthew Woodcraft and the sgfmill contributors 85 | 86 | Permission is hereby granted, free of charge, to any person obtaining a copy 87 | of this software and associated documentation files (the "Software"), to deal 88 | in the Software without restriction, including without limitation the rights 89 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 90 | copies of the Software, and to permit persons to whom the Software is 91 | furnished to do so, subject to the following conditions: 92 | 93 | The above copyright notice and this permission notice shall be included in all 94 | copies or substantial portions of the Software. 95 | 96 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 97 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 98 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 99 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 100 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 101 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 102 | SOFTWARE. 103 | 104 | 105 | Contributors 106 | ------------ 107 | 108 | See the 'Licence' page in the HTML documentation (doc/licence.rst). 109 | 110 | 111 | Contact 112 | ------- 113 | 114 | Please send any bug reports, suggestions, patches, questions &c to 115 | 116 | Matthew Woodcraft 117 | matthew@woodcraft.me.uk 118 | 119 | 120 | Changelog 121 | --------- 122 | 123 | See the 'Changes' page in the HTML documentation (doc/changes.rst). 124 | 125 | mjw 2018-05-20 126 | -------------------------------------------------------------------------------- /build_docs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import os 5 | import shutil 6 | import sys 7 | 8 | from pathlib import Path 9 | 10 | import sphinx 11 | 12 | def main(): 13 | parser = argparse.ArgumentParser() 14 | parser.add_argument("--all", "-a", action="store_true") 15 | parser.add_argument("--nitpick", "-n", action="store_true") 16 | args = parser.parse_args() 17 | 18 | project_dir = Path(Path.cwd(), __file__).parent 19 | sys.path.append(str(project_dir)) 20 | 21 | docs_dir = Path(project_dir, "doc") 22 | build_dir = Path(docs_dir, "_build") 23 | 24 | if args.all: 25 | if build_dir.exists(): 26 | shutil.rmtree(str(build_dir)) 27 | 28 | argv = ["build_docs"] 29 | if args.nitpick: 30 | argv.append("-n") 31 | argv += [ 32 | "-b", "html", 33 | "-d", str(Path(build_dir, "doctrees")), 34 | str(docs_dir), 35 | str(Path(build_dir, "html")) 36 | ] 37 | sys.exit(sphinx.main(argv)) 38 | 39 | main() 40 | -------------------------------------------------------------------------------- /doc/_static/sgfmill.css_t: -------------------------------------------------------------------------------- 1 | @import url("default.css"); 2 | 3 | p.topic-title { 4 | color: {{ theme_headtextcolor }}; 5 | } 6 | 7 | li.current > a {color: yellow;} 8 | 9 | div.tip { 10 | background-color: #EEEEEE; 11 | border: 1px solid #CCCCCC; 12 | } 13 | 14 | div.caution { 15 | background-color: #EEEEEE; 16 | border: 1px solid #A00000; 17 | } 18 | 19 | abbr { 20 | border-bottom: none; 21 | text-decoration: none; 22 | } 23 | 24 | th.field-name { 25 | background-color: #E4E4E4; 26 | font-weight: normal; 27 | } 28 | 29 | div#modules table.docutils { 30 | width : 100%; 31 | margin-bottom: 3ex; 32 | } 33 | 34 | -------------------------------------------------------------------------------- /doc/_templates/wholetoc.html: -------------------------------------------------------------------------------- 1 |

{{ _('Table Of Contents') }}

2 | {{ toctree(collapse=False) }} 3 | -------------------------------------------------------------------------------- /doc/ascii_boards.rst: -------------------------------------------------------------------------------- 1 | The :mod:`!ascii_boards` module 2 | ------------------------------- 3 | 4 | .. module:: sgfmill.ascii_boards 5 | :synopsis: ASCII Go board diagrams. 6 | 7 | The :mod:`!sgfmill.ascii_boards` module contains functions for producing and 8 | interpreting ASCII diagrams of Go board positions. 9 | 10 | It supports square boards up to size 25x25, which is the upper limit of the 11 | notation it uses (and also the upper limit for |gtp|). 12 | 13 | 14 | .. function:: render_board(board) 15 | 16 | :rtype: string 17 | 18 | Returns an ASCII diagram of the position on the :class:`.Board` *board*. 19 | 20 | The returned string does not end with a newline. 21 | 22 | :: 23 | 24 | >>> b = boards.Board(9) 25 | >>> b.play(2, 5, 'b') 26 | >>> b.play(3, 6, 'w') 27 | >>> print(ascii_boards.render_board(b)) 28 | 9 . . . . . . . . . 29 | 8 . . . . . . . . . 30 | 7 . . . . . . . . . 31 | 6 . . . . . . . . . 32 | 5 . . . . . . . . . 33 | 4 . . . . . . o . . 34 | 3 . . . . . # . . . 35 | 2 . . . . . . . . . 36 | 1 . . . . . . . . . 37 | A B C D E F G H J 38 | 39 | See also the :script:`show_sgf.py` example script. 40 | 41 | 42 | .. function:: interpret_diagram(diagram, size[, board]) 43 | 44 | :rtype: :class:`.Board` 45 | 46 | Returns the position given in an ASCII diagram. 47 | 48 | *diagram* should be a string in the format returned by 49 | :func:`render_board`, representing a position with the specified size. 50 | Leading and trailing whitespace is ignored. 51 | 52 | If the diagram is not in the right form, this function may raise 53 | :exc:`ValueError` or may return a 'best guess'. 54 | 55 | If the optional *board* parameter is provided, it must be an empty 56 | :class:`.Board` of the right size; the same object will be returned (this 57 | option is provided so you can use a different Board class). 58 | -------------------------------------------------------------------------------- /doc/boards.rst: -------------------------------------------------------------------------------- 1 | The :mod:`!boards` module 2 | ------------------------- 3 | 4 | .. module:: sgfmill.boards 5 | :synopsis: Go board representation. 6 | 7 | The :mod:`!sgfmill.boards` module provides a Go board representation for use 8 | with the functions in :mod:`sgfmill.sgf_moves`. 9 | 10 | Everything in this module works with boards of arbitrarily large sizes. 11 | 12 | The implementation is not designed for speed (even as Python code goes), and 13 | is certainly not appropriate for implementing a playing engine. 14 | 15 | The module contains a single class: 16 | 17 | 18 | .. class:: Board(side) 19 | 20 | A :class:`!Board` object represents a legal position on a Go board. 21 | 22 | Instantiate with the board size, as an int >= 1. Only square boards are 23 | supported. The board is initially empty. 24 | 25 | Board objects do not maintain any history information. 26 | 27 | Board objects have the following attributes (which should be treated as 28 | read-only): 29 | 30 | .. attribute:: side 31 | 32 | The board size. 33 | 34 | .. attribute:: board_points 35 | 36 | A list of *points*, giving all points on the board. 37 | 38 | 39 | The principal :class:`!Board` methods are :meth:`!get` and :meth:`!play`. 40 | Their *row* and *col* parameters should be ints representing coordinates in 41 | the :ref:`system ` used for a *point*. 42 | 43 | .. method:: Board.get(row, col) 44 | 45 | :rtype: *colour* or ``None`` 46 | 47 | Returns the contents of the specified point. 48 | 49 | Raises :exc:`IndexError` if the coordinates are out of range. 50 | 51 | .. method:: Board.play(row, col, colour) 52 | 53 | :rtype: *move* 54 | 55 | Places a stone of the specified *colour* on the specified point. 56 | 57 | Raises :exc:`IndexError` if the coordinates are out of range. 58 | 59 | Raises :exc:`ValueError` if the point isn't empty. 60 | 61 | Carries out any captures which follow from the placement, including 62 | self-captures. 63 | 64 | This method doesn't enforce any ko rule. 65 | 66 | The return value indicates whether, immediately following this move, any 67 | point would be forbidden by the :term:`simple ko` rule. If so, that point 68 | is returned; otherwise the return value is ``None``. 69 | 70 | 71 | The other :class:`!Board` methods are: 72 | 73 | .. method:: Board.is_empty() 74 | 75 | :rtype: bool 76 | 77 | Returns ``True`` if all points on the board are empty. 78 | 79 | .. method:: Board.list_occupied_points() 80 | 81 | :rtype: list of pairs (*colour*, *point*) 82 | 83 | Returns a list of all nonempty points, in unspecified order. 84 | 85 | .. method:: Board.area_score() 86 | 87 | :rtype: int 88 | 89 | Calculates the area score of a position, assuming that all stones are 90 | alive. The result is the number of points controlled (occupied or 91 | surrounded) by Black minus the number of points controlled by White. 92 | 93 | Doesn't take any :term:`komi` into account. 94 | 95 | .. method:: Board.copy() 96 | 97 | :rtype: :class:`!Board` 98 | 99 | Returns an independent copy of the board. 100 | 101 | .. method:: Board.apply_setup(black_points, white_points, empty_points) 102 | 103 | :rtype: bool 104 | 105 | Adds and/or removes stones on arbitrary points. This is intended to support 106 | behaviour like |sgf| ``AB``/``AW``/``AE`` properties. 107 | 108 | Each parameter is an iterable of *points*. 109 | 110 | Raises :exc:`IndexError` if any points are out of range. 111 | 112 | This method applies all the specified additions and removals, then removes 113 | any groups with no liberties (so the resulting position is always legal). 114 | 115 | If the same point is specified in more than one list, the order in which 116 | the instructions are applied is undefined. 117 | 118 | Returns ``True`` if the position was legal as specified. 119 | -------------------------------------------------------------------------------- /doc/changes.rst: -------------------------------------------------------------------------------- 1 | Changes 2 | ======= 3 | 4 | * Sped up the :class:`.Board` implementation (thanks to Lin, Yong Xiang). 5 | 6 | * The minimum Python version is now 3.5. 7 | 8 | 9 | Sgfmill 1.1.1 (2018-05-20) 10 | -------------------------- 11 | 12 | * Sped up the :class:`.Board` implementation (thanks to Seth Troisi). 13 | 14 | 15 | Sgfmill 1.1 (2018-02-11) 16 | ------------------------ 17 | 18 | * The parser now permits lower-case letters in *PropIdents*; see 19 | :doc:`parsing` for details. 20 | 21 | * Bug fix: :meth:`.Tree_node.set` didn't check its ``identifier`` parameter 22 | was a well-formed *PropIdent*. 23 | 24 | * Bug fix: :meth:`.Tree_node.set` was willing to store invalid values for 25 | properties of type Number, if fed invalid input. 26 | 27 | 28 | Sgfmill 1.0 (2017-04-17) 29 | ------------------------ 30 | 31 | * Python 3 port of the SGF code from Gomill__ 0.8. 32 | 33 | * Added the :mod:`.sgf_board_interface` module. 34 | 35 | .. __: https://mjw.woodcraft.me.uk/gomill/ 36 | 37 | -------------------------------------------------------------------------------- /doc/common.rst: -------------------------------------------------------------------------------- 1 | The :mod:`~sgfmill.common` module 2 | --------------------------------- 3 | 4 | .. module:: sgfmill.common 5 | :synopsis: Go-related utility functions. 6 | 7 | The :mod:`!sgfmill.common` module provides a few Go-related utility functions, 8 | mostly used only by :mod:`sgfmill.ascii_boards`. 9 | 10 | It is designed to be safe to use as ``from common import *``. 11 | 12 | The vertex functions suppose square boards up to size 25x25, which is the 13 | upper limit of the notation they use (and also the upper limit for |gtp|). 14 | 15 | .. function:: opponent_of(colour) 16 | 17 | :rtype: *colour* 18 | 19 | Returns the other colour:: 20 | 21 | >>> opponent_of('b') 22 | 'w' 23 | 24 | .. function:: colour_name(colour) 25 | 26 | :rtype: string 27 | 28 | Returns the (lower-case) full name of a *colour*:: 29 | 30 | >>> colour_name('b') 31 | 'black' 32 | 33 | .. function:: format_vertex(move) 34 | 35 | :rtype: string 36 | 37 | Returns a string describing a *move* in conventional notation:: 38 | 39 | >>> format_vertex((3, 0)) 40 | 'A4' 41 | >>> format_vertex(None) 42 | 'pass' 43 | 44 | The result is suitable for use directly in |GTP| responses. Note that ``I`` 45 | is omitted from the letters used to indicate columns, so the maximum 46 | supported column value is ``25``. 47 | 48 | .. function:: format_vertex_list(moves) 49 | 50 | :rtype: string 51 | 52 | Returns a string describing a sequence of *moves*:: 53 | 54 | >>> format_vertex_list([(0, 1), (2, 3), None]) 55 | 'B1,D3,pass' 56 | >>> format_vertex_list([]) 57 | '' 58 | 59 | .. function:: move_from_vertex(vertex, board_size) 60 | 61 | :rtype: *move* 62 | 63 | Interprets the string *vertex* as conventional notation, assuming a square 64 | board whose side is *board_size*:: 65 | 66 | >>> move_from_vertex("A4", 9) 67 | (3, 0) 68 | >>> move_from_vertex("a4", 9) 69 | (3, 0) 70 | >>> move_from_vertex("pass", 9) 71 | None 72 | 73 | Raises :exc:`ValueError` if it can't parse the string, or if the resulting 74 | point would be off the board. 75 | 76 | Treats *vertex* case-insensitively. 77 | 78 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | import sgfmill 2 | 3 | extensions = [ 4 | 'sphinx.ext.intersphinx', 5 | 'sphinx.ext.viewcode', 6 | ] 7 | templates_path = ['_templates'] 8 | source_suffix = '.rst' 9 | source_encoding = 'utf-8' 10 | master_doc = 'index' 11 | project = u'Sgfmill' 12 | copyright = u'2009-2018, Matthew Woodcraft and the sgfmill contributors' 13 | version = sgfmill.__version__ 14 | release = sgfmill.__version__ 15 | exclude_patterns = ['_build'] 16 | pygments_style = 'vs' 17 | modindex_common_prefix = ['sgfmill.'] 18 | 19 | html_theme = 'default' 20 | html_theme_options = { 21 | 'nosidebar' : False, 22 | #'rightsidebar' : True, 23 | 'stickysidebar' : False, 24 | 25 | 'footerbgcolor' : '#3d3011', 26 | #'footertextcolor' : , 27 | 'sidebarbgcolor' : '#3d3011', 28 | #'sidebartextcolor' : , 29 | 'sidebarlinkcolor' : '#d8d898', 30 | 'relbarbgcolor' : '#523f13', 31 | #'relbartextcolor' : , 32 | #'relbarlinkcolor' : , 33 | #'bgcolor' : , 34 | #'textcolor' : , 35 | 'linkcolor' : '#7c5f35', 36 | 'visitedlinkcolor' : '#7c5f35', 37 | #'headbgcolor' : , 38 | 'headtextcolor' : '#5c4320', 39 | #'headlinkcolor' : , 40 | #'codebgcolor' : , 41 | #'codetextcolor' : , 42 | 43 | 'externalrefs' : True, 44 | } 45 | 46 | html_static_path = ['_static'] 47 | html_sidebars = { '**':[ 48 | 'wholetoc.html', 49 | 'searchbox.html', 50 | ], } 51 | html_style = "sgfmill.css" 52 | 53 | intersphinx_mapping = {'python': ('http://docs.python.org/3', 54 | 'python-inv.txt')} 55 | 56 | rst_epilog = """ 57 | .. |gtp| replace:: :abbr:`GTP (Go Text Protocol)` 58 | .. |sgf| replace:: :abbr:`SGF (Smart Game Format)` 59 | """ 60 | 61 | def setup(app): 62 | app.add_object_type('script', 'script', 63 | indextemplate='pair: %s; example script', 64 | objname="Example script") 65 | 66 | -------------------------------------------------------------------------------- /doc/contact.rst: -------------------------------------------------------------------------------- 1 | Contact 2 | ======= 3 | 4 | Sgfmill's home page is http://mjw.woodcraft.me.uk/sgfmill/. 5 | 6 | Updated versions will be made available for download from that site, as well 7 | as the `Python package index`__. 8 | 9 | .. __: https://pypi.python.org/pypi/sgfmill/ 10 | 11 | I'm happy to receive any bug reports, suggestions, patches, questions and so 12 | on at . 13 | 14 | There is also a Github repository at https://github.com/mattheww/sgfmill. 15 | -------------------------------------------------------------------------------- /doc/encoding.rst: -------------------------------------------------------------------------------- 1 | Character encoding 2 | ================== 3 | 4 | .. currentmodule:: sgfmill.sgf 5 | 6 | The |sgf| format is defined as containing ASCII-encoded data, possibly with 7 | non-ASCII characters in Text and SimpleText property values. The low-level 8 | Sgfmill functions for loading and serialising |sgf| data work with Python 9 | bytes or bytes-like objects. 10 | 11 | The encoding used for Text and SimpleText property values is given by the 12 | ``CA`` root property (if that isn't present, the encoding is ``ISO-8859-1``). 13 | 14 | In order for an encoding to be used in Sgfmill, it must exist as a Python 15 | built-in codec, and it must be compatible with ASCII (at least whitespace, 16 | ``\``, ``]``, and ``:`` must be in the usual places). Behaviour is unspecified 17 | if a non-ASCII-compatible encoding is requested. 18 | 19 | When encodings are passed as parameters (or returned from functions), they are 20 | represented using the names or aliases of Python built-in codecs (eg 21 | ``"UTF-8"`` or ``"ISO-8859-1"``). See `standard encodings`__ for a list. 22 | Values of the ``CA`` property are interpreted in the same way. 23 | 24 | .. __: https://docs.python.org/3/library/codecs.html#standard-encodings 25 | 26 | 27 | .. _raw_property_encoding: 28 | 29 | The raw property encoding 30 | ------------------------- 31 | 32 | Each :class:`.Sgf_game` and :class:`.Tree_node` has a fixed :dfn:`raw property 33 | encoding`, which is the encoding used internally to store the property values. 34 | The :meth:`Tree_node.get_raw` and :meth:`Tree_node.set_raw` methods use the 35 | raw property encoding. 36 | 37 | When an |sgf| game is loaded from a bytes-like object, the raw property 38 | encoding is taken from the ``CA`` root property (unless overridden). 39 | Improperly encoded property values will not be detected until they are 40 | accessed (:meth:`~Tree_node.get` will raise :exc:`ValueError`; use 41 | :meth:`~Tree_node.get_raw` to retrieve the actual bytes). 42 | 43 | When an |sgf| game is created from a Python string (which contains Unicode 44 | characters), the raw property encoding is always ``UTF-8``. 45 | 46 | 47 | .. _changing_ca: 48 | 49 | Changing the CA property 50 | ------------------------ 51 | 52 | When an |sgf| game is serialised to a string, the encoding represented by the 53 | ``CA`` root property is used. This :dfn:`target encoding` will be the same as 54 | the raw property encoding unless ``CA`` has been changed since the 55 | :class:`.Sgf_game` was created. 56 | 57 | When the raw property encoding and the target encoding match, the raw property 58 | values are included unchanged in the output (even if they are improperly 59 | encoded.) 60 | 61 | Otherwise, if any raw property value is improperly encoded, 62 | :exc:`UnicodeDecodeError` is raised, and if any property value can't be 63 | represented in the target encoding, :exc:`UnicodeEncodeError` is raised. 64 | 65 | If the target encoding doesn't identify a Python codec, :exc:`ValueError` is 66 | raised. The behaviour of :meth:`~Sgf_game.serialise` is unspecified if the 67 | target encoding isn't ASCII-compatible (eg, UTF-16). 68 | 69 | 70 | .. _transcoding: 71 | 72 | Transcoding 73 | ----------- 74 | 75 | Because changing the ``CA`` property has no effect until you serialise the 76 | game, it doesn't broaden the set of characters you can use when you 77 | :meth:`~Tree_node.set` a property. 78 | 79 | If you plan to save a file as ``UTF-8`` and want to be able to set arbitrary 80 | strings, you can ensure the raw property encoding is ``UTF-8`` by changing 81 | ``CA`` and reloading the game:: 82 | 83 | game = sgf.Sgf_game.from_bytes(...) 84 | game.get_root().set("CA", "utf-8") 85 | game = sgf.Sgf_game.from_bytes(game.serialise()) 86 | game.get_root().set("PB", "本因坊秀策") 87 | 88 | -------------------------------------------------------------------------------- /doc/examples.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ======== 3 | 4 | Reading from a file 5 | ------------------- 6 | 7 | :: 8 | 9 | from sgfmill import sgf 10 | with open("foo.sgf", "rb") as f: 11 | game = sgf.Sgf_game.from_bytes(f.read()) 12 | winner = game.get_winner() 13 | board_size = game.get_size() 14 | root_node = game.get_root() 15 | b_player = root_node.get("PB") 16 | w_player = root_node.get("PW") 17 | for node in game.get_main_sequence(): 18 | print(node.get_move()) 19 | 20 | 21 | Recording a game 22 | ---------------- 23 | 24 | :: 25 | 26 | from sgfmill import sgf 27 | game = sgf.Sgf_game(size=13) 28 | for move_info in ...: 29 | node = game.extend_main_sequence() 30 | node.set_move(move_info.colour, move_info.move) 31 | if move_info.comment is not None: 32 | node.set("C", move_info.comment) 33 | with open(pathname, "wb") as f: 34 | f.write(game.serialise()) 35 | 36 | 37 | Modifying a game 38 | ---------------- 39 | 40 | :: 41 | 42 | >>> from sgfmill import sgf 43 | >>> game = sgf.Sgf_game.from_string("(;FF[4]GM[1]SZ[9];B[ee];W[ge])") 44 | >>> root_node = game.get_root() 45 | >>> root_node.set("RE", "B+R") 46 | >>> new_node = game.extend_main_sequence() 47 | >>> new_node.set_move("b", (2, 3)) 48 | >>> [node.get_move() for node in game.get_main_sequence()] 49 | [(None, None), ('b', (4, 4)), ('w', (4, 6)), ('b', (2, 3))] 50 | >>> game.serialise() 51 | b'(;FF[4]CA[UTF-8]GM[1]RE[B+R]SZ[9];B[ee];W[ge];B[dg])\n' 52 | 53 | 54 | See also the :script:`show_sgf.py` and :script:`split_sgf_collection.py` 55 | example scripts. 56 | 57 | 58 | The example scripts 59 | ------------------- 60 | 61 | The following example scripts are available in the :file:`examples/` directory 62 | of the Sgfmill source distribution. 63 | 64 | They may be independently useful, as well as illustrating the library API. 65 | 66 | See the top of each script for further information. 67 | 68 | See :ref:`running the example scripts ` for notes 69 | on making the :mod:`!sgfmill` package available for use with the example 70 | scripts. 71 | 72 | 73 | .. script:: show_sgf.py 74 | 75 | Prints an ASCII diagram of the position from an |sgf| file. 76 | 77 | This demonstrates the :mod:`~sgfmill.sgf`, :mod:`~sgfmill.sgf_moves`, and 78 | :mod:`~sgfmill.ascii_boards` modules. 79 | 80 | 81 | .. script:: split_sgf_collection.py 82 | 83 | Splits a file containing an |sgf| game collection into multiple files. 84 | 85 | This demonstrates the parsing functions from the :mod:`!sgf_grammar` module. 86 | 87 | 88 | -------------------------------------------------------------------------------- /doc/glossary.rst: -------------------------------------------------------------------------------- 1 | Glossary 2 | ======== 3 | 4 | .. glossary:: 5 | 6 | GTP 7 | The Go Text Protocol 8 | 9 | A communication protocol used to control Go-playing programs. Gomill 10 | uses only GTP version 2, which is specified at 11 | http://www.lysator.liu.se/~gunnar/gtp/gtp2-spec-draft2/gtp2-spec.html. 12 | 13 | (As of April 2017, the specification describes itself as a draft, but it 14 | has remained stable for several years and is widely implemented.) 15 | 16 | 17 | SGF 18 | The Smart Game Format 19 | 20 | A text-based file format used for storing Go game records. 21 | 22 | Gomill uses version FF[4], which is specified at 23 | http://www.red-bean.com/sgf/index.html. 24 | 25 | 26 | komi 27 | Additional points awarded to White in final scoring. 28 | 29 | 30 | simple ko 31 | A Go rule prohibiting repetition of the immediately-preceding position. 32 | 33 | 34 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | ******* 2 | Sgfmill 3 | ******* 4 | 5 | .. toctree:: 6 | :maxdepth: 3 7 | :titlesonly: 8 | :includehidden: 9 | 10 | intro 11 | examples 12 | property_types 13 | sgfmill_package 14 | properties 15 | encoding 16 | parsing 17 | porting 18 | install 19 | contact 20 | changes 21 | licence 22 | glossary 23 | 24 | * :ref:`genindex` 25 | * :ref:`search` 26 | 27 | -------------------------------------------------------------------------------- /doc/install.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | .. contents:: Page contents 5 | :local: 6 | :backlinks: none 7 | 8 | 9 | Requirements 10 | ------------ 11 | 12 | Sgfmill requires Python 3.2 or later. There are no other requirements. 13 | 14 | This is a Python 3 version of the |sgf| code from the Python 2 Gomill__ project. 15 | If you need Python 2 support, please use Gomill instead. 16 | 17 | .. __: https://mjw.woodcraft.me.uk/gomill/ 18 | 19 | 20 | Installing 21 | ---------- 22 | 23 | Sgfmill can be installed from the Python Package Index:: 24 | 25 | pip3 install sgfmill 26 | 27 | To remove an installed version of Sgfmill, run :: 28 | 29 | pip3 uninstall sgfmill 30 | 31 | 32 | Downloading sources and documentation 33 | ------------------------------------- 34 | 35 | The source distribution can be downloaded from the `Python Package index`__, 36 | or from http://mjw.woodcraft.me.uk/sgfmill/, as a file named 37 | :file:`sgfmill-{version}.tar.gz`. 38 | 39 | .. __: https://pypi.python.org/pypi/sgfmill 40 | 41 | This documentation is distributed separately in html form at 42 | http://mjw.woodcraft.me.uk/sgfmill/ as :file:`sgfmill-doc-{version}.tar.gz`. 43 | 44 | The version-control history is available at 45 | https://github.com/mattheww/sgfmill. 46 | 47 | To install from the source distribution:: 48 | 49 | python3 setup.py bdist_wheel 50 | pip3 install --user dist/sgfmill-*.whl 51 | 52 | 53 | Running the test suite 54 | ---------------------- 55 | 56 | The testsuite is available from the source distribution or a version-control 57 | checkout. 58 | 59 | To run the testsuite against the distributed :mod:`!sgfmill` package, change to 60 | the distribution directory and run :: 61 | 62 | ./run_sgfmill_testsuite 63 | 64 | 65 | To run the testsuite against an installed :mod:`!sgfmill` package, change to 66 | the distribution directory and run :: 67 | 68 | python test_installed_sgfmill.py 69 | 70 | 71 | .. _running the example scripts: 72 | 73 | Running the example scripts 74 | --------------------------- 75 | 76 | The example scripts are included in the source distribution. To run them, it 77 | is simplest to install the :mod:`!sgfmill` package first. 78 | 79 | If you do not wish to do so, you can run :: 80 | 81 | export PYTHONPATH= 82 | 83 | so that the example scripts will be able to find the :mod:`!sgfmill` package. 84 | 85 | 86 | 87 | Building the documentation 88 | -------------------------- 89 | 90 | The sources for this HTML documentation are included in the Sgfmill source 91 | distribution. To build the documentation, change to the distribution directory 92 | and run :: 93 | 94 | ./build_docs 95 | 96 | The documentation will be generated in :file:`doc/_build/html`. 97 | 98 | Requirements: 99 | 100 | - Sphinx__ version 1.0 or later (tested with 1.2) 101 | 102 | .. __: http://sphinx.pocoo.org/ 103 | 104 | -------------------------------------------------------------------------------- /doc/intro.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | 4 | Sgfmill is a Python library for reading and writing Go game records using 5 | Smart Game Format (:term:`SGF`). 6 | 7 | It supports: 8 | 9 | * loading |sgf| game records to make a Python object representation 10 | * creating |sgf| game objects from scratch 11 | * setting properties and manipulating the tree structure 12 | * serialising game records to |sgf| data 13 | * applying setup stones and moves to a Go board position 14 | 15 | It is intended for use with |SGF| version FF[4], which is specified at 16 | http://www.red-bean.com/sgf/index.html. 17 | 18 | It has support for the game-specific properties for Go, but not those of other 19 | games. 20 | 21 | Point, Move and Stone values are interpreted as Go points. 22 | 23 | 24 | Python language support 25 | ----------------------- 26 | 27 | Sgfmill requires Python 3.5 or newer. 28 | 29 | It is a Python 3 version of the |sgf| code from the Python 2 Gomill__ project. 30 | If you need Python 2 support, please use Gomill instead. 31 | 32 | If you need support for older versions of Python 3, you can use sgfmill 1.1.1. 33 | 34 | .. __: https://mjw.woodcraft.me.uk/gomill/ 35 | 36 | -------------------------------------------------------------------------------- /doc/licence.rst: -------------------------------------------------------------------------------- 1 | Licence 2 | ======= 3 | 4 | Sgfmill is copyright 2009-2018 Matthew Woodcraft and the sgfmill contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | 24 | .. Note:: This is the licence commonly known as the 'MIT' Licence. 25 | 26 | 27 | Contributors 28 | ------------ 29 | 30 | * Matthew Woodcraft 31 | * Seth Troisi 32 | 33 | -------------------------------------------------------------------------------- /doc/parsing.rst: -------------------------------------------------------------------------------- 1 | Parser behaviour 2 | ================ 3 | 4 | The parser permits non-|sgf| content to appear before the beginning and after 5 | the end of the game. It identifies the start of |sgf| content by looking for 6 | ``(;`` (with possible whitespace between the two characters). 7 | 8 | The parser accepts at most 64 letters in *PropIdents* (there is no formal limit 9 | in the specification, but no standard property has more than 2; strings as 10 | long as 9 letters have been found in the wild). 11 | 12 | The parser doesn't perform any checks on property values. In particular, it 13 | allows multiple values to be present for any property. 14 | 15 | The parser doesn't, in general, attempt to 'fix' ill-formed |sgf| content. As 16 | an exception, if a *PropIdent* appears more than once in a node it is 17 | converted to a single property with multiple values. 18 | 19 | The parser permits lower-case letters in *PropIdents* (these are allowed in 20 | some ancient |sgf| variants, and are apparently seen in the wild). It ignores 21 | those letters, so for example ``CoPyright`` is treated as a synonym for ``CP`` 22 | and should be retrieved using ``node.get("CP")``. 23 | 24 | -------------------------------------------------------------------------------- /doc/porting.rst: -------------------------------------------------------------------------------- 1 | Porting to Python 3 2 | =================== 3 | 4 | .. currentmodule:: sgfmill.sgf 5 | 6 | Sgfmill is a Python 3 version of the |sgf| code from the Gomill__ project. 7 | 8 | .. __: https://mjw.woodcraft.me.uk/gomill/ 9 | 10 | 11 | The differences are as follows: 12 | 13 | There is a new :func:`Sgf_game.from_bytes` classmethod, which behaves like the old 14 | :func:`!Sgf_game.from_string`. 15 | 16 | :func:`Sgf_game.from_string` now expects a Python 3 string (which contains 17 | Unicode characters), and forces the game's encoding to UTF-8. 18 | 19 | :meth:`Sgf_game.serialise` now returns a bytes object. 20 | 21 | :meth:`Tree_node.get_raw` and :meth:`Tree_node.set_raw` (and related methods) 22 | now use bytes objects. 23 | 24 | For Text and SimpleText properties, :meth:`Tree_node.get` and 25 | :meth:`Tree_node.set` use Python 3 strings. 26 | 27 | 28 | The exact rules for what 'raw' data is accepted for some property types have 29 | changed, due to changes in the Python language features used to implement the 30 | parser. In particular: 31 | 32 | * CESU-8 is no longer accepted when parsing data which purports to be UTF-8. 33 | 34 | * The raw values accepted for properties of type Real are now determined by 35 | Python 3's `float()`__ function rather than the platform libc (exactly what 36 | is accepted will depend on the Python minor version). 37 | 38 | * The interpreter for type Number is more lenient, accepting spaces anywhere 39 | in the string, and 'grouping underscores' from Python 3.6 on. 40 | 41 | 42 | .. __: https://docs.python.org/3/library/functions.html#float 43 | -------------------------------------------------------------------------------- /doc/properties.rst: -------------------------------------------------------------------------------- 1 | List of SGF properties 2 | ====================== 3 | 4 | Sgfmill knows about all general and Go-specific |sgf| properties defined in 5 | FF[4]: 6 | 7 | ====== ========================== =================== 8 | Id |sgf| type Meaning 9 | ====== ========================== =================== 10 | ``AB`` list of Stone Add Black 11 | ``AE`` list of Point Add Empty 12 | ``AN`` SimpleText Annotation 13 | ``AP`` SimpleText:SimpleText Application 14 | ``AR`` list of Point:Point Arrow 15 | ``AW`` list of Stone Add White 16 | ``B`` Move Black move 17 | ``BL`` Real Black time left 18 | ``BM`` Double Bad move 19 | ``BR`` SimpleText Black rank 20 | ``BT`` SimpleText Black team 21 | ``C`` Text Comment 22 | ``CA`` SimpleText Charset 23 | ``CP`` SimpleText Copyright 24 | ``CR`` list of Point Circle 25 | ``DD`` elist of Point Dim Points 26 | ``DM`` Double Even position 27 | ``DO`` None Doubtful 28 | ``DT`` SimpleText Date 29 | ``EV`` SimpleText Event 30 | ``FF`` Number File format 31 | ``FG`` None | Number:SimpleText Figure 32 | ``GB`` Double Good for Black 33 | ``GC`` Text Game comment 34 | ``GM`` Number Game 35 | ``GN`` SimpleText Game name 36 | ``GW`` Double Good for White 37 | ``HA`` Number Handicap 38 | ``HO`` Double Hotspot 39 | ``IT`` None Interesting 40 | ``KM`` Real Komi 41 | ``KO`` None Ko 42 | ``LB`` list of Point:SimpleText Label 43 | ``LN`` list of Point:Point Line 44 | ``MA`` list of Point Mark 45 | ``MN`` Number Set move number 46 | ``N`` SimpleText Node name 47 | ``OB`` Number Overtime stones left for Black 48 | ``ON`` SimpleText Opening 49 | ``OT`` SimpleText Overtime description 50 | ``OW`` Number Overtime stones left for White 51 | ``PB`` SimpleText Black player name 52 | ``PC`` SimpleText Place 53 | ``PL`` Colour Player to play 54 | ``PM`` Number Print move mode 55 | ``PW`` SimpleText White player name 56 | ``RE`` SimpleText Result 57 | ``RO`` SimpleText Round 58 | ``RU`` SimpleText Rules 59 | ``SL`` list of Point Selected 60 | ``SO`` SimpleText Source 61 | ``SQ`` list of Point Square 62 | ``ST`` Number Style 63 | ``SZ`` Number Size 64 | ``TB`` elist of Point Black territory 65 | ``TE`` Double Tesuji 66 | ``TM`` Real Time limit 67 | ``TR`` list of Point Triangle 68 | ``TW`` elist of Point White territory 69 | ``UC`` Double Unclear position 70 | ``US`` SimpleText User 71 | ``V`` Real Value 72 | ``VW`` elist of Point View 73 | ``W`` Move White move 74 | ``WL`` Real White time left 75 | ``WR`` SimpleText White rank 76 | ``WT`` SimpleText White team 77 | ====== ========================== =================== 78 | 79 | -------------------------------------------------------------------------------- /doc/property_types.rst: -------------------------------------------------------------------------------- 1 | Property types 2 | ============== 3 | 4 | .. currentmodule:: sgfmill.sgf 5 | 6 | .. _basic_go_types: 7 | 8 | Basic Go types 9 | -------------- 10 | 11 | Sgfmill represents Go colours and moves as follows: 12 | 13 | ======== =========================================== 14 | Name Possible values 15 | ======== =========================================== 16 | *colour* single-character string: ``'b'`` or ``'w'`` 17 | *point* pair (*int*, *int*) of coordinates 18 | *move* *point* or ``None`` (for a pass) 19 | ======== =========================================== 20 | 21 | The terms *colour*, *point*, and *move* are used as above throughout this 22 | documentation (in particular, when describing parameters and return types). 23 | 24 | *colour* values are used to represent players, as well as stones on the board. 25 | (When a way to represent an empty point is needed, ``None`` is used.) 26 | 27 | *point* values are treated as (row, column). The bottom left is ``(0, 0)`` 28 | (the same orientation as |gtp|, but not |sgf|). So the coordinates for a 9x9 29 | board are as follows:: 30 | 31 | 9 (8,0) . . . . . (8,8) 32 | 8 . . . . . . . . . 33 | 7 . . . . . . . . . 34 | 6 . . . . . . . . . 35 | 5 . . . . . . . . . 36 | 4 . . . . . . . . . 37 | 3 . . . . . . . . . 38 | 2 . . . . . . . . . 39 | 1 (0,0) . . . . . (0,8) 40 | A B C D E F G H J 41 | 42 | There are functions in the :mod:`~sgfmill.common` module to convert between 43 | these coordinates and the conventional (``T19``\ -style) notation. 44 | 45 | Sgfmill is designed to work with square boards, up to size 26x26. 46 | 47 | 48 | .. _sgf_property_types: 49 | 50 | SGF property types 51 | ------------------ 52 | 53 | The following table shows how |sgf| property types are represented as Python 54 | values (eg by the :meth:`Tree_node.get` and :meth:`Tree_node.set` methods). 55 | 56 | =========== ======================== 57 | |sgf| type Python representation 58 | =========== ======================== 59 | None ``True`` 60 | Number int 61 | Real float 62 | Double ``1`` or ``2`` (int) 63 | Colour *colour* 64 | SimpleText string 65 | Text string 66 | Stone *point* 67 | Point *point* 68 | Move *move* 69 | =========== ======================== 70 | 71 | Sgfmill doesn't distinguish the Point and Stone |sgf| property types. It 72 | rejects representations of 'pass' for the Point and Stone types, but accepts 73 | them for Move (this is not what is described in the |sgf| specification, but 74 | it does correspond to the properties in which 'pass' makes sense). 75 | 76 | Values of list or elist types are represented as Python lists. An empty elist 77 | is represented as an empty Python list (in contrast, the raw value is a list 78 | containing a single empty string). 79 | 80 | Values of compose types are represented as Python pairs (tuples of length 81 | two). ``FG`` values are either a pair (int, string) or ``None``. 82 | 83 | For Text and SimpleText values, :meth:`~Tree_node.get` and 84 | :meth:`~Tree_node.set` take care of escaping. You can store arbitrary strings 85 | in a Text value and retrieve them unchanged, with the following exceptions: 86 | 87 | * all linebreaks are normalised to ``\n`` 88 | 89 | * whitespace other than line breaks is converted to a single space 90 | 91 | :meth:`~Tree_node.get` accepts compressed point lists, but 92 | :meth:`~Tree_node.set` never produces them (some |sgf| viewers still don't 93 | support them). 94 | 95 | In some cases, :meth:`~Tree_node.get` will accept values which are not 96 | strictly permitted in |sgf|, if there's a sensible way to interpret them. In 97 | particular, empty lists are accepted for all list types (not only elists). 98 | 99 | In some cases, :meth:`~Tree_node.set` will accept values which are not exactly 100 | in the Python representation listed, if there's a natural way to convert them 101 | to the |sgf| representation. 102 | 103 | Both :meth:`~Tree_node.get` and :meth:`~Tree_node.set` check that Point values 104 | are in range for the board size. Neither :meth:`~Tree_node.get` nor 105 | :meth:`~Tree_node.set` pays attention to range restrictions for values of type 106 | Number. 107 | 108 | Examples:: 109 | 110 | >>> node.set('KO', True) 111 | >>> node.get_raw('KO') 112 | b'' 113 | >>> node.set('HA', 3) 114 | >>> node.set('KM', 5.5) 115 | >>> node.set('GB', 2) 116 | >>> node.set('PL', 'w') 117 | >>> node.set('RE', 'W+R') 118 | >>> node.set('GC', 'Example game\n[for documentation]') 119 | >>> node.get_raw('GC') 120 | b'Example game\n[for documentation\\]' 121 | >>> node.set('B', (2, 3)) 122 | >>> node.get_raw('B') 123 | b'dg' 124 | >>> node.set('LB', [((6, 0), "label 1"), ((6, 1), "label 2")]) 125 | >>> node.get_raw_list('LB') 126 | [b'ac:label 1', b'bc:label 2'] 127 | 128 | 129 | 130 | -------------------------------------------------------------------------------- /doc/python-inv.txt: -------------------------------------------------------------------------------- 1 | # Sphinx inventory version 1 2 | # Project: Python 3 | # Version: 3 4 | datetime.date class library/datetime.html 5 | -------------------------------------------------------------------------------- /doc/sgf.rst: -------------------------------------------------------------------------------- 1 | The :mod:`!sgf` module 2 | ---------------------- 3 | 4 | .. module:: sgfmill.sgf 5 | :synopsis: High level SGF interface. 6 | 7 | The :mod:`!sgfmill.sgf` module provides the main high-level |sgf| interface. 8 | 9 | It defines two public classes: 10 | 11 | * :doc:`Sgf_game ` to represent an |sgf| ``GameTree``; 12 | * :doc:`Tree_node ` to represent an |sgf| ``Node``. 13 | 14 | .. toctree:: 15 | :titlesonly: 16 | :hidden: 17 | 18 | sgf_games 19 | tree_nodes 20 | -------------------------------------------------------------------------------- /doc/sgf_board_interface.rst: -------------------------------------------------------------------------------- 1 | The :mod:`!sgf_board_interface` module 2 | -------------------------------------- 3 | 4 | .. module:: sgfmill.sgf_board_interface 5 | :synopsis: Description of the board interfaces required by sgf_moves. 6 | 7 | The :mod:`!sgfmill.sgf_board_interface` module defines two abstract classes, 8 | for documentation purposes: 9 | 10 | .. class:: Interface_for_get_setup_and_moves 11 | 12 | The board interface required by :func:`.sgf_moves.get_setup_and_moves()`. 13 | 14 | .. class:: Interface_for_set_initial_position 15 | 16 | The board interface required by :func:`.sgf_moves.set_initial_position()`. 17 | 18 | 19 | 20 | Use the 'source' links to the right to see the definitions. 21 | 22 | -------------------------------------------------------------------------------- /doc/sgf_games.rst: -------------------------------------------------------------------------------- 1 | :class:`!Sgf_game` objects 2 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 3 | 4 | .. currentmodule:: sgfmill.sgf 5 | 6 | |sgf| data is represented using :class:`!Sgf_game` objects. Each object 7 | represents the data for a single |sgf| file (corresponding to a ``GameTree`` 8 | in the |sgf| spec). This is typically used to represent a single game, 9 | possibly with variations (but it could be something else, such as a problem 10 | set). 11 | 12 | .. contents:: Page contents 13 | :local: 14 | :backlinks: none 15 | 16 | 17 | Creating :class:`!Sgf_game`\ s 18 | """""""""""""""""""""""""""""" 19 | 20 | An :class:`!Sgf_game` can either be created from scratch or loaded from a 21 | string. 22 | 23 | To create one from scratch, instantiate an :class:`!Sgf_game` object directly: 24 | 25 | .. class:: Sgf_game(size[, encoding="UTF-8"]) 26 | 27 | *size* is an integer from 1 to 26, indicating the board size. 28 | 29 | The optional *encoding* parameter specifies the :ref:`raw property encoding 30 | ` to use for the game. 31 | 32 | When a game is created this way, the following root properties are initially 33 | set: :samp:`FF[4]`, :samp:`GM[1]`, :samp:`SZ[{size}]`, and 34 | :samp:`CA[{encoding}]`. 35 | 36 | To create a game from existing |sgf| data, use the 37 | :func:`!Sgf_game.from_bytes` or :func:`!Sgf_game.from_string` classmethod: 38 | 39 | .. classmethod:: Sgf_game.from_bytes(bb[, override_encoding=None]) 40 | 41 | :rtype: :class:`!Sgf_game` 42 | 43 | Creates an :class:`!Sgf_game` from the |sgf| data in *bb*, which must be 44 | a bytes-like object. 45 | 46 | Raises :exc:`ValueError` if it can't parse the data, or if the ``SZ`` or 47 | ``CA`` properties are unacceptable. No error is reported for other 48 | malformed property values. See also :doc:`parsing`. 49 | 50 | Assumes the data is in the encoding described by the ``CA`` property in the 51 | root node (defaulting to ``"ISO-8859-1"``), and uses that as the :ref:`raw 52 | property encoding `. 53 | 54 | But if *override_encoding* is present, assumes the data is in that encoding 55 | (no matter what the ``CA`` property says), and sets the ``CA`` property and 56 | raw property encoding to match. 57 | 58 | The board size is taken from the ``SZ`` propery in the root node 59 | (defaulting to ``19``). Board sizes greater than ``26`` are rejected. 60 | 61 | 62 | Example:: 63 | 64 | g = sgf.Sgf_game.from_bytes( 65 | b"(;FF[4]GM[1]SZ[9]CA[UTF-8];B[ee];W[ge])", 66 | override_encoding="iso8859-1") 67 | 68 | 69 | .. classmethod:: Sgf_game.from_string(s) 70 | 71 | :rtype: :class:`!Sgf_game` 72 | 73 | Creates an :class:`!Sgf_game` from the |sgf| data in *s*, which must be a 74 | string. 75 | 76 | Raises :exc:`ValueError` if it can't parse the data, or if the ``SZ`` or 77 | ``CA`` properties are unacceptable. No error is reported for other 78 | malformed property values. See also :doc:`parsing`. 79 | 80 | The game's :ref:`raw property encoding ` and ``CA`` 81 | property will be ``"UTF-8"`` (replacing any ``CA`` property present in the 82 | string). 83 | 84 | The board size is taken from the ``SZ`` propery in the root node 85 | (defaulting to ``19``). Board sizes greater than ``26`` are rejected. 86 | 87 | Example:: 88 | 89 | g = sgf.Sgf_game.from_string( 90 | "(;FF[4]GM[1]SZ[9]CA[UTF-8];B[ee];W[ge])") 91 | 92 | 93 | Serialising :class:`!Sgf_game`\ s 94 | """"""""""""""""""""""""""""""""" 95 | 96 | To retrieve the |sgf| data as bytes, use the :meth:`!serialise` method: 97 | 98 | .. method:: Sgf_game.serialise([wrap]) 99 | 100 | :rtype: bytes 101 | 102 | Produces the |sgf| representation of the data in the :class:`!Sgf_game`. 103 | 104 | Returns a bytes object, in the encoding specified by the ``CA`` root 105 | property (defaulting to ``"ISO-8859-1"``). 106 | 107 | See :ref:`changing_ca` for details of the behaviour if the ``CA`` property 108 | is changed from its initial value. 109 | 110 | :meth:`!serialise` makes some effort to keep the output line length to no 111 | more than 79 bytes. Pass ``None`` in the *wrap* parameter to disable this 112 | behaviour, or pass an integer to specify a different limit. 113 | 114 | 115 | Accessing the game's nodes 116 | """""""""""""""""""""""""" 117 | 118 | The complete game tree is represented using :class:`Tree_node` objects, which 119 | are used to access the |sgf| properties. An :class:`!Sgf_game` always has at 120 | least one node, the :dfn:`root node`. 121 | 122 | .. method:: Sgf_game.get_root() 123 | 124 | :rtype: :class:`Tree_node` 125 | 126 | Returns the root node of the game tree. 127 | 128 | The complete game tree can be accessed through the root node, but the 129 | following convenience methods are also provided. They return the same 130 | :class:`Tree_node` objects that would be reached via the root node. 131 | 132 | Some of the convenience methods are for accessing the :dfn:`leftmost` 133 | variation of the game tree. This is the variation which appears first in the 134 | |sgf| ``GameTree``, often shown in graphical editors as the topmost horizontal 135 | line of nodes. In a game tree without variations, the leftmost variation is 136 | just the whole game. 137 | 138 | 139 | .. method:: Sgf_game.get_last_node() 140 | 141 | :rtype: :class:`Tree_node` 142 | 143 | Returns the last (leaf) node in the leftmost variation. 144 | 145 | .. method:: Sgf_game.get_main_sequence() 146 | 147 | :rtype: list of :class:`Tree_node` objects 148 | 149 | Returns the complete leftmost variation. The first element is the root 150 | node, and the last is a leaf. 151 | 152 | .. method:: Sgf_game.get_main_sequence_below(node) 153 | 154 | :rtype: list of :class:`Tree_node` objects 155 | 156 | Returns the leftmost variation beneath the :class:`Tree_node` *node*. The 157 | first element is the first child of *node*, and the last is a leaf. 158 | 159 | Note that this isn't necessarily part of the leftmost variation of the 160 | game as a whole. 161 | 162 | .. method:: Sgf_game.get_main_sequence_above(node) 163 | 164 | :rtype: list of :class:`Tree_node` objects 165 | 166 | Returns the partial variation leading to the :class:`Tree_node` *node*. The 167 | first element is the root node, and the last is the parent of *node*. 168 | 169 | .. method:: Sgf_game.extend_main_sequence() 170 | 171 | :rtype: :class:`Tree_node` 172 | 173 | Creates a new :class:`Tree_node`, adds it to the leftmost variation, and 174 | returns it. 175 | 176 | This is equivalent to 177 | :meth:`get_last_node`\ .\ :meth:`~Tree_node.new_child` 178 | 179 | 180 | Root node properties 181 | """""""""""""""""""" 182 | 183 | The root node contains global properties for the game tree, and typically also 184 | contains *game-info* properties. It sometimes also contains *setup* properties 185 | (for example, if the game does not begin with an empty board). 186 | 187 | Changing the ``FF`` and ``GM`` properties is permitted, but Sgfmill will carry 188 | on using the FF[4] and GM[1] (Go) rules. 189 | 190 | Changing ``SZ`` is not permitted (but if the size is 19 you may remove the 191 | property). 192 | 193 | Changing ``CA`` is permitted (this controls the encoding used by 194 | :meth:`~Sgf_game.serialise`). 195 | 196 | The following methods provide convenient access to some of the root node's 197 | |sgf| properties. The main difference between using these methods and using 198 | :meth:`~Tree_node.get` on the root node is that these methods return the 199 | appropriate default value if the property is not present. 200 | 201 | .. method:: Sgf_game.get_size() 202 | 203 | :rtype: integer 204 | 205 | Returns the board size (``19`` if the ``SZ`` root property isn't present). 206 | 207 | .. method:: Sgf_game.get_charset() 208 | 209 | :rtype: string 210 | 211 | Returns the effective value of the ``CA`` root property (``ISO-8859-1`` if 212 | the ``CA`` root property isn't present). 213 | 214 | The returned value is a codec name in normalised form, which may not be 215 | identical to the string returned by ``get_root().get("CA")``. Raises 216 | :exc:`ValueError` if the property value doesn't identify a Python codec. 217 | 218 | This gives the encoding that would be used by :meth:`serialise`. It is not 219 | necessarily the same as the :doc:`raw property encoding ` (use 220 | :meth:`~Tree_node.get_encoding` on the root node to retrieve that). 221 | 222 | 223 | .. method:: Sgf_game.get_komi() 224 | 225 | :rtype: float 226 | 227 | Returns the :term:`komi` (``0.0`` if the ``KM`` root property isn't 228 | present). 229 | 230 | Raises :exc:`ValueError` if the ``KM`` root property is present but 231 | malformed. 232 | 233 | .. method:: Sgf_game.get_handicap() 234 | 235 | :rtype: integer or ``None`` 236 | 237 | Returns the number of handicap stones. 238 | 239 | Returns ``None`` if the ``HA`` root property isn't present, or if it has 240 | value zero (which isn't strictly permitted). 241 | 242 | Raises :exc:`ValueError` if the ``HA`` property is otherwise malformed. 243 | 244 | .. method:: Sgf_game.get_player_name(colour) 245 | 246 | :rtype: string or ``None`` 247 | 248 | Returns the name of the specified player, or ``None`` if the required 249 | ``PB`` or ``PW`` root property isn't present. 250 | 251 | .. method:: Sgf_game.get_winner() 252 | 253 | :rtype: *colour* 254 | 255 | Returns the colour of the winning player. 256 | 257 | Returns ``None`` if the ``RE`` root property isn't present, or if neither 258 | player won. 259 | 260 | .. method:: Sgf_game.set_date([date]) 261 | 262 | Sets the ``DT`` root property, to a single date. 263 | 264 | If *date* is specified, it should be a :class:`datetime.date`. Otherwise 265 | the current date is used. 266 | 267 | (|sgf| allows ``DT`` to be rather more complicated than a single date, so 268 | there's no corresponding get_date() method.) 269 | 270 | 271 | -------------------------------------------------------------------------------- /doc/sgf_moves.rst: -------------------------------------------------------------------------------- 1 | The :mod:`!sgf_moves` module 2 | ---------------------------- 3 | 4 | .. module:: sgfmill.sgf_moves 5 | :synopsis: Higher-level processing of moves and positions from SGF games. 6 | 7 | The :mod:`!sgfmill.sgf_moves` module contains some higher-level functions for 8 | processing moves and game positions. 9 | 10 | (They are the sort of thing you might need to implement 'load SGF' and 'save 11 | as SGF' features in a Go-playing program.) 12 | 13 | 14 | .. function:: get_setup_and_moves(sgf_game[, board]) 15 | 16 | :rtype: tuple (:class:`.Board`, list of tuples (*colour*, *move*)) 17 | 18 | Returns the initial setup and the following moves from an 19 | :class:`.Sgf_game`. 20 | 21 | The board represents the position described by ``AB`` and/or ``AW`` 22 | properties in the |sgf| game's root node. :exc:`ValueError` is raised if 23 | this position isn't legal. 24 | 25 | The moves are from the game's leftmost variation. Doesn't check that the 26 | moves are legal. 27 | 28 | Raises :exc:`ValueError` if the game has structure it doesn't support. 29 | 30 | Currently doesn't support ``AB``/``AW``/``AE`` properties after the root 31 | node. 32 | 33 | If the optional *board* parameter is provided, it must be an empty 34 | :class:`.Board` of the right size; the same object will be returned. This 35 | option is provided so you can use a different Board class (see 36 | :class:`.Interface_for_get_setup_and_moves` for what it needs to implement). 37 | 38 | See also the :script:`show_sgf.py` example script. 39 | 40 | 41 | .. function:: set_initial_position(sgf_game, board) 42 | 43 | Adds ``AB``/``AW``/``AE`` properties to an :class:`.Sgf_game`'s root node, 44 | to reflect the position from a :class:`.Board`. 45 | 46 | Replaces any existing ``AB``/``AW``/``AE`` properties in the root node. 47 | 48 | If you wish to use your own board class, see 49 | :class:`.Interface_for_set_initial_position` for what it needs to 50 | implement. 51 | 52 | 53 | .. function:: indicate_first_player(sgf_game) 54 | 55 | Adds a ``PL`` property to an :class:`.Sgf_game`'s root node if appropriate, 56 | to indicate which colour is first to play. 57 | 58 | Looks at the first child of the root to see who the first player is, and 59 | sets ``PL`` it isn't the expected player (Black normally, but White if 60 | there is a handicap), or if there are non-handicap setup stones. 61 | 62 | -------------------------------------------------------------------------------- /doc/sgfmill_package.rst: -------------------------------------------------------------------------------- 1 | The :mod:`sgfmill` package 2 | ========================== 3 | 4 | All Sgfmill code is contained in modules under the :mod:`!sgfmill` package. 5 | 6 | The package module itself defines only a single constant: 7 | 8 | .. module:: sgfmill 9 | :synopsis: Tools for testing and tuning Go-playing programs. 10 | 11 | .. data:: __version__ 12 | 13 | The library version, as a string (like ``"1.0"``). 14 | 15 | 16 | .. toctree:: 17 | :hidden: 18 | :maxdepth: 3 19 | :titlesonly: 20 | 21 | sgf 22 | sgf_moves 23 | sgf_board_interface 24 | boards 25 | ascii_boards 26 | common 27 | 28 | 29 | Modules 30 | ------- 31 | 32 | The package includes the following modules: 33 | 34 | .. the descriptions here should normally match the module :synopsis:, and 35 | therefore the module index. 36 | 37 | ========================================= ======================================================================== 38 | |sgf|-specific 39 | ========================================= ======================================================================== 40 | :mod:`~sgfmill.sgf` High level |sgf| interface. 41 | :mod:`~sgfmill.sgf_moves` Higher-level processing of moves and positions from |sgf| games. 42 | :mod:`~sgfmill.sgf_board_interface` Go-board interface required by :mod:`!sgf_moves`. 43 | :mod:`~!sgfmill.sgf_grammar` The |sgf| parser. 44 | :mod:`~!sgfmill.sgf_properties` Interpreting |sgf| property values. 45 | ========================================= ======================================================================== 46 | 47 | ========================================= ======================================================================== 48 | Go-related support code 49 | ========================================= ======================================================================== 50 | :mod:`~sgfmill.boards` Go board representation. 51 | :mod:`~sgfmill.ascii_boards` ASCII Go board diagrams. 52 | :mod:`~sgfmill.common` Go-related utility functions. 53 | ========================================= ======================================================================== 54 | 55 | The main public interface is in the :mod:`~sgfmill.sgf` module. 56 | 57 | The :mod:`~sgfmill.sgf_moves` module contains some higher-level functions for 58 | processing moves and positions. 59 | 60 | The :mod:`~sgfmill.sgf_board_interface` module defines an abstract board 61 | interface required by functions in :mod:`!sgf_moves` (and implemented by 62 | :class:`boards.Board`). 63 | 64 | The :mod:`!sgf_grammar` and :mod:`!sgf_properties` modules are 65 | used to implement the :mod:`!sgf` module, and are not currently documented. 66 | 67 | The :mod:`~sgfmill.boards` module provides a Go board representation, used by 68 | :mod:`!sgf_moves`. 69 | 70 | The :mod:`~sgfmill.ascii_boards` module supports ASCII board diagrams, used by some 71 | example scripts and the testsuite. 72 | 73 | The :mod:`~sgfmill.common` module provides a few Go-related utility functions, 74 | mostly used only by :mod:`~sgfmill.ascii_boards`. 75 | 76 | -------------------------------------------------------------------------------- /doc/tree_nodes.rst: -------------------------------------------------------------------------------- 1 | :class:`!Tree_node` objects 2 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 3 | 4 | .. currentmodule:: sgfmill.sgf 5 | 6 | .. class:: Tree_node 7 | 8 | A Tree_node object represents a single node from an |sgf| file. 9 | 10 | Don't instantiate :class:`!Tree_node` objects directly; retrieve them from 11 | :class:`Sgf_game` objects. 12 | 13 | .. contents:: Page contents 14 | :local: 15 | :backlinks: none 16 | 17 | 18 | Attributes 19 | """""""""" 20 | 21 | Tree_node objects have the following attributes (which should be treated as 22 | read-only): 23 | 24 | .. attribute:: owner 25 | 26 | The :class:`Sgf_game` that the node belongs to. 27 | 28 | .. attribute:: parent 29 | 30 | The node's parent :class:`!Tree_node` (``None`` for the root node). 31 | 32 | 33 | Tree navigation 34 | """"""""""""""" 35 | 36 | A :class:`!Tree_node` acts as a list-like container of its children: it can be 37 | indexed, sliced, and iterated over like a list, and it supports the `index`__ 38 | method. 39 | 40 | A :class:`!Tree_node` with no children is treated as having truth value false. 41 | 42 | For example, to find all leaf nodes:: 43 | 44 | def print_leaf_comments(node): 45 | if node: 46 | for child in node: 47 | print_leaf_comments(child) 48 | else: 49 | if node.has_property("C"): 50 | print(node.get("C")) 51 | else: 52 | print("--") 53 | 54 | .. __: https://docs.python.org/3/library/stdtypes.html#mutable-sequence-types 55 | 56 | 57 | Property access 58 | """"""""""""""" 59 | 60 | Each node holds a number of :dfn:`properties`. Each property is identified by 61 | a short string called the :dfn:`PropIdent`, eg ``"SZ"`` or ``"B"``. See 62 | :doc:`properties` for a list of the standard properties. See the 63 | :term:`SGF` specification for full details. See :doc:`parsing` for 64 | restrictions on well-formed *PropIdents*. 65 | 66 | Sgfmill doesn't enforce |sgf|'s restrictions on where properties can appear 67 | (eg, the distinction between *setup* and *move* properties). 68 | 69 | The principal methods for accessing the node's properties are: 70 | 71 | .. method:: Tree_node.get(identifier) 72 | 73 | Returns a native Python representation of the value of the property whose 74 | *PropIdent* is *identifier*. 75 | 76 | Raises :exc:`KeyError` if the property isn't present. 77 | 78 | Raises :exc:`ValueError` if it detects that the property value is 79 | malformed. 80 | 81 | See :ref:`sgf_property_types` for details of how property values are 82 | represented in Python. 83 | 84 | See :doc:`properties` for a list of the known properties. Any other 85 | property is treated as having type Text. 86 | 87 | .. method:: Tree_node.set(identifier, value) 88 | 89 | Sets the value of the property whose *PropIdent* is *identifier*. 90 | 91 | *value* should be a native Python representation of the required property 92 | value (as returned by :meth:`get`). 93 | 94 | Raises :exc:`ValueError` if the identifier isn't a well-formed *PropIdent*, 95 | or if the property value isn't acceptable. 96 | 97 | See :ref:`sgf_property_types` for details of how property values should be 98 | represented in Python. 99 | 100 | See :doc:`properties` for a list of the known properties. Setting 101 | nonstandard properties is permitted; they are treated as having type Text. 102 | 103 | .. method:: Tree_node.unset(identifier) 104 | 105 | Removes the property whose *PropIdent* is *identifier* from the node. 106 | 107 | Raises :exc:`KeyError` if the property isn't currently present. 108 | 109 | .. method:: Tree_node.has_property(identifier) 110 | 111 | :rtype: bool 112 | 113 | Checks whether the property whose *PropIdent* is *identifier* is present. 114 | 115 | .. method:: Tree_node.properties() 116 | 117 | :rtype: list of strings 118 | 119 | Lists the properties which are present in the node. 120 | 121 | Returns a list of *PropIdents*, in unspecified order. 122 | 123 | .. method:: Tree_node.find_property(identifier) 124 | 125 | Returns the value of the property whose *PropIdent* is *identifier*, 126 | looking in the node's ancestors if necessary. 127 | 128 | This is intended for use with properties of type *game-info*, and with 129 | properties which have the *inherit* attribute. 130 | 131 | It looks first in the node itself, then in its parent, and so on up to the 132 | root, returning the first value it finds. Otherwise the behaviour is the 133 | same as :meth:`get`. 134 | 135 | Raises :exc:`KeyError` if no node defining the property is found. 136 | 137 | 138 | .. method:: Tree_node.find(identifier) 139 | 140 | :rtype: :class:`!Tree_node` or ``None`` 141 | 142 | Returns the nearest node defining the property whose *PropIdent* is 143 | *identifier*. 144 | 145 | Searches in the same way as :meth:`find_property`, but returns the node 146 | rather than the property value. Returns ``None`` if no node defining the 147 | property is found. 148 | 149 | 150 | Convenience methods for properties 151 | """""""""""""""""""""""""""""""""" 152 | 153 | The following convenience methods are also provided, for more flexible access 154 | to a few of the most important properties: 155 | 156 | .. method:: Tree_node.get_move() 157 | 158 | :rtype: tuple (*colour*, *move*) 159 | 160 | Indicates which of the the ``B`` or ``W`` properties is present, and 161 | returns its value. 162 | 163 | Returns (``None``, ``None``) if neither property is present. 164 | 165 | .. method:: Tree_node.set_move(colour, move) 166 | 167 | Sets the ``B`` or ``W`` property. If the other property is currently 168 | present, it is removed. 169 | 170 | Sgfmill doesn't attempt to ensure that moves are legal. 171 | 172 | .. method:: Tree_node.get_setup_stones() 173 | 174 | :rtype: tuple (set of *points*, set of *points*, set of *points*) 175 | 176 | Returns the settings of the ``AB``, ``AW``, and ``AE`` properties. 177 | 178 | The tuple elements represent black, white, and empty points respectively. 179 | If a property is missing, the corresponding set is empty. 180 | 181 | .. method:: Tree_node.set_setup_stones(black, white[, empty]) 182 | 183 | Sets the ``AB``, ``AW``, and ``AE`` properties. 184 | 185 | Each parameter should be a sequence or set of *points*. If a parameter 186 | value is empty (or, in the case of *empty*, if the parameter is 187 | omitted) the corresponding property will be unset. 188 | 189 | .. method:: Tree_node.has_setup_stones() 190 | 191 | :rtype: bool 192 | 193 | Returns ``True`` if the ``AB``, ``AW``, or ``AE`` property is present. 194 | 195 | .. method:: Tree_node.add_comment_text(text) 196 | 197 | If the ``C`` property isn't already present, adds it with the value given 198 | by the string *text*. 199 | 200 | Otherwise, appends *text* to the existing ``C`` property value, preceded by 201 | two newlines. 202 | 203 | 204 | Board size and raw property encoding 205 | """""""""""""""""""""""""""""""""""" 206 | 207 | Each :class:`!Tree_node` knows its game's board size, and its :ref:`raw 208 | property encoding ` (because these are needed to 209 | interpret property values). They can be retrieved using the following methods: 210 | 211 | .. method:: Tree_node.get_size() 212 | 213 | :rtype: int 214 | 215 | .. method:: Tree_node.get_encoding() 216 | 217 | :rtype: string 218 | 219 | This returns the name of the raw property encoding (in a normalised form, 220 | which may not be the same as the string originally used to specify the 221 | encoding). 222 | 223 | An attempt to change the value of the ``SZ`` property so that it doesn't match 224 | the board size will raise :exc:`ValueError` (even if the node isn't the root). 225 | 226 | 227 | Access to raw property values 228 | """"""""""""""""""""""""""""" 229 | 230 | Raw property values are bytes objects, containing the exact bytes that go 231 | between the ``[`` and ``]`` in the |sgf| file. They should be treated as being 232 | encoded in the node's :ref:`raw property encoding ` 233 | (but there is no guarantee that they hold properly encoded data). 234 | 235 | The following methods are provided for access to raw property values. They can 236 | be used to access malformed values, or to avoid the standard escape processing 237 | and whitespace conversion for Text and SimpleText values. 238 | 239 | When setting raw property values, any data that is a well formed |sgf| 240 | *PropValue* is accepted: that is, any byte-string that that doesn't contain an 241 | unescaped ``]`` or end with an unescaped ``\``. There is no check that the 242 | string is properly encoded in the raw property encoding. 243 | 244 | .. method:: Tree_node.get_raw_list(identifier) 245 | 246 | :rtype: nonempty list of bytes objects 247 | 248 | Returns the raw values of the property whose *PropIdent* is *identifier*. 249 | 250 | Raises :exc:`KeyError` if the property isn't currently present. 251 | 252 | If the property value is an empty elist, returns a list containing a single 253 | empty bytes object. 254 | 255 | .. method:: Tree_node.get_raw(identifier) 256 | 257 | :rtype: bytes object 258 | 259 | Returns the raw value of the property whose *PropIdent* is *identifier*. 260 | 261 | Raises :exc:`KeyError` if the property isn't currently present. 262 | 263 | If the property has multiple `PropValue`\ s, returns the first. If the 264 | property value is an empty elist, returns an empty bytes object. 265 | 266 | .. method:: Tree_node.get_raw_property_map(identifier) 267 | 268 | :rtype: dict: string → list of bytes objects 269 | 270 | Returns a dict mapping *PropIdents* to lists of raw values. 271 | 272 | Returns the same dict object each time it's called. 273 | 274 | Treat the returned dict object as read-only. 275 | 276 | .. method:: Tree_node.set_raw_list(identifier, values) 277 | 278 | Sets the raw values of the property whose *PropIdent* is *identifier*. 279 | 280 | *values* must be a nonempty list of bytes objects. To specify an empty 281 | elist, pass a list containing a single empty bytes object. 282 | 283 | Raises :exc:`ValueError` if the identifier isn't a well-formed *PropIdent*, 284 | or if any value isn't a well-formed *PropValue*. 285 | 286 | .. method:: Tree_node.set_raw(identifier, value) 287 | 288 | Sets the raw value of the property whose *PropIdent* is *identifier*. 289 | 290 | *value* must be a bytes object. 291 | 292 | Raises :exc:`ValueError` if the identifier isn't a well-formed *PropIdent*, 293 | or if the value isn't a well-formed *PropValue*. 294 | 295 | 296 | Tree manipulation 297 | """"""""""""""""" 298 | 299 | The following methods are provided for manipulating the tree: 300 | 301 | .. method:: Tree_node.new_child([index]) 302 | 303 | :rtype: :class:`!Tree_node` 304 | 305 | Creates a new :class:`!Tree_node` and adds it to the tree as this node's 306 | last child. 307 | 308 | If the optional integer *index* parameter is present, the new node is 309 | inserted in the list of children at the specified index instead (with the 310 | same behaviour as :meth:`!list.insert`). 311 | 312 | Returns the new node. 313 | 314 | .. method:: Tree_node.delete() 315 | 316 | Removes the node from the tree (along with all its descendents). 317 | 318 | Raises :exc:`ValueError` if called on the root node. 319 | 320 | You should not continue to use a node which has been removed from its tree. 321 | 322 | .. method:: Tree_node.reparent(new_parent[, index]) 323 | 324 | Moves the node from one part of the tree to another (along with all its 325 | descendents). 326 | 327 | *new_parent* must be a node belonging to the same game. 328 | 329 | Raises :exc:`ValueError` if the operation would create a loop in the tree 330 | (ie, if *new_parent* is the node being moved or one of its descendents). 331 | 332 | If the optional integer *index* parameter is present, the new node is 333 | inserted in the new parent's list of children at the specified index; 334 | otherwise it is placed at the end. 335 | 336 | This method can be used to reorder variations. For example, to make a node 337 | the leftmost variation of its parent:: 338 | 339 | node.reparent(node.parent, 0) 340 | 341 | -------------------------------------------------------------------------------- /examples/show_sgf.py: -------------------------------------------------------------------------------- 1 | """Show the position from an SGF file. 2 | 3 | This demonstrates the sgf and ascii_boards modules. 4 | 5 | """ 6 | 7 | import sys 8 | from optparse import OptionParser 9 | 10 | from sgfmill import ascii_boards 11 | from sgfmill import sgf 12 | from sgfmill import sgf_moves 13 | 14 | def show_sgf_file(pathname, move_number): 15 | f = open(pathname, "rb") 16 | sgf_src = f.read() 17 | f.close() 18 | try: 19 | sgf_game = sgf.Sgf_game.from_bytes(sgf_src) 20 | except ValueError: 21 | raise Exception("bad sgf file") 22 | 23 | try: 24 | board, plays = sgf_moves.get_setup_and_moves(sgf_game) 25 | except ValueError as e: 26 | raise Exception(str(e)) 27 | if move_number is not None: 28 | move_number = max(0, move_number-1) 29 | plays = plays[:move_number] 30 | 31 | for colour, move in plays: 32 | if move is None: 33 | continue 34 | row, col = move 35 | try: 36 | board.play(row, col, colour) 37 | except ValueError: 38 | raise Exception("illegal move in sgf file") 39 | 40 | print(ascii_boards.render_board(board)) 41 | print() 42 | 43 | _description = """\ 44 | Show the position from an SGF file. If a move number is specified, the position 45 | before that move is shown (this is to match the behaviour of GTP loadsgf). 46 | """ 47 | 48 | def main(argv): 49 | parser = OptionParser(usage="%prog [move number]", 50 | description=_description) 51 | opts, args = parser.parse_args(argv) 52 | if not args: 53 | parser.error("not enough arguments") 54 | pathname = args[0] 55 | if len(args) > 2: 56 | parser.error("too many arguments") 57 | if len(args) == 2: 58 | try: 59 | move_number = int(args[1]) 60 | except ValueError: 61 | parser.error("invalid integer value: %s" % args[1]) 62 | else: 63 | move_number = None 64 | try: 65 | show_sgf_file(pathname, move_number) 66 | except Exception as e: 67 | print("show_sgf:", str(e), file=sys.stderr) 68 | sys.exit(1) 69 | 70 | if __name__ == "__main__": 71 | main(sys.argv[1:]) 72 | 73 | -------------------------------------------------------------------------------- /examples/split_sgf_collection.py: -------------------------------------------------------------------------------- 1 | """Split an SGF collection into separate files. 2 | 3 | This demonstrates the parsing functions from the sgf_grammar module. 4 | 5 | """ 6 | 7 | import os 8 | import sys 9 | from optparse import OptionParser 10 | 11 | from sgfmill import sgf_grammar 12 | from sgfmill import sgf 13 | 14 | def split_sgf_collection(pathname): 15 | f = open(pathname, "rb") 16 | sgf_src = f.read() 17 | f.close() 18 | dirname, basename = os.path.split(pathname) 19 | root, ext = os.path.splitext(basename) 20 | dstdir = os.path.join(dirname, root + "_files") 21 | os.mkdir(dstdir) 22 | try: 23 | coarse_games = sgf_grammar.parse_sgf_collection(sgf_src) 24 | except ValueError as e: 25 | raise Exception("error parsing file: %s" % e) 26 | for i, coarse_game in enumerate(coarse_games): 27 | sgf_game = sgf.Sgf_game.from_coarse_game_tree(coarse_game) 28 | sgf_game.get_root().add_comment_text( 29 | "Split from %s (game %d)" % (basename, i+1)) 30 | split_pathname = os.path.join(dstdir, "%s_%d%s" % (root, i+1, ext)) 31 | with open(split_pathname, "wb") as f: 32 | f.write(sgf_game.serialise()) 33 | 34 | 35 | _description = """\ 36 | Split a file containing an SGF game collection into multiple files. 37 | """ 38 | 39 | def main(argv): 40 | parser = OptionParser(usage="%prog ", 41 | description=_description) 42 | opts, args = parser.parse_args(argv) 43 | if not args: 44 | parser.error("not enough arguments") 45 | pathname = args[0] 46 | if len(args) > 1: 47 | parser.error("too many arguments") 48 | try: 49 | split_sgf_collection(pathname) 50 | except Exception as e: 51 | print("sgf_splitter:", str(e), file=sys.stderr) 52 | sys.exit(1) 53 | 54 | if __name__ == "__main__": 55 | main(sys.argv[1:]) 56 | 57 | -------------------------------------------------------------------------------- /release_sgfmill.py: -------------------------------------------------------------------------------- 1 | """Package a sgfmill release. 2 | 3 | Requires a release_sgfmill.conf file, eg: 4 | 5 | « 6 | # All paths are relative to this config file 7 | repo_dir = "." 8 | working_dir = "./tmp/release" 9 | log_pathname = "./tmp/release/release.log" 10 | target_dir = "./tmp/release" 11 | html_files_to_remove = [ 12 | ".buildinfo", 13 | ] 14 | » 15 | 16 | """ 17 | 18 | import os 19 | import re 20 | import shutil 21 | import sys 22 | from optparse import OptionParser 23 | from subprocess import check_call, check_output, CalledProcessError, Popen, PIPE 24 | 25 | class Failure(Exception): 26 | pass 27 | 28 | def read_python_file(pathname): 29 | dummy = {} 30 | result = {} 31 | with open(pathname, 'rb') as f: 32 | bb = f.read() 33 | exec(bb, dummy, result) 34 | return result 35 | 36 | def is_safe_tag(s): 37 | if s in (".", ".."): 38 | return False 39 | return bool(re.search(r"\A[-_.a-zA-Z0-9]+\Z", s)) 40 | 41 | def is_acceptable_version(s): 42 | if s in (".", ".."): 43 | return False 44 | if len(s) > 12: 45 | return False 46 | return bool(re.search(r"\A[-_.a-zA-Z0-9]+\Z", s)) 47 | 48 | def export_tag(dst, repo_dir, tag): 49 | """Export from the git tag. 50 | 51 | repo_dir -- git repository to export from (must contain .git) 52 | tag -- tag to export 53 | dst -- directory to export into 54 | 55 | The exported tree will be placed in a directory named inside . 56 | 57 | All files have the timestamp of the commit referred to by the tag. 58 | 59 | """ 60 | if not os.path.isdir(os.path.join(repo_dir, ".git")): 61 | raise Failure("No .git repo in %s" % repo_dir) 62 | try: 63 | check_call("git archive --remote=%s --prefix=%s/ %s | tar -C %s -xf -" % 64 | (repo_dir, tag, tag, dst), 65 | shell=True) 66 | except CalledProcessError: 67 | raise Failure("export failed") 68 | 69 | def prepare_dir(): 70 | """Make any required changes in the exported distribution directory. 71 | 72 | cwd must be the distribution directory (the project root). 73 | 74 | """ 75 | os.rename("README.sdist.txt", "README.txt") 76 | os.remove("README.rst") 77 | 78 | def get_version(): 79 | """Obtain the sgfmill version from setup.py.""" 80 | try: 81 | output = check_output("python setup.py --version".split(), 82 | universal_newlines=True) 83 | except CalledProcessError: 84 | raise Failure("'setup.py --version' failed") 85 | version = output.strip() 86 | if not is_acceptable_version(version): 87 | raise Failure("bad version: %s" % repr(version)) 88 | return version 89 | 90 | def make_sdist(version, logfile): 91 | """Run 'setup.py sdist'. 92 | 93 | cwd must be the distribution directory (the project root). 94 | 95 | Returns the pathname of the sdist tar.gz, relative to the distribution 96 | directory. 97 | 98 | """ 99 | try: 100 | check_call("python setup.py sdist".split(), stdout=logfile) 101 | except CalledProcessError: 102 | raise Failure("'setup.py sdist' failed") 103 | result = os.path.join("dist", "sgfmill-%s.tar.gz" % version) 104 | if not os.path.exists(result): 105 | raise Failure("'setup.py sdist' did not create %s" % result) 106 | return result 107 | 108 | def make_wheel(version, logfile): 109 | """Run 'setup.py bdist_wheel'. 110 | 111 | cwd must be the distribution directory (the project root). 112 | 113 | Returns the pathname of the sdist tar.gz, relative to the distribution 114 | directory. 115 | 116 | """ 117 | try: 118 | check_call("python setup.py bdist_wheel".split(), stdout=logfile) 119 | except CalledProcessError: 120 | raise Failure("'setup.py bdist_wheel' failed") 121 | result = os.path.join("dist", "sgfmill-%s-py3-none-any.whl" % version) 122 | if not os.path.exists(result): 123 | raise Failure("'setup.py bdist_wheel' did not create %s" % result) 124 | return result 125 | 126 | def make_sphinx(version, logfile, html_files_to_remove): 127 | """Run build_docs and make a tarball. 128 | 129 | cwd must be the distribution directory (the project root). 130 | 131 | Returns the pathname of the docs tar.gz, relative to the distribution 132 | directory. 133 | 134 | """ 135 | try: 136 | check_call(["./build_docs"], stdout=logfile) 137 | except CalledProcessError: 138 | raise Failure("'build_docs' failed") 139 | htmlpath = os.path.join("doc", "_build", "html") 140 | for filename in html_files_to_remove: 141 | os.remove(os.path.join(htmlpath, filename)) 142 | os.rename(htmlpath, "sgfmill-doc-%s" % version) 143 | try: 144 | check_call(("tar -czf sgfmill-doc-%s.tar.gz sgfmill-doc-%s" % 145 | (version, version)).split()) 146 | except CalledProcessError: 147 | raise Failure("tarring up sgfmill-doc failed") 148 | return "sgfmill-doc-%s.tar.gz" % version 149 | 150 | def do_release(tag, config_pathname): 151 | config_dir = os.path.abspath(os.path.dirname(config_pathname)) 152 | os.chdir(config_dir) 153 | 154 | try: 155 | config = read_python_file(config_pathname) 156 | except Exception as e: 157 | raise Failure("error reading config file:\n%s" % e) 158 | 159 | export_dir = os.path.join(config['working_dir'], tag) 160 | if os.path.exists(export_dir): 161 | shutil.rmtree(export_dir) 162 | export_tag(config['working_dir'], config['repo_dir'], tag) 163 | logfile = open(config['log_pathname'], "w") 164 | os.chdir(export_dir) 165 | prepare_dir() 166 | version = get_version() 167 | sdist_pathname = make_sdist(version, logfile) 168 | wheel_pathname = make_wheel(version, logfile) 169 | docs_pathname = make_sphinx(version, logfile, 170 | config['html_files_to_remove']) 171 | os.chdir(config_dir) 172 | logfile.close() 173 | sdist_dst = os.path.join(config['target_dir'], 174 | os.path.basename(sdist_pathname)) 175 | wheel_dst = os.path.join(config['target_dir'], 176 | os.path.basename(wheel_pathname)) 177 | docs_dst = os.path.join(config['target_dir'], 178 | os.path.basename(docs_pathname)) 179 | if os.path.exists(sdist_dst): 180 | os.remove(sdist_dst) 181 | if os.path.exists(wheel_dst): 182 | os.remove(wheel_dst) 183 | if os.path.exists(docs_dst): 184 | os.remove(docs_dst) 185 | shutil.move(os.path.join(export_dir, sdist_pathname), sdist_dst) 186 | shutil.move(os.path.join(export_dir, wheel_pathname), wheel_dst) 187 | shutil.move(os.path.join(export_dir, docs_pathname), docs_dst) 188 | shutil.rmtree(export_dir) 189 | 190 | 191 | USAGE = """\ 192 | %(prog)s \ 193 | """ 194 | 195 | def main(argv): 196 | parser = OptionParser(usage=USAGE) 197 | opts, args = parser.parse_args(argv) 198 | if len(args) != 1: 199 | parser.error("wrong number of arguments") 200 | tag = args[0] 201 | if not is_safe_tag(tag): 202 | parser.error("ill-formed tag") 203 | config_pathname = os.path.join( 204 | os.path.abspath(os.path.dirname(__file__)), "release_sgfmill.conf") 205 | try: 206 | if not os.path.exists(config_pathname): 207 | raise Failure("config file %s does not exist" % config_pathname) 208 | do_release(tag, config_pathname) 209 | except (OSError, Failure) as e: 210 | print("release_sgfmill.py: %s" % e, file=sys.stderr) 211 | sys.exit(1) 212 | 213 | if __name__ == "__main__": 214 | main(sys.argv[1:]) 215 | -------------------------------------------------------------------------------- /run_sgfmill_testsuite: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | python3 -m sgfmill_tests.run_sgfmill_testsuite "$@" 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | import sgfmill 4 | 5 | SGFMILL_URL = "https://mjw.woodcraft.me.uk/sgfmill/" 6 | VERSION = sgfmill.__version__ 7 | 8 | LONG_DESCRIPTION = """\ 9 | Sgfmill is a Python library for reading and writing Go game records 10 | using Smart Game Format (SGF). 11 | 12 | It supports: 13 | 14 | * loading SGF game records to make a Python object representation 15 | * creating SGF game objects from scratch 16 | * setting properties and manipulating the tree structure 17 | * serialising game records to SGF data 18 | * applying setup stones and moves to a Go board position 19 | 20 | Download: %(SGFMILL_URL)sdownload/sgfmill-%(VERSION)s.tar.gz 21 | 22 | Documentation: %(SGFMILL_URL)sdownload/sgfmill-doc-%(VERSION)s.tar.gz 23 | 24 | Online Documentation: %(SGFMILL_URL)sdoc/%(VERSION)s/ 25 | 26 | Changelog: %(SGFMILL_URL)sdoc/%(VERSION)s/changes.html 27 | 28 | Github: https://github.com/mattheww/sgfmill 29 | 30 | """ % vars() 31 | 32 | setup(name='sgfmill', 33 | version=VERSION, 34 | url=SGFMILL_URL, 35 | description=\ 36 | "Library for reading and writing files using Smart Game Format (SGF).", 37 | long_description=LONG_DESCRIPTION, 38 | author="Matthew Woodcraft", 39 | author_email="matthew@woodcraft.me.uk", 40 | packages=['sgfmill'], 41 | zip_safe=False, 42 | classifiers=[ 43 | "Development Status :: 5 - Production/Stable", 44 | "Intended Audience :: Developers", 45 | "License :: OSI Approved :: MIT License", 46 | "Natural Language :: English", 47 | "Programming Language :: Python :: 3", 48 | "Programming Language :: Python :: 3.2", 49 | "Programming Language :: Python :: 3.3", 50 | "Programming Language :: Python :: 3.4", 51 | "Programming Language :: Python :: 3.5", 52 | "Programming Language :: Python :: 3.6", 53 | "Programming Language :: Python", 54 | "Topic :: Games/Entertainment :: Board Games", 55 | "Topic :: Software Development :: Libraries :: Python Modules", 56 | ], 57 | keywords="go,baduk,weiqi,sgf", 58 | license="MIT", 59 | ) 60 | 61 | -------------------------------------------------------------------------------- /sgfmill/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.1.1" 2 | -------------------------------------------------------------------------------- /sgfmill/ascii_boards.py: -------------------------------------------------------------------------------- 1 | """ASCII board representation.""" 2 | 3 | from . import boards 4 | from .common import column_letters 5 | 6 | def render_grid(point_formatter, size): 7 | """Render a board-shaped grid as a list of strings. 8 | 9 | point_formatter -- function (row, col) -> string of length 2. 10 | 11 | Returns a list of strings. 12 | 13 | """ 14 | column_header_string = " ".join(column_letters[i] for i in range(size)) 15 | result = [] 16 | if size > 9: 17 | rowstart = "%2d " 18 | padding = " " 19 | else: 20 | rowstart = "%d " 21 | padding = "" 22 | for row in range(size-1, -1, -1): 23 | result.append(rowstart % (row+1) + 24 | " ".join(point_formatter(row, col) 25 | for col in range(size))) 26 | result.append(padding + " " + column_header_string) 27 | return result 28 | 29 | _point_strings = { 30 | None : " .", 31 | 'b' : " #", 32 | 'w' : " o", 33 | } 34 | 35 | def render_board(board): 36 | """Render an sgfmill Board in ascii. 37 | 38 | Returns a string without final newline. 39 | 40 | """ 41 | def format_pt(row, col): 42 | return _point_strings.get(board.get(row, col), " ?") 43 | return "\n".join(render_grid(format_pt, board.side)) 44 | 45 | def interpret_diagram(diagram, size, board=None): 46 | """Set up the position from a diagram. 47 | 48 | diagram -- board representation as from render_board() 49 | size -- int 50 | 51 | Returns a Board. 52 | 53 | If the optional 'board' parameter is provided, it must be an empty board of 54 | the right size; the same object will be returned. 55 | 56 | Ignores leading and trailing whitespace. 57 | 58 | An ill-formed diagram may give ValueError or a 'best guess'. 59 | 60 | """ 61 | if board is None: 62 | board = boards.Board(size) 63 | else: 64 | if board.side != size: 65 | raise ValueError("wrong board size, must be %d" % size) 66 | if not board.is_empty(): 67 | raise ValueError("board not empty") 68 | lines = diagram.strip().split("\n") 69 | colours = {'#' : 'b', 'o' : 'w', '.' : None} 70 | if size > 9: 71 | extra_offset = 1 72 | else: 73 | extra_offset = 0 74 | try: 75 | for (row, col) in board.board_points: 76 | colour = colours[lines[size-row-1][3*(col+1)+extra_offset]] 77 | if colour is not None: 78 | board.play(row, col, colour) 79 | except Exception: 80 | raise ValueError 81 | return board 82 | 83 | 84 | -------------------------------------------------------------------------------- /sgfmill/boards.py: -------------------------------------------------------------------------------- 1 | """Go board representation.""" 2 | 3 | from itertools import chain 4 | import functools 5 | 6 | from .common import opponent_of 7 | 8 | class _Group: 9 | """Represent a solidly-connected group. 10 | 11 | Public attributes: 12 | colour 13 | points 14 | is_surrounded 15 | 16 | Points are coordinate pairs (row, col). 17 | 18 | """ 19 | 20 | class _Region: 21 | """Represent an empty region. 22 | 23 | Public attributes: 24 | points 25 | neighbouring_colours 26 | 27 | Points are coordinate pairs (row, col). 28 | 29 | """ 30 | def __init__(self): 31 | self.points = set() 32 | self.neighbouring_colours = set() 33 | 34 | @functools.lru_cache(maxsize=30) 35 | def _get_all_board_points(side): 36 | return [(_row, _col) for _row in range(side) for _col in range(side)] 37 | 38 | @functools.lru_cache(maxsize=30*30*4) 39 | def _get_neighbours(row, col, side): 40 | neighbours = tuple() 41 | for r, c in [(row-1, col), (row+1, col), (row, col-1), (row, col+1)]: 42 | if (0 <= r < side) and (0 <= c < side): 43 | neighbours += ((r, c),) 44 | return neighbours 45 | 46 | @functools.lru_cache(maxsize=30*30*4) 47 | def _get_neighbours_and_self(row, col, side): 48 | return _get_neighbours(row, col, side) + ((row, col),) 49 | 50 | class Board: 51 | """A legal Go position. 52 | 53 | Supports playing stones with captures, and area scoring. 54 | 55 | Public attributes: 56 | side -- board size (int >= 2) 57 | board_points -- list of coordinates of all points on the board 58 | 59 | """ 60 | def __init__(self, side): 61 | self.side = side 62 | if side < 2: 63 | raise ValueError 64 | self._board_points = _get_all_board_points(side) 65 | self.board = [] 66 | for row in range(side): 67 | self.board.append([None] * side) 68 | self._is_empty = True 69 | 70 | @property 71 | def board_points(self): 72 | # Returns a copy so that modifying it is safe 73 | return self._board_points[:] 74 | 75 | def copy(self): 76 | """Return an independent copy of this Board.""" 77 | b = Board(self.side) 78 | b.board = [self.board[i][:] for i in range(self.side)] 79 | b._is_empty = self._is_empty 80 | return b 81 | 82 | def _make_group(self, row, col, colour): 83 | points = set() 84 | is_surrounded = True 85 | to_handle = set() 86 | to_handle.add((row, col)) 87 | while to_handle: 88 | point = to_handle.pop() 89 | points.add(point) 90 | r, c = point 91 | for neighbour in _get_neighbours(r, c, self.side): 92 | (r1, c1) = neighbour 93 | neigh_colour = self.board[r1][c1] 94 | if neigh_colour is None: 95 | is_surrounded = False 96 | elif neigh_colour == colour: 97 | if neighbour not in points: 98 | to_handle.add(neighbour) 99 | group = _Group() 100 | group.colour = colour 101 | group.points = points 102 | group.is_surrounded = is_surrounded 103 | return group 104 | 105 | def _make_empty_region(self, row, col): 106 | points = set() 107 | neighbouring_colours = set() 108 | to_handle = set() 109 | to_handle.add((row, col)) 110 | while to_handle: 111 | point = to_handle.pop() 112 | points.add(point) 113 | r, c = point 114 | for neighbour in _get_neighbours(r, c, self.side): 115 | (r1, c1) = neighbour 116 | neigh_colour = self.board[r1][c1] 117 | if neigh_colour is None: 118 | if neighbour not in points: 119 | to_handle.add(neighbour) 120 | else: 121 | neighbouring_colours.add(neigh_colour) 122 | region = _Region() 123 | region.points = points 124 | region.neighbouring_colours = neighbouring_colours 125 | return region 126 | 127 | def _find_surrounded_groups(self, r, c): 128 | """Find solidly-connected groups with 0 liberties adjacent to r,c. 129 | 130 | Returns a list of _Groups. 131 | 132 | """ 133 | surrounded = [] 134 | handled = set() 135 | for (row, col) in _get_neighbours_and_self(r, c, self.side): 136 | colour = self.board[row][col] 137 | if colour is None: 138 | continue 139 | 140 | point = (row, col) 141 | if point in handled: 142 | continue 143 | 144 | group = self._make_group(row, col, colour) 145 | if group.is_surrounded: 146 | surrounded.append(group) 147 | handled.update(group.points) 148 | return surrounded 149 | 150 | def _find_all_surrounded_groups(self): 151 | """Find all solidly-connected groups with 0 liberties. 152 | 153 | Returns a list of _Groups. 154 | 155 | """ 156 | surrounded = [] 157 | handled = set() 158 | for (row, col) in self._board_points: 159 | colour = self.board[row][col] 160 | if colour is None: 161 | continue 162 | point = (row, col) 163 | if point in handled: 164 | continue 165 | group = self._make_group(row, col, colour) 166 | if group.is_surrounded: 167 | surrounded.append(group) 168 | handled.update(group.points) 169 | return surrounded 170 | 171 | def is_empty(self): 172 | """Return a boolean indicating whether the board is empty.""" 173 | return self._is_empty 174 | 175 | def get(self, row, col): 176 | """Return the state of the specified point. 177 | 178 | Returns a colour, or None for an empty point. 179 | 180 | Raises IndexError if the coordinates are out of range. 181 | 182 | """ 183 | if row < 0 or col < 0: 184 | raise IndexError 185 | return self.board[row][col] 186 | 187 | def play(self, row, col, colour): 188 | """Play a move on the board. 189 | 190 | Raises IndexError if the coordinates are out of range. 191 | 192 | Raises ValueError if the specified point isn't empty. 193 | 194 | Performs any necessary captures. Allows self-captures. Doesn't enforce 195 | any ko rule. 196 | 197 | Returns the point forbidden by simple ko, or None 198 | 199 | """ 200 | if row < 0 or col < 0: 201 | raise IndexError 202 | opponent = opponent_of(colour) 203 | if self.board[row][col] is not None: 204 | raise ValueError 205 | self.board[row][col] = colour 206 | self._is_empty = False 207 | surrounded = self._find_surrounded_groups(row, col) 208 | simple_ko_point = None 209 | if surrounded: 210 | if len(surrounded) == 1: 211 | to_capture = surrounded 212 | if len(to_capture[0].points) == self.side*self.side: 213 | self._is_empty = True 214 | else: 215 | to_capture = [group for group in surrounded 216 | if group.colour == opponent] 217 | if len(to_capture) == 1 and len(to_capture[0].points) == 1: 218 | self_capture = [group for group in surrounded 219 | if group.colour == colour] 220 | if len(self_capture[0].points) == 1: 221 | (simple_ko_point,) = to_capture[0].points 222 | for group in to_capture: 223 | for r, c in group.points: 224 | self.board[r][c] = None 225 | return simple_ko_point 226 | 227 | def apply_setup(self, black_points, white_points, empty_points): 228 | """Add setup stones or removals to the position. 229 | 230 | This is intended to support SGF AB/AW/AE commands. 231 | 232 | Each parameter is an iterable of coordinate pairs (row, col). 233 | 234 | Applies all the setup specifications, then removes any groups with no 235 | liberties (so the resulting position is always legal). 236 | 237 | If the same point is specified in more than one list, the order in which 238 | they're applied is undefined. 239 | 240 | Returns a boolean saying whether the position was legal as specified. 241 | 242 | Raises IndexError if any coordinates are out of range. 243 | 244 | """ 245 | for (row, col) in chain(black_points, white_points, empty_points): 246 | if row < 0 or col < 0 or row >= self.side or col >= self.side: 247 | raise IndexError 248 | for (row, col) in black_points: 249 | self.board[row][col] = 'b' 250 | for (row, col) in white_points: 251 | self.board[row][col] = 'w' 252 | for (row, col) in empty_points: 253 | self.board[row][col] = None 254 | captured = self._find_all_surrounded_groups() 255 | for group in captured: 256 | for row, col in group.points: 257 | self.board[row][col] = None 258 | self._is_empty = True 259 | for (row, col) in self._board_points: 260 | if self.board[row][col] is not None: 261 | self._is_empty = False 262 | break 263 | return not(captured) 264 | 265 | def list_occupied_points(self): 266 | """List all nonempty points. 267 | 268 | Returns a list of pairs (colour, (row, col)) 269 | 270 | """ 271 | result = [] 272 | for (row, col) in self._board_points: 273 | colour = self.board[row][col] 274 | if colour is not None: 275 | result.append((colour, (row, col))) 276 | return result 277 | 278 | def area_score(self): 279 | """Calculate the area score of a position. 280 | 281 | Assumes all stones are alive. 282 | 283 | Returns black score minus white score. 284 | 285 | Doesn't take komi into account. 286 | 287 | """ 288 | scores = {'b' : 0, 'w' : 0} 289 | handled = set() 290 | for (row, col) in self._board_points: 291 | colour = self.board[row][col] 292 | if colour is not None: 293 | scores[colour] += 1 294 | continue 295 | point = (row, col) 296 | if point in handled: 297 | continue 298 | region = self._make_empty_region(row, col) 299 | region_size = len(region.points) 300 | for colour in ('b', 'w'): 301 | if colour in region.neighbouring_colours: 302 | scores[colour] += region_size 303 | handled.update(region.points) 304 | return scores['b'] - scores['w'] 305 | 306 | -------------------------------------------------------------------------------- /sgfmill/common.py: -------------------------------------------------------------------------------- 1 | """Domain-dependent utility functions for sgfmill. 2 | 3 | This module is designed to be used with 'from common import *'. 4 | 5 | This is for Go-specific utilities. 6 | 7 | """ 8 | 9 | __all__ = ["opponent_of", "colour_name", "format_vertex", "format_vertex_list", 10 | "move_from_vertex"] 11 | 12 | _opponents = {"b":"w", "w":"b"} 13 | def opponent_of(colour): 14 | """Return the opponent colour. 15 | 16 | colour -- 'b' or 'w' 17 | 18 | Returns 'b' or 'w'. 19 | 20 | """ 21 | try: 22 | return _opponents[colour] 23 | except KeyError: 24 | raise ValueError from None 25 | 26 | def colour_name(colour): 27 | """Return the (lower-case) full name of a colour. 28 | 29 | colour -- 'b' or 'w' 30 | 31 | """ 32 | try: 33 | return {'b': 'black', 'w': 'white'}[colour] 34 | except KeyError: 35 | raise ValueError from None 36 | 37 | 38 | column_letters = "ABCDEFGHJKLMNOPQRSTUVWXYZ" 39 | 40 | def format_vertex(move): 41 | """Return coordinates as a string like 'A1', or 'pass'. 42 | 43 | move -- pair (row, col), or None for a pass 44 | 45 | The result is suitable for use directly in GTP responses. 46 | 47 | """ 48 | if move is None: 49 | return "pass" 50 | row, col = move 51 | if not 0 <= row < 25 or not 0 <= col < 25: 52 | raise ValueError 53 | return column_letters[col] + str(row+1) 54 | 55 | def format_vertex_list(moves): 56 | """Return a list of coordinates as a string like 'A1,B2'.""" 57 | return ",".join(map(format_vertex, moves)) 58 | 59 | def move_from_vertex(vertex, board_size): 60 | """Interpret a string representing a vertex, as specified by GTP. 61 | 62 | Returns a pair of coordinates (row, col) in range(0, board_size) 63 | 64 | Raises ValueError with an appropriate message if 'vertex' isn't a valid GTP 65 | vertex specification for a board of size 'board_size'. 66 | 67 | """ 68 | if not 0 < board_size <= 25: 69 | raise ValueError("board_size out of range") 70 | try: 71 | s = vertex.lower() 72 | except Exception: 73 | raise ValueError("invalid vertex") from None 74 | if s == "pass": 75 | return None 76 | try: 77 | col_c = s[0] 78 | if (not "a" <= col_c <= "z") or col_c == "i": 79 | raise ValueError 80 | if col_c > "i": 81 | col = ord(col_c) - ord("b") 82 | else: 83 | col = ord(col_c) - ord("a") 84 | row = int(s[1:]) - 1 85 | if row < 0: 86 | raise ValueError 87 | except (IndexError, ValueError): 88 | raise ValueError("invalid vertex: '%s'" % s) from None 89 | if not (col < board_size and row < board_size): 90 | raise ValueError("vertex is off board: '%s'" % s) 91 | return row, col 92 | 93 | -------------------------------------------------------------------------------- /sgfmill/sgf_board_interface.py: -------------------------------------------------------------------------------- 1 | """Description of the board interfaces required by sgf_moves.""" 2 | 3 | class Interface_for_get_setup_and_moves: 4 | """Interface required by sgf_moves.get_setup_and_moves(). 5 | 6 | Required public attributes: 7 | side -- board size (int >= 2) 8 | 9 | """ 10 | 11 | def is_empty(self): 12 | """Return a boolean indicating whether the board is empty.""" 13 | raise NotImplementedError 14 | 15 | def apply_setup(self, black_points, white_points, empty_points): 16 | """Add setup stones or removals to the position. 17 | 18 | See boards.Board.apply_setup() for details. 19 | 20 | """ 21 | raise NotImplementedError 22 | 23 | class Interface_for_set_initial_position: 24 | """Interface required by sgf_moves.set_initial_position().""" 25 | 26 | def list_occupied_points(self): 27 | """List all nonempty points. 28 | 29 | Returns a list of pairs (colour, (row, col)) 30 | 31 | """ 32 | raise NotImplementedError 33 | -------------------------------------------------------------------------------- /sgfmill/sgf_grammar.py: -------------------------------------------------------------------------------- 1 | """Parse and serialise SGF data. 2 | 3 | This is intended for use with SGF FF[4]; see http://www.red-bean.com/sgf/ 4 | 5 | Nothing in this module is Go-specific. 6 | 7 | This module is encoding-agnostic: it works with bytes-like objects representing 8 | 8-bit strings in an arbitrary 'ascii-compatible' encoding. 9 | 10 | 11 | In the documentation below, a _property map_ is a dict mapping a PropIdent to a 12 | nonempty list of raw property values. 13 | 14 | A raw property value is a bytes object containing a PropValue without its 15 | enclosing brackets, but with backslashes and line endings left untouched. 16 | 17 | So a property map's keys should pass is_valid_property_identifier(), and its 18 | values should pass is_valid_property_value(). 19 | 20 | """ 21 | 22 | import re 23 | import string 24 | 25 | 26 | _propident_re = re.compile(r"\A[A-Z]{1,64}\Z") 27 | _propvalue_re = re.compile(br"\A [^\\\]]* (?: \\. [^\\\]]* )* \Z", 28 | re.VERBOSE | re.DOTALL) 29 | _find_start_re = re.compile(br"\(\s*;") 30 | _tokenise_re = re.compile(br""" 31 | \s* 32 | (?: 33 | \[ (?P [^\\\]]* (?: \\. [^\\\]]* )* ) \] # PropValue 34 | | 35 | (?P [A-Za-z]{1,64} ) # PropIdent (accepting lc) 36 | | 37 | (?P [;()] ) # delimiter 38 | ) 39 | """, re.VERBOSE | re.DOTALL) 40 | 41 | 42 | def is_valid_property_identifier(s): 43 | """Check whether 's' is a well-formed PropIdent. 44 | 45 | s -- string (_not_ a bytes object). 46 | 47 | Details: 48 | - it doesn't permit lower-case letters 49 | - it accepts at most 64 letters (there is no limit in the spec; no 50 | standard property has more than 2; a report from 2017-04 says the 51 | longest found in the wild is "MULTIGOGM") 52 | 53 | This accepts the same values as the tokeniser, except that the tokeniser 54 | does permit lower-case letters. 55 | 56 | """ 57 | return bool(_propident_re.search(s)) 58 | 59 | def is_valid_property_value(bb): 60 | """Check whether 'bb' is a well-formed PropValue. 61 | 62 | bb -- bytes-like object 63 | 64 | This accepts the same values as the tokeniser: any string that doesn't 65 | contain an unescaped ] or end with an unescaped \ . 66 | 67 | """ 68 | return bool(_propvalue_re.search(bb)) 69 | 70 | 71 | _lcbytes = string.ascii_lowercase.encode() 72 | 73 | def tokenise(bb, start_position=0): 74 | """Tokenise a string containing SGF data. 75 | 76 | bb -- bytes-like object 77 | start_position -- index into 'bb' 78 | 79 | Skips leading junk. 80 | 81 | Returns a list of pairs (token type : str, contents : bytes), 82 | and also the index in 'bb' of the start of the unprocessed 'tail'. 83 | 84 | token types and contents: 85 | I -- PropIdent: upper-case letters 86 | V -- PropValue: raw value, without the enclosing brackets 87 | D -- delimiter: ';', '(', or ')' 88 | 89 | Stops when it has seen as many closing parens as open ones, at the end of 90 | the string, or when it first finds something it can't tokenise. 91 | 92 | The first two tokens are always '(' and ';' (otherwise it won't find the 93 | start of the content). 94 | 95 | Accepts lower-case letters in PropIdents (these were allowed in some 96 | ancient SGF variants, and are still seen in the wild); the returned 97 | PropIdent has the lower-case letters removed (for example, 'AddBlack' is 98 | returned as 'AB'), and therefore passes is_valid_property_identifier(). 99 | 100 | """ 101 | result = [] 102 | m = _find_start_re.search(bb, start_position) 103 | if not m: 104 | return [], 0 105 | i = m.start() 106 | depth = 0 107 | while True: 108 | m = _tokenise_re.match(bb, i) 109 | if not m: 110 | break 111 | group = m.lastgroup 112 | token = m.group(m.lastindex) 113 | if group == 'I': 114 | token = token.translate(None, _lcbytes) 115 | result.append((group, token)) 116 | i = m.end() 117 | if group == 'D': 118 | if token == b'(': 119 | depth += 1 120 | elif token == b')': 121 | depth -= 1 122 | if depth == 0: 123 | break 124 | return result, i 125 | 126 | class Coarse_game_tree: 127 | """An SGF GameTree. 128 | 129 | This is a direct representation of the SGF parse tree. It's 'coarse' in the 130 | sense that the objects in the tree structure represent node sequences, not 131 | individual nodes. 132 | 133 | Public attributes 134 | sequence -- nonempty list of property maps 135 | children -- list of Coarse_game_trees 136 | 137 | The sequence represents the nodes before the variations. 138 | 139 | """ 140 | def __init__(self): 141 | self.sequence = [] # must be at least one node 142 | self.children = [] # may be empty 143 | 144 | def _parse_sgf_game(bb, start_position): 145 | """Common implementation for parse_sgf_game and parse_sgf_games.""" 146 | tokens, end_position = tokenise(bb, start_position) 147 | if not tokens: 148 | return None, None 149 | stack = [] 150 | game_tree = None 151 | sequence = None 152 | properties = None 153 | index = 0 154 | try: 155 | while True: 156 | token_type, token = tokens[index] 157 | index += 1 158 | if token_type == 'V': 159 | raise ValueError("unexpected value") 160 | if token_type == 'D': 161 | if token == b';': 162 | if sequence is None: 163 | raise ValueError("unexpected node") 164 | properties = {} 165 | sequence.append(properties) 166 | else: 167 | if sequence is not None: 168 | if not sequence: 169 | raise ValueError("empty sequence") 170 | game_tree.sequence = sequence 171 | sequence = None 172 | if token == b'(': 173 | stack.append(game_tree) 174 | game_tree = Coarse_game_tree() 175 | sequence = [] 176 | else: 177 | # token == b')' 178 | variation = game_tree 179 | game_tree = stack.pop() 180 | if game_tree is None: 181 | break 182 | game_tree.children.append(variation) 183 | properties = None 184 | else: 185 | # token_type == 'I' 186 | prop_ident = token.decode("ascii") 187 | prop_values = [] 188 | while True: 189 | token_type, token = tokens[index] 190 | if token_type != 'V': 191 | break 192 | index += 1 193 | prop_values.append(token) 194 | if not prop_values: 195 | raise ValueError("property with no values") 196 | try: 197 | if prop_ident in properties: 198 | properties[prop_ident] += prop_values 199 | else: 200 | properties[prop_ident] = prop_values 201 | except TypeError: 202 | raise ValueError("property value outside a node") from None 203 | except IndexError: 204 | raise ValueError("unexpected end of SGF data") from None 205 | assert index == len(tokens) 206 | return variation, end_position 207 | 208 | def parse_sgf_game(bb): 209 | """Read a single SGF game from bytes data, returning the parse tree. 210 | 211 | bb -- bytes-like object 212 | 213 | Returns a Coarse_game_tree. 214 | 215 | Applies the rules for FF[4]. 216 | 217 | Raises ValueError if can't parse the data. 218 | 219 | If a property appears more than once in a node (which is not permitted by 220 | the spec), treats it the same as a single property with multiple values. 221 | 222 | 223 | Identifies the start of the SGF content by looking for '(;' (with possible 224 | whitespace between); ignores everything preceding that. Ignores everything 225 | following the first game. 226 | 227 | """ 228 | game_tree, _ = _parse_sgf_game(bb, 0) 229 | if game_tree is None: 230 | raise ValueError("no SGF data found") 231 | return game_tree 232 | 233 | def parse_sgf_collection(bb): 234 | """Read an SGF game collection, returning the parse trees. 235 | 236 | bb -- bytes-like object 237 | 238 | Returns a nonempty list of Coarse_game_trees. 239 | 240 | Raises ValueError if no games were found in the data. 241 | 242 | Raises ValueError if there is an error parsing a game. See 243 | parse_sgf_game() for details. 244 | 245 | 246 | Ignores non-SGF data before the first game, between games, and after the 247 | final game. Identifies the start of each game in the same way as 248 | parse_sgf_game(). 249 | 250 | """ 251 | position = 0 252 | result = [] 253 | while True: 254 | try: 255 | game_tree, position = _parse_sgf_game(bb, position) 256 | except ValueError as e: 257 | raise ValueError("error parsing game %d: %s" % (len(result), e)) \ 258 | from None 259 | if game_tree is None: 260 | break 261 | result.append(game_tree) 262 | if not result: 263 | raise ValueError("no SGF data found") 264 | return result 265 | 266 | 267 | def block_format(pieces, width=79): 268 | """Concatenate strings, adding newlines. 269 | 270 | pieces -- iterable of bytes-like objects 271 | width -- int (default 79) 272 | 273 | Returns b"".join(pieces), with added newlines between pieces as necessary 274 | to avoid lines longer than 'width' (using nothing more sophisticated than a 275 | byte-count). 276 | 277 | Leaves newlines inside 'pieces' untouched, and ignores them in its width 278 | calculation. If a single piece is longer than 'width', it will become a 279 | single long line in the output. 280 | 281 | """ 282 | lines = [] 283 | line = b"" 284 | for bb in pieces: 285 | if len(line) + len(bb) > width: 286 | lines.append(line) 287 | line = b"" 288 | line += bb 289 | if line: 290 | lines.append(line) 291 | return b"\n".join(lines) 292 | 293 | def serialise_game_tree(game_tree, wrap=79): 294 | """Serialise an SGF game as a string. 295 | 296 | game_tree -- Coarse_game_tree 297 | wrap -- int (default 79), or None 298 | 299 | Returns a bytes object, ending with a newline. 300 | 301 | If 'wrap' is not None, makes some effort to keep output lines no longer 302 | than 'wrap'. 303 | 304 | """ 305 | l = [] 306 | to_serialise = [game_tree] 307 | while to_serialise: 308 | game_tree = to_serialise.pop() 309 | if game_tree is None: 310 | l.append(b")") 311 | continue 312 | l.append(b"(") 313 | for properties in game_tree.sequence: 314 | l.append(b";") 315 | # Force FF to the front, largely to work around a Quarry bug which 316 | # makes it ignore the first few bytes of the file. 317 | for prop_ident, prop_values in sorted( 318 | properties.items(), 319 | key=lambda kv: (-(kv[0]=="FF"), kv[0])): 320 | # Make a single string for each property, to get prettier 321 | # block_format output. 322 | m = [prop_ident.encode("ascii")] 323 | for value in prop_values: 324 | m.append(b"[" + value + b"]") 325 | l.append(b"".join(m)) 326 | to_serialise.append(None) 327 | to_serialise.extend(reversed(game_tree.children)) 328 | l.append(b"\n") 329 | if wrap is None: 330 | return b"".join(l) 331 | else: 332 | return block_format(l, wrap) 333 | 334 | 335 | def make_tree(game_tree, root, node_builder, node_adder): 336 | """Construct a node tree from a Coarse_game_tree. 337 | 338 | game_tree -- Coarse_game_tree 339 | root -- node 340 | node_builder -- function taking parameters (parent node, property map) 341 | returning a node 342 | node_adder -- function taking a pair (parent node, child node) 343 | 344 | Builds a tree of nodes corresponding to this GameTree, calling 345 | node_builder() to make new nodes and node_adder() to add child nodes to 346 | their parent. 347 | 348 | Makes no further assumptions about the node type. 349 | 350 | """ 351 | to_build = [(root, game_tree, 0)] 352 | while to_build: 353 | node, game_tree, index = to_build.pop() 354 | if index < len(game_tree.sequence) - 1: 355 | child = node_builder(node, game_tree.sequence[index+1]) 356 | node_adder(node, child) 357 | to_build.append((child, game_tree, index+1)) 358 | else: 359 | node._children = [] 360 | for child_tree in game_tree.children: 361 | child = node_builder(node, child_tree.sequence[0]) 362 | node_adder(node, child) 363 | to_build.append((child, child_tree, 0)) 364 | 365 | def make_coarse_game_tree(root, get_children, get_properties): 366 | """Construct a Coarse_game_tree from a node tree. 367 | 368 | root -- node 369 | get_children -- function taking a node, returning a sequence of nodes 370 | get_properties -- function taking a node, returning a property map 371 | 372 | Returns a Coarse_game_tree. 373 | 374 | Walks the node tree based at 'root' using get_children(), and uses 375 | get_properties() to extract the raw properties. 376 | 377 | Makes no further assumptions about the node type. 378 | 379 | Doesn't check that the property maps have well-formed keys and values. 380 | 381 | """ 382 | result = Coarse_game_tree() 383 | to_serialise = [(result, root)] 384 | while to_serialise: 385 | game_tree, node = to_serialise.pop() 386 | while True: 387 | game_tree.sequence.append(get_properties(node)) 388 | children = get_children(node) 389 | if len(children) != 1: 390 | break 391 | node = children[0] 392 | for child in children: 393 | child_tree = Coarse_game_tree() 394 | game_tree.children.append(child_tree) 395 | to_serialise.append((child_tree, child)) 396 | return result 397 | 398 | 399 | def main_sequence_iter(game_tree): 400 | """Provide the 'leftmost' complete sequence of a Coarse_game_tree. 401 | 402 | game_tree -- Coarse_game_tree 403 | 404 | Returns an iterable of property maps. 405 | 406 | If the game has no variations, this provides the complete game. Otherwise, 407 | it chooses the first variation each time it has a choice. 408 | 409 | """ 410 | while True: 411 | for properties in game_tree.sequence: 412 | yield properties 413 | if not game_tree.children: 414 | break 415 | game_tree = game_tree.children[0] 416 | 417 | 418 | _split_compose_re = re.compile( 419 | br"( (?: [^\\:] | \\. )* ) :", 420 | re.VERBOSE | re.DOTALL) 421 | 422 | def parse_compose(bb): 423 | """Split the parts of an SGF Compose value. 424 | 425 | If the value is a well-formed Compose, returns a pair of strings. 426 | 427 | If it isn't (ie, there is no delimiter), returns the complete string and 428 | None. 429 | 430 | Interprets backslash escapes in order to find the delimiter, but leaves 431 | backslash escapes unchanged in the returned strings. 432 | 433 | """ 434 | m = _split_compose_re.match(bb) 435 | if not m: 436 | return bb, None 437 | return m.group(1), bb[m.end():] 438 | 439 | def compose(bb1, bb2): 440 | """Construct a value of Compose value type. 441 | 442 | bb1, bb2 -- serialised form of a property value (bytes-like objects) 443 | 444 | (This is only needed if the type of the first value permits colons.) 445 | 446 | """ 447 | return bb1.replace(b":", b"\\:") + b":" + bb2 448 | 449 | 450 | _newline_re = re.compile(br"\n\r|\r\n|\n|\r") 451 | _whitespace_table = bytes.maketrans(b"\t\f\v", b" ") 452 | _chunk_re = re.compile(br" [^\n\\]+ | [\n\\] ", re.VERBOSE) 453 | 454 | def simpletext_value(bb): 455 | """Convert a raw SimpleText property value to the bytestring it represents. 456 | 457 | bb -- bytes-like object 458 | 459 | Returns a bytes object, in the same encoding as 'bb'. 460 | 461 | This interprets escape characters, and does whitespace mapping: 462 | 463 | - backslash followed by linebreak (LF, CR, LFCR, or CRLF) disappears 464 | - any other linebreak is replaced by a space 465 | - any other whitespace character is replaced by a space 466 | - other backslashes disappear (but double-backslash -> single-backslash) 467 | 468 | """ 469 | bb = _newline_re.sub(b"\n", bb) 470 | bb = bb.translate(_whitespace_table) 471 | is_escaped = False 472 | result = [] 473 | for chunk in _chunk_re.findall(bb): 474 | if is_escaped: 475 | if chunk != b"\n": 476 | result.append(chunk) 477 | is_escaped = False 478 | elif chunk == b"\\": 479 | is_escaped = True 480 | elif chunk == b"\n": 481 | result.append(b" ") 482 | else: 483 | result.append(chunk) 484 | return b"".join(result) 485 | 486 | def text_value(bb): 487 | """Convert a raw Text property value to the bytestring it represents. 488 | 489 | bb -- bytes-like object 490 | 491 | Returns a bytes object, in the same encoding as 'bb'. 492 | 493 | This interprets escape characters, and does whitespace mapping: 494 | 495 | - linebreak (LF, CR, LFCR, or CRLF) is converted to \n 496 | - any other whitespace character is replaced by a space 497 | - backslash followed by linebreak disappears 498 | - other backslashes disappear (but double-backslash -> single-backslash) 499 | 500 | """ 501 | bb = _newline_re.sub(b"\n", bb) 502 | bb = bb.translate(_whitespace_table) 503 | is_escaped = False 504 | result = [] 505 | for chunk in _chunk_re.findall(bb): 506 | if is_escaped: 507 | if chunk != b"\n": 508 | result.append(chunk) 509 | is_escaped = False 510 | elif chunk == b"\\": 511 | is_escaped = True 512 | else: 513 | result.append(chunk) 514 | return b"".join(result) 515 | 516 | def escape_text(bb): 517 | """Convert a bytestring to a raw Text property value that represents it. 518 | 519 | bb -- bytes-like object, in the desired output encoding. 520 | 521 | Returns a bytes object which passes is_valid_property_value(). 522 | 523 | Normally text_value(escape_text(bb)) == bb, but there are the following 524 | exceptions: 525 | - all linebreaks are are normalised to \n 526 | - whitespace other than line breaks is converted to a single space 527 | 528 | """ 529 | return bb.replace(b"\\", b"\\\\").replace(b"]", b"\\]") 530 | 531 | -------------------------------------------------------------------------------- /sgfmill/sgf_moves.py: -------------------------------------------------------------------------------- 1 | """Higher-level processing of moves and positions from SGF games.""" 2 | 3 | from . import sgf_properties 4 | 5 | def get_setup_and_moves(sgf_game, board=None): 6 | """Return the initial setup and the following moves from an Sgf_game. 7 | 8 | Returns a pair (board, plays) 9 | 10 | board -- boards.Board 11 | plays -- list of pairs (colour, move) 12 | moves are (row, col), or None for a pass. 13 | 14 | The board represents the position described by AB and/or AW properties 15 | in the root node. 16 | 17 | The moves are from the game's 'leftmost' variation. 18 | 19 | Raises ValueError if this position isn't legal. 20 | 21 | Raises ValueError if there are any AB/AW/AE properties after the root 22 | node. 23 | 24 | Doesn't check whether the moves are legal. 25 | 26 | If the optional 'board' parameter is provided, it must be an empty board of 27 | the right size; the same object will be returned. 28 | See sgf_board_interface.Interface_for_get_setup_and_moves 29 | for the interface it should support. 30 | 31 | """ 32 | size = sgf_game.get_size() 33 | if board is None: 34 | from . import boards 35 | board = boards.Board(size) 36 | else: 37 | if board.side != size: 38 | raise ValueError("wrong board size, must be %d" % size) 39 | if not board.is_empty(): 40 | raise ValueError("board not empty") 41 | root = sgf_game.get_root() 42 | nodes = sgf_game.main_sequence_iter() 43 | ab, aw, ae = root.get_setup_stones() 44 | if ab or aw: 45 | is_legal = board.apply_setup(ab, aw, ae) 46 | if not is_legal: 47 | raise ValueError("setup position not legal") 48 | colour, raw = root.get_raw_move() 49 | if colour is not None: 50 | raise ValueError("mixed setup and moves in root node") 51 | next(nodes) 52 | moves = [] 53 | for node in nodes: 54 | if node.has_setup_stones(): 55 | raise ValueError("setup properties after the root node") 56 | colour, raw = node.get_raw_move() 57 | if colour is not None: 58 | moves.append((colour, sgf_properties.interpret_go_point(raw, size))) 59 | return board, moves 60 | 61 | def set_initial_position(sgf_game, board): 62 | """Add setup stones to an Sgf_game reflecting a board position. 63 | 64 | sgf_game -- Sgf_game 65 | board -- Board object 66 | 67 | Replaces any existing setup stones in the Sgf_game's root node. 68 | 69 | 'board' may be a boards.Board instance, or anything providing the interface 70 | described by sgf_board_interface.Interface_for_set_initial_position(). 71 | 72 | """ 73 | stones = {'b' : set(), 'w' : set()} 74 | for (colour, point) in board.list_occupied_points(): 75 | stones[colour].add(point) 76 | sgf_game.get_root().set_setup_stones(stones['b'], stones['w']) 77 | 78 | def indicate_first_player(sgf_game): 79 | """Add a PL property to the root node if appropriate. 80 | 81 | Looks at the first child of the root to see who the first player is, and 82 | sets PL it isn't the expected player (ie, black normally, but white if 83 | there is a handicap), or if there are non-handicap setup stones. 84 | 85 | """ 86 | root = sgf_game.get_root() 87 | first_player, move = root[0].get_move() 88 | if first_player is None: 89 | return 90 | has_handicap = root.has_property("HA") 91 | if root.has_property("AW"): 92 | specify_pl = True 93 | elif root.has_property("AB") and not has_handicap: 94 | specify_pl = True 95 | elif not has_handicap and first_player == 'w': 96 | specify_pl = True 97 | elif has_handicap and first_player == 'b': 98 | specify_pl = True 99 | else: 100 | specify_pl = False 101 | if specify_pl: 102 | root.set('PL', first_player) 103 | 104 | -------------------------------------------------------------------------------- /sgfmill_tests/__init__.py: -------------------------------------------------------------------------------- 1 | # sgfmill_tests package 2 | -------------------------------------------------------------------------------- /sgfmill_tests/board_test_data.py: -------------------------------------------------------------------------------- 1 | play_tests = [ 2 | 3 | # code, list of moves to play, board representation, simple ko point, score 4 | 5 | ('blank', [ 6 | ], """\ 7 | 9 . . . . . . . . . 8 | 8 . . . . . . . . . 9 | 7 . . . . . . . . . 10 | 6 . . . . . . . . . 11 | 5 . . . . . . . . . 12 | 4 . . . . . . . . . 13 | 3 . . . . . . . . . 14 | 2 . . . . . . . . . 15 | 1 . . . . . . . . . 16 | A B C D E F G H J 17 | """, None, 0), 18 | 19 | ('twostone', [ 20 | "B B2", "W C2", 21 | ], """\ 22 | 9 . . . . . . . . . 23 | 8 . . . . . . . . . 24 | 7 . . . . . . . . . 25 | 6 . . . . . . . . . 26 | 5 . . . . . . . . . 27 | 4 . . . . . . . . . 28 | 3 . . . . . . . . . 29 | 2 . # o . . . . . . 30 | 1 . . . . . . . . . 31 | A B C D E F G H J 32 | """, None, 0), 33 | 34 | ('many-groups-1-capture', [ 35 | "B C3", "W D3", "B C5", "B C4", "W D4", "B H1", "B B9", "B J6", 36 | "B A7", "B B7", "W A3", "W J2", "W H2", "W G2", "W J3", 37 | "B F7", 38 | "W E6", "W G8", "W G6", "W F8", "W E7", "W F6", "W G7", "W E8", 39 | ], """\ 40 | 9 . # . . . . . . . 41 | 8 . . . . o o o . . 42 | 7 # # . . o . o . . 43 | 6 . . . . o o o . # 44 | 5 . . # . . . . . . 45 | 4 . . # o . . . . . 46 | 3 o . # o . . . . o 47 | 2 . . . . . . o o o 48 | 1 . . . . . . . # . 49 | A B C D E F G H J 50 | """, None, -8), 51 | 52 | ('corner-bl', [ 53 | "B A1", "W B1", "W A2", 54 | ], """\ 55 | 9 . . . . . . . . . 56 | 8 . . . . . . . . . 57 | 7 . . . . . . . . . 58 | 6 . . . . . . . . . 59 | 5 . . . . . . . . . 60 | 4 . . . . . . . . . 61 | 3 . . . . . . . . . 62 | 2 o . . . . . . . . 63 | 1 . o . . . . . . . 64 | A B C D E F G H J 65 | """, None, -81), 66 | 67 | ('corner-all', [ 68 | "B A1", "W B1", "W A2", 69 | "B J1", "W H1", "W J2", 70 | "B A9", "W B9", "W A8", 71 | "B J9", "W H9", "W J8", 72 | ], """\ 73 | 9 . o . . . . . o . 74 | 8 o . . . . . . . o 75 | 7 . . . . . . . . . 76 | 6 . . . . . . . . . 77 | 5 . . . . . . . . . 78 | 4 . . . . . . . . . 79 | 3 . . . . . . . . . 80 | 2 o . . . . . . . o 81 | 1 . o . . . . . o . 82 | A B C D E F G H J 83 | """, None, -81), 84 | 85 | ('multiple', [ 86 | "W D4", "B D3", "W C5", "B C4", "W E5", "B E4", 87 | "B B5", "B F5", "B C6", "B E6", "B D7", "W D6", 88 | "B D5", 89 | ], """\ 90 | 9 . . . . . . . . . 91 | 8 . . . . . . . . . 92 | 7 . . . # . . . . . 93 | 6 . . # . # . . . . 94 | 5 . # . # . # . . . 95 | 4 . . # . # . . . . 96 | 3 . . . # . . . . . 97 | 2 . . . . . . . . . 98 | 1 . . . . . . . . . 99 | A B C D E F G H J 100 | """, None, 81), 101 | 102 | ('large', [ 103 | "W D2", "W G2", "W E3", "W F3", "W F4", "W D5", "W E5", "W F5", "B E2", "B F2", 104 | "B D3", "B G3", "B D4", "B G4", "B C5", "B G5", "B D6", "B E6", "B F6", "B E4", 105 | ], """\ 106 | 9 . . . . . . . . . 107 | 8 . . . . . . . . . 108 | 7 . . . . . . . . . 109 | 6 . . . # # # . . . 110 | 5 . . # . . . # . . 111 | 4 . . . # # . # . . 112 | 3 . . . # . . # . . 113 | 2 . . . o # # o . . 114 | 1 . . . . . . . . . 115 | A B C D E F G H J 116 | """, None, 16), 117 | 118 | ('pre-recapture', [ 119 | "W A1", "W B1", "W B2", "W C2", "W D3", "W E3", "W A4", "W B4", "W C4", "W E4", 120 | "B A2", "B D2", "B E2", "B A3", "B B3", "B F3", "B D4", "B F4", "B E5", 121 | "B C3", 122 | ], """\ 123 | 9 . . . . . . . . . 124 | 8 . . . . . . . . . 125 | 7 . . . . . . . . . 126 | 6 . . . . . . . . . 127 | 5 . . . . # . . . . 128 | 4 o o o # . # . . . 129 | 3 # # # . . # . . . 130 | 2 # o o # # . . . . 131 | 1 o o . . . . . . . 132 | A B C D E F G H J 133 | """, None, 6), 134 | 135 | ('recapture', [ 136 | "W A1", "W B1", "W B2", "W C2", "W D3", "W E3", "W A4", "W B4", "W C4", "W E4", 137 | "B A2", "B D2", "B E2", "B A3", "B B3", "B F3", "B D4", "B F4", "B E5", 138 | "B C3", "W D3", 139 | ], """\ 140 | 9 . . . . . . . . . 141 | 8 . . . . . . . . . 142 | 7 . . . . . . . . . 143 | 6 . . . . . . . . . 144 | 5 . . . . # . . . . 145 | 4 o o o # . # . . . 146 | 3 . . . o . # . . . 147 | 2 . o o # # . . . . 148 | 1 o o . . . . . . . 149 | A B C D E F G H J 150 | """, None, -6), 151 | 152 | ('self-capture-1', [ 153 | "B D4", "B C5", "B E5", "B D6", "W D5", 154 | ], """\ 155 | 9 . . . . . . . . . 156 | 8 . . . . . . . . . 157 | 7 . . . . . . . . . 158 | 6 . . . # . . . . . 159 | 5 . . # . # . . . . 160 | 4 . . . # . . . . . 161 | 3 . . . . . . . . . 162 | 2 . . . . . . . . . 163 | 1 . . . . . . . . . 164 | A B C D E F G H J 165 | """, None, 81), 166 | 167 | ('self-capture-2', [ 168 | "B D4", "B E4", "B C5", "B F5", "B D6", "B E6", "W D5", "W E5", 169 | ], """\ 170 | 9 . . . . . . . . . 171 | 8 . . . . . . . . . 172 | 7 . . . . . . . . . 173 | 6 . . . # # . . . . 174 | 5 . . # . . # . . . 175 | 4 . . . # # . . . . 176 | 3 . . . . . . . . . 177 | 2 . . . . . . . . . 178 | 1 . . . . . . . . . 179 | A B C D E F G H J 180 | """, None, 81), 181 | 182 | ('self-capture-3', [ 183 | "B D4", "B E4", "B F4", "B C5", "B G5", "B D6", "B E6", "B F6", 184 | "W D5", "W F5", "W E5", 185 | ], """\ 186 | 9 . . . . . . . . . 187 | 8 . . . . . . . . . 188 | 7 . . . . . . . . . 189 | 6 . . . # # # . . . 190 | 5 . . # . . . # . . 191 | 4 . . . # # # . . . 192 | 3 . . . . . . . . . 193 | 2 . . . . . . . . . 194 | 1 . . . . . . . . . 195 | A B C D E F G H J 196 | """, None, 81), 197 | 198 | ('self-capture-many', [ 199 | "B E2", "B D3", "B F3", "B D4", "B F4", "B G4", "B C5", "B H5", "B C6", 200 | "B F6", "B G6", "B D7", "B E7", 201 | "W E3", "W E4", "W D5", "W F5", "W G5", "W D6", "W E6", "W E5", 202 | ], """\ 203 | 9 . . . . . . . . . 204 | 8 . . . . . . . . . 205 | 7 . . . # # . . . . 206 | 6 . . # . . # # . . 207 | 5 . . # . . . . # . 208 | 4 . . . # . # # . . 209 | 3 . . . # . # . . . 210 | 2 . . . . # . . . . 211 | 1 . . . . . . . . . 212 | A B C D E F G H J 213 | """, None, 81), 214 | 215 | ('ko-corner', [ 216 | "B A1", "B B2", "B A3", 217 | "W B1", "W A2", 218 | ], """\ 219 | 9 . . . . . . . . . 220 | 8 . . . . . . . . . 221 | 7 . . . . . . . . . 222 | 6 . . . . . . . . . 223 | 5 . . . . . . . . . 224 | 4 . . . . . . . . . 225 | 3 # . . . . . . . . 226 | 2 o # . . . . . . . 227 | 1 . o . . . . . . . 228 | A B C D E F G H J 229 | """, 'A1', -1), 230 | 231 | ('notko-twocaptured', [ 232 | "B B2", "B B3", "B A4", 233 | "W B1", "W A2", "W A3", 234 | "B A1", 235 | ], """\ 236 | 9 . . . . . . . . . 237 | 8 . . . . . . . . . 238 | 7 . . . . . . . . . 239 | 6 . . . . . . . . . 240 | 5 . . . . . . . . . 241 | 4 # . . . . . . . . 242 | 3 . # . . . . . . . 243 | 2 . # . . . . . . . 244 | 1 # o . . . . . . . 245 | A B C D E F G H J 246 | """, None, 5), 247 | 248 | ('notko-tworecaptured', [ 249 | "B A1", "B B3", "B A4", 250 | "W B1", "W B2", "W A3", 251 | "B A2", 252 | ], """\ 253 | 9 . . . . . . . . . 254 | 8 . . . . . . . . . 255 | 7 . . . . . . . . . 256 | 6 . . . . . . . . . 257 | 5 . . . . . . . . . 258 | 4 # . . . . . . . . 259 | 3 . # . . . . . . . 260 | 2 # o . . . . . . . 261 | 1 # o . . . . . . . 262 | A B C D E F G H J 263 | """, None, 3), 264 | 265 | ] 266 | 267 | 268 | score_tests = [ 269 | 270 | # code, board representation, score 271 | 272 | ('empty', """\ 273 | 9 . . . . . . . . . 274 | 8 . . . . . . . . . 275 | 7 . . . . . . . . . 276 | 6 . . . . . . . . . 277 | 5 . . . . . . . . . 278 | 4 . . . . . . . . . 279 | 3 . . . . . . . . . 280 | 2 . . . . . . . . . 281 | 1 . . . . . . . . . 282 | A B C D E F G H J 283 | """, 0), 284 | 285 | ('onestone', """\ 286 | 9 . . . . . . . . . 287 | 8 . . . . . . . . . 288 | 7 . . . . . . . . . 289 | 6 . . . . . . . . . 290 | 5 . . . . . . . . . 291 | 4 . . . . . . . . . 292 | 3 . . . . . . . . . 293 | 2 . # . . . . . . . 294 | 1 . . . . . . . . . 295 | A B C D E F G H J 296 | """, 81), 297 | 298 | ('easy', """\ 299 | 9 . . . # o . . . . 300 | 8 . . . # o . . . . 301 | 7 . . . # o . . . . 302 | 6 . . . # o . . . . 303 | 5 . . . # o . . . . 304 | 4 . . . # o . . . . 305 | 3 . . . # o . . . . 306 | 2 . . . # o . . . . 307 | 1 . . . # o . . . . 308 | A B C D E F G H J 309 | """, -9), 310 | 311 | ('spoilt', """\ 312 | 9 . . . # o . . . . 313 | 8 . . . # o . . . . 314 | 7 . . . # o . . . . 315 | 6 . . . # o . . . . 316 | 5 . . . # o . . # . 317 | 4 . . . # o . . . . 318 | 3 . . . # o . . . . 319 | 2 . . . # o . . . . 320 | 1 . . . # o . . . . 321 | A B C D E F G H J 322 | """, 28), 323 | 324 | ('busy', """\ 325 | 9 . . o . . o # . # 326 | 8 . o o o o o # . # 327 | 7 . o . o o # # # o 328 | 6 o . o # o # o o o 329 | 5 o o # # # # # o o 330 | 4 . o o # . o # o . 331 | 3 o # # # o o o o o 332 | 2 . o o # # # o o . 333 | 1 o . o # . # o o o 334 | A B C D E F G H J 335 | """, -26), 336 | 337 | ] 338 | 339 | 340 | setup_tests = [ 341 | 342 | # code, black points, white points, empty points, diagram, is_legal 343 | 344 | ('blank', 345 | [], 346 | [], 347 | [], 348 | """\ 349 | 9 . . . . . . . . . 350 | 8 . . . . . . . . . 351 | 7 . . . . . . . . . 352 | 6 . . . . . . . . . 353 | 5 . . . . . . . . . 354 | 4 . . . . . . . . . 355 | 3 . . . . . . . . . 356 | 2 . . . . . . . . . 357 | 1 . . . . . . . . . 358 | A B C D E F G H J 359 | """, True), 360 | 361 | ('simple', 362 | ['D4', 'D5'], 363 | ['B1', 'J9'], 364 | [], 365 | """\ 366 | 9 . . . . . . . . o 367 | 8 . . . . . . . . . 368 | 7 . . . . . . . . . 369 | 6 . . . . . . . . . 370 | 5 . . . # . . . . . 371 | 4 . . . # . . . . . 372 | 3 . . . . . . . . . 373 | 2 . . . . . . . . . 374 | 1 . o . . . . . . . 375 | A B C D E F G H J 376 | """, True), 377 | 378 | ('illegal', 379 | ['A8', 'B9'], 380 | ['A9'], 381 | [], 382 | """\ 383 | 9 . # . . . . . . . 384 | 8 # . . . . . . . . 385 | 7 . . . . . . . . . 386 | 6 . . . . . . . . . 387 | 5 . . . . . . . . . 388 | 4 . . . . . . . . . 389 | 3 . . . . . . . . . 390 | 2 . . . . . . . . . 391 | 1 . . . . . . . . . 392 | A B C D E F G H J 393 | """, False), 394 | 395 | ] 396 | -------------------------------------------------------------------------------- /sgfmill_tests/board_tests.py: -------------------------------------------------------------------------------- 1 | """Tests for boards.py and ascii_boards.py 2 | 3 | We test these together because it's convenient for later boards tests to use 4 | ascii_boards facilities. 5 | 6 | """ 7 | 8 | from sgfmill.common import format_vertex, move_from_vertex 9 | from sgfmill import ascii_boards 10 | from sgfmill import boards 11 | 12 | from . import sgfmill_test_support 13 | from . import board_test_data 14 | 15 | def make_tests(suite): 16 | suite.addTests(sgfmill_test_support.make_simple_tests(globals())) 17 | for t in board_test_data.play_tests: 18 | suite.addTest(Play_test_TestCase(*t)) 19 | for t in board_test_data.score_tests: 20 | suite.addTest(Score_test_TestCase(*t)) 21 | for t in board_test_data.setup_tests: 22 | suite.addTest(Setup_test_TestCase(*t)) 23 | 24 | def test_attributes(tc): 25 | b = boards.Board(5) 26 | tc.assertEqual(b.side, 5) 27 | tc.assertEqual( 28 | b.board_points, 29 | [(0, 0), (0, 1), (0, 2), (0, 3), (0, 4), 30 | (1, 0), (1, 1), (1, 2), (1, 3), (1, 4), 31 | (2, 0), (2, 1), (2, 2), (2, 3), (2, 4), 32 | (3, 0), (3, 1), (3, 2), (3, 3), (3, 4), 33 | (4, 0), (4, 1), (4, 2), (4, 3), (4, 4)]) 34 | 35 | def test_basics(tc): 36 | tc.assertRaises(ValueError, boards.Board, 1) 37 | tc.assertRaises(ValueError, boards.Board, 0) 38 | tc.assertRaises(ValueError, boards.Board, -1) 39 | tc.assertRaises((TypeError, ValueError), boards.Board, (19, 19)) 40 | 41 | b = boards.Board(9) 42 | 43 | tc.assertTrue(b.is_empty()) 44 | tc.assertCountEqual(b.list_occupied_points(), []) 45 | 46 | tc.assertEqual(b.get(2, 3), None) 47 | b.play(2, 3, 'b') 48 | tc.assertEqual(b.get(2, 3), 'b') 49 | tc.assertFalse(b.is_empty()) 50 | b.play(3, 4, 'w') 51 | 52 | with tc.assertRaises(ValueError): 53 | b.play(3, 4, 'w') 54 | 55 | with tc.assertRaises(ValueError): 56 | b.play(5, 2, None) 57 | 58 | tc.assertCountEqual(b.list_occupied_points(), 59 | [('b', (2, 3)), ('w', (3, 4))]) 60 | 61 | def test_range_checks(tc): 62 | b = boards.Board(9) 63 | tc.assertRaises(IndexError, b.get, -1, 2) 64 | tc.assertRaises(IndexError, b.get, 9, 2) 65 | tc.assertRaises(IndexError, b.get, 2, -1) 66 | tc.assertRaises(IndexError, b.get, 2, 9) 67 | tc.assertRaises(IndexError, b.play, -1, 2, 'b') 68 | tc.assertRaises(IndexError, b.play, 9, 2, 'b') 69 | tc.assertRaises(IndexError, b.play, 2, -1, 'b') 70 | tc.assertRaises(IndexError, b.play, 2, 9, 'b') 71 | tc.assertEqual(b, boards.Board(9)) 72 | 73 | 74 | _9x9_expected = """\ 75 | 9 . . . . . . . . . 76 | 8 . . . . . . . . . 77 | 7 . . . . . . . . . 78 | 6 . . . . . . . . . 79 | 5 . . . . . . . . . 80 | 4 . . . . o . . . . 81 | 3 . . . # . . . . . 82 | 2 . . . . . . . . . 83 | 1 . . . . . . . . . 84 | A B C D E F G H J\ 85 | """ 86 | 87 | _13x13_expected = """\ 88 | 13 . . . . . . . . . . . . . 89 | 12 . . . . . . . . . . . . . 90 | 11 . . . . . . . . . . . . . 91 | 10 . . . . . . . . . . . . . 92 | 9 . . . . . . . . . . . . . 93 | 8 . . . . . . . . . . . . . 94 | 7 . . . . . . . . . . . . . 95 | 6 . . . . . . . . . . . . . 96 | 5 . . . . . . . . . . . . . 97 | 4 . . . . o . . . . . . . . 98 | 3 . . . # . . . . . . . . . 99 | 2 . . . . . . . . . . . . . 100 | 1 . . . . . . . . . . . . . 101 | A B C D E F G H J K L M N\ 102 | """ 103 | 104 | def test_render_board_9x9(tc): 105 | b = boards.Board(9) 106 | b.play(2, 3, 'b') 107 | b.play(3, 4, 'w') 108 | tc.assertDiagramEqual(ascii_boards.render_board(b), _9x9_expected) 109 | 110 | def test_render_board_13x13(tc): 111 | b = boards.Board(13) 112 | b.play(2, 3, 'b') 113 | b.play(3, 4, 'w') 114 | tc.assertDiagramEqual(ascii_boards.render_board(b), _13x13_expected) 115 | 116 | def test_interpret_diagram(tc): 117 | b1 = boards.Board(9) 118 | b1.play(2, 3, 'b') 119 | b1.play(3, 4, 'w') 120 | b2 = ascii_boards.interpret_diagram(_9x9_expected, 9) 121 | tc.assertEqual(b1, b2) 122 | b3 = boards.Board(9) 123 | b4 = ascii_boards.interpret_diagram(_9x9_expected, 9, b3) 124 | tc.assertIs(b3, b4) 125 | tc.assertEqual(b1, b3) 126 | tc.assertRaisesRegex(ValueError, "board not empty", 127 | ascii_boards.interpret_diagram, _9x9_expected, 9, b3) 128 | b5 = boards.Board(19) 129 | tc.assertRaisesRegex(ValueError, "wrong board size, must be 9$", 130 | ascii_boards.interpret_diagram, _9x9_expected, 9, b5) 131 | 132 | tc.assertRaises(ValueError, ascii_boards.interpret_diagram, "nonsense", 9) 133 | b6 = ascii_boards.interpret_diagram(_13x13_expected, 13) 134 | tc.assertDiagramEqual(ascii_boards.render_board(b6), _13x13_expected) 135 | 136 | padded = "\n\n" + _9x9_expected + "\n\n" 137 | tc.assertEqual(b1, ascii_boards.interpret_diagram(padded, 9)) 138 | 139 | def test_get_neighbours(tc): 140 | neighbours = boards._get_neighbours(0, 0, 5) 141 | tc.assertEqual(2, len(neighbours)) 142 | tc.assertTrue((0, 1) in neighbours) 143 | tc.assertTrue((1, 0) in neighbours) 144 | tc.assertFalse((0, 0) in neighbours) 145 | tc.assertFalse((-1, 0) in neighbours) 146 | tc.assertFalse((0, -1) in neighbours) 147 | 148 | def test_get_neighbours_and_self(tc): 149 | neighbours = boards._get_neighbours(0, 0, 5) 150 | neighbour_and_self = boards._get_neighbours_and_self(0, 0, 5) 151 | tc.assertEqual(len(neighbours) + 1, len(neighbour_and_self)) 152 | for n in neighbours: 153 | tc.assertTrue(n in neighbour_and_self) 154 | tc.assertTrue((0, 0) in neighbour_and_self) 155 | 156 | def test_copy(tc): 157 | b1 = boards.Board(9) 158 | b1.play(2, 3, 'b') 159 | b1.play(3, 4, 'w') 160 | b2 = b1.copy() 161 | tc.assertEqual(b1, b2) 162 | b2.play(5, 5, 'b') 163 | b2.play(2, 1, 'b') 164 | tc.assertNotEqual(b1, b2) 165 | b1.play(5, 5, 'b') 166 | b1.play(2, 1, 'b') 167 | tc.assertEqual(b1, b2) 168 | 169 | def test_full_board_selfcapture(tc): 170 | b = boards.Board(9) 171 | tc.assertTrue(b.is_empty()) 172 | tc.assertCountEqual(b.list_occupied_points(), []) 173 | for row in range(9): 174 | for col in range(9): 175 | b.play(row, col, 'b') 176 | tc.assertEqual(b, boards.Board(9)) 177 | tc.assertIs(b.is_empty(), True) 178 | 179 | def test_apply_setup_range_checks(tc): 180 | b = boards.Board(9) 181 | tc.assertRaises(IndexError, b.apply_setup, [(1, 1), (9, 2)], [], []) 182 | tc.assertRaises(IndexError, b.apply_setup, [], [(2, 2), (2, -3)], []) 183 | tc.assertRaises(IndexError, b.apply_setup, [], [], [(3, 3), (-3, 2)]) 184 | tc.assertEqual(b, boards.Board(9)) 185 | 186 | 187 | class Play_test_TestCase(sgfmill_test_support.Sgfmill_ParameterisedTestCase): 188 | """Check final position reached by playing a sequence of moves.""" 189 | test_name = "play_test" 190 | parameter_names = ('moves', 'diagram', 'ko_vertex', 'score') 191 | 192 | def runTest(self): 193 | b = boards.Board(9) 194 | ko_point = None 195 | for move in self.moves: 196 | colour, vertex = move.split() 197 | colour = colour.lower() 198 | row, col = move_from_vertex(vertex, b.side) 199 | ko_point = b.play(row, col, colour) 200 | self.assertBoardEqual(b, self.diagram) 201 | if ko_point is None: 202 | ko_vertex = None 203 | else: 204 | ko_vertex = format_vertex(ko_point) 205 | self.assertEqual(ko_vertex, self.ko_vertex, "wrong ko point") 206 | self.assertEqual(b.area_score(), self.score, "wrong score") 207 | 208 | 209 | class Score_test_TestCase(sgfmill_test_support.Sgfmill_ParameterisedTestCase): 210 | """Check score of a diagram.""" 211 | test_name = "score_test" 212 | parameter_names = ('diagram', 'score') 213 | 214 | def runTest(self): 215 | b = ascii_boards.interpret_diagram(self.diagram, 9) 216 | self.assertEqual(b.area_score(), self.score, "wrong score") 217 | 218 | 219 | class Setup_test_TestCase(sgfmill_test_support.Sgfmill_ParameterisedTestCase): 220 | """Check apply_setup().""" 221 | test_name = "setup_test" 222 | parameter_names = ('black_points', 'white_points', 'empty_points', 223 | 'diagram', 'is_legal') 224 | 225 | def runTest(self): 226 | def _interpret(moves): 227 | return [move_from_vertex(v, b.side) for v in moves] 228 | 229 | b = boards.Board(9) 230 | is_legal = b.apply_setup(_interpret(self.black_points), 231 | _interpret(self.white_points), 232 | _interpret(self.empty_points)) 233 | self.assertBoardEqual(b, self.diagram) 234 | if self.is_legal: 235 | self.assertTrue(is_legal, "setup should be considered legal") 236 | else: 237 | self.assertFalse(is_legal, "setup should be considered illegal") 238 | -------------------------------------------------------------------------------- /sgfmill_tests/common_tests.py: -------------------------------------------------------------------------------- 1 | """Tests for common.py.""" 2 | 3 | import string 4 | 5 | from . import sgfmill_test_support 6 | 7 | from sgfmill import common 8 | 9 | def make_tests(suite): 10 | suite.addTests(sgfmill_test_support.make_simple_tests(globals())) 11 | 12 | 13 | def test_opponent_of(tc): 14 | oo = common.opponent_of 15 | tc.assertEqual(oo('b'), 'w') 16 | tc.assertEqual(oo('w'), 'b') 17 | tc.assertRaises(ValueError, oo, 'x') 18 | tc.assertRaises(ValueError, oo, None) 19 | tc.assertRaises(ValueError, oo, 'B') 20 | 21 | def test_colour_name(tc): 22 | cn = common.colour_name 23 | tc.assertEqual(cn('b'), 'black') 24 | tc.assertEqual(cn('w'), 'white') 25 | tc.assertRaises(ValueError, cn, 'x') 26 | tc.assertRaises(ValueError, cn, None) 27 | tc.assertRaises(ValueError, cn, 'B') 28 | 29 | def test_column_letters(tc): 30 | tc.assertEqual(common.column_letters, 31 | "".join(c for c in string.ascii_uppercase if c != 'I')) 32 | 33 | def test_format_vertex(tc): 34 | fv = common.format_vertex 35 | tc.assertEqual(fv(None), "pass") 36 | tc.assertEqual(fv((0, 0)), "A1") 37 | tc.assertEqual(fv((8, 8)), "J9") 38 | tc.assertEqual(fv((1, 5)), "F2") 39 | tc.assertEqual(fv((24, 24)), "Z25") 40 | tc.assertRaises(ValueError, fv, (-1, 2)) 41 | tc.assertRaises(ValueError, fv, (2, -1)) 42 | tc.assertRaises(ValueError, fv, (25, 1)) 43 | tc.assertRaises(ValueError, fv, (1, 25)) 44 | 45 | def test_format_vertex_list(tc): 46 | fvl = common.format_vertex_list 47 | tc.assertEqual(fvl([]), "") 48 | tc.assertEqual(fvl([(0, 0)]), "A1") 49 | tc.assertEqual(fvl([(0, 0), (1, 5)]), "A1,F2") 50 | tc.assertEqual(fvl([(0, 0), None, (1, 5)]), "A1,pass,F2") 51 | 52 | def test_move_from_vertex(tc): 53 | cv = common.move_from_vertex 54 | tc.assertEqual(cv("pass", 9), None) 55 | tc.assertEqual(cv("pAss", 9), None) 56 | tc.assertEqual(cv("A1", 9), (0, 0)) 57 | tc.assertEqual(cv("a1", 9), (0, 0)) 58 | tc.assertEqual(cv("A01", 9), (0, 0)) 59 | tc.assertEqual(cv("J9", 9), (8, 8)) 60 | tc.assertEqual(cv("M11", 19), (10, 11)) 61 | tc.assertRaises(ValueError, cv, "M11", 9) 62 | tc.assertRaises(ValueError, cv, "K9", 9) 63 | tc.assertRaises(ValueError, cv, "J10", 9) 64 | tc.assertRaises(ValueError, cv, "I5", 9) 65 | tc.assertRaises(ValueError, cv, "", 9) 66 | tc.assertRaises(ValueError, cv, "29", 9) 67 | tc.assertRaises(ValueError, cv, "@9", 9) 68 | tc.assertRaises(ValueError, cv, "A-3", 9) 69 | tc.assertRaises(ValueError, cv, None, 9) 70 | tc.assertRaises(ValueError, cv, "A1", 0) 71 | tc.assertRaises(ValueError, cv, "A1", 30) 72 | 73 | -------------------------------------------------------------------------------- /sgfmill_tests/run_sgfmill_testsuite.py: -------------------------------------------------------------------------------- 1 | """Construct and run the sgfmill testsuite.""" 2 | 3 | import unittest 4 | import sys 5 | from collections import defaultdict 6 | from optparse import OptionParser 7 | 8 | test_modules = [ 9 | 'common_tests', 10 | 'board_tests', 11 | 'sgf_grammar_tests', 12 | 'sgf_properties_tests', 13 | 'sgf_tests', 14 | 'sgf_moves_tests', 15 | ] 16 | 17 | def get_test_module(name): 18 | """Import the specified sgfmill_tests module and return it.""" 19 | dotted_name = "sgfmill_tests." + name 20 | __import__(dotted_name) 21 | return sys.modules[dotted_name] 22 | 23 | def run_testsuite(suite, failfast, buffer): 24 | """Run the specified testsuite. 25 | 26 | suite -- TestSuite 27 | failfast -- bool (stop at first failing test) 28 | buffer -- bool (show stderr/stdout only for failing tests) 29 | 30 | Output is to stderr 31 | 32 | """ 33 | try: 34 | # This gives 'catchbreak' behaviour 35 | unittest.signals.installHandler() 36 | except Exception: 37 | pass 38 | runner = unittest.TextTestRunner(failfast=failfast, buffer=buffer) 39 | runner.run(suite) 40 | 41 | class UnknownTest(Exception): 42 | """Unknown test module or test name.""" 43 | 44 | def make_testsuite(module_names, tests_by_module): 45 | """Import testsuite modules and make the TestCases. 46 | 47 | module_names -- set of module names (empty means all) 48 | tests_by_module -- map module_name -> set of test names (empty means all) 49 | 50 | Returns a TestSuite. 51 | 52 | Test names are as given by test.id() 53 | For function-based tests, that means . 54 | For parameterised tests, it's .: 55 | 56 | The tests in the returned suite are always in their 'natural' order; the 57 | order of command line items has no effect. 58 | 59 | Raises UnknownTest if a specified test or test module doesn't exist. 60 | 61 | """ 62 | result = unittest.TestSuite() 63 | for module_name in sorted(module_names): 64 | if module_name not in test_modules: 65 | raise UnknownTest("unknown module: %s" % module_name) 66 | for module_name in test_modules: 67 | if module_names and (module_name not in module_names): 68 | continue 69 | mdl = get_test_module(module_name) 70 | suite = unittest.TestSuite() 71 | mdl.make_tests(suite) 72 | test_names = tests_by_module[module_name] 73 | if not test_names: 74 | result.addTests(suite) 75 | continue 76 | existing = set(test.id() for test in suite) 77 | for test_name in sorted(test_names): 78 | if test_name not in existing: 79 | raise UnknownTest("unknown test: %s" % test_name) 80 | for test in suite: 81 | if test.id() in test_names: 82 | result.addTest(test) 83 | return result 84 | 85 | def interpret_args(args): 86 | """Interpret command-line arguments. 87 | 88 | Returns a pair (module_names, tests_by_module), for make_testsuite(). 89 | 90 | """ 91 | tests_by_module = defaultdict(set) 92 | module_names = set() 93 | for arg in args: 94 | module_name, is_compound, _ = arg.partition(".") 95 | module_names.add(module_name) 96 | if is_compound: 97 | tests_by_module[module_name].add(arg) 98 | return module_names, tests_by_module 99 | 100 | def run(argv): 101 | parser = OptionParser(usage="%prog [options] [module|module.test] ...") 102 | parser.add_option("-f", "--failfast", action="store_true", 103 | help="stop after first test") 104 | parser.add_option("-p", "--nobuffer", action="store_true", 105 | help="show stderr/stdout for successful tests") 106 | (options, args) = parser.parse_args(argv) 107 | module_names, tests_by_module = interpret_args(args) 108 | try: 109 | suite = make_testsuite(module_names, tests_by_module) 110 | except UnknownTest as e: 111 | parser.error(str(e)) 112 | run_testsuite(suite, options.failfast, not options.nobuffer) 113 | 114 | if __name__ == "__main__": 115 | run(sys.argv[1:]) 116 | 117 | -------------------------------------------------------------------------------- /sgfmill_tests/sgf_grammar_tests.py: -------------------------------------------------------------------------------- 1 | """Tests for sgf_grammar.py.""" 2 | 3 | from . import sgfmill_test_support 4 | 5 | from sgfmill import sgf_grammar 6 | 7 | def make_tests(suite): 8 | suite.addTests(sgfmill_test_support.make_simple_tests(globals())) 9 | 10 | def test_is_valid_property_identifier(tc): 11 | ivpi = sgf_grammar.is_valid_property_identifier 12 | tc.assertIs(ivpi("B"), True) 13 | tc.assertIs(ivpi("PB"), True) 14 | tc.assertIs(ivpi("ABCDEFGH"), True) 15 | tc.assertIs(ivpi("MULTIGOGM"), True) 16 | tc.assertIs(ivpi(64*"X"), True) 17 | tc.assertIs(ivpi(65*"X"), False) 18 | tc.assertIs(ivpi(""), False) 19 | tc.assertIs(ivpi("b"), False) 20 | tc.assertIs(ivpi("Player"), False) 21 | tc.assertIs(ivpi("P2"), False) 22 | tc.assertIs(ivpi(" PB"), False) 23 | tc.assertIs(ivpi("PB "), False) 24 | tc.assertIs(ivpi("P B"), False) 25 | tc.assertIs(ivpi("PB\x00"), False) 26 | 27 | def test_is_valid_property_value(tc): 28 | ivpv = sgf_grammar.is_valid_property_value 29 | tc.assertIs(ivpv(b""), True) 30 | tc.assertIs(ivpv(b"hello world"), True) 31 | tc.assertIs(ivpv(b"hello\nworld"), True) 32 | tc.assertIs(ivpv(b"hello \x00 world"), True) 33 | tc.assertIs(ivpv(b"hello \xa3 world"), True) 34 | tc.assertIs(ivpv(b"hello \xc2\xa3 world"), True) 35 | tc.assertIs(ivpv(b"hello \\-) world"), True) 36 | tc.assertIs(ivpv(b"hello (;[) world"), True) 37 | tc.assertIs(ivpv(b"[hello world]"), False) 38 | tc.assertIs(ivpv(b"hello ] world"), False) 39 | tc.assertIs(ivpv(b"hello \\] world"), True) 40 | tc.assertIs(ivpv(b"hello world \\"), False) 41 | tc.assertIs(ivpv(b"hello world \\\\"), True) 42 | tc.assertIs(ivpv(b"x" * 70000), True) 43 | 44 | def test_tokeniser(tc): 45 | tokenise = sgf_grammar.tokenise 46 | 47 | tc.assertEqual(tokenise(b"(;B[ah][]C[a\xa3b])")[0], 48 | [('D', b'('), 49 | ('D', b';'), 50 | ('I', b'B'), 51 | ('V', b'ah'), 52 | ('V', b''), 53 | ('I', b'C'), 54 | ('V', b'a\xa3b'), 55 | ('D', b')')]) 56 | 57 | def check_complete(bb, *args): 58 | tokens, tail_index = tokenise(bb, *args) 59 | tc.assertEqual(tail_index, len(bb)) 60 | return len(tokens) 61 | 62 | def check_incomplete(bb, *args): 63 | tokens, tail_index = tokenise(bb, *args) 64 | return len(tokens), tail_index 65 | 66 | # check surrounding junk 67 | tc.assertEqual(check_complete(b""), 0) 68 | tc.assertEqual(check_complete(b"junk (;B[ah])"), 5) 69 | tc.assertEqual(check_incomplete(b"junk"), (0, 0)) 70 | tc.assertEqual(check_incomplete(b"junk (B[ah])"), (0, 0)) 71 | tc.assertEqual(check_incomplete(b"(;B[ah]) junk"), (5, 8)) 72 | 73 | # check paren-balance count 74 | tc.assertEqual(check_incomplete(b"(; ))(([ag]B C[ah])"), (3, 4)) 75 | tc.assertEqual(check_incomplete(b"(;( )) (;)"), (5, 6)) 76 | tc.assertEqual(check_incomplete(b"(;(()())) (;)"), (9, 9)) 77 | 78 | # check start_position 79 | tc.assertEqual(check_complete(b"(; ))(;B[ah])", 4), 5) 80 | tc.assertEqual(check_complete(b"(; ))junk (;B[ah])", 4), 5) 81 | 82 | tc.assertEqual(check_complete(b"(;XX[abc][def]KO[];B[bc])"), 11) 83 | tc.assertEqual(check_complete(b"( ;XX[abc][def]KO[];B[bc])"), 11) 84 | tc.assertEqual(check_complete(b"(; XX[abc][def]KO[];B[bc])"), 11) 85 | tc.assertEqual(check_complete(b"(;XX [abc][def]KO[];B[bc])"), 11) 86 | tc.assertEqual(check_complete(b"(;XX[abc] [def]KO[];B[bc])"), 11) 87 | tc.assertEqual(check_complete(b"(;XX[abc][def] KO[];B[bc])"), 11) 88 | tc.assertEqual(check_complete(b"(;XX[abc][def]KO [];B[bc])"), 11) 89 | tc.assertEqual(check_complete(b"(;XX[abc][def]KO[] ;B[bc])"), 11) 90 | tc.assertEqual(check_complete(b"(;XX[abc][def]KO[]; B[bc])"), 11) 91 | tc.assertEqual(check_complete(b"(;XX[abc][def]KO[];B [bc])"), 11) 92 | tc.assertEqual(check_complete(b"(;XX[abc][def]KO[];B[bc] )"), 11) 93 | 94 | tc.assertEqual(check_complete(b"( ;\nB\t[ah]\f[ef]\v)"), 6) 95 | tc.assertEqual(check_complete(b"(;[Ran\xc2\xa3dom :\nstu@ff][ef]"), 4) 96 | tc.assertEqual(check_complete(b"(;[ah)])"), 4) 97 | 98 | # check PropIdent rule 99 | tc.assertEqual(check_complete(b"(;" + (8*b"X")), 3) 100 | tc.assertEqual(check_complete(b"(;" + (9*b"X")), 3) 101 | tc.assertEqual(check_complete(b"(;" + (64*b"X")), 3) 102 | tc.assertEqual(check_complete(b"(;" + (65*b"X")), 4) 103 | 104 | tc.assertEqual(check_incomplete(b"(;B[ag"), (3, 3)) 105 | tc.assertEqual(check_incomplete(b"(;B[ag)"), (3, 3)) 106 | tc.assertEqual(check_incomplete(b"(;+B[ag])"), (2, 2)) 107 | tc.assertEqual(check_incomplete(b"(;B+[ag])"), (3, 3)) 108 | tc.assertEqual(check_incomplete(b"(;B[ag]+)"), (4, 7)) 109 | 110 | tc.assertEqual(check_complete(br"(;[ab \] cd][ef]"), 4) 111 | tc.assertEqual(check_complete(br"(;[ab \] cd\\][ef]"), 4) 112 | tc.assertEqual(check_complete(br"(;[ab \] cd\\\\][ef]"), 4) 113 | tc.assertEqual(check_complete(br"(;[ab \] \\\] cd][ef]"), 4) 114 | tc.assertEqual(check_incomplete(br"(;B[ag\])"), (3, 3)) 115 | tc.assertEqual(check_incomplete(br"(;B[ag\\\])"), (3, 3)) 116 | 117 | def test_tokeniser_lower_case_propidents(tc): 118 | tokenise = sgf_grammar.tokenise 119 | 120 | tc.assertEqual(tokenise(b"(;AddBlack[ag])")[0], 121 | [('D', b'('), 122 | ('D', b';'), 123 | ('I', b'AB'), 124 | ('V', b'ag'), 125 | ('D', b')')]) 126 | 127 | def test_parser_structure(tc): 128 | parse_sgf_game = sgf_grammar.parse_sgf_game 129 | 130 | def shape(bb): 131 | coarse_game = parse_sgf_game(bb) 132 | return len(coarse_game.sequence), len(coarse_game.children) 133 | 134 | tc.assertEqual(shape(b"(;C[abc]KO[];B[bc])"), (2, 0)) 135 | tc.assertEqual(shape(b"initial junk (;C[abc]KO[];B[bc])"), (2, 0)) 136 | tc.assertEqual(shape(b"(;C[abc]KO[];B[bc]) final junk"), (2, 0)) 137 | tc.assertEqual(shape(b"(;C[abc]KO[];B[bc]) (;B[ag])"), (2, 0)) 138 | 139 | tc.assertRaisesRegex(ValueError, "no SGF data found", 140 | parse_sgf_game, b"") 141 | tc.assertRaisesRegex(ValueError, "no SGF data found", 142 | parse_sgf_game, b"junk") 143 | tc.assertRaisesRegex(ValueError, "no SGF data found", 144 | parse_sgf_game, b"()") 145 | tc.assertRaisesRegex(ValueError, "no SGF data found", 146 | parse_sgf_game, b"(B[ag])") 147 | tc.assertRaisesRegex(ValueError, "no SGF data found", 148 | parse_sgf_game, b"B[ag]") 149 | tc.assertRaisesRegex(ValueError, "no SGF data found", 150 | parse_sgf_game, b"[ag]") 151 | 152 | tc.assertEqual(shape(b"(;C[abc]AB[ab][bc];B[bc])"), (2, 0)) 153 | tc.assertEqual(shape(b"(;C[abc] AB[ab]\n[bc]\t;B[bc])"), (2, 0)) 154 | tc.assertEqual(shape(b"(;C[abc]KO[];;B[bc])"), (3, 0)) 155 | tc.assertEqual(shape(b"(;)"), (1, 0)) 156 | 157 | tc.assertRaisesRegex(ValueError, "property with no values", 158 | parse_sgf_game, b"(;B)") 159 | tc.assertRaisesRegex(ValueError, "unexpected value", 160 | parse_sgf_game, b"(;[ag])") 161 | tc.assertRaisesRegex(ValueError, "unexpected value", 162 | parse_sgf_game, b"(;[ag][ah])") 163 | tc.assertRaisesRegex(ValueError, "unexpected value", 164 | parse_sgf_game, b"(;[B][ag])") 165 | tc.assertRaisesRegex(ValueError, "unexpected end of SGF data", 166 | parse_sgf_game, b"(;B[ag]") 167 | tc.assertRaisesRegex(ValueError, "unexpected end of SGF data", 168 | parse_sgf_game, b"(;B[ag][)]") 169 | tc.assertRaisesRegex(ValueError, "property with no values", 170 | parse_sgf_game, b"(;B;W[ah])") 171 | tc.assertRaisesRegex(ValueError, "unexpected value", 172 | parse_sgf_game, b"(;B[ag](;[ah]))") 173 | tc.assertRaisesRegex(ValueError, "property with no values", 174 | parse_sgf_game, b"(;B W[ag])") 175 | 176 | def test_parser_tree_structure(tc): 177 | parse_sgf_game = sgf_grammar.parse_sgf_game 178 | 179 | def shape(bb): 180 | coarse_game = parse_sgf_game(bb) 181 | return len(coarse_game.sequence), len(coarse_game.children) 182 | 183 | tc.assertEqual(shape(b"(;C[abc]AB[ab](;B[bc]))"), (1, 1)) 184 | tc.assertEqual(shape(b"(;C[abc]AB[ab](;B[bc])))"), (1, 1)) 185 | tc.assertEqual(shape(b"(;C[abc]AB[ab](;B[bc])(;B[bd]))"), (1, 2)) 186 | 187 | def shapetree(bb): 188 | def _shapetree(coarse_game): 189 | return ( 190 | len(coarse_game.sequence), 191 | [_shapetree(pg) for pg in coarse_game.children]) 192 | return _shapetree(parse_sgf_game(bb)) 193 | 194 | tc.assertEqual(shapetree(b"(;C[abc]AB[ab](;B[bc])))"), 195 | (1, [(1, [])]) 196 | ) 197 | tc.assertEqual(shapetree(b"(;C[abc]AB[ab](;B[bc]))))"), 198 | (1, [(1, [])]) 199 | ) 200 | tc.assertEqual(shapetree(b"(;C[abc]AB[ab](;B[bc])(;B[bd])))"), 201 | (1, [(1, []), (1, [])]) 202 | ) 203 | tc.assertEqual(shapetree(b""" 204 | (;C[abc]AB[ab];C[];C[] 205 | (;B[bc]) 206 | (;B[bd];W[ca] (;B[da])(;B[db];W[ea]) ) 207 | )"""), 208 | (3, [ 209 | (1, []), 210 | (2, [(1, []), (2, [])]) 211 | ]) 212 | ) 213 | 214 | tc.assertRaisesRegex(ValueError, "unexpected end of SGF data", 215 | parse_sgf_game, b"(;B[ag];W[ah](;B[ai])") 216 | tc.assertRaisesRegex(ValueError, "empty sequence", 217 | parse_sgf_game, b"(;B[ag];())") 218 | tc.assertRaisesRegex(ValueError, "empty sequence", 219 | parse_sgf_game, b"(;B[ag]())") 220 | tc.assertRaisesRegex(ValueError, "empty sequence", 221 | parse_sgf_game, b"(;B[ag]((;W[ah])(;W[ai]))") 222 | tc.assertRaisesRegex(ValueError, "unexpected node", 223 | parse_sgf_game, b"(;B[ag];W[ah](;B[ai]);W[bd])") 224 | tc.assertRaisesRegex(ValueError, "property value outside a node", 225 | parse_sgf_game, b"(;B[ag];(W[ah];B[ai]))") 226 | tc.assertRaisesRegex(ValueError, "property value outside a node", 227 | parse_sgf_game, b"(;B[ag](;W[ah];)B[ai])") 228 | tc.assertRaisesRegex(ValueError, "property value outside a node", 229 | parse_sgf_game, b"(;B[ag](;W[ah])(B[ai]))") 230 | 231 | def test_parser_properties(tc): 232 | parse_sgf_game = sgf_grammar.parse_sgf_game 233 | 234 | def props(bb): 235 | coarse_game = parse_sgf_game(bb) 236 | return coarse_game.sequence 237 | 238 | tc.assertEqual(props(b"(;C[abc]KO[]AB[ai][bh][ee];B[ bc])"), 239 | [{'C': [b'abc'], 'KO': [b''], 'AB': [b'ai', b'bh', b'ee']}, 240 | {'B': [b' bc']}]) 241 | 242 | tc.assertEqual(props(br"(;C[ab \] \) cd\\])"), 243 | [{'C': [br"ab \] \) cd\\"]}]) 244 | 245 | tc.assertEqual(props(b"(;XX[1]YY[2]XX[3]YY[4])"), 246 | [{'XX': [b'1', b'3'], 'YY' : [b'2', b'4']}]) 247 | 248 | def test_parse_sgf_collection(tc): 249 | parse_sgf_collection = sgf_grammar.parse_sgf_collection 250 | 251 | tc.assertRaisesRegex(ValueError, "no SGF data found", 252 | parse_sgf_collection, b"") 253 | tc.assertRaisesRegex(ValueError, "no SGF data found", 254 | parse_sgf_collection, b"()") 255 | 256 | games = parse_sgf_collection(b"(;C[abc]AB[ab];X[];X[](;B[bc]))") 257 | tc.assertEqual(len(games), 1) 258 | tc.assertEqual(len(games[0].sequence), 3) 259 | 260 | games = parse_sgf_collection(b"(;X[1];X[2];X[3](;B[bc])) (;Y[1];Y[2])") 261 | tc.assertEqual(len(games), 2) 262 | tc.assertEqual(len(games[0].sequence), 3) 263 | tc.assertEqual(len(games[1].sequence), 2) 264 | 265 | games = parse_sgf_collection( 266 | b"dummy (;X[1];X[2];X[3](;B[bc])) junk (;Y[1];Y[2]) Nonsense") 267 | tc.assertEqual(len(games), 2) 268 | tc.assertEqual(len(games[0].sequence), 3) 269 | tc.assertEqual(len(games[1].sequence), 2) 270 | 271 | games = parse_sgf_collection( 272 | b"(( (;X[1];X[2];X[3](;B[bc])) ();) (;Y[1];Y[2]) )(Nonsense") 273 | tc.assertEqual(len(games), 2) 274 | tc.assertEqual(len(games[0].sequence), 3) 275 | tc.assertEqual(len(games[1].sequence), 2) 276 | 277 | with tc.assertRaises(ValueError) as ar: 278 | parse_sgf_collection( 279 | b"(( (;X[1];X[2];X[3](;B[bc])) ();) (;Y[1];Y[2]") 280 | tc.assertEqual(str(ar.exception), 281 | "error parsing game 1: unexpected end of SGF data") 282 | 283 | 284 | def test_parse_compose(tc): 285 | pc = sgf_grammar.parse_compose 286 | tc.assertEqual(pc(b"word"), (b"word", None)) 287 | tc.assertEqual(pc(b"word:"), (b"word", b"")) 288 | tc.assertEqual(pc(b"word:?"), (b"word", b"?")) 289 | tc.assertEqual(pc(b"word:123"), (b"word", b"123")) 290 | tc.assertEqual(pc(b"word:123:456"), (b"word", b"123:456")) 291 | tc.assertEqual(pc(b":123"), (b"", b"123")) 292 | tc.assertEqual(pc(br"word\:more"), (br"word\:more", None)) 293 | tc.assertEqual(pc(br"word\:more:?"), (br"word\:more", b"?")) 294 | tc.assertEqual(pc(br"word\\:more:?"), (b"word\\\\", b"more:?")) 295 | tc.assertEqual(pc(br"word\\\:more:?"), (br"word\\\:more", b"?")) 296 | tc.assertEqual(pc(b"word\\\nmore:123"), (b"word\\\nmore", b"123")) 297 | 298 | def test_text_value(tc): 299 | text_value = sgf_grammar.text_value 300 | tc.assertEqual(text_value(b"abc "), b"abc ") 301 | tc.assertEqual(text_value(b"ab c"), b"ab c") 302 | tc.assertEqual(text_value(b"ab\tc"), b"ab c") 303 | tc.assertEqual(text_value(b"ab \tc"), b"ab c") 304 | tc.assertEqual(text_value(b"ab\nc"), b"ab\nc") 305 | tc.assertEqual(text_value(b"ab\\\nc"), b"abc") 306 | tc.assertEqual(text_value(b"ab\\\\\nc"), b"ab\\\nc") 307 | tc.assertEqual(text_value(b"ab\xa0c"), b"ab\xa0c") 308 | 309 | tc.assertEqual(text_value(b"ab\rc"), b"ab\nc") 310 | tc.assertEqual(text_value(b"ab\r\nc"), b"ab\nc") 311 | tc.assertEqual(text_value(b"ab\n\rc"), b"ab\nc") 312 | tc.assertEqual(text_value(b"ab\r\n\r\nc"), b"ab\n\nc") 313 | tc.assertEqual(text_value(b"ab\r\n\r\n\rc"), b"ab\n\n\nc") 314 | tc.assertEqual(text_value(b"ab\\\r\nc"), b"abc") 315 | tc.assertEqual(text_value(b"ab\\\n\nc"), b"ab\nc") 316 | 317 | tc.assertEqual(text_value(b"ab\\\tc"), b"ab c") 318 | 319 | # These can't actually appear as SGF PropValues; anything sane will do 320 | tc.assertEqual(text_value(b"abc\\"), b"abc") 321 | tc.assertEqual(text_value(b"abc]"), b"abc]") 322 | 323 | def test_simpletext_value(tc): 324 | simpletext_value = sgf_grammar.simpletext_value 325 | tc.assertEqual(simpletext_value(b"abc "), b"abc ") 326 | tc.assertEqual(simpletext_value(b"ab c"), b"ab c") 327 | tc.assertEqual(simpletext_value(b"ab\tc"), b"ab c") 328 | tc.assertEqual(simpletext_value(b"ab \tc"), b"ab c") 329 | tc.assertEqual(simpletext_value(b"ab\nc"), b"ab c") 330 | tc.assertEqual(simpletext_value(b"ab\\\nc"), b"abc") 331 | tc.assertEqual(simpletext_value(b"ab\\\\\nc"), b"ab\\ c") 332 | tc.assertEqual(simpletext_value(b"ab\xa0c"), b"ab\xa0c") 333 | 334 | tc.assertEqual(simpletext_value(b"ab\rc"), b"ab c") 335 | tc.assertEqual(simpletext_value(b"ab\r\nc"), b"ab c") 336 | tc.assertEqual(simpletext_value(b"ab\n\rc"), b"ab c") 337 | tc.assertEqual(simpletext_value(b"ab\r\n\r\nc"), b"ab c") 338 | tc.assertEqual(simpletext_value(b"ab\r\n\r\n\rc"), b"ab c") 339 | tc.assertEqual(simpletext_value(b"ab\\\r\nc"), b"abc") 340 | tc.assertEqual(simpletext_value(b"ab\\\n\nc"), b"ab c") 341 | 342 | tc.assertEqual(simpletext_value(b"ab\\\tc"), b"ab c") 343 | 344 | # These can't actually appear as SGF PropValues; anything sane will do 345 | tc.assertEqual(simpletext_value(b"abc\\"), b"abc") 346 | tc.assertEqual(simpletext_value(b"abc]"), b"abc]") 347 | 348 | def test_escape_text(tc): 349 | tc.assertEqual(sgf_grammar.escape_text(b"abc"), b"abc") 350 | tc.assertEqual(sgf_grammar.escape_text(br"a\bc"), br"a\\bc") 351 | tc.assertEqual(sgf_grammar.escape_text(br"ab[c]"), br"ab[c\]") 352 | tc.assertEqual(sgf_grammar.escape_text(br"a\]bc"), br"a\\\]bc") 353 | 354 | def test_text_roundtrip(tc): 355 | def roundtrip(bb): 356 | return sgf_grammar.text_value(sgf_grammar.escape_text(bb)) 357 | tc.assertEqual(roundtrip(br"abc"), br"abc") 358 | tc.assertEqual(roundtrip(br"a\bc"), br"a\bc") 359 | tc.assertEqual(roundtrip(b"abc\\"), b"abc\\") 360 | tc.assertEqual(roundtrip(b"ab]c"), b"ab]c") 361 | tc.assertEqual(roundtrip(b"abc]"), b"abc]") 362 | tc.assertEqual(roundtrip(br"abc\]"), br"abc\]") 363 | tc.assertEqual(roundtrip(b"ab\nc"), b"ab\nc") 364 | tc.assertEqual(roundtrip(b"ab\n c"), b"ab\n c") 365 | 366 | tc.assertEqual(roundtrip(b"ab\tc"), b"ab c") 367 | tc.assertEqual(roundtrip(b"ab\r\nc\n"), b"ab\nc\n") 368 | 369 | def test_serialise_game_tree(tc): 370 | serialised = (b"(;AB[aa][ab][ac]C[comment \xa3];W[ab];C[];C[]" 371 | b"(;B[bc])(;B[bd];W[ca](;B[da])(;B[db];\n" 372 | b"W[ea])))\n") 373 | coarse_game = sgf_grammar.parse_sgf_game(serialised) 374 | tc.assertEqual(sgf_grammar.serialise_game_tree(coarse_game), serialised) 375 | tc.assertEqual(sgf_grammar.serialise_game_tree(coarse_game, wrap=None), 376 | serialised.replace(b"\n", b"")+b"\n") 377 | 378 | def test_parse_bytearray(tc): 379 | # We document that these functions accept a "bytes-like object" 380 | bb = b'(;C[abc]AB[ab](;B[bc])(;B[bd])))' 381 | cg1 = sgf_grammar.parse_sgf_game(bb) 382 | cg2 = sgf_grammar.parse_sgf_game(bytearray(bb)) 383 | tc.assertEqual(sgf_grammar.serialise_game_tree(cg1), 384 | sgf_grammar.serialise_game_tree(cg2)) 385 | 386 | -------------------------------------------------------------------------------- /sgfmill_tests/sgf_moves_tests.py: -------------------------------------------------------------------------------- 1 | from . import sgfmill_test_support 2 | 3 | from sgfmill import ascii_boards 4 | from sgfmill import boards 5 | from sgfmill import sgf 6 | from sgfmill import sgf_moves 7 | 8 | def make_tests(suite): 9 | suite.addTests(sgfmill_test_support.make_simple_tests(globals())) 10 | 11 | 12 | SAMPLE_SGF = """\ 13 | (;AP[testsuite:0]CA[utf-8]DT[2009-06-06]FF[4]GM[1]KM[7.5]PB[Black engine] 14 | PL[B]PW[White engine]RE[W+R]SZ[9]AB[ai][bh][ee]AW[fc][gc];B[dg];W[ef]C[comment 15 | on two lines];B[];W[tt]C[Final comment]) 16 | """ 17 | 18 | DIAGRAM1 = """\ 19 | 9 . . . . . . . . . 20 | 8 . . . . . . . . . 21 | 7 . . . . . o o . . 22 | 6 . . . . . . . . . 23 | 5 . . . . # . . . . 24 | 4 . . . . . . . . . 25 | 3 . . . . . . . . . 26 | 2 . # . . . . . . . 27 | 1 # . . . . . . . . 28 | A B C D E F G H J 29 | """ 30 | 31 | DIAGRAM2 = """\ 32 | 9 . . . . . . . . . 33 | 8 . . . . . . . . . 34 | 7 . . . . . . . . . 35 | 6 . . . . . . . . . 36 | 5 . . . . . . . . . 37 | 4 . . . . # . . . . 38 | 3 . . . . . . . . . 39 | 2 . . # . . . . . . 40 | 1 . . . . . . . . . 41 | A B C D E F G H J 42 | """ 43 | 44 | 45 | def test_get_setup_and_moves(tc): 46 | g1 = sgf.Sgf_game.from_string(SAMPLE_SGF) 47 | board1, plays1 = sgf_moves.get_setup_and_moves(g1) 48 | tc.assertBoardEqual(board1, DIAGRAM1) 49 | tc.assertEqual(plays1, 50 | [('b', (2, 3)), ('w', (3, 4)), ('b', None), ('w', None)]) 51 | 52 | g2 = sgf.Sgf_game(size=9) 53 | root = g2.get_root() 54 | root.set("AB", [(1, 2), (3, 4)]) 55 | node = g2.extend_main_sequence() 56 | node.set("B", (5, 6)) 57 | node = g2.extend_main_sequence() 58 | node.set("W", (5, 7)) 59 | board2, plays2 = sgf_moves.get_setup_and_moves(g2) 60 | tc.assertBoardEqual(board2, DIAGRAM2) 61 | tc.assertEqual(plays2, 62 | [('b', (5, 6)), ('w', (5, 7))]) 63 | 64 | g3 = sgf.Sgf_game.from_string("(;AB[ab][ba]AW[aa])") 65 | tc.assertRaisesRegex(ValueError, "setup position not legal", 66 | sgf_moves.get_setup_and_moves, g3) 67 | 68 | g4 = sgf.Sgf_game.from_string("(;SZ[9];B[ab];AW[bc])") 69 | tc.assertRaisesRegex(ValueError, "setup properties after the root node", 70 | sgf_moves.get_setup_and_moves, g4) 71 | 72 | g5 = sgf.Sgf_game.from_string("(;SZ[26];B[ab];W[bc])") 73 | board5, plays5 = sgf_moves.get_setup_and_moves(g5) 74 | tc.assertEqual(plays5, 75 | [('b', (24, 0)), ('w', (23, 1))]) 76 | 77 | 78 | def test_get_setup_and_moves_move_in_root(tc): 79 | # A move in the root node is allowed (though deprecated) if there are no 80 | # setup stones. 81 | g1 = sgf.Sgf_game(size=9) 82 | root = g1.get_root() 83 | root.set("B", (1, 2)) 84 | node = g1.extend_main_sequence() 85 | node.set("W", (3, 4)) 86 | board1, plays1 = sgf_moves.get_setup_and_moves(g1) 87 | tc.assertTrue(board1.is_empty()) 88 | tc.assertEqual(plays1, 89 | [('b', (1, 2)), ('w', (3, 4))]) 90 | 91 | g2 = sgf.Sgf_game(size=9) 92 | root = g2.get_root() 93 | root.set("B", (1, 2)) 94 | root.set("AW", [(3, 3)]) 95 | node = g2.extend_main_sequence() 96 | node.set("W", (3, 4)) 97 | tc.assertRaisesRegex(ValueError, "mixed setup and moves in root node", 98 | sgf_moves.get_setup_and_moves, g2) 99 | 100 | def test_get_setup_and_moves_board_provided(tc): 101 | b = boards.Board(9) 102 | g1 = sgf.Sgf_game.from_string(SAMPLE_SGF) 103 | board1, plays1 = sgf_moves.get_setup_and_moves(g1, b) 104 | tc.assertIs(board1, b) 105 | tc.assertBoardEqual(board1, DIAGRAM1) 106 | tc.assertEqual(plays1, 107 | [('b', (2, 3)), ('w', (3, 4)), ('b', None), ('w', None)]) 108 | tc.assertRaisesRegex(ValueError, "board not empty", 109 | sgf_moves.get_setup_and_moves, g1, b) 110 | b2 = boards.Board(19) 111 | tc.assertRaisesRegex(ValueError, "wrong board size, must be 9$", 112 | sgf_moves.get_setup_and_moves, g1, b2) 113 | 114 | 115 | def test_set_initial_position(tc): 116 | board = ascii_boards.interpret_diagram(DIAGRAM1, 9) 117 | sgf_game = sgf.Sgf_game(9) 118 | sgf_moves.set_initial_position(sgf_game, board) 119 | root = sgf_game.get_root() 120 | tc.assertEqual(root.get("AB"), {(0, 0), (1, 1), (4, 4)}) 121 | tc.assertEqual(root.get("AW"), {(6, 5), (6, 6)}) 122 | tc.assertRaises(KeyError, root.get, 'AE') 123 | 124 | def test_indicate_first_player(tc): 125 | # Normal game 126 | g1 = sgf.Sgf_game.from_bytes(b"(;FF[4]GM[1]SZ[9];B[aa];W[ab])") 127 | sgf_moves.indicate_first_player(g1) 128 | tc.assertEqual(g1.serialise(), 129 | b"(;FF[4]GM[1]SZ[9];B[aa];W[ab])\n") 130 | # White plays first 131 | g2 = sgf.Sgf_game.from_bytes(b"(;FF[4]GM[1]SZ[9];W[aa];B[ab])") 132 | sgf_moves.indicate_first_player(g2) 133 | tc.assertEqual(g2.serialise(), 134 | b"(;FF[4]GM[1]PL[W]SZ[9];W[aa];B[ab])\n") 135 | # No moves 136 | g3 = sgf.Sgf_game.from_bytes(b"(;FF[4]GM[1]SZ[9];C[no game])") 137 | sgf_moves.indicate_first_player(g3) 138 | tc.assertEqual(g3.serialise(), 139 | b"(;FF[4]GM[1]SZ[9];C[no game])\n") 140 | # Normal handicap game 141 | g4 = sgf.Sgf_game.from_bytes(b"(;FF[4]GM[1]HA[5]SZ[9];W[aa];B[ab])") 142 | sgf_moves.indicate_first_player(g4) 143 | tc.assertEqual(g4.serialise(), 144 | b"(;FF[4]GM[1]HA[5]SZ[9];W[aa];B[ab])\n") 145 | # Handicap game, black plays first 146 | g5 = sgf.Sgf_game.from_bytes(b"(;FF[4]GM[1]HA[5]SZ[9];B[aa];W[ab])") 147 | sgf_moves.indicate_first_player(g5) 148 | tc.assertEqual(g5.serialise(), 149 | b"(;FF[4]GM[1]HA[5]PL[B]SZ[9];B[aa];W[ab])\n") 150 | # White setup stones 151 | g6 = sgf.Sgf_game.from_bytes(b"(;AW[bc]FF[4]GM[1]SZ[9];B[aa];W[ab])") 152 | sgf_moves.indicate_first_player(g6) 153 | tc.assertEqual(g6.serialise(), 154 | b"(;FF[4]AW[bc]GM[1]PL[B]SZ[9];B[aa];W[ab])\n") 155 | # Black setup stones 156 | g7 = sgf.Sgf_game.from_bytes(b"(;AB[bc]FF[4]GM[1]SZ[9];B[aa];W[ab])") 157 | sgf_moves.indicate_first_player(g7) 158 | tc.assertEqual(g7.serialise(), 159 | b"(;FF[4]AB[bc]GM[1]PL[B]SZ[9];B[aa];W[ab])\n") 160 | # Black setup stones, handicap, white plays first 161 | g8 = sgf.Sgf_game.from_bytes( 162 | b"(;FF[4]AB[bc][cd]GM[1]HA[2]SZ[9];W[aa];B[ab])") 163 | sgf_moves.indicate_first_player(g8) 164 | tc.assertEqual(g8.serialise(), 165 | b"(;FF[4]AB[bc][cd]GM[1]HA[2]SZ[9];W[aa];B[ab])\n") 166 | -------------------------------------------------------------------------------- /sgfmill_tests/sgf_properties_tests.py: -------------------------------------------------------------------------------- 1 | """Tests for sgf_properties.py.""" 2 | 3 | from . import sgfmill_test_support 4 | 5 | from sgfmill import sgf_properties 6 | 7 | def make_tests(suite): 8 | suite.addTests(sgfmill_test_support.make_simple_tests(globals())) 9 | 10 | def test_interpret_simpletext(tc): 11 | def interpret(bb, encoding): 12 | context = sgf_properties._Context(19, encoding) 13 | return sgf_properties.interpret_simpletext(bb, context) 14 | tc.assertEqual(interpret(b"a\nb\\\\c", "utf-8"), "a b\\c") 15 | s = "test \N{POUND SIGN}" 16 | tc.assertEqual(interpret(s.encode("utf-8"), "UTF-8"), s) 17 | tc.assertEqual(interpret(s.encode("iso-8859-1"), "ISO-8859-1"), s) 18 | tc.assertRaises(UnicodeDecodeError, interpret, 19 | s.encode("iso-8859-1"), "UTF-8") 20 | tc.assertRaises(UnicodeDecodeError, interpret, s.encode("utf-8"), "ASCII") 21 | 22 | def test_serialise_simpletext(tc): 23 | def serialise(s, encoding): 24 | context = sgf_properties._Context(19, encoding) 25 | return sgf_properties.serialise_simpletext(s, context) 26 | tc.assertEqual(serialise("ab\\c", "utf-8"), b"ab\\\\c") 27 | s = "test \N{POUND SIGN}" 28 | tc.assertEqual(serialise(s, "UTF-8"), s.encode("utf-8")) 29 | tc.assertEqual(serialise(s, "ISO-8859-1"), s.encode("iso-8859-1")) 30 | tc.assertRaises(UnicodeEncodeError, serialise, "\N{EN DASH}", "ISO-8859-1") 31 | tc.assertRaisesRegex(TypeError, "^expected string, given bytes$", 32 | serialise, b'test', "utf-8") 33 | 34 | def test_interpret_text(tc): 35 | def interpret(bb, encoding): 36 | context = sgf_properties._Context(19, encoding) 37 | return sgf_properties.interpret_text(bb, context) 38 | tc.assertEqual(interpret(b"a\nb\\\\c", "utf-8"), "a\nb\\c") 39 | s = "test \N{POUND SIGN}" 40 | tc.assertEqual(interpret(s.encode("utf-8"), "UTF-8"), s) 41 | tc.assertEqual(interpret(s.encode("iso-8859-1"), "ISO-8859-1"), s) 42 | tc.assertRaises(UnicodeDecodeError, interpret, 43 | s.encode("iso-8859-1"), "UTF-8") 44 | tc.assertRaises(UnicodeDecodeError, interpret, s.encode("utf-8"), "ASCII") 45 | 46 | def test_serialise_text(tc): 47 | def serialise(s, encoding): 48 | context = sgf_properties._Context(19, encoding) 49 | return sgf_properties.serialise_text(s, context) 50 | tc.assertEqual(serialise("ab\\c", "utf-8"), b"ab\\\\c") 51 | s = "test \N{POUND SIGN}" 52 | tc.assertEqual(serialise(s, "UTF-8"), s.encode("utf-8")) 53 | tc.assertEqual(serialise(s, "ISO-8859-1"), s.encode("iso-8859-1")) 54 | tc.assertRaises(UnicodeEncodeError, serialise, "\N{EN DASH}", "ISO-8859-1") 55 | tc.assertRaisesRegex(TypeError, "^expected string, given bytes$", 56 | serialise, b'test', "utf-8") 57 | 58 | def test_interpret_none(tc): 59 | interpret_none = sgf_properties.interpret_none 60 | tc.assertIs(interpret_none(b""), True) 61 | tc.assertIs(interpret_none(b"xxx"), True) 62 | 63 | def test_serialise_none(tc): 64 | serialise_none = sgf_properties.serialise_none 65 | tc.assertEqual(serialise_none(None), b"") 66 | tc.assertEqual(serialise_none(1), b"") 67 | tc.assertEqual(serialise_none("x"), b"") 68 | 69 | def test_interpret_number(tc): 70 | interpret_number = sgf_properties.interpret_number 71 | tc.assertEqual(interpret_number(b"1"), 1) 72 | tc.assertIs(type(interpret_number(b"1")), int) 73 | tc.assertEqual(interpret_number(b"0"), 0) 74 | tc.assertEqual(interpret_number(b"-1"), -1) 75 | tc.assertEqual(interpret_number(b"+1"), 1) 76 | tc.assertEqual(interpret_number(b" 3"), 3) 77 | tc.assertEqual(interpret_number(b"4\n"), 4) 78 | tc.assertEqual(interpret_number(b"+ 1"), 1) 79 | tc.assertEqual(interpret_number(b"- 1"), -1) 80 | tc.assertEqual(interpret_number(b"1 2"), 12) 81 | # Python 3.6 or later will accept this 82 | #tc.assertEqual(interpret_number(b"1_2"), 12) 83 | tc.assertRaises(ValueError, interpret_number, b"1.5") 84 | tc.assertRaises(ValueError, interpret_number, b"0xaf") 85 | tc.assertRaises(Exception, interpret_number, 1) 86 | #tc.assertRaises(TypeError, interpret_number, "1") 87 | 88 | def test_serialise_number(tc): 89 | serialise_number = sgf_properties.serialise_number 90 | tc.assertEqual(serialise_number(0), b"0") 91 | tc.assertEqual(serialise_number(1), b"1") 92 | tc.assertEqual(serialise_number(2), b"2") 93 | tc.assertEqual(serialise_number(2.0), b"2") 94 | tc.assertEqual(serialise_number(2.5), b"2") 95 | tc.assertRaises(TypeError, serialise_number, "1") 96 | 97 | def test_interpret_real(tc): 98 | interpret_real = sgf_properties.interpret_real 99 | tc.assertEqual(interpret_real(b"1"), 1.0) 100 | tc.assertIs(type(interpret_real(b"1")), float) 101 | tc.assertEqual(interpret_real(b"0"), 0.0) 102 | tc.assertEqual(interpret_real(b"1.0"), 1.0) 103 | tc.assertEqual(interpret_real(b"1.5"), 1.5) 104 | tc.assertEqual(interpret_real(b"-1.5"), -1.5) 105 | tc.assertEqual(interpret_real(b"+0.5"), 0.5) 106 | tc.assertRaises(ValueError, interpret_real, b"+") 107 | tc.assertRaises(ValueError, interpret_real, b"0xaf") 108 | tc.assertRaises(ValueError, interpret_real, b"inf") 109 | tc.assertRaises(ValueError, interpret_real, b"-inf") 110 | tc.assertRaises(ValueError, interpret_real, b"NaN") 111 | tc.assertRaises(ValueError, interpret_real, b"1e400") 112 | tc.assertRaises(TypeError, interpret_real, None) 113 | #tc.assertRaises(TypeError, interpret_real, "1.0") 114 | #tc.assertRaises(TypeError, interpret_real, 1.0) 115 | 116 | def test_serialise_real(tc): 117 | serialise_real = sgf_properties.serialise_real 118 | tc.assertEqual(serialise_real(1), b"1") 119 | tc.assertEqual(serialise_real(-1), b"-1") 120 | tc.assertEqual(serialise_real(1.0), b"1") 121 | tc.assertEqual(serialise_real(-1.0), b"-1") 122 | tc.assertEqual(serialise_real(1.5), b"1.5") 123 | tc.assertEqual(serialise_real(-1.5), b"-1.5") 124 | tc.assertEqual(serialise_real(0.001), b"0.001") 125 | tc.assertEqual(serialise_real(0.0001), b"0.0001") 126 | tc.assertEqual(serialise_real(0.00001), b"0") 127 | tc.assertEqual(serialise_real(1e15), b"1000000000000000") 128 | tc.assertEqual(serialise_real(1e16), b"10000000000000000") 129 | tc.assertEqual(serialise_real(1e17), b"100000000000000000") 130 | tc.assertEqual(serialise_real(1e18), b"1000000000000000000") 131 | tc.assertEqual(serialise_real(-1e18), b"-1000000000000000000") 132 | # 1e400 is inf 133 | tc.assertRaises(ValueError, serialise_real, 1e400) 134 | tc.assertRaises(ValueError, serialise_real, float("NaN")) 135 | 136 | def test_interpret_double(tc): 137 | interpret_double = sgf_properties.interpret_double 138 | tc.assertEqual(interpret_double(b"1"), 1) 139 | tc.assertEqual(interpret_double(b"2"), 2) 140 | tc.assertEqual(interpret_double(b"x"), 1) 141 | tc.assertEqual(interpret_double(b""), 1) 142 | 143 | def test_serialise_double(tc): 144 | serialise_double = sgf_properties.serialise_double 145 | tc.assertEqual(serialise_double(1), b"1") 146 | tc.assertEqual(serialise_double(2), b"2") 147 | tc.assertEqual(serialise_double(0), b"1") 148 | tc.assertEqual(serialise_double(3), b"1") 149 | 150 | def test_interpret_colour(tc): 151 | interpret_colour = sgf_properties.interpret_colour 152 | tc.assertEqual(interpret_colour(b"b"), "b") 153 | tc.assertEqual(interpret_colour(b"w"), "w") 154 | tc.assertEqual(interpret_colour(b"B"), "b") 155 | tc.assertEqual(interpret_colour(b"W"), "w") 156 | tc.assertRaises(ValueError, interpret_colour, b"") 157 | tc.assertRaises(ValueError, interpret_colour, b"x") 158 | 159 | def test_serialise_colour(tc): 160 | serialise_colour = sgf_properties.serialise_colour 161 | tc.assertEqual(serialise_colour('b'), b"B") 162 | tc.assertEqual(serialise_colour('w'), b"W") 163 | tc.assertRaises(ValueError, serialise_colour, "") 164 | tc.assertRaises(ValueError, serialise_colour, "x") 165 | tc.assertRaises(ValueError, serialise_colour, "B") 166 | tc.assertRaises(ValueError, serialise_colour, "W") 167 | 168 | def test_interpret_move(tc): 169 | def interpret_move(s, size): 170 | context = sgf_properties._Context(size, "UTF-8") 171 | return sgf_properties.interpret_move(s, context) 172 | tc.assertEqual(interpret_move(b"aa", 19), (18, 0)) 173 | tc.assertEqual(interpret_move(b"ai", 19), (10, 0)) 174 | tc.assertEqual(interpret_move(b"ba", 9), (8, 1)) 175 | tc.assertEqual(interpret_move(b"tt", 21), (1, 19)) 176 | tc.assertIs(interpret_move(b"tt", 19), None) 177 | tc.assertIs(interpret_move(b"", 19), None) 178 | tc.assertIs(interpret_move(b"", 21), None) 179 | tc.assertRaises(ValueError, interpret_move, b"Aa", 19) 180 | tc.assertRaises(ValueError, interpret_move, b"aA", 19) 181 | tc.assertRaises(ValueError, interpret_move, b"aaa", 19) 182 | tc.assertRaises(ValueError, interpret_move, b"a", 19) 183 | tc.assertRaises(ValueError, interpret_move, b"au", 19) 184 | tc.assertRaises(ValueError, interpret_move, b"ua", 19) 185 | tc.assertRaises(ValueError, interpret_move, b"a`", 19) 186 | tc.assertRaises(ValueError, interpret_move, b"`a", 19) 187 | tc.assertRaises(ValueError, interpret_move, b"11", 19) 188 | tc.assertRaises(ValueError, interpret_move, b" aa", 19) 189 | tc.assertRaises(ValueError, interpret_move, b"aa\x00", 19) 190 | tc.assertRaises(TypeError, interpret_move, None, 19) 191 | tc.assertRaises(TypeError, interpret_move, "aa", 19) 192 | tc.assertRaises(TypeError, interpret_move, (b'a', b'a'), 19) 193 | # tc.assertRaises(TypeError, interpret_move, (97, 97), 19) 194 | 195 | def test_serialise_move(tc): 196 | def serialise_move(s, size): 197 | context = sgf_properties._Context(size, "UTF-8") 198 | return sgf_properties.serialise_move(s, context) 199 | tc.assertEqual(serialise_move((18, 0), 19), b"aa") 200 | tc.assertEqual(serialise_move((10, 0), 19), b"ai") 201 | tc.assertEqual(serialise_move((8, 1), 19), b"bk") 202 | tc.assertEqual(serialise_move((8, 1), 9), b"ba") 203 | tc.assertEqual(serialise_move((1, 19), 21), b"tt") 204 | tc.assertEqual(serialise_move(None, 19), b"tt") 205 | tc.assertEqual(serialise_move(None, 20), b"") 206 | tc.assertRaises(ValueError, serialise_move, (3, 3), 0) 207 | tc.assertRaises(ValueError, serialise_move, (3, 3), 27) 208 | tc.assertRaises(ValueError, serialise_move, (9, 0), 9) 209 | tc.assertRaises(ValueError, serialise_move, (-1, 0), 9) 210 | tc.assertRaises(ValueError, serialise_move, (0, 9), 9) 211 | tc.assertRaises(ValueError, serialise_move, (0, -1), 9) 212 | tc.assertRaises(TypeError, serialise_move, (1, 1.5), 9) 213 | 214 | def test_interpret_point(tc): 215 | def interpret_point(s, size): 216 | context = sgf_properties._Context(size, "UTF-8") 217 | return sgf_properties.interpret_point(s, context) 218 | tc.assertEqual(interpret_point(b"aa", 19), (18, 0)) 219 | tc.assertEqual(interpret_point(b"ai", 19), (10, 0)) 220 | tc.assertEqual(interpret_point(b"ba", 9), (8, 1)) 221 | tc.assertEqual(interpret_point(b"tt", 21), (1, 19)) 222 | tc.assertRaises(ValueError, interpret_point, b"tt", 19) 223 | tc.assertRaises(ValueError, interpret_point, b"", 19) 224 | tc.assertRaises(ValueError, interpret_point, b"", 21) 225 | tc.assertRaises(ValueError, interpret_point, b"Aa", 19) 226 | tc.assertRaises(ValueError, interpret_point, b"aA", 19) 227 | tc.assertRaises(ValueError, interpret_point, b"aaa", 19) 228 | tc.assertRaises(ValueError, interpret_point, b"a", 19) 229 | tc.assertRaises(ValueError, interpret_point, b"au", 19) 230 | tc.assertRaises(ValueError, interpret_point, b"ua", 19) 231 | tc.assertRaises(ValueError, interpret_point, b"a`", 19) 232 | tc.assertRaises(ValueError, interpret_point, b"`a", 19) 233 | tc.assertRaises(ValueError, interpret_point, b"11", 19) 234 | tc.assertRaises(ValueError, interpret_point, b" aa", 19) 235 | tc.assertRaises(ValueError, interpret_point, b"aa\x00", 19) 236 | tc.assertRaises(TypeError, interpret_point, None, 19) 237 | tc.assertRaises(TypeError, interpret_point, (b'a', b'a'), 19) 238 | # tc.assertRaises(TypeError, interpret_point, (97, 97), 19) 239 | 240 | def test_serialise_point(tc): 241 | def serialise_point(s, size): 242 | context = sgf_properties._Context(size, "UTF-8") 243 | return sgf_properties.serialise_point(s, context) 244 | tc.assertEqual(serialise_point((18, 0), 19), b"aa") 245 | tc.assertEqual(serialise_point((10, 0), 19), b"ai") 246 | tc.assertEqual(serialise_point((8, 1), 19), b"bk") 247 | tc.assertEqual(serialise_point((8, 1), 9), b"ba") 248 | tc.assertEqual(serialise_point((1, 19), 21), b"tt") 249 | tc.assertRaises(ValueError, serialise_point, None, 19) 250 | tc.assertRaises(ValueError, serialise_point, None, 20) 251 | tc.assertRaises(ValueError, serialise_point, (3, 3), 0) 252 | tc.assertRaises(ValueError, serialise_point, (3, 3), 27) 253 | tc.assertRaises(ValueError, serialise_point, (9, 0), 9) 254 | tc.assertRaises(ValueError, serialise_point, (-1, 0), 9) 255 | tc.assertRaises(ValueError, serialise_point, (0, 9), 9) 256 | tc.assertRaises(ValueError, serialise_point, (0, -1), 9) 257 | tc.assertRaises(TypeError, serialise_point, (1, 1.5), 9) 258 | 259 | 260 | def test_interpret_point_list(tc): 261 | def ipl(l, size): 262 | context = sgf_properties._Context(size, "UTF-8") 263 | return sgf_properties.interpret_point_list(l, context) 264 | tc.assertEqual(ipl([], 19), 265 | set()) 266 | tc.assertEqual(ipl([b"aa"], 19), 267 | {(18, 0)}) 268 | tc.assertEqual(ipl([b"aa", b"ai"], 19), 269 | {(18, 0), (10, 0)}) 270 | tc.assertEqual(ipl([b"ab:bc"], 19), 271 | {(16, 0), (16, 1), (17, 0), (17, 1)}) 272 | tc.assertEqual(ipl([b"ab:bc", b"aa"], 19), 273 | {(18, 0), (16, 0), (16, 1), (17, 0), (17, 1)}) 274 | # overlap is forbidden by the spec, but we accept it 275 | tc.assertEqual(ipl([b"aa", b"aa"], 19), 276 | {(18, 0)}) 277 | tc.assertEqual(ipl([b"ab:bc", b"bb:bc"], 19), 278 | {(16, 0), (16, 1), (17, 0), (17, 1)}) 279 | # 1x1 rectangles are forbidden by the spec, but we accept them 280 | tc.assertEqual(ipl([b"aa", b"bb:bb"], 19), 281 | {(18, 0), (17, 1)}) 282 | # 'backwards' rectangles are forbidden by the spec, and we reject them 283 | tc.assertRaises(ValueError, ipl, [b"ab:aa"], 19) 284 | tc.assertRaises(ValueError, ipl, [b"ba:aa"], 19) 285 | tc.assertRaises(ValueError, ipl, [b"bb:aa"], 19) 286 | 287 | tc.assertRaises(ValueError, ipl, [b"aa", b"tt"], 19) 288 | tc.assertRaises(ValueError, ipl, [b"aa", b""], 19) 289 | tc.assertRaises(ValueError, ipl, [b"aa:", b"aa"], 19) 290 | tc.assertRaises(ValueError, ipl, [b"aa:tt", b"aa"], 19) 291 | tc.assertRaises(ValueError, ipl, [b"tt:aa", b"aa"], 19) 292 | 293 | def test_compressed_point_list_spec_example(tc): 294 | # Checks the examples at http://www.red-bean.com/sgf/DD_VW.html 295 | def sgf_point(move, size): 296 | row, col = move 297 | row = size - row - 1 298 | col_s = b"abcdefghijklmnopqrstuvwxy"[col] 299 | row_s = b"abcdefghijklmnopqrstuvwxy"[row] 300 | return bytes((col_s, row_s)) 301 | 302 | def ipl(l, size): 303 | context = sgf_properties._Context(size, "UTF-8") 304 | return sgf_properties.interpret_point_list(l, context) 305 | tc.assertEqual( 306 | set(sgf_point(move, 9) for move in ipl([b"ac:ic"], 9)), 307 | {b"ac", b"bc", b"cc", b"dc", b"ec", b"fc", b"gc", b"hc", b"ic"}) 308 | tc.assertEqual( 309 | set(sgf_point(move, 9) for move in ipl([b"ae:ie"], 9)), 310 | {b"ae", b"be", b"ce", b"de", b"ee", b"fe", b"ge", b"he", b"ie"}) 311 | tc.assertEqual( 312 | set(sgf_point(move, 9) for move in ipl([b"aa:bi", b"ca:ce"], 9)), 313 | {b"aa", b"ab", b"ac", b"ad", b"ae", b"af", b"ag", b"ah", b"ai", 314 | b"bi", b"bh", b"bg", b"bf", b"be", b"bd", b"bc", b"bb", b"ba", 315 | b"ca", b"cb", b"cc", b"cd", b"ce"}) 316 | 317 | def test_serialise_point_list(tc): 318 | def ipl(l, size): 319 | context = sgf_properties._Context(size, "UTF-8") 320 | return sgf_properties.interpret_point_list(l, context) 321 | def spl(l, size): 322 | context = sgf_properties._Context(size, "UTF-8") 323 | return sgf_properties.serialise_point_list(l, context) 324 | 325 | tc.assertEqual(spl([(18, 0), (17, 1)], 19), [b'aa', b'bb']) 326 | tc.assertEqual(spl([(17, 1), (18, 0)], 19), [b'aa', b'bb']) 327 | tc.assertEqual(spl([], 9), []) 328 | tc.assertEqual(ipl(spl([(1,2), (3,4), (4,5)], 19), 19), 329 | {(1,2), (3,4), (4,5)}) 330 | tc.assertRaises(ValueError, spl, [(18, 0), None], 19) 331 | 332 | 333 | def test_AP(tc): 334 | def serialise(arg): 335 | context = sgf_properties._Context(19, "UTF-8") 336 | return sgf_properties.serialise_AP(arg, context) 337 | def interpret(arg): 338 | context = sgf_properties._Context(19, "UTF-8") 339 | return sgf_properties.interpret_AP(arg, context) 340 | 341 | tc.assertEqual(serialise(("foo:bar", "2\n3")), b"foo\\:bar:2\n3") 342 | tc.assertEqual(interpret(b"foo\\:bar:2 3"), ("foo:bar", "2 3")) 343 | tc.assertEqual(interpret(b"foo bar"), ("foo bar", "")) 344 | 345 | def test_ARLN(tc): 346 | def serialise(arg, size): 347 | context = sgf_properties._Context(size, "UTF-8") 348 | return sgf_properties.serialise_ARLN_list(arg, context) 349 | def interpret(arg, size): 350 | context = sgf_properties._Context(size, "UTF-8") 351 | return sgf_properties.interpret_ARLN_list(arg, context) 352 | 353 | tc.assertEqual(serialise([], 19), []) 354 | tc.assertEqual(interpret([], 19), []) 355 | tc.assertEqual(serialise([((7, 0), (5, 2)), ((4, 3), (2, 5))], 9), 356 | [b'ab:cd', b'de:fg']) 357 | tc.assertEqual(interpret([b'ab:cd', b'de:fg'], 9), 358 | [((7, 0), (5, 2)), ((4, 3), (2, 5))]) 359 | tc.assertRaises(ValueError, serialise, [((7, 0), None)], 9) 360 | tc.assertRaises(ValueError, interpret, [b'ab:tt', b'de:fg'], 9) 361 | 362 | def test_FG(tc): 363 | def serialise(arg): 364 | context = sgf_properties._Context(19, "UTF-8") 365 | return sgf_properties.serialise_FG(arg, context) 366 | def interpret(arg): 367 | context = sgf_properties._Context(19, "UTF-8") 368 | return sgf_properties.interpret_FG(arg, context) 369 | tc.assertEqual(serialise(None), b"") 370 | tc.assertEqual(interpret(b""), None) 371 | tc.assertEqual(serialise((515, "th]is")), b"515:th\\]is") 372 | tc.assertEqual(interpret(b"515:th\\]is"), (515, "th]is")) 373 | 374 | def test_LB(tc): 375 | def serialise(arg, size): 376 | context = sgf_properties._Context(size, "UTF-8") 377 | return sgf_properties.serialise_LB_list(arg, context) 378 | def interpret(arg, size): 379 | context = sgf_properties._Context(size, "UTF-8") 380 | return sgf_properties.interpret_LB_list(arg, context) 381 | tc.assertEqual(serialise([], 19), []) 382 | tc.assertEqual(interpret([], 19), []) 383 | tc.assertEqual( 384 | serialise([((6, 0), "lbl"), ((6, 1), "lb]l2")], 9), 385 | [b"ac:lbl", b"bc:lb\\]l2"]) 386 | tc.assertEqual( 387 | interpret([b"ac:lbl", b"bc:lb\\]l2"], 9), 388 | [((6, 0), "lbl"), ((6, 1), "lb]l2")]) 389 | tc.assertRaises(ValueError, serialise, [(None, "lbl")], 9) 390 | tc.assertRaises(ValueError, interpret, [b':lbl', b'de:lbl2'], 9) 391 | 392 | 393 | def test_presenter_interpret(tc): 394 | p9 = sgf_properties.Presenter(9, "UTF-8") 395 | p19 = sgf_properties.Presenter(19, "UTF-8") 396 | tc.assertEqual(p9.interpret('KO', [b""]), True) 397 | tc.assertEqual(p9.interpret('SZ', [b"9"]), 9) 398 | tc.assertEqual(p9.interpret('B', [b"ca"]), (8, 2)) 399 | tc.assertEqual(p19.interpret('B', [b"ca"]), (18, 2)) 400 | tc.assertRaisesRegex(ValueError, "multiple values", 401 | p9.interpret, 'SZ', [b"9", b"blah"]) 402 | tc.assertEqual(p9.interpret('CR', [b"ab", b"cd"]), {(5, 2), (7, 0)}) 403 | tc.assertRaises(ValueError, p9.interpret, 'SZ', []) 404 | tc.assertRaises(ValueError, p9.interpret, 'CR', []) 405 | tc.assertEqual(p9.interpret('DD', [b""]), set()) 406 | # all lists are treated like elists 407 | tc.assertEqual(p9.interpret('CR', [b""]), set()) 408 | 409 | def test_presenter_serialise(tc): 410 | p9 = sgf_properties.Presenter(9, "UTF-8") 411 | p19 = sgf_properties.Presenter(19, "UTF-8") 412 | 413 | tc.assertEqual(p9.serialise('KO', True), [b""]) 414 | tc.assertEqual(p9.serialise('SZ', 9), [b"9"]) 415 | tc.assertEqual(p9.serialise('KM', 3.5), [b"3.5"]) 416 | tc.assertEqual(p9.serialise('C', "foo\\:b]ar\n"), [b"foo\\\\:b\\]ar\n"]) 417 | tc.assertEqual(p9.serialise('B', (8, 2)), [b"ca"]) 418 | tc.assertEqual(p19.serialise('B', (18, 2)), [b"ca"]) 419 | tc.assertEqual(p9.serialise('B', None), [b"tt"]) 420 | tc.assertEqual(p19.serialise('AW', {(17, 1), (18, 0)}), [b"aa", b"bb"]) 421 | tc.assertEqual(p9.serialise('DD', [(1, 2), (3, 4)]), [b"ch", b"ef"]) 422 | tc.assertEqual(p9.serialise('DD', []), [b""]) 423 | tc.assertRaisesRegex(ValueError, "empty list", p9.serialise, 'CR', []) 424 | tc.assertEqual(p9.serialise('AP', ("na:me", "2.3")), [b"na\\:me:2.3"]) 425 | tc.assertEqual(p9.serialise('FG', (515, "th]is")), [b"515:th\\]is"]) 426 | tc.assertEqual(p9.serialise('XX', "foo\\bar"), [b"foo\\\\bar"]) 427 | 428 | tc.assertRaises(ValueError, p9.serialise, 'B', (1, 9)) 429 | 430 | def test_presenter_private_properties(tc): 431 | p9 = sgf_properties.Presenter(9, "UTF-8") 432 | tc.assertEqual(p9.serialise('XX', "9"), [b"9"]) 433 | tc.assertEqual(p9.interpret('XX', [b"9"]), "9") 434 | p9.set_private_property_type(p9.get_property_type("SZ")) 435 | tc.assertEqual(p9.serialise('XX', 9), [b"9"]) 436 | tc.assertEqual(p9.interpret('XX', [b"9"]), 9) 437 | p9.set_private_property_type(None) 438 | tc.assertRaisesRegex(ValueError, "unknown property", 439 | p9.serialise, 'XX', "foo\\bar") 440 | tc.assertRaisesRegex(ValueError, "unknown property", 441 | p9.interpret, 'XX', [b"asd"]) 442 | 443 | -------------------------------------------------------------------------------- /sgfmill_tests/sgfmill_test_support.py: -------------------------------------------------------------------------------- 1 | """Sgfmill-specific test support code.""" 2 | 3 | import re 4 | import textwrap 5 | import unittest 6 | 7 | from . import test_framework 8 | 9 | from sgfmill.common import format_vertex 10 | from sgfmill import ascii_boards 11 | from sgfmill import boards 12 | 13 | # This makes TestResult ignore lines from this module in tracebacks 14 | __unittest = True 15 | 16 | 17 | def dedent(v): 18 | """Variant of textwrap.dedent which also accepts bytes.""" 19 | if isinstance(v, bytes): 20 | return textwrap.dedent(v.decode("iso-8859-1")).encode("iso-8859-1") 21 | else: 22 | return textwrap.dedent(v) 23 | 24 | 25 | def compare_boards(b1, b2): 26 | """Check whether two boards have the same position. 27 | 28 | returns a pair (position_is_the_same, message) 29 | 30 | """ 31 | if b1.side != b2.side: 32 | raise ValueError("size is different: %s, %s" % (b1.side, b2.side)) 33 | differences = [] 34 | for row, col in b1.board_points: 35 | if b1.get(row, col) != b2.get(row, col): 36 | differences.append((row, col)) 37 | if not differences: 38 | return True, None 39 | msg = "boards differ at %s" % " ".join(map(format_vertex, differences)) 40 | try: 41 | msg += "\n%s\n%s" % ( 42 | ascii_boards.render_board(b1), ascii_boards.render_board(b2)) 43 | except Exception: 44 | pass 45 | return False, msg 46 | 47 | def compare_boards_or_diagrams(b1, b2): 48 | """Variant of compare_boards which allows diagrams too. 49 | 50 | returns a pair (position_is_the_same, message) 51 | 52 | Compares as boards if the diagram can be interpreted; otherwise renders the 53 | board and compares as strings. 54 | 55 | If given two diagrams, compares them as strings. 56 | 57 | Note that board comparision is more lenient than string comparison, to 58 | whatever extent interpret_diagram() is lenient (in particular it accepts 59 | leading and trailing whitespace). 60 | 61 | """ 62 | def coerce(board, diagram): 63 | try: 64 | return board, ascii_boards.interpret_diagram(diagram, board.side) 65 | except ValueError: 66 | return ascii_boards.render_board(board), diagram 67 | if isinstance(b1, boards.Board) and isinstance(b2, str): 68 | b1, b2 = coerce(b1, b2) 69 | elif isinstance(b2, boards.Board) and isinstance(b1, str): 70 | b2, b1 = coerce(b2, b1) 71 | if isinstance(b1, boards.Board): 72 | return compare_boards(b1, b2) 73 | else: 74 | return compare_diagrams(b1, b2) 75 | 76 | def compare_diagrams(d1, d2): 77 | """Compare two ascii board diagrams. 78 | 79 | returns a pair (strings_are_equal, message) 80 | 81 | (assertMultiLineEqual tends to look nasty for these, so we just show them 82 | both in full) 83 | 84 | """ 85 | if d1 == d2: 86 | return True, None 87 | return False, "diagrams differ:\n%s\n\n%s" % (d1, d2) 88 | 89 | class Sgfmill_testcase_mixin: 90 | """TestCase mixin adding support for sgfmill-specific types. 91 | 92 | Board/diagram features: 93 | assertBoardEqual 94 | assertDiagramEqual 95 | assertEqual and assertNotEqual for Boards 96 | 97 | """ 98 | def init_sgfmill_testcase_mixin(self): 99 | self.addTypeEqualityFunc(boards.Board, self.assertBoardEqual) 100 | 101 | def _format_message(self, msg, standardMsg): 102 | # This is the same as _formatMessage from python 3.4 unittest; copying 103 | # it because it's not part of the public API. 104 | if not self.longMessage: 105 | return msg or standardMsg 106 | if msg is None: 107 | return standardMsg 108 | try: 109 | return '%s : %s' % (standardMsg, msg) 110 | except UnicodeDecodeError: 111 | return '%s : %s' % (unittest.util.safe_repr(standardMsg), 112 | unittest.util.safe_repr(msg)) 113 | 114 | def assertBoardEqual(self, b1, b2, msg=None): 115 | """assertEqual for two boards. 116 | 117 | Accepts diagrams too; see compare_boards_or_diagrams. 118 | 119 | """ 120 | are_equal, desc = compare_boards_or_diagrams(b1, b2) 121 | if not are_equal: 122 | self.fail(self._format_message(msg, desc+"\n")) 123 | 124 | def assertDiagramEqual(self, d1, d2, msg=None): 125 | """Variant of assertMultiLineEqual for board diagrams. 126 | 127 | Checks that two strings are equal, with difference reporting 128 | appropriate for board diagrams. 129 | 130 | """ 131 | are_equal, desc = compare_diagrams(d1, d2) 132 | if not are_equal: 133 | self.fail(self._format_message(msg, desc+"\n")) 134 | 135 | def assertNotEqual(self, first, second, msg=None): 136 | if isinstance(first, boards.Board) and isinstance(second, boards.Board): 137 | are_equal, _ = compare_boards(first, second) 138 | if not are_equal: 139 | return 140 | msg = self._format_message(msg, 'boards have the same position') 141 | raise self.failureException(msg) 142 | super().assertNotEqual(first, second, msg) 143 | 144 | 145 | class Sgfmill_SimpleTestCase(Sgfmill_testcase_mixin, 146 | test_framework.SimpleTestCase): 147 | """SimpleTestCase with the Sgfmill mixin.""" 148 | def __init__(self, *args, **kwargs): 149 | super().__init__(*args, **kwargs) 150 | self.init_sgfmill_testcase_mixin() 151 | 152 | class Sgfmill_ParameterisedTestCase(Sgfmill_testcase_mixin, 153 | test_framework.ParameterisedTestCase): 154 | """ParameterisedTestCase with the Sgfmill mixin.""" 155 | def __init__(self, *args, **kwargs): 156 | super().__init__(*args, **kwargs) 157 | self.init_sgfmill_testcase_mixin() 158 | 159 | 160 | def make_simple_tests(source, prefix="test_"): 161 | """Make test cases from a module's test_xxx functions. 162 | 163 | See test_framework for details. 164 | 165 | The test functions can use the Sgfmill_testcase_mixin enhancements. 166 | 167 | """ 168 | return test_framework.make_simple_tests( 169 | source, prefix, testcase_class=Sgfmill_SimpleTestCase) 170 | -------------------------------------------------------------------------------- /sgfmill_tests/test_framework.py: -------------------------------------------------------------------------------- 1 | """Generic (non-sgfmill-specific) test framework code.""" 2 | 3 | import sys 4 | import unittest 5 | 6 | # This makes TestResult ignore lines from this module in tracebacks 7 | __unittest = True 8 | 9 | class SupporterError(Exception): 10 | """Exception raised by support objects when something goes wrong. 11 | 12 | This is raised to indicate things like sequencing errors detected by mock 13 | objects. 14 | 15 | """ 16 | 17 | class SimpleTestCase(unittest.TestCase): 18 | """TestCase which runs a single function. 19 | 20 | Instantiate with the test function, which takes a TestCase parameter, eg: 21 | def test_xxx(tc): 22 | tc.assertEqual(2+2, 4) 23 | 24 | """ 25 | 26 | def __init__(self, fn): 27 | super().__init__() 28 | self.fn = fn 29 | try: 30 | self.name = fn.__module__.split(".", 1)[-1] + "." + fn.__name__ 31 | except AttributeError: 32 | self.name = str(fn) 33 | 34 | def runTest(self): 35 | self.fn(self) 36 | 37 | def id(self): 38 | return self.name 39 | 40 | def shortDescription(self): 41 | return None 42 | 43 | def __str__(self): 44 | return self.name 45 | 46 | def __repr__(self): 47 | return "" % self.name 48 | 49 | 50 | class ParameterisedTestCase(unittest.TestCase): 51 | """Parameterised testcase. 52 | 53 | Subclasses should define: 54 | test_name -- short string 55 | parameter_names -- list of identifiers 56 | runTest 57 | 58 | """ 59 | def __init__(self, code, *parameters): 60 | super().__init__() 61 | self.code = code 62 | self.name = "%s.%s:%s" % (self.__class__.__module__.split(".", 1)[-1], 63 | self.test_name, code) 64 | for name, value in zip(self.parameter_names, parameters): 65 | setattr(self, name, value) 66 | 67 | def runTest(self): 68 | raise NotImplementedError 69 | 70 | def id(self): 71 | return self.name 72 | 73 | def shortDescription(self): 74 | return None 75 | 76 | def __str__(self): 77 | return self.name 78 | 79 | def __repr__(self): 80 | return "<%s: %s>" % (self.__class__.__name__, self.name) 81 | 82 | 83 | 84 | def _function_sort_key(fn): 85 | try: 86 | return fn.__code__.co_firstlineno 87 | except AttributeError: 88 | return str(fn) 89 | 90 | def make_simple_tests(source, prefix="test_", testcase_class=SimpleTestCase): 91 | """Make test cases from a module's test_xxx functions. 92 | 93 | source -- dict (usually a module's globals()). 94 | prefix -- string (default "test_") 95 | testcase_class -- SimpleTestCase subclass to use 96 | 97 | Returns a list of TestCase objects. 98 | 99 | This makes a TestCase for each function in the values of 'source' whose 100 | name begins with 'prefix'. 101 | 102 | The list is in the order of function definition (using the line number 103 | attribute). 104 | 105 | """ 106 | functions = [value for name, value in source.items() 107 | if name.startswith(prefix) and callable(value)] 108 | functions.sort(key=_function_sort_key) 109 | return [testcase_class(fn) for fn in functions] 110 | 111 | -------------------------------------------------------------------------------- /test_installed_sgfmill.py: -------------------------------------------------------------------------------- 1 | """Run the sgfmill testsuite against an installed sgfmill package.""" 2 | 3 | import imp 4 | import os 5 | import sys 6 | 7 | from pathlib import Path 8 | 9 | project_dir = Path(Path.cwd(), __file__).parent 10 | 11 | # Remove the distribution directory from sys.path 12 | if os.path.abspath(sys.path[0]) == str(project_dir): 13 | del sys.path[0] 14 | 15 | try: 16 | import sgfmill 17 | except ImportError: 18 | sys.exit("test_installed_sgfmill: can't find the sgfmill package") 19 | 20 | PACKAGE_NAME = "sgfmill_tests" 21 | 22 | # Make sgfmill_tests importable without the sibling sgfmill 23 | packagepath = Path(project_dir, PACKAGE_NAME) 24 | mdl = imp.load_package(PACKAGE_NAME, str(packagepath)) 25 | sys.modules[PACKAGE_NAME] = mdl 26 | 27 | found = Path(Path.cwd(), sgfmill.__file__).parent 28 | print("testing sgfmill package in %s" % found, file=sys.stderr) 29 | from sgfmill_tests import run_sgfmill_testsuite 30 | run_sgfmill_testsuite.run(sys.argv[1:]) 31 | 32 | --------------------------------------------------------------------------------