Coverage for enderchest/config.py: 97%

77 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-05-04 01:41 +0000

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

2 

3import ast 

4import datetime as dt 

5from configparser import ConfigParser, ParsingError 

6from io import StringIO 

7from pathlib import Path 

8from typing import Any, Iterable, Mapping, Sequence 

9 

10from ._version import get_versions 

11 

12 

13def get_configurator() -> ConfigParser: 

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

15 

16 Returns 

17 ------- 

18 ConfigParser 

19 The pre-configured configurator 

20 """ 

21 configurator = ConfigParser( 

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

23 ) 

24 configurator.optionxform = str # type: ignore 

25 return configurator 

26 

27 

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

29 """Read in a configuration file 

30 

31 Parameters 

32 ---------- 

33 config_file : Path 

34 The path to the configuration file 

35 

36 Returns 

37 ------- 

38 ConfigParser 

39 The parsed configuration 

40 

41 Raises 

42 ------ 

43 ValueError 

44 If the config file at that location cannot be parsed 

45 FileNotFoundError 

46 If there is no config file at the specified location 

47 """ 

48 configurator = get_configurator() 

49 try: 

50 assert configurator.read(config_file) 

51 except ParsingError as bad_cfg: 

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

53 except AssertionError as not_read: 

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

55 return configurator 

56 

57 

58def dumps( 

59 header: str | None, 

60 properties: dict[str, Any], 

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

62) -> str: 

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

64 

65 Parameters 

66 ---------- 

67 header: str 

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

69 properties: dict 

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

71 **sections : dict or list 

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

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

74 

75 Returns 

76 ------- 

77 str 

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

79 """ 

80 config = get_configurator() 

81 

82 config.add_section("properties") 

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

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

85 

86 config.set( 

87 "properties", 

88 "last-modified", 

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

90 ) 

91 config.set( 

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

93 ) 

94 

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

96 section_name = to_ini_key(section) 

97 config.add_section(section_name) 

98 if isinstance(values, Mapping): 

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

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

101 else: 

102 for value in values: 

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

104 

105 buffer = StringIO() 

106 if header: 

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

108 config.write(buffer) 

109 buffer.seek(0) # rewind 

110 return buffer.read() 

111 

112 

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

114 """Style guide enforcement for INI keys 

115 

116 Parameters 

117 ---------- 

118 key : str 

119 The entry key to normalize 

120 

121 Returns 

122 ------- 

123 str 

124 The normalized key 

125 """ 

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

127 

128 

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

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

131 

132 Parameters 

133 ---------- 

134 value 

135 The value to format 

136 

137 Returns 

138 ------- 

139 str 

140 The formatted INI-appropriate value 

141 """ 

142 if isinstance(value, str): 

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

144 return value 

145 if value is None: 

146 return "" 

147 if isinstance(value, Iterable): 

148 return list_to_ini(list(value)) 

149 if isinstance(value, dt.datetime): 

150 return value.isoformat(sep=" ") 

151 if isinstance(value, dt.date): 

152 # note that datetimes are considered dates 

153 return value.isoformat() 

154 

155 return str(value) 

156 

157 

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

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

160 

161 Parameters 

162 ---------- 

163 values : list-like 

164 the values in the list 

165 

166 Returns 

167 ------- 

168 str 

169 The formatted INI-appropriate value 

170 """ 

171 if len(values) == 0: 

172 return "" 

173 if len(values) == 1: 

174 return values[0] 

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

176 

177 

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

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

180 

181 Parameters 

182 ---------- 

183 entry : str 

184 The raw entry from the INI 

185 

186 Returns 

187 ------- 

188 list of str 

189 The parsed entries 

190 

191 Notes 

192 ----- 

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

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

195 I've got going on. 

196 """ 

197 entry = entry.strip() 

198 try: 

199 parsed = ast.literal_eval(entry) 

200 if isinstance(parsed, str): 

201 return [parsed] 

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

203 except (TypeError, ValueError, SyntaxError): 

204 # if only it were that easy... 

205 pass 

206 

207 values: list[str] = [] 

208 for line in entry.splitlines(): 

209 try: 

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

211 except (TypeError, ValueError, SyntaxError): 

212 values.append(line.strip()) 

213 return values