From bf9ce90a5b5102c91c5aa65b98e16803eb529cb4 Mon Sep 17 00:00:00 2001
From: Juhani Numminen <juhaninumminen0@gmail.com>
Date: Tue, 6 Oct 2020 14:35:28 +0300
Subject: [PATCH] Refactor cardset config.txt parser to a new module

---
 pysollib/app.py                           | 129 ++-----------------
 pysollib/cardsetparser.py                 | 150 ++++++++++++++++++++++
 scripts/gen_individual_importing_tests.py |   1 +
 tests/lib/pysol_tests/cardsetparser.py    |  62 +++++++++
 4 files changed, 222 insertions(+), 120 deletions(-)
 create mode 100644 pysollib/cardsetparser.py
 create mode 100644 tests/lib/pysol_tests/cardsetparser.py

diff --git a/pysollib/app.py b/pysollib/app.py
index 380b9b6d..d0b7ef88 100644
--- a/pysollib/app.py
+++ b/pysollib/app.py
@@ -33,6 +33,7 @@ from pysollib.actions import PysolMenubar
 from pysollib.actions import PysolToolbar
 from pysollib.app_stat_result import GameStatResult
 from pysollib.app_statistics import Statistics
+from pysollib.cardsetparser import read_cardset_config
 from pysollib.gamedb import GAME_DB, GI, loadGame
 from pysollib.help import destroy_help_html, help_about
 from pysollib.images import Images, SubsampledImages
@@ -53,7 +54,7 @@ from pysollib.pysoltk import SelectCardsetDialogWithPreview
 from pysollib.pysoltk import SelectDialogTreeData
 from pysollib.pysoltk import destroy_find_card_dialog
 from pysollib.pysoltk import loadImage, wm_withdraw
-from pysollib.resource import CSI, Cardset, CardsetConfig, CardsetManager
+from pysollib.resource import CSI, CardsetManager
 from pysollib.resource import Music, MusicManager
 from pysollib.resource import Sample, SampleManager
 from pysollib.resource import Tile, TileManager
@@ -1070,125 +1071,14 @@ Please select a %(correct_type)s type cardset.
 
     # read & parse a cardset config.txt file - see class Cardset in resource.py
     def _readCardsetConfig(self, dirname, filename):
