Skip to content

Commit 2661ee0

Browse files
committed
Add settings persistence.
1 parent b0d17ce commit 2661ee0

3 files changed

Lines changed: 209 additions & 13 deletions

File tree

README.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,24 +33,34 @@ Currently they are selected from the answers present in a 3x3 square visually su
3333

3434
## Optional Features
3535

36+
Features can be turned on or off using command line options.
37+
For features listed in this section the setting is saved by the program.
38+
This means the setting applies also on future program executions, unless explicitly changed.
39+
3640
### Showing Feedback
3741

3842
By default the application provides feedback on **incorrect** answers.
3943
The application will briefly pause, highlighting the correct and incorrect answers using green and red background.
40-
Pass the `--no-show-feedback` option to disable providing feedback.
44+
- Pass the `--no-show-feedback` option to disable providing feedback.
45+
- Pass the `--show-feedback` option to enable providing feedback.
4146

4247
### Showing Scores
4348

4449
By default the application displays the total number of correct and incorrect answers.
4550
They are displayed in the lower corners of the main window.
46-
Pass the `--no-show-scores` option to hide scores.
47-
48-
Use the`--score-font` option to select the font to use for displaying scores.
51+
- Pass the `--no-show-scores` option to hide scores.
52+
- Pass the `--show-scores` option to show scores.
53+
- Use the`--score-font` option to select the font to use for displaying scores.
4954

5055
### Limiting the Number of Questions
5156

5257
By default the application will keep asking questions until it is closed.
53-
Pass the `--limit N` option to exit once `N` questions have been answered correctly.
58+
- Pass the `--limit N` option to exit once `N` questions have been answered correctly.
59+
- Pass `--limit 0` to bring back the default behaviour of never-ending questions.
60+
61+
## Special options
62+
63+
Options listed in this section only apply to the current execution of the program.
5464

5565
### Showing Saved State
5666

settings_test.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
#!/usr/bin/python3
2+
3+
# tabliczka: a program for learning multiplication table
4+
# Copyright 2022 Marcin Owsiany <marcin@owsiany.pl>
5+
6+
# This program is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License as published by
8+
# the Free Software Foundation, either version 2 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# This program is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License
17+
# along with this program. If not, see <http://www.gnu.org/licenses/>
18+
19+
import unittest
20+
import tabliczka
21+
22+
23+
class FakeSettingsFS:
24+
25+
def read(self):
26+
pass
27+
28+
def write(self, settings):
29+
pass
30+
31+
32+
class NoSettingsFS(FakeSettingsFS):
33+
pass
34+
35+
36+
class SomeSettingsFS(FakeSettingsFS):
37+
38+
def __init__(self, settings):
39+
self._settings = settings
40+
41+
def read(self):
42+
return self._settings
43+
44+
def write(self, settings):
45+
self._settings.clear()
46+
self._settings.update(settings)
47+
48+
49+
class TestSettings(unittest.TestCase):
50+
51+
def test_no_settings(self):
52+
fs = NoSettingsFS()
53+
args = tabliczka.get_argument_parser().parse_args([])
54+
s = tabliczka.Settings(fs, args)
55+
self.assertEqual(s.limit, None)
56+
self.assertEqual(s.show_feedback, True)
57+
self.assertEqual(s.show_scores, True)
58+
self.assertEqual(s.score_font, 'monospace')
59+
60+
def test_settings_from_fs(self):
61+
fs = SomeSettingsFS(dict(
62+
limit=1,
63+
show_feedback=False,
64+
show_scores=False,
65+
score_font='dingbats'))
66+
args = tabliczka.get_argument_parser().parse_args([])
67+
s = tabliczka.Settings(fs, args)
68+
self.assertEqual(s.limit, 1)
69+
self.assertEqual(s.show_feedback, False)
70+
self.assertEqual(s.show_scores, False)
71+
self.assertEqual(s.score_font, 'dingbats')
72+
73+
def test_settings_from_fs_overridden(self):
74+
fs = SomeSettingsFS(dict(
75+
limit=1,
76+
show_feedback=False,
77+
show_scores=False,
78+
score_font='dingbats'))
79+
args = tabliczka.get_argument_parser().parse_args([
80+
'--limit', '2',
81+
'--show-feedback',
82+
'--show-scores',
83+
'--score-font=asdf'
84+
])
85+
s = tabliczka.Settings(fs, args)
86+
self.assertEqual(s.limit, 2)
87+
self.assertEqual(s.show_feedback, True)
88+
self.assertEqual(s.show_scores, True)
89+
self.assertEqual(s.score_font, 'asdf')
90+
91+
def test_settings_lifecycle(self):
92+
settings_backend = dict()
93+
fs = SomeSettingsFS(settings_backend)
94+
args = tabliczka.get_argument_parser().parse_args([
95+
'--limit', '2',
96+
'--no-show-feedback',
97+
'--no-show-scores',
98+
'--score-font=asdf'
99+
])
100+
s = tabliczka.Settings(fs, args)
101+
self.assertEqual(s.limit, 2)
102+
self.assertEqual(s.show_feedback, False)
103+
self.assertEqual(s.show_scores, False)
104+
self.assertEqual(s.score_font, 'asdf')
105+
self.assertDictEqual(settings_backend, dict(
106+
limit=2,
107+
show_feedback=False,
108+
show_scores=False,
109+
score_font='asdf'))
110+
111+
args2 = tabliczka.get_argument_parser().parse_args([])
112+
s = tabliczka.Settings(fs, args)
113+
self.assertEqual(s.limit, 2)
114+
self.assertEqual(s.show_feedback, False)
115+
self.assertEqual(s.show_scores, False)
116+
self.assertEqual(s.score_font, 'asdf')
117+
self.assertDictEqual(settings_backend, dict(
118+
limit=2,
119+
show_feedback=False,
120+
show_scores=False,
121+
score_font='asdf'))
122+
123+
def test_settings_lifecycle_partial(self):
124+
settings_backend = dict()
125+
fs = SomeSettingsFS(settings_backend)
126+
args = tabliczka.get_argument_parser().parse_args([
127+
'--no-show-feedback'
128+
])
129+
s = tabliczka.Settings(fs, args)
130+
self.assertEqual(s.limit, None)
131+
self.assertEqual(s.show_feedback, False)
132+
self.assertEqual(s.show_scores, True)
133+
self.assertEqual(s.score_font, 'monospace')
134+
self.assertDictEqual(settings_backend, dict(show_feedback=False))
135+
136+
args2 = tabliczka.get_argument_parser().parse_args([])
137+
s = tabliczka.Settings(fs, args)
138+
self.assertEqual(s.limit, None)
139+
self.assertEqual(s.show_feedback, False)
140+
self.assertEqual(s.show_scores, True)
141+
self.assertEqual(s.score_font, 'monospace')
142+
self.assertDictEqual(settings_backend, dict(show_feedback=False))
143+
144+
145+
if __name__ == '__main__':
146+
unittest.main()
147+

