Coverage for enderchest/config.py: 97%

78 statements  

« prev     ^ index     » next       coverage.py v7.7.1, created at 2025-03-28 20:32 +0000

1"""Helpers for parsing and writing INI-format config files""" 

2 

3import ast 

4import datetime as dt 

5from collections.abc import Iterable, Mapping, Sequence 

6from configparser import ConfigParser, ParsingError 

7from io import StringIO 

8from pathlib import Path 

9from typing import Any 

10 

11from ._version import get_versions 

12 

13 

14def get_configurator() -> ConfigParser: 

15 """Generate a configuration parser capable of reading or writing INI files 

16 

17 Returns 

18 ------- 

19 ConfigParser 

20 The pre-configured configurator 

21 """ 

22 configurator = ConfigParser( 

23 allow_no_value=True, inline_comment_prefixes=(";",), interpolation=None 

24 ) 

25 configurator.optionxform = str # type: ignore 

26 return configurator 

27 

28 

29def read_cfg(config_file: Path) -> ConfigParser: 

30 """Read in a configuration file 

31 

32 Parameters 

33 ---------- 

34 config_file : Path 

35 The path to the configuration file 

36 

37 Returns 

38 ------- 

39 ConfigParser 

40 The parsed configuration 

41 

42 Raises 

43 ------ 

44 ValueError 

45 If the config file at that location cannot be parsed 

46 FileNotFoundError 

47 If there is no config file at the specified location 

48 """ 

49 configurator = get_configurator() 

50 try: 

51 assert configurator.read(config_file) 

52 except ParsingError as bad_cfg: 

53 raise ValueError(f"Could not parse {config_file}") from bad_cfg 

54 except AssertionError as not_read: 

55 raise FileNotFoundError(f"Could not open {config_file}") from not_read 

56 return configurator 

57 

58 

59def dumps( 

60 header: str | None, 

61 properties: dict[str, Any], 

62 **sections: Mapping[str, Any] | Sequence[str], 

63) -> str: 

64 """Serialize a configuration into an INI-formatted string 

65 

66 Parameters 

67 ---------- 

68 header: str 

69 A header to render as a comment at the top of the file 

70 properties: dict 

71 The "main" section contents. Note that this method will add some of its own 

72 **sections : dict or list 

73 Any additional sections to write. Each section may consist of a set 

74 of key-value pairs or they might simply be a list of values 

75 

76 Returns 

77 ------- 

78 str 

79 The contents of the configuration, suitable for writing to file 

80 """ 

81 config = get_configurator() 

82 

83 config.add_section("properties") 

84 for key, value in properties.items(): 

85 config.set("properties", to_ini_key(key), to_ini_value(value)) 

86 

87 config.set( 

88 "properties", 

89 "last-modified", 

90 to_ini_value(dt.datetime.now()), 

91 ) 

92 config.set( 

93 "properties", "generated-by-enderchest-version", get_versions()["version"] 

94 ) 

95 

96 for section, values in sections.items(): 

97 section_name = to_ini_key(section) 

98 config.add_section(section_name) 

99 if isinstance(values, Mapping): 

100 for key, value in values.items(): 

101 config.set(section_name, to_ini_key(key), to_ini_value(value)) 

102 else: 

103 for value in values: 

104 config.set(section_name, to_ini_value(value)) 

105 

106 buffer = StringIO() 

107 if header: 

108 buffer.write(f"; {header}\n") 

109 config.write(buffer) 

110 buffer.seek(0) # rewind 

111 return buffer.read() 

112 

113 

114def to_ini_key(key: str) -> str: 

115 """Style guide enforcement for INI keys 

116 

117 Parameters 

118 ---------- 

119 key : str 

120 The entry key to normalize 

121 

122 Returns 

123 ------- 

124 str 

125 The normalized key 

126 """ 

127 return key.replace("_", "-") 

128 

129 

130def to_ini_value(value: Any) -> str: 

131 """Format a value into a string suitable for use in an INI entry 

132 

133 Parameters 

134 ---------- 

135 value 

136 The value to format 

137 

138 Returns 

139 ------- 

140 str 

141 The formatted INI-appropriate value 

142 """ 

143 if isinstance(value, str): 

144 # have to put in this check since strings are iterable 

145 return value 

146 if value is None: 

147 return "" 

148 if isinstance(value, Iterable): 

149 return list_to_ini(list(value)) 

150 if isinstance(value, dt.datetime): 

151 return value.isoformat(sep=" ") 

152 if isinstance(value, dt.date): 

153 # note that datetimes are considered dates 

154 return value.isoformat() 

155 

156 return str(value) 

157 

158 

159def list_to_ini(values: Sequence) -> str: 

160 """Format a list of values into a string suitable for use in an INI entry 

161 

162 Parameters 

163 ---------- 

164 values : list-like 

165 the values in the list 

166 

167 Returns 

168 ------- 

169 str 

170 The formatted INI-appropriate value 

171 """ 

172 if len(values) == 0: 

173 return "" 

174 if len(values) == 1: 

175 return values[0] 

176 return "\n" + "\n".join(values) 

177 

178 

179def parse_ini_list(entry: str) -> list[str]: 

180 """Parse a list from an INI config entry 

181 

182 Parameters 

183 ---------- 

184 entry : str 

185 The raw entry from the INI 

186 

187 Returns 

188 ------- 

189 list of str 

190 The parsed entries 

191 

192 Notes 

193 ----- 

194 This method is *only* for parsing specific values of a key-value entry 

195 *and not* for the whole "section is the key, lines are the values" thing 

196 I've got going on. 

197 """ 

198 entry = entry.strip() 

199 try: 

200 parsed = ast.literal_eval(entry) 

201 if isinstance(parsed, str): 

202 return [parsed] 

203 return [str(value) for value in parsed] 

204 except (TypeError, ValueError, SyntaxError): 

205 # if only it were that easy... 

206 pass 

207 

208 values: list[str] = [] 

209 for line in entry.splitlines(): 

210 try: 

211 values.append(str(ast.literal_eval(line))) 

212 except (TypeError, ValueError, SyntaxError): 

213 values.append(line.strip()) 

214 return values