-        with open(filename, "r") as f:
-            lines = f.readlines()
-        lines = [line.strip() for line in lines]
-        if not lines[0].startswith("PySol"):
-            return None
-        config = CardsetConfig()
-        if not self._parseCardsetConfig(config, lines):
-            # print filename, 'invalid config'
-            return None
-        if config.CARDD > self.top.winfo_screendepth():
-            return None
-        cs = Cardset()
-        cs.dir = dirname
-        cs.update(config.__dict__)
-        return cs
-
-    def _parseCardsetConfig(self, cs, line):
-        def perr(line, field=None, msg=''):
-            if not DEBUG:
-                return
-            if field:
-                print_err('_parseCardsetConfig error: line #%d, field #%d %s'
-                          % (line, field, msg))
-            else:
-                print_err('_parseCardsetConfig error: line #%d: %s'
-                          % (line, msg))
-        if len(line) < 6:
-            perr(1, msg='number of lines')
-            return 0
-        # line[0]: magic identifier, possible version information
-        fields = [f.strip() for f in line[0].split(';')]
-        if len(fields) >= 2:
-            m = re.search(r"^(\d+)$", fields[1])
-            if m:
-                cs.version = int(m.group(1))
-        if cs.version >= 3:
-            if len(fields) < 5:
-                perr(1, msg='number of fields')
-                return 0
-            cs.ext = fields[2]
-            m = re.search(r"^(\d+)$", fields[3])
-            if not m:
-                perr(1, 3, 'not integer')
-                return 0
-            cs.type = int(m.group(1))
-            m = re.search(r"^(\d+)$", fields[4])
-            if not m:
-                perr(1, 4, 'not integer')
-                return 0
-            cs.ncards = int(m.group(1))
-        if cs.version >= 4:
-            if len(fields) < 6:
-                perr(1, msg='number of fields')
-                return 0
-            styles = fields[5].split(",")
-            for s in styles:
-                m = re.search(r"^\s*(\d+)\s*$", s)
-                if not m:
-                    perr(1, 5, 'not integer')
-                    return 0
-                s = int(m.group(1))
-                if s not in cs.styles:
-                    cs.styles.append(s)
-        if cs.version >= 5:
-            if len(fields) < 7:
-                perr(1, msg='number of fields')
-                return 0
-            m = re.search(r"^(\d+)$", fields[6])
-            if not m:
-                perr(1, 6, 'not integer')
-                return 0
-            cs.year = int(m.group(1))
-        if len(cs.ext) < 2 or cs.ext[0] != ".":
-            perr(1, msg='invalid extention')
-            return 0
-        # line[1]: identifier/name
-        if not line[1]:
-            perr(2, msg='empty line')
-            return 0
-        cs.ident = line[1]
-        m = re.search(r"^(.*;)?([^;]+)$", cs.ident)
-        if not m:
-            perr(2, msg='invalid format')
-            return 0
-        cs.name = m.group(2).strip()
-        # line[2]: CARDW, CARDH, CARDD
-        m = re.search(r"^(\d+)\s+(\d+)\s+(\d+)", line[2])
-        if not m:
-            perr(3, msg='invalid format')
-            return 0
-        cs.CARDW, cs.CARDH, cs.CARDD = \
-            int(m.group(1)), int(m.group(2)), int(m.group(3))
-        # line[3]: CARD_UP_YOFFSET, CARD_DOWN_YOFFSET,
-        # SHADOW_XOFFSET, SHADOW_YOFFSET
-        m = re.search(r"^(\d+)\s+(\d+)\s+(\d+)\s+(\d+)", line[3])
-        if not m:
-            perr(4, msg='invalid format')
-            return 0
-        cs.CARD_XOFFSET = int(m.group(1))
-        cs.CARD_YOFFSET = int(m.group(2))
-        cs.SHADOW_XOFFSET = int(m.group(3))
-        cs.SHADOW_YOFFSET = int(m.group(4))
-        # line[4]: default background
-        back = line[4]
-        if not back:
-            perr(5, msg='empty line')
-            return 0
-        # line[5]: all available backgrounds
-        cs.backnames = [f.strip() for f in line[5].split(';')]
-        if back in cs.backnames:
-            cs.backindex = cs.backnames.index(back)
-        else:
-            cs.backnames.insert(0, back)
-            cs.backindex = 0
+        cs = read_cardset_config(dirname, filename)
         # set offsets from options.cfg
         if cs.ident in self.opt.offsets:
             cs.CARD_XOFFSET, cs.CARD_YOFFSET = self.opt.offsets[cs.ident]
-        # if cs.type != 1: print cs.type, cs.name
-        return 1
+
+        if cs.CARDD > self.top.winfo_screendepth():
+            return None
+        return cs
 
     def initCardsets(self):
         manager = self.cardset_manager
@@ -1232,9 +1122,8 @@ Please select a %(correct_type)s type cardset.
                                     # print '+', cs.name
                                     fnames[cs.name] = 1
                             else:
-                                print_err('fail _readCardsetConfig: %s %s'
-                                          % (d, f))
-                                pass
+                                print_err('failed to parse cardset file: %s'
+                                          % f)
                         except Exception:
                             # traceback.print_exc()
                             pass
