Skip to content

Commit 6940495

Browse files
committed
Make theme name case insensitive
1 parent aaee99a commit 6940495

File tree

5 files changed

+146
-59
lines changed

5 files changed

+146
-59
lines changed

termtosvg/__main__.py

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,15 @@
1414
logger = logging.getLogger('termtosvg')
1515
LOG_FILENAME = os.path.join(tempfile.gettempdir(), 'termtosvg.log')
1616

17-
18-
configuration = config.init_read_conf()
19-
available_themes = [section for section in configuration if section != 'GLOBAL']
20-
2117
USAGE = """termtosvg [output_file] [--theme THEME] [--help] [--verbose]
22-
2318
Record a terminal session and render an SVG animation on the fly
2419
"""
25-
2620
EPILOG = "See also 'termtosvg record --help' and 'termtosvg render --help'"
2721
RECORD_USAGE = """termtosvg record [output_file] [--verbose] [--help]"""
2822
RENDER_USAGE = """termtosvg render input_file [output_file] [--theme THEME] [--verbose] [--help]"""
2923

3024

31-
def parse(args):
25+
def parse(args, themes):
3226
# type: (List) -> Tuple[Union[None, str], argparse.Namespace]
3327
# Usage: termtosvg [--theme THEME] [--verbose] [output_file]
3428
verbose_parser = argparse.ArgumentParser(add_help=False)
@@ -38,16 +32,14 @@ def parse(args):
3832
action='store_true',
3933
help='increase log messages verbosity'
4034
)
41-
4235
theme_parser = argparse.ArgumentParser(add_help=False)
4336
theme_parser.add_argument(
4437
'--theme',
4538
help='color theme used to render the terminal session ({})'.format(
46-
', '.join(available_themes)),
47-
choices=available_themes,
39+
', '.join(themes)),
40+
choices=themes,
4841
metavar='THEME'
4942
)
50-
5143
parser = argparse.ArgumentParser(
5244
prog='termtosvg',
5345
parents=[theme_parser, verbose_parser],
@@ -107,7 +99,11 @@ def main(args=None, input_fileno=None, output_fileno=None):
10799
if output_fileno is None:
108100
output_fileno = sys.stdout.fileno()
109101

110-
command, args = parse(args[1:])
102+
configuration = config.init_read_conf()
103+
available_themes = config.CaseInsensitiveDict(**configuration)
104+
del available_themes['global']
105+
106+
command, args = parse(args[1:], available_themes)
111107

112108
logger.setLevel(logging.INFO)
113109

termtosvg/config.py

Lines changed: 79 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -7,45 +7,80 @@
77

88
import termtosvg.asciicast as asciicast
99

10+
1011
logger = logging.getLogger(__name__)
1112
logger.addHandler(logging.NullHandler())
1213

13-
_PKG_CONFIGURATION_PATH = os.path.join('data', 'termtosvg.ini')
14-
DEFAULT_CONFIG = pkg_resources.resource_string(__name__, _PKG_CONFIGURATION_PATH).decode('utf-8')
14+
PKG_CONF_PATH = os.path.join('data', 'termtosvg.ini')
15+
DEFAULT_CONFIG = pkg_resources.resource_string(__name__, PKG_CONF_PATH).decode('utf-8')
16+
17+
18+
class CaseInsensitiveDict(dict):
19+
@classmethod
20+
def _lower_key(cls, key):
21+
return key.lower() if isinstance(key, str) else key
22+
23+
def __init__(self, *args, **kwargs):
24+
super(CaseInsensitiveDict, self).__init__(*args, **kwargs)
25+
for key in list(self.keys()):
26+
value = super(CaseInsensitiveDict, self).pop(key)
27+
self.__setitem__(key, value)
28+
29+
def __getitem__(self, key):
30+
lower_case_key = self.__class__._lower_key(key)
31+
return super(CaseInsensitiveDict, self).__getitem__(lower_case_key)
32+
33+
def __setitem__(self, key, value):
34+
lower_case_key = self.__class__._lower_key(key)
35+
super(CaseInsensitiveDict, self).__setitem__(lower_case_key, value)
36+
37+
def __delitem__(self, key):
38+
lower_case_key = self.__class__._lower_key(key)
39+
return super(CaseInsensitiveDict, self).__delitem__(lower_case_key)
1540

41+
def __contains__(self, key):
42+
lower_case_key = self.__class__._lower_key(key)
43+
return super(CaseInsensitiveDict, self).__contains__(lower_case_key)
1644

17-
if 'XDG_CONFIG_HOME' in os.environ:
18-
USER_CONFIG_DIR = os.environ['XDG_CONFIG_HOME']
19-
elif 'HOME' in os.environ:
20-
USER_CONFIG_DIR = os.path.join(os.environ['HOME'], '.config')
21-
else:
22-
logger.info('Environment variable XDG_CONFIG_HOME and HOME are not set: user configuration '
23-
'cannot be used')
24-
USER_CONFIG_DIR = None
45+
def pop(self, key, *args, **kwargs):
46+
lower_case_key = self.__class__._lower_key(key)
47+
return super(CaseInsensitiveDict, self).pop(lower_case_key, *args, **kwargs)
48+
49+
def get(self, key, *args, **kwargs):
50+
lower_case_key = self.__class__._lower_key(key)
51+
return super(CaseInsensitiveDict, self).get(lower_case_key, *args, **kwargs)
52+
53+
def setdefault(self, key, *args, **kwargs):
54+
lower_case_key = self.__class__._lower_key(key)
55+
return super(CaseInsensitiveDict, self).setdefault(lower_case_key, *args, **kwargs)
56+
57+
def update(self, E=None, **F):
58+
super(CaseInsensitiveDict, self).update(self.__class__(E))
59+
super(CaseInsensitiveDict, self).update(self.__class__(**F))
2560

2661

2762
def conf_to_dict(configuration):
28-
# type: (str) -> Dict[str, Union[Dict[str, str], asciicast.AsciiCastTheme]]
63+
# type: (str) -> CaseInsensitiveDict[str, Union[Dict[str, str], asciicast.AsciiCastTheme]]
2964
"""Read a configuration string in INI format and return a dictionary
3065
3166
Raise a subclass of configparser.Error if parsing the configuration string fails
3267
Raise ValueError if the color theme is invalid
3368
"""
34-
user_config = configparser.ConfigParser(comment_prefixes=(';',))
35-
user_config.read_string(configuration)
36-
config_dict = {
37-
'GLOBAL': {
38-
'font': user_config.get('GLOBAL', 'font'),
39-
'theme': user_config.get('GLOBAL', 'theme'),
69+
parser = configparser.ConfigParser(dict_type=CaseInsensitiveDict,
70+
comment_prefixes=(';',))
71+
parser.read_string(configuration)
72+
config_dict = CaseInsensitiveDict({
73+
'global': {
74+
'font': parser.get('global', 'font'),
75+
'theme': parser.get('global', 'theme'),
4076
}
41-
}
77+
})
4278

43-
themes = user_config.sections()
44-
themes.remove('GLOBAL')
79+
themes = [theme.lower() for theme in parser.sections() if theme.lower() != 'global']
4580
for theme_name in themes:
46-
fg = user_config.get(theme_name, 'foreground', fallback='')
47-
bg = user_config.get(theme_name, 'background', fallback='')
48-
palette = ':'.join(user_config.get(theme_name, 'color{}'.format(i), fallback='')
81+
fg = parser.get(theme_name, 'foreground', fallback='')
82+
bg = parser.get(theme_name, 'background', fallback='')
83+
palette = ':'.join(parser.get(theme_name, 'color{}'.format(i), fallback='')
4984
for i in range(16))
5085

5186
# This line raises ValueError if the color theme is invalid
@@ -69,32 +104,38 @@ def get_configuration(user_config, default_config):
69104

70105
# Override default values with user configuration
71106
for section in user_config_dict:
72-
if section == 'GLOBAL':
107+
if section.lower() == 'global':
73108
for _property in 'theme', 'font':
74-
if _property in user_config_dict['GLOBAL']:
75-
config_dict['GLOBAL'][_property] = user_config_dict['GLOBAL'][_property]
109+
config_dict['GLOBAL'][_property] = user_config_dict['global'][_property]
76110
else:
77111
config_dict[section] = user_config_dict[section]
78112

79113
return config_dict
80114

81115

82-
def init_read_conf(user_config_dir=USER_CONFIG_DIR, default_config=DEFAULT_CONFIG):
116+
def init_read_conf():
117+
if 'XDG_CONFIG_HOME' in os.environ:
118+
user_config_dir = os.environ['XDG_CONFIG_HOME']
119+
elif 'HOME' in os.environ:
120+
user_config_dir = os.path.join(os.environ['HOME'], '.config')
121+
else:
122+
logger.info('Environment variable XDG_CONFIG_HOME and HOME are not set: user '
123+
'configuration cannot be used')
124+
user_config_dir = None
125+
126+
if user_config_dir is None:
127+
return get_configuration(DEFAULT_CONFIG, DEFAULT_CONFIG)
128+
83129
config_dir = os.path.join(user_config_dir, 'termtosvg')
84130
config_path = os.path.join(config_dir, 'termtosvg.ini')
85131
try:
86132
with open(config_path, 'r') as config_file:
87133
user_config = config_file.read()
88134
except FileNotFoundError:
89135
user_config = DEFAULT_CONFIG
90-
try:
91-
with open(config_path, 'w') as config_file:
92-
config_file.write(DEFAULT_CONFIG)
93-
logger.info('Created default configuration file: {}'.format(config_path))
94-
except FileNotFoundError:
95-
os.makedirs(config_dir)
96-
with open(config_path, 'w') as config_file:
97-
config_file.write(DEFAULT_CONFIG)
98-
logger.info('Created default configuration file: {}'.format(config_path))
99-
100-
return get_configuration(user_config, default_config)
136+
os.makedirs(config_dir, exist_ok=True)
137+
with open(config_path, 'w') as config_file:
138+
config_file.write(DEFAULT_CONFIG)
139+
logger.info('Created default configuration file: {}'.format(config_path))
140+
141+
return get_configuration(user_config, DEFAULT_CONFIG)

termtosvg/data/termtosvg.ini

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
; TERMTOSVG CONFIGURATION FILE
22
;
33
; Configuration file structure:
4-
; - The 'GLOBAL' section defines the font and theme used by termtosvg
4+
; - The 'global' section defines the font and theme used by termtosvg
55
; - All other sections define color schemes
66
;
7+
; Section and property names are case insensitive
8+
;
9+
710

8-
[GLOBAL]
11+
[global]
912
; properties of this section:
1013
; - FONT: CSS font family used in the SVG animation (Deja Vu Sans Mono,
1114
; Lucida Console, Monaco...)
@@ -17,7 +20,7 @@
1720
; this file, or one of the default themes of termtosvg (circus,
1821
; classic-dark, classic-light, dracula, isotope, marrakesh, material,
1922
; monokai, solarized-dark, solarized-light, zenburn)
20-
;
23+
2124
font = Deja Vu Sans Mono
2225
theme = solarized-dark
2326

@@ -41,8 +44,7 @@ theme = solarized-dark
4144
; https://github.com/chriskempson/base16-xresources
4245

4346
[circus]
44-
; Authors: Stephan Boyer (https://github.com/stepchowfun)
45-
; and Esther Wang
47+
; Authors: Stephan Boyer (https://github.com/stepchowfun) and Esther Wang
4648
foreground = #a7a7a7
4749
background = #191919
4850
color0 = #191919

tests/test___main__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def test_parse(self):
4646

4747
for args in test_cases:
4848
with self.subTest(case=args):
49-
__main__.parse(args)
49+
__main__.parse(args, ['solarized-light', 'solarized-dark'])
5050

5151
@staticmethod
5252
def run_main(shell_commands, args):
@@ -96,6 +96,6 @@ def test_main(self):
9696
args = ['termtosvg', '--verbose']
9797
TestMain.run_main(SHELL_COMMANDS, args)
9898

99-
with self.subTest(case='record and render on the fly (circus theme)'):
100-
args = ['termtosvg', svg_filename, '--theme', 'circus', '--verbose']
99+
with self.subTest(case='record and render on the fly (uppercase circus theme +)'):
100+
args = ['termtosvg', svg_filename, '--theme', 'CIRCUS', '--verbose']
101101
TestMain.run_main(SHELL_COMMANDS, args)

tests/test_config.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import tempfile
12
import unittest
3+
from unittest.mock import patch
24

35
import termtosvg.config as config
46

@@ -18,7 +20,7 @@
1820
color6=#666666
1921
color7=#777777
2022
"""
21-
23+
UPPERCASE_CONFIG = MINIMAL_CONFIG.upper()
2224
NO_GLOBAL_SECTION_CONFIG = MINIMAL_CONFIG.replace('[GLOBAL]', ';[GLOBAL]')
2325
NO_FONT_CONFIG = MINIMAL_CONFIG.replace('font', ';font')
2426
NO_THEME_CONFIG = MINIMAL_CONFIG.replace('theme', ';theme')
@@ -30,6 +32,17 @@
3032

3133
class TestConf(unittest.TestCase):
3234
def test_conf_to_dict(self):
35+
test_cases = [
36+
('Minimal config', MINIMAL_CONFIG),
37+
('Uppercase config', UPPERCASE_CONFIG),
38+
]
39+
for case, configuration in test_cases:
40+
with self.subTest(case=case):
41+
config_dict = config.conf_to_dict(configuration)
42+
self.assertEqual(config_dict['GlOBal']['font'].lower(), 'deja vu sans mono')
43+
self.assertEqual(config_dict['Dark'].fg.lower(), '#ffffff')
44+
self.assertEqual(config_dict['dark'].bg.lower(), '#000000')
45+
3346
with self.subTest(case='minimal config'):
3447
config_dict = config.conf_to_dict(MINIMAL_CONFIG)
3548
self.assertEqual(config_dict['GLOBAL']['font'], 'Deja Vu Sans Mono')
@@ -62,3 +75,38 @@ def test_get_configuration(self):
6275
self.assertEqual(config_dict['dark'].bg.lower(), '#ffffff')
6376
palette = config_dict['dark'].palette.split(':')
6477
self.assertEqual(palette[0].lower(), '#ffffff')
78+
79+
def test_init_read_conf(self):
80+
with self.subTest(case='XDG_CONFIG_HOME'):
81+
mock_environ = {
82+
'XDG_CONFIG_HOME': tempfile.mkdtemp(prefix='termtosvg_config_')
83+
}
84+
with patch('os.environ', mock_environ):
85+
# First call should create config dirs and return it
86+
self.assertEqual(config.conf_to_dict(config.DEFAULT_CONFIG),
87+
config.init_read_conf())
88+
# Second call only reads the config file which was created by the first call
89+
self.assertEqual(config.conf_to_dict(config.DEFAULT_CONFIG),
90+
config.init_read_conf())
91+
92+
with self.subTest(case='XDG_CONFIG_HOME'):
93+
mock_environ = {
94+
'XDG_CONFIG_HOME': tempfile.mkdtemp(prefix='termtosvg_config_')
95+
}
96+
with patch('os.environ', mock_environ):
97+
self.assertEqual(config.conf_to_dict(config.DEFAULT_CONFIG),
98+
config.init_read_conf())
99+
100+
with self.subTest(case='HOME'):
101+
mock_environ = {
102+
'HOME': tempfile.mkdtemp(prefix='termtosvg_config_')
103+
}
104+
with patch('os.environ', mock_environ):
105+
self.assertEqual(config.conf_to_dict(config.DEFAULT_CONFIG),
106+
config.init_read_conf())
107+
108+
with self.subTest(case='No environment variable'):
109+
mock_environ = {}
110+
with patch('os.environ', mock_environ):
111+
self.assertEqual(config.conf_to_dict(config.DEFAULT_CONFIG),
112+
config.init_read_conf())

0 commit comments

Comments
 (0)