Coverage for enderchest/config.py: 97%

77 statements  

« prev     ^ index     » next       coverage.py v7.4.1, created at 2024-02-06 16:00 +0000

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

2import ast 

3import datetime as dt 

4from configparser import ConfigParser, ParsingError 

5from io import StringIO 

6from pathlib import Path 

7from typing import Any, Iterable, Mapping, Sequence 

8 

9from ._version import get_versions 

10 

11 

12def get_configurator() -> ConfigParser: 

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

14 

15 Returns 

16 ------- 

17 ConfigParser 

18 The pre-configured configurator 

19 """ 

20 configurator = ConfigParser( 

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

22 ) 

23 configurator.optionxform = str # type: ignore 

24 return configurator 

25 

26 

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

28 """Read in a configuration file 

29 

30 Parameters 

31 ---------- 

32 config_file : Path 

33 The path to the configuration file 

34 

35 Returns 

36 ------- 

37 ConfigParser 

38 The parsed configuration 

39 

40 Raises 

41 ------ 

42 ValueError 

43 If the config file at that location cannot be parsed 

44 FileNotFoundError 

45 If there is no config file at the specified location 

46 """ 

47 configurator = get_configurator() 

48 try: 

49 assert configurator.read(config_file) 

50 except ParsingError as bad_cfg: 

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

52 except AssertionError as not_read: 

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

54 return configurator 

55 

56 

57def dumps( 

58 header: str | None, 

59 properties: dict[str, Any], 

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

61) -> str: 

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

63 

64 Parameters 

65 ---------- 

66 header: str 

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

68 properties: dict 

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

70 **sections : dict or list 

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

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

73 

74 Returns 

75 ------- 

76 str 

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

78 """ 

79 config = get_configurator() 

80 

81 config.add_section("properties") 

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

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

84 

85 config.set( 

86 "properties", 

87 "last-modified", 

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

89 ) 

90 config.set( 

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

92 ) 

93 

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

95 section_name = to_ini_key(section) 

96 config.add_section(section_name) 

97 if isinstance(values, Mapping): 

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

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

100 else: 

101 for value in values: 

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

103 

104 buffer = StringIO() 

105 if header: 

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

107 config.write(buffer) 

108 buffer.seek(0) # rewind 

109 return buffer.read() 

110 

111 

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

113 """Style guide enforcement for INI keys 

114 

115 Parameters 

116 ---------- 

117 key : str 

118 The entry key to normalize 

119 

120 Returns 

121 ------- 

122 str 

123 The normalized key 

124 """ 

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

126 

127 

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

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

130 

131 Parameters 

132 ---------- 

133 value 

134 The value to format 

135 

136 Returns 

137 ------- 

138 str 

139 The formatted INI-appropriate value 

140 """ 

141 if isinstance(value, str): 

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

143 return value 

144 if value is None: 

145 return "" 

146 if isinstance(value, Iterable): 

147 return list_to_ini(list(value)) 

148 if isinstance(value, dt.datetime): 

149 return value.isoformat(sep=" ") 

150 if isinstance(value, dt.date): 

151 # note that datetimes are considered dates 

152 return value.isoformat() 

153 

154 return str(value) 

155 

156 

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

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

159 

160 Parameters 

161 ---------- 

162 values : list-like 

163 the values in the list 

164 

165 Returns 

166 ------- 

167 str 

168 The formatted INI-appropriate value 

169 """ 

170 if len(values) == 0: 

171 return "" 

172 if len(values) == 1: 

173 return values[0] 

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

175 

176 

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

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

179 

180 Parameters 

181 ---------- 

182 entry : str 

183 The raw entry from the INI 

184 

185 Returns 

186 ------- 

187 list of str 

188 The parsed entries 

189 

190 Notes 

191 ----- 

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

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

194 I've got going on. 

195 """ 

196 entry = entry.strip() 

197 try: 

198 parsed = ast.literal_eval(entry) 

199 if isinstance(parsed, str): 

200 return [parsed] 

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

202 except (TypeError, ValueError, SyntaxError): 

203 # if only it were that easy... 

204 pass 

205 

206 values: list[str] = [] 

207 for line in entry.splitlines(): 

208 try: 

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

210 except (TypeError, ValueError, SyntaxError): 

211 values.append(line.strip()) 

212 return values