tabliczka.py

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
import argparse
2121
import itertools
22+
import json
2223
import logging
2324
import pickle
2425
import os
@@ -46,6 +47,7 @@
4647
_xdg_state_home = os.environ.get('XDG_STATE_HOME') or os.path.join(_home, '.local', 'state')
4748
_state_home = os.path.join(_xdg_state_home, 'tabliczka')
4849
_state_file = os.path.join(_state_home, 'state.pickle')
50+
_settings_filename = os.path.join(_state_home, 'settings.json')
4951

5052

5153
class QuitException(Exception):
@@ -60,7 +62,7 @@ def get_argument_parser():
6062
parser.add_argument('--dump', action='store_true', help='Just show the saved state and quit.')
6163
parser.add_argument('--debug', action='store_true', help='Turn on debug-level logging.')
6264
parser.add_argument('--repl', action='store_true', help='Start the REPL before main program.')
63-
# Options that control behaviour.
65+
# Options that control behaviour. These are persisted in the settings file.
6466
parser.add_argument('--limit', type=int, help='Quit after correctly solving this many questions (0 means no limit).')
6567
parser.add_argument('--show-feedback', action=argparse.BooleanOptionalAction, help='Show feedback on wrong answers.')
6668
parser.add_argument('--show-scores', action=argparse.BooleanOptionalAction, help='Show scores in main window.')
@@ -69,6 +71,20 @@ def get_argument_parser():
6971
return parser
7072

7173

74+
class FS:
75+
def read(self):
76+
try:
77+
with open(_settings_filename, "r") as settings_file:
78+
return json.load(settings_file)
79+
except FileNotFoundError:
80+
pass
81+
82+
def write(self, settings):
83+
os.makedirs(_state_home, mode=0o700, exist_ok=True)
84+
with open(_settings_filename, "w") as settings_file:
85+
json.dump(settings, settings_file)
86+
87+
7288
def main():
7389

7490
parser = get_argument_parser()
@@ -87,7 +103,8 @@ def main():
87103
import code
88104
code.interact()
89105

90-
settings = Settings(args)
106+
fs = FS()
107+
settings = Settings(fs, args)
91108

92109
with get_ui_class(args.ui)(settings) as ui:
93110
try:
@@ -101,24 +118,46 @@ def get_ui_class(ui_name):
101118

102119

103120
class Settings:
104-
def __init__(self, args):
105-
self._args = args
121+
122+
def __init__(self, fs, parsed_args):
123+
self._s = dict((k, None) for k in [
124+
'limit', 'show_scores', 'show_feedback', 'score_font'])
125+
self._load_settings(fs)
126+
self._merge_settings(parsed_args)
127+
self._save_settings(fs)
106128

107129
@property
108130
def limit(self):
109-
return self._args.limit
131+
return self._s['limit']
110132

111133
@property
112134
def show_scores(self):
113-
return _truthify(self._args.show_scores)
135+
return _truthify(self._s['show_scores'])
114136

115137
@property
116138
def show_feedback(self):
117-
return _truthify(self._args.show_scores)
139+
return _truthify(self._s['show_feedback'])
118140

119141
@property
120142
def score_font(self):
121-
return self._args.score_font or _DEFAULT_SCORE_FONT
143+
return self._s['score_font'] or _DEFAULT_SCORE_FONT
144+
145+
def _load_settings(self, fs):
146+
loaded = fs.read()
147+
if not loaded:
148+
return
149+
for s in self._s.keys():
150+
if s in loaded and loaded[s] is not None:
151+
self._s[s] = loaded[s]
152+
153+
def _merge_settings(self, overrides):
154+
for s in self._s.keys():
155+
override = getattr(overrides, s, None)
156+
if override is not None:
157+
self._s[s] = override
158+
159+
def _save_settings(self, fs):
160+
fs.write(dict(kv for kv in self._s.items() if kv[1] is not None))
122161

123162

124163
def _truthify(setting):

0 commit comments

Comments
 (0)