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

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). 

4 

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``` 

17 

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. 

21 

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`. 

26 

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""" 

31 

32from __future__ import annotations 

33 

34import json 

35import os 

36import tempfile 

37from dataclasses import asdict, dataclass, field, fields 

38from pathlib import Path 

39 

40FILE_NAME = "vibefpm.json" 

41FORMAT = 1 # the file's "vibefpm" version 

42DEFAULT_PROFILE = "(default)" # (as the Profile list shows it; "" in the file) 

43 

44 

45class ConfigError(Exception): 

46 """A configuration file that can't be read (the message says why).""" 

47 

48 

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 

62 

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 

70 

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 

97 

98 

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 

102 

103 

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 

108 

109 

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 

121 

122 

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 

137 

138 

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 

151 

152 

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) 

166 

167 

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) 

178 

179 

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])) 

184 

185 

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"} 

191 

192 

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 

198 

199 

200_NONE = object() # (an option not in the file) 

201 

202 

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).""" 

209 

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 

217 

218 def exists(self) -> bool: 

219 return self.path.is_file() 

220 

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) 

228 

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 

240 

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 

249 

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) 

258 

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 

279 

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) 

285 

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) 

289 

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) 

294 

295 def remove(self, name: str): 

296 """Take a configuration out (OSError, ConfigError).""" 

297 self._check_writable() 

298 self.configurations = remove(self.path, name) 

299 

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 

311 

312 

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)