Coverage for enderchest/config.py: 97%
78 statements
« prev ^ index » next coverage.py v7.7.1, created at 2025-03-28 20:32 +0000
« 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"""
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
11from ._version import get_versions
14def get_configurator() -> ConfigParser:
15 """Generate a configuration parser capable of reading or writing INI files
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
29def read_cfg(config_file: Path) -> ConfigParser:
30 """Read in a configuration file
32 Parameters
33 ----------
34 config_file : Path
35 The path to the configuration file
37 Returns
38 -------
39 ConfigParser
40 The parsed configuration
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
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
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
76 Returns
77 -------
78 str
79 The contents of the configuration, suitable for writing to file
80 """
81 config = get_configurator()
83 config.add_section("properties")
84 for key, value in properties.items():
85 config.set("properties", to_ini_key(key), to_ini_value(value))
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 )
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))
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()
114def to_ini_key(key: str) -> str:
115 """Style guide enforcement for INI keys
117 Parameters
118 ----------
119 key : str
120 The entry key to normalize
122 Returns
123 -------
124 str
125 The normalized key
126 """
127 return key.replace("_", "-")
130def to_ini_value(value: Any) -> str:
131 """Format a value into a string suitable for use in an INI entry
133 Parameters
134 ----------
135 value
136 The value to format
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()
156 return str(value)
159def list_to_ini(values: Sequence) -> str:
160 """Format a list of values into a string suitable for use in an INI entry
162 Parameters
163 ----------
164 values : list-like
165 the values in the list
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)
179def parse_ini_list(entry: str) -> list[str]:
180 """Parse a list from an INI config entry
182 Parameters
183 ----------
184 entry : str
185 The raw entry from the INI
187 Returns
188 -------
189 list of str
190 The parsed entries
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
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