Coverage for vibefpmgui/build_configs.py: 97%
199 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""Build configurations: named sets of Build options for a project, kept in a
2JSON file beside its fpm.toml (`FILE_NAME` unless another is opened), so
3they can be shared (e.g. committed with the project).
5The file::
6```json
7 {
8 "vibefpm": 1,
9 "configurations": [
10 {"name": "release (ifx)", "profile": "release", "features": [],
11 "compiler": "ifx", "flags": "", "target": "", "extra_args": "",
12 "program_args": "", "parallel_tests": 4, "coverage": false,
13 "verbose": false}
14 ]
15 }
16```
18A configuration has every Build option but the fpm executable (a path on
19this computer, kept in the app's settings). An option left out of a
20configuration has its default; unknown keys are kept as they are.
22Beside the configurations, "new_configuration" is New configuration's Build
23options (those not saved as one): a configuration without a name. Then
24"options" (one set, for every configuration in it) are the GUI's for the
25project: see `vibefpmgui.project_options`.
27`ConfigFile` is a project's file as the app has it: read whole (a file that
28can't be read whole isn't used, nor written over), and written a part at a
29time, the rest kept as it is.
30"""
32from __future__ import annotations
34import json
35import os
36import tempfile
37from dataclasses import asdict, dataclass, field, fields
38from pathlib import Path
40FILE_NAME = "vibefpm.json"
41FORMAT = 1 # the file's "vibefpm" version
42DEFAULT_PROFILE = "(default)" # (as the Profile list shows it; "" in the file)
45class ConfigError(Exception):
46 """A configuration file that can't be read (the message says why)."""
49@dataclass
50class Configuration:
51 name: str
52 profile: str = DEFAULT_PROFILE
53 features: list[str] = field(default_factory=list)
54 compiler: str = ""
55 flags: str = ""
56 target: str = ""
57 extra_args: str = ""
58 program_args: str = ""
59 parallel_tests: int = 1
60 coverage: bool = False
61 verbose: bool = False
63 def to_json(self, named: bool = True) -> dict:
64 """As the file has it (``named`` False: New configuration's, without a name)."""
65 data = asdict(self)
66 data["profile"] = "" if self.profile == DEFAULT_PROFILE else self.profile
67 if not named:
68 del data["name"]
69 return data
71 @classmethod
72 def from_json(cls, data: dict, name: str | None = None) -> Configuration:
73 """A configuration from the file (a value of the wrong type: its
74 default). ``name``: one without a name in the file (New configuration's)."""
75 if name is None:
76 name = data.get("name")
77 if not isinstance(name, str) or not name.strip():
78 raise ConfigError("A configuration has no name.")
79 config = cls(name.strip())
80 for f in fields(cls):
81 if f.name == "name" or f.name not in data:
82 continue
83 value, default = data[f.name], getattr(config, f.name)
84 if isinstance(default, bool):
85 ok = isinstance(value, bool)
86 elif isinstance(default, int):
87 ok = isinstance(value, int) and not isinstance(value, bool) and value >= 1
88 elif isinstance(default, list):
89 ok = isinstance(value, list) and all(isinstance(v, str) for v in value)
90 else:
91 ok = isinstance(value, str)
92 if ok:
93 setattr(config, f.name, list(value) if isinstance(value, list) else value)
94 if not config.profile:
95 config.profile = DEFAULT_PROFILE
96 return config
99def default_path(manifest: Path) -> Path:
100 """Where a project's configurations go unless another file was opened."""
101 return Path(manifest).parent / FILE_NAME
104def manifest_for(path: Path) -> Path | None:
105 """The fpm.toml a configuration file belongs to (beside it), if there is one."""
106 manifest = Path(path).parent / "fpm.toml"
107 return manifest if manifest.is_file() else None
110def _read(path: Path) -> dict:
111 try:
112 data = json.loads(Path(path).read_text(encoding="utf-8"))
113 except OSError as exc:
114 raise ConfigError(f"Can't read {path}: {exc.strerror or exc}") from exc
115 except (ValueError, UnicodeDecodeError) as exc:
116 raise ConfigError(f"{Path(path).name} isn't valid JSON: {exc}") from exc
117 if not isinstance(data, dict) or not isinstance(data.get("configurations", []), list):
118 raise ConfigError(f"{Path(path).name} isn't a VibeFPM configuration file "
119 "(no \"configurations\" list).")
120 return data
123def load(path: Path) -> list[Configuration]:
124 """The configurations in a file, in its order (none if it doesn't exist)."""
125 if not Path(path).exists():
126 return []
127 configs, seen = [], set()
128 for item in _read(path).get("configurations", []):
129 if not isinstance(item, dict):
130 raise ConfigError(f"{Path(path).name}: a configuration isn't an object.")
131 config = Configuration.from_json(item)
132 if config.name in seen:
133 raise ConfigError(f"{Path(path).name}: two configurations are named “{config.name}”.")
134 seen.add(config.name)
135 configs.append(config)
136 return configs
139def _write(path: Path, data: dict):
140 """Write the file whole or not at all (a temporary file, then renamed)."""
141 path = Path(path)
142 text = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
143 handle, temporary = tempfile.mkstemp(prefix=".vibefpm-", suffix=".json", dir=path.parent)
144 try:
145 with os.fdopen(handle, "w", encoding="utf-8", newline="\n") as f:
146 f.write(text)
147 os.replace(temporary, path)
148 except BaseException:
149 Path(temporary).unlink(missing_ok=True)
150 raise
153def _with_configurations(path: Path, change) -> list[Configuration]:
154 """Read the file (or start one), let ``change`` edit its list of
155 configurations (as JSON objects), and write it back; the result. Other
156 keys, and keys of a configuration this app doesn't know, are kept; a
157 file that can't be read whole isn't written over (ConfigError)."""
158 load(path) # (every configuration checked first)
159 data = _read(path) if Path(path).exists() else {}
160 items = [item for item in data.get("configurations", []) if isinstance(item, dict)]
161 change(items)
162 data = {"vibefpm": data.get("vibefpm", FORMAT), **{k: v for k, v in data.items() if k != "vibefpm"},
163 "configurations": items}
164 _write(path, data)
165 return load(path)
168def save(path: Path, config: Configuration) -> list[Configuration]:
169 """Add a configuration to the file, or replace the one with its name."""
170 def change(items: list[dict]):
171 new = config.to_json()
172 for i, item in enumerate(items):
173 if item.get("name") == config.name:
174 items[i] = {**item, **new}
175 return
176 items.append(new)
177 return _with_configurations(path, change)
180def remove(path: Path, name: str) -> list[Configuration]:
181 """Take the configuration named ``name`` out of the file."""
182 return _with_configurations(path, lambda items: items.__setitem__(
183 slice(None), [item for item in items if item.get("name") != name]))
186# Build options a file's "options" had before they went to "new_configuration"
187# (the app's settings keys for them): moved there when it's read.
188_OLD_OPTION_FIELDS = {"profile": "profile", "compiler": "compiler", "flags": "flags", "extra": "extra_args",
189 "target": "target", "program_args": "program_args", "verbose": "verbose",
190 "coverage": "coverage", "test_jobs": "parallel_tests", "features": "features"}
193def _object(data: dict, key: str, path: Path) -> dict | None:
194 value = data.get(key)
195 if value is not None and not isinstance(value, dict):
196 raise ConfigError(f"{Path(path).name}: \"{key}\" isn't an object.")
197 return value
200_NONE = object() # (an option not in the file)
203class ConfigFile:
204 """A project's configuration file, as the app has it: ``configurations``,
205 New configuration's Build options (``new_configuration``: None if the
206 file has none) and the GUI's ``options`` (None: there's no file, or it
207 can't be read, ``error`` saying why; then none of it is used, and it
208 isn't written over)."""
210 def __init__(self, path: Path):
211 self.path = Path(path)
212 self.configurations: list[Configuration] = []
213 self.new_configuration: Configuration | None = None
214 self.options: dict | None = None
215 self.error = ""
216 self._written: tuple | None = None # (options, new configuration) as the file has them
218 def exists(self) -> bool:
219 return self.path.is_file()
221 def _load(self) -> tuple[list[Configuration], dict | None, dict | None]:
222 """Everything in the file, checked (ConfigError if any of it can't be read)."""
223 if not self.exists():
224 return [], None, None
225 configurations = load(self.path)
226 data = _read(self.path)
227 return configurations, _object(data, "options", self.path), _object(data, "new_configuration", self.path)
229 def read(self) -> ConfigFile:
230 """Read it all (the file's options and New configuration replace those had)."""
231 try:
232 configurations, options, new = self._load()
233 except ConfigError as exc:
234 self.configurations, self.options, self.new_configuration = [], None, None
235 self.error, self._written = str(exc), None
236 return self
237 self.configurations, self.error = configurations, ""
238 self._take_gui(options, new)
239 return self
241 def _take_gui(self, options: dict | None, new: dict | None):
242 self._written = (options, new) if self.exists() else None
243 self.options = dict(options or {}) if self.exists() else None
244 new = dict(new or {})
245 for key, name in _OLD_OPTION_FIELDS.items(): # (from a file written before: moved)
246 if self.options is not None and key in self.options:
247 new.setdefault(name, self.options.pop(key))
248 self.new_configuration = Configuration.from_json(new, name="") if new else None
250 def reread_configurations(self):
251 """Its configurations read again (the project reloaded: the file may
252 have changed; its options and New configuration are the window's
253 until they're written, or it's saved in the editor: see reread)."""
254 try:
255 self.configurations, self.error = self._load()[0], ""
256 except ConfigError as exc:
257 self.configurations, self.error = [], str(exc)
259 def reread(self) -> tuple[set[str], bool]:
260 """Read it all again (it was saved in the editor): (the options
261 changed in it since it was last read or written, whether New
262 configuration was). Those changed are taken (``options`` and
263 ``new_configuration``: the file's)."""
264 try:
265 configurations, options, new = self._load()
266 except ConfigError as exc:
267 self.configurations, self.error = [], str(exc)
268 return set(), False
269 self.configurations, self.error = configurations, ""
270 if not self.exists(): 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true
271 return set(), False
272 before, before_new = self._written or (None, None)
273 before, options_now = before or {}, options or {}
274 changed = {key for key in {*before, *options_now} if before.get(key, _NONE) != options_now.get(key, _NONE)}
275 new_changed = (new or {}) != (before_new or {})
276 if changed or new_changed:
277 self._take_gui(options, new)
278 return changed, new_changed
280 def start_options(self):
281 """It was just made (the first configuration saved): it holds the
282 project's options from now on (none yet)."""
283 if self.options is None and self.exists() and not self.error: 283 ↛ exitline 283 didn't return from function 'start_options' because the condition on line 283 was always true
284 self.options, self._written = {}, ({}, None)
286 def _check_writable(self):
287 if self.error and self.exists(): # (not written over: fix it, or move it away)
288 raise ConfigError(self.error)
290 def save(self, config: Configuration):
291 """Add a configuration, or replace the one with its name (OSError, ConfigError)."""
292 self._check_writable()
293 self.configurations = save(self.path, config)
295 def remove(self, name: str):
296 """Take a configuration out (OSError, ConfigError)."""
297 self._check_writable()
298 self.configurations = remove(self.path, name)
300 def write_options(self, options: dict, new: Configuration) -> bool:
301 """Write the options and New configuration, if they changed (True:
302 written; OSError, ConfigError)."""
303 if self.options is None or self.error:
304 return False
305 written = (options, new.to_json(named=False))
306 if written == self._written:
307 return False
308 save_options(self.path, *written)
309 self.options, self.new_configuration, self._written = dict(options), new, written
310 return True
313def save_options(path: Path, options: dict, new_configuration: dict):
314 """Put ``options`` and New configuration's Build options in the file
315 (which must exist and be readable), its configurations and anything else
316 in it kept, in the order it's read: the configurations, New
317 configuration, the options."""
318 load(path) # (every configuration checked first: a file that can't be read isn't written over)
319 data = {k: v for k, v in _read(path).items() if k not in ("new_configuration", "options")}
320 data["new_configuration"] = new_configuration
321 data["options"] = options
322 _write(path, data)