Coverage for enderchest/config.py: 97%
77 statements
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-06 16:00 +0000
« 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
9from ._version import get_versions
12def get_configurator() -> ConfigParser:
13 """Generate a configuration parser capable of reading or writing INI files
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
27def read_cfg(config_file: Path) -> ConfigParser:
28 """Read in a configuration file
30 Parameters
31 ----------
32 config_file : Path
33 The path to the configuration file
35 Returns
36 -------
37 ConfigParser
38 The parsed configuration
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
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
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
74 Returns
75 -------
76 str
77 The contents of the configuration, suitable for writing to file
78 """
79 config = get_configurator()
81 config.add_section("properties")
82 for key, value in properties.items():
83 config.set("properties", to_ini_key(key), to_ini_value(value))
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 )
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))
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()
112def to_ini_key(key: str) -> str:
113 """Style guide enforcement for INI keys
115 Parameters
116 ----------
117 key : str
118 The entry key to normalize
120 Returns
121 -------
122 str
123 The normalized key
124 """
125 return key.replace("_", "-")
128def to_ini_value(value: Any) -> str:
129 """Format a value into a string suitable for use in an INI entry
131 Parameters
132 ----------
133 value
134 The value to format
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()
154 return str(value)
157def list_to_ini(values: Sequence) -> str:
158 """Format a list of values into a string suitable for use in an INI entry
160 Parameters
161 ----------
162 values : list-like
163 the values in the list
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)
177def parse_ini_list(entry: str) -> list[str]:
178 """Parse a list from an INI config entry
180 Parameters
181 ----------
182 entry : str
183 The raw entry from the INI
185 Returns
186 -------
187 list of str
188 The parsed entries
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
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