diff --git a/pysollib/cardsetparser.py b/pysollib/cardsetparser.py
new file mode 100644
index 00000000..65a698f1
--- /dev/null
+++ b/pysollib/cardsetparser.py
@@ -0,0 +1,150 @@
+#!/usr/bin/env python
+# -*- mode: python; coding: utf-8; -*-
+# ---------------------------------------------------------------------------##
+#
+#  Copyright (C) 1998-2003 Markus Franz Xaver Johannes Oberhumer
+#  Copyright (C) 2003 Mt. Hood Playing Card Co.
+#  Copyright (C) 2005-2009 Skomoroh
+#
+#  This program is free software: you can redistribute it and/or modify
+#  it under the terms of the GNU General Public License as published by
+#  the Free Software Foundation, either version 3 of the License, or
+#  (at your option) any later version.
+#
+#  This program is distributed in the hope that it will be useful,
+#  but WITHOUT ANY WARRANTY; without even the implied warranty of
+#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#  GNU General Public License for more details.
+#
+#  You should have received a copy of the GNU General Public License
+#  along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+# ---------------------------------------------------------------------------##
+
+import re
+
+from pysollib.mfxutil import print_err
+from pysollib.resource import Cardset, CardsetConfig
+
+
+def read_cardset_config(dirname, filename):
+    """Parse a cardset config file and produce a Cardset object.
+
+    This function returns None if any errors occurred.
+    """
+    with open(filename, "r") as f:
+        lines = f.readlines()
+    lines = [line.strip() for line in lines]
+    if not lines[0].startswith("PySol"):
+        return None
+    config = parse_cardset_config(lines)
+    if not config:
+        print_err('invalid cardset: %s' % filename)
+        return None
+    cs = Cardset()
+    cs.dir = dirname
+    cs.update(config.__dict__)
+    return cs
+
+
+def parse_cardset_config(line):
+    def perr(line_no, field=None, msg=''):
+        if field:
+            print_err('cannot parse cardset config: line #%d, field #%d: %s'
+                      % (line_no, field, msg))
+        else:
+            print_err('cannot parse cardset config: line #%d: %s'
+                      % (line_no, msg))
+
+    cs = CardsetConfig()
+    if len(line) < 6:
+        perr(1, msg='not enough lines in file')
+        return None
+    # line[0]: magic identifier, possible version information
+    fields = [f.strip() for f in line[0].split(';')]
+    if len(fields) >= 2:
+        m = re.search(r"^(\d+)$", fields[1])
+        if m:
+            cs.version = int(m.group(1))
+    if cs.version >= 3:
+        if len(fields) < 5:
+            perr(1, msg='not enough fields')
+            return None
+        cs.ext = fields[2]
+        m = re.search(r"^(\d+)$", fields[3])
+        if not m:
+            perr(1, 3, 'not integer')
+            return None
+        cs.type = int(m.group(1))
+        m = re.search(r"^(\d+)$", fields[4])
+        if not m:
+            perr(1, 4, 'not integer')
+            return None
+        cs.ncards = int(m.group(1))
+    if cs.version >= 4:
+        if len(fields) < 6:
+            perr(1, msg='not enough fields')
+            return None
+        styles = fields[5].split(",")
+        for s in styles:
+            m = re.search(r"^\s*(\d+)\s*$", s)
+            if not m:
+                perr(1, 5, 'not integer')
+                return None
+            s = int(m.group(1))
+            if s not in cs.styles:
+                cs.styles.append(s)
+    if cs.version >= 5:
+        if len(fields) < 7:
+            perr(1, msg='not enough fields')
+            return None
+        m = re.search(r"^(\d+)$", fields[6])
+        if not m:
+            perr(1, 6, 'not integer')
+            return None
+        cs.year = int(m.group(1))
+    if len(cs.ext) < 2 or cs.ext[0] != ".":
+        perr(1, msg='specifies an invalid file extension')
+        return None
+    # line[1]: identifier/name
+    if not line[1]:
+        perr(2, msg='unexpected empty line')
+        return None
+    cs.ident = line[1]
+    m = re.search(r"^(.*;)?([^;]+)$", cs.ident)
+    if not m:
+        perr(2, msg='invalid format')
+        return None
+    cs.name = m.group(2).strip()
+    # line[2]: CARDW, CARDH, CARDD
+    m = re.search(r"^(\d+)\s+(\d+)\s+(\d+)", line[2])
+    if not m:
+        perr(3, msg='invalid format')
+        return None
+    cs.CARDW, cs.CARDH, cs.CARDD = \
+        int(m.group(1)), int(m.group(2)), int(m.group(3))
+    # line[3]: CARD_UP_YOFFSET, CARD_DOWN_YOFFSET,
+    # SHADOW_XOFFSET, SHADOW_YOFFSET
+    m = re.search(r"^(\d+)\s+(\d+)\s+(\d+)\s+(\d+)", line[3])
+    if not m:
+        perr(4, msg='invalid format')
+        return None
+    cs.CARD_XOFFSET = int(m.group(1))
+    cs.CARD_YOFFSET = int(m.group(2))
+    cs.SHADOW_XOFFSET = int(m.group(3))
+    cs.SHADOW_YOFFSET = int(m.group(4))
+    # line[4]: default background
+    back = line[4]
+    if not back:
+        perr(5, msg='unexpected empty line')
+        return None
+    # line[5]: all available backgrounds
+    cs.backnames = [f.strip() for f in line[5].split(';')]
+    if back in cs.backnames:
+        cs.backindex = cs.backnames.index(back)
+    else:
+        cs.backnames.insert(0, back)
+        cs.backindex = 0
+
+    # if cs.type != 1: print cs.type, cs.name
+    return cs
diff --git a/scripts/gen_individual_importing_tests.py b/scripts/gen_individual_importing_tests.py
index ed425d44..a4d529ba 100644
--- a/scripts/gen_individual_importing_tests.py
+++ b/scripts/gen_individual_importing_tests.py
@@ -44,6 +44,7 @@ print('ok 1 - imported')
 for ver in PY_VERS:
     for mod in [
             'pysol_tests.acard_unit',
+            'pysol_tests.cardsetparser',
             'pysol_tests.game_drag',
             'pysol_tests.hint',
             'pysol_tests.import_file1',
diff --git a/tests/lib/pysol_tests/cardsetparser.py b/tests/lib/pysol_tests/cardsetparser.py
new file mode 100644
index 00000000..1c506f9c
--- /dev/null
+++ b/tests/lib/pysol_tests/cardsetparser.py
@@ -0,0 +1,62 @@
+# Written by Juhani Numminen, under the MIT Expat License.
+
+import unittest
+
+from pysollib.cardsetparser import parse_cardset_config
+from pysollib.resource import CSI, CardsetConfig
+
+
+class MyTests(unittest.TestCase):
+
+    def _assertCcEqual(self, a, b, msg=None):
+        """Assert that CardsetConfig objects a and b have equal attributes."""
+        return self.assertDictEqual(a.__dict__, b.__dict__, msg)
+
+    def test_good_cardset(self):
+        config_txt = """\
+PySolFC solitaire cardset;4;.gif;1;52;7
+123-dondorf;Dondorf
+79 123 8
+16 25 7 7
+back01.gif
+back01.gif
+"""
+
+        reference = CardsetConfig()
+        reference.update(dict(
+            version=4,
+            ext='.gif',
+            type=CSI.TYPE_FRENCH,
+            ncards=52,
+            styles=[7],
+            ident='123-dondorf;Dondorf',
+            name='Dondorf',
+            CARDW=79,
+            CARDH=123,
+            CARDD=8,
+            CARD_XOFFSET=16,
+            CARD_YOFFSET=25,
+            SHADOW_XOFFSET=7,
+            SHADOW_YOFFSET=7,
+            backindex=0,
+            backnames=['back01.gif'],
+            ))
+        self._assertCcEqual(
+            parse_cardset_config(config_txt.split('\n')),
+            reference,
+            'parse_cardset_config should parse well-formed v4 config.txt ' +
+            'correctly')
+
+    def test_reject_too_few_fields(self):
+        config_txt = """\
+PySolFC solitaire cardset;4;.gif;1;52
+123-dondorf;Dondorf
+79 123 8
+16 25 7 7
+back01.gif
+back01.gif
+"""
+        self.assertIsNone(
+            parse_cardset_config(config_txt.split('\n')),
+            'parse_cardset_config should reject v4 config.txt with ' +
+            'a missing field on the first line')