Coverage for enderchest/config.py: 97%
77 statements
« prev ^ index » next coverage.py v7.5.0, created at 2024-05-04 01:41 +0000
« 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"""
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
10from ._version import get_versions
13def get_configurator() -> ConfigParser:
14 """Generate a configuration parser capable of reading or writing INI files
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
28def read_cfg(config_file: Path) -> ConfigParser:
29 """Read in a configuration file
31 Parameters
32 ----------
33 config_file : Path
34 The path to the configuration file
36 Returns
37 -------
38 ConfigParser
39 The parsed configuration
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
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
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
75 Returns
76 -------
77 str
78 The contents of the configuration, suitable for writing to file
79 """
80 config = get_configurator()
82 config.add_section("properties")
83 for key, value in properties.items():
84 config.set("properties", to_ini_key(key), to_ini_value(value))
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 )
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))
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()
113def to_ini_key(key: str) -> str:
114 """Style guide enforcement for INI keys
116 Parameters
117 ----------
118 key : str
119 The entry key to normalize
121 Returns
122 -------
123 str
124 The normalized key
125 """
126 return key.replace("_", "-")
129def to_ini_value(value: Any) -> str:
130 """Format a value into a string suitable for use in an INI entry
132 Parameters
133 ----------
134 value
135 The value to format
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()
155 return str(value)
158def list_to_ini(values: Sequence) -> str:
159 """Format a list of values into a string suitable for use in an INI entry
161 Parameters
162 ----------
163 values : list-like
164 the values in the list
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)
178def parse_ini_list(entry: str) -> list[str]:
179 """Parse a list from an INI config entry
181 Parameters
182 ----------
183 entry : str
184 The raw entry from the INI
186 Returns
187 -------
188 list of str
189 The parsed entries
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
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