Coverage for src/lilbee/core/profile_files.py: 100%

371 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +0000

1"""Profile files: the file format, its validation, discovery across the folders, and writes.""" 

2 

3from __future__ import annotations 

4 

5import re 

6import tomllib 

7from collections.abc import Callable, Mapping, Sequence 

8from dataclasses import dataclass, replace 

9from enum import StrEnum 

10from functools import cache, partial 

11from importlib.metadata import version as installed_version 

12from pathlib import Path 

13from typing import Any, TypeVar 

14 

15import tomli_w 

16from packaging.version import InvalidVersion, Version 

17from pydantic import ValidationError 

18 

19from lilbee.core.config import Config, cfg 

20from lilbee.core.config.enums import ProfileScope 

21from lilbee.core.config.resolve import PROFILE_FIELDS, builtin_value 

22from lilbee.core.security import write_text_atomically 

23from lilbee.core.system import canonical_data_root, default_data_dir 

24 

25T = TypeVar("T") 

26 

27PROFILES_DIRNAME = "profiles" 

28BUILTIN_DIRNAME = "builtin" 

29PROFILE_SUFFIX = ".toml" 

30PACKAGE_PROFILES_DIR = Path(__file__).resolve().parent.parent / PROFILES_DIRNAME 

31MAX_PROFILE_BYTES = 256 * 1024 

32 

33DEFAULT_PROFILE_NAME = "Default" 

34PROFILE_FORMAT = 1 

35 

36META_TABLE = "profile" 

37VALUES_TABLE = "values" 

38_TOP_LEVEL_KEYS = frozenset({META_TABLE, VALUES_TABLE}) 

39_STRING_META_KEYS = ("name", "description", "tested_on", "min_lilbee", "evidence") 

40_META_KEYS = frozenset({*_STRING_META_KEYS, "authors", "format"}) 

41_AUTHOR_KEYS = frozenset({"name", "github"}) 

42 

43MAX_NAME_LENGTH = 40 

44_NAME_PATTERN = re.compile(rf"[A-Za-z0-9 _()-]{{1,{MAX_NAME_LENGTH}}}") 

45# words a profile name cannot be: fixed route words and the file names Windows keeps for devices 

46RESERVED_NAME_KEYS = frozenset( 

47 { 

48 "active", 

49 "new", 

50 "discard", 

51 "import", 

52 "validate", 

53 "con", 

54 "prn", 

55 "aux", 

56 "nul", 

57 *(f"{port}{digit}" for port in ("com", "lpt") for digit in range(10)), 

58 } 

59) 

60_KEY_DROPPED = re.compile(r"[()]") 

61_KEY_SEPARATORS = re.compile(r"[\s_-]+") 

62 

63 

64class ProfileFolder(StrEnum): 

65 """A folder lilbee reads profiles from, highest precedence first.""" 

66 

67 PROJECT = "project" 

68 GLOBAL = "global" 

69 BUILTIN = "builtin" 

70 

71 

72class NameProblem(StrEnum): 

73 """Why a name cannot go on a new profile file in a folder.""" 

74 

75 EMPTY = "empty" 

76 INVALID = "invalid" 

77 RESERVED = "reserved" 

78 RESERVED_WORD = "reserved_word" 

79 TAKEN = "taken" 

80 

81 

82class ProfileFileError(ValueError): 

83 """A profile file lilbee cannot use, with the reason as its message.""" 

84 

85 

86class ProfileNameClashError(ProfileFileError): 

87 """A write's profile name already names another file in the same folder.""" 

88 

89 

90@dataclass(frozen=True) 

91class ProfileAuthor: 

92 """One credited author of a profile.""" 

93 

94 name: str 

95 github: str | None 

96 

97 

98@dataclass(frozen=True) 

99class ProfileFile: 

100 """A valid profile file: its metadata and the settings it holds.""" 

101 

102 name: str 

103 description: str | None 

104 authors: tuple[ProfileAuthor, ...] 

105 tested_on: str | None 

106 format: int 

107 min_lilbee: str | None 

108 evidence: str | None 

109 values: Mapping[str, Any] 

110 

111 

112@dataclass(frozen=True) 

113class ProfileEntry: 

114 """One profile file found in a folder; broken files carry the reason in ``error``.""" 

115 

116 name: str 

117 folder: ProfileFolder 

118 path: Path 

119 file: ProfileFile | None 

120 error: str | None 

121 shadowed_by: ProfileFolder | None = None 

122 

123 

124@dataclass(frozen=True) 

125class ProfileCatalog: 

126 """Every profile file found: Default, then the other built-ins, then the rest.""" 

127 

128 entries: tuple[ProfileEntry, ...] 

129 

130 def usable_names(self) -> list[str]: 

131 """Names a pick can apply: valid profiles that no higher folder hides.""" 

132 return [e.name for e in self.entries if e.file is not None and e.shadowed_by is None] 

133 

134 def find(self, name: str) -> ProfileEntry | None: 

135 """The entry a name picks: the highest-precedence one with that lookup key.""" 

136 key = profile_key(name) 

137 return next( 

138 (e for e in self.entries if e.shadowed_by is None and profile_key(e.name) == key), 

139 None, 

140 ) 

141 

142 

143def profile_key(name: str) -> str: 

144 """The lookup key of a name and the stem of a file lilbee writes for it: a lowercase slug.""" 

145 words = _KEY_DROPPED.sub("", name.casefold()) 

146 return _KEY_SEPARATORS.sub("-", words).strip("-") 

147 

148 

149def profile_folders(data_root: Path) -> list[tuple[ProfileFolder, Path]]: 

150 """The profile folders for *data_root*, highest precedence first.""" 

151 global_root = default_data_dir() 

152 folders = [ 

153 (ProfileFolder.GLOBAL, global_root / PROFILES_DIRNAME), 

154 (ProfileFolder.BUILTIN, PACKAGE_PROFILES_DIR / BUILTIN_DIRNAME), 

155 ] 

156 if canonical_data_root(data_root) != canonical_data_root(global_root): 

157 folders.insert(0, (ProfileFolder.PROJECT, data_root / PROFILES_DIRNAME)) 

158 return folders 

159 

160 

161def _needs_newer(min_lilbee: str | None) -> str | None: 

162 """The "needs a newer lilbee" message when *min_lilbee* is above the running version.""" 

163 if min_lilbee is None or Version(min_lilbee) <= Version(installed_version("lilbee")): 

164 return None 

165 return f"Needs lilbee {min_lilbee} or newer" 

166 

167 

168def _optional_str(meta: Mapping[str, Any], key: str) -> str | None: 

169 value = meta.get(key) 

170 # untyped TOML: a metadata field can hold any TOML type 

171 if value is not None and not isinstance(value, str): 

172 raise ProfileFileError(f"{key} must be text") 

173 return value 

174 

175 

176def _parse_author(raw: Any) -> ProfileAuthor: 

177 # untyped TOML: each author must be a table with a text name 

178 if not isinstance(raw, dict) or not isinstance(raw.get("name"), str): 

179 raise ProfileFileError("Each author needs a name") 

180 unknown = sorted(set(raw) - _AUTHOR_KEYS) 

181 if unknown: 

182 raise ProfileFileError(f"Unknown author field: {unknown[0]}") 

183 return ProfileAuthor(name=raw["name"], github=_optional_str(raw, "github")) 

184 

185 

186def _parse_authors(meta: Mapping[str, Any]) -> tuple[ProfileAuthor, ...]: 

187 raw = meta.get("authors", []) 

188 # untyped TOML: authors must be an array of tables 

189 if not isinstance(raw, list): 

190 raise ProfileFileError("authors must be a list of { name, github } tables") 

191 return tuple(_parse_author(item) for item in raw) 

192 

193 

194def _parse_format(meta: Mapping[str, Any]) -> int: 

195 value = meta.get("format", PROFILE_FORMAT) 

196 if value != PROFILE_FORMAT or isinstance(value, bool): 

197 raise ProfileFileError( 

198 f"Unknown profile format {value!r}; this lilbee reads format {PROFILE_FORMAT}" 

199 ) 

200 return PROFILE_FORMAT 

201 

202 

203def _parse_min_lilbee(meta: Mapping[str, Any]) -> str | None: 

204 value = _optional_str(meta, "min_lilbee") 

205 if value is None: 

206 return None 

207 try: 

208 Version(value) 

209 except InvalidVersion: 

210 raise ProfileFileError(f"min_lilbee is not a version: {value!r}") from None 

211 return value 

212 

213 

214def _bad_name(name: str) -> bool: 

215 return _NAME_PATTERN.fullmatch(name) is None or not profile_key(name) 

216 

217 

218def _check_name(meta: Mapping[str, Any], stem: str) -> str: 

219 raw = _optional_str(meta, "name") 

220 name = stem if raw is None else raw 

221 if _bad_name(name): 

222 raise ProfileFileError( 

223 f"Bad name {name!r}: use 1 to {MAX_NAME_LENGTH} ASCII letters, digits, spaces, " 

224 "hyphens, underscores or parentheses" 

225 ) 

226 if profile_key(name) in RESERVED_NAME_KEYS: 

227 raise ProfileFileError(f"Reserved name: {name} cannot name a profile") 

228 return name 

229 

230 

231def _assign(trial: Config, key: str, value: Any) -> Any: 

232 """*value* as *trial* holds it after its field validator runs; raises on a bad value.""" 

233 try: 

234 setattr(trial, key, value) 

235 except ValidationError as exc: 

236 raise ProfileFileError(f"Bad value for {key}: {exc.errors()[0]['msg']}") from None 

237 # A validator's TypeError is a bad value 

238 except TypeError as exc: 

239 raise ProfileFileError(f"Bad value for {key}: {exc}") from None 

240 return getattr(trial, key) 

241 

242 

243def normalized_values(values: Mapping[str, Any]) -> dict[str, Any]: 

244 """Each value as Config holds it once its field validator runs; raises on a bad value.""" 

245 trial = cfg.model_copy() 

246 return {key: _assign(trial, key, value) for key, value in values.items()} 

247 

248 

249def _normalized(trial: Config, key: str, value: Any, min_lilbee: str | None) -> Any: 

250 """*value* as Config holds it; raises when *key* is not a profile setting or *value* is bad.""" 

251 if key not in Config.model_fields: 

252 raise ProfileFileError(_needs_newer(min_lilbee) or f"Unknown setting: {key}") 

253 if key not in PROFILE_FIELDS: 

254 raise ProfileFileError(f"Profiles cannot set {key}") 

255 try: 

256 return _assign(trial, key, value) 

257 except ProfileFileError as exc: 

258 raise ProfileFileError(_needs_newer(min_lilbee) or str(exc)) from None 

259 

260 

261def _overlap_problems(values: Mapping[str, Any]) -> list[str]: 

262 size, overlap = values.get("chunk_size"), values.get("chunk_overlap") 

263 if size is not None and overlap is not None and overlap >= size: 

264 return [f"chunk_overlap ({overlap}) must be < chunk_size ({size})"] 

265 return [] 

266 

267 

268def _evidence_problems(values: Mapping[str, Any], evidence: str | None) -> list[str]: 

269 """A built-in profile sets a retrieval value unequal to the default only with evidence.""" 

270 if evidence is not None: 

271 return [] 

272 return [ 

273 f"Sets retrieval setting {key} without evidence" 

274 for key, value in values.items() 

275 if PROFILE_FIELDS[key] is ProfileScope.RETRIEVAL and value != builtin_value(key) 

276 ] 

277 

278 

279def _value_problems( 

280 raw: Mapping[str, Any], min_lilbee: str | None, folder: ProfileFolder, evidence: str | None 

281) -> list[str]: 

282 problems: list[str] = [] 

283 normalized: dict[str, Any] = {} 

284 trial = cfg.model_copy() 

285 for key, value in raw.items(): 

286 try: 

287 normalized[key] = _normalized(trial, key, value, min_lilbee) 

288 except ProfileFileError as exc: 

289 problems.append(str(exc)) 

290 problems += _overlap_problems(normalized) 

291 if folder is ProfileFolder.BUILTIN: 

292 problems += _evidence_problems(normalized, evidence) 

293 return problems 

294 

295 

296def _checked(problems: list[str], check: Callable[[], T]) -> T | None: 

297 """The result of *check*, or None with its reason added to *problems*.""" 

298 try: 

299 return check() 

300 except ProfileFileError as exc: 

301 problems.append(str(exc)) 

302 return None 

303 

304 

305def _meta_problems(meta: Mapping[str, Any], stem: str) -> tuple[list[str], str | None]: 

306 """Every problem in the ``[profile]`` table, and its ``min_lilbee`` when that is valid.""" 

307 problems = [f"Unknown profile field: {key}" for key in sorted(set(meta) - _META_KEYS)] 

308 _checked(problems, partial(_check_name, meta, stem)) 

309 for key in ("description", "tested_on", "evidence"): 

310 _checked(problems, partial(_optional_str, meta, key)) 

311 min_lilbee = _checked(problems, partial(_parse_min_lilbee, meta)) 

312 _checked(problems, partial(_parse_authors, meta)) 

313 _checked(problems, partial(_parse_format, meta)) 

314 return problems, min_lilbee 

315 

316 

317def profile_problems(data: Mapping[str, Any], stem: str, folder: ProfileFolder) -> list[str]: 

318 """Every reason parsed TOML is not a valid profile file; empty when it is one.""" 

319 problems = [f"Unknown table: {key}" for key in sorted(set(data) - _TOP_LEVEL_KEYS)] 

320 meta = data.get(META_TABLE, {}) 

321 # untyped TOML: [profile] must be a table 

322 if not isinstance(meta, dict): 

323 problems.append("[profile] must be a table") 

324 meta = {} 

325 meta_problems, min_lilbee = _meta_problems(meta, stem) 

326 problems += meta_problems 

327 raw = data.get(VALUES_TABLE) 

328 # untyped TOML: [values] must be a table 

329 if not isinstance(raw, dict): 

330 problems.append("Missing [values] table") 

331 else: 

332 evidence = meta.get("evidence") 

333 # untyped TOML: a non-text evidence is already a problem and counts as none 

334 evidence = evidence if isinstance(evidence, str) else None 

335 problems += _value_problems(raw, min_lilbee, folder, evidence) 

336 return list(dict.fromkeys(problems)) 

337 

338 

339def parse_profile(data: Mapping[str, Any], stem: str, folder: ProfileFolder) -> ProfileFile: 

340 """Validate parsed TOML as a profile file; raises ``ProfileFileError`` with the first reason.""" 

341 problems = profile_problems(data, stem, folder) 

342 if problems: 

343 raise ProfileFileError(problems[0]) 

344 meta = data.get(META_TABLE, {}) 

345 return ProfileFile( 

346 name=_check_name(meta, stem), 

347 description=meta.get("description"), 

348 authors=_parse_authors(meta), 

349 tested_on=meta.get("tested_on"), 

350 format=PROFILE_FORMAT, 

351 min_lilbee=meta.get("min_lilbee"), 

352 evidence=meta.get("evidence"), 

353 values=dict(data[VALUES_TABLE]), 

354 ) 

355 

356 

357def _check_size(size: int) -> None: 

358 """Raise ``ProfileFileError`` when *size* bytes is over the profile file cap.""" 

359 if size > MAX_PROFILE_BYTES: 

360 raise ProfileFileError( 

361 f"The file is over {MAX_PROFILE_BYTES // 1024} KB, too large for a profile file" 

362 ) 

363 

364 

365def _parse_toml(text: str) -> dict[str, Any]: 

366 """The parsed TOML in *text*; raises ``ProfileFileError`` when it is too large or not TOML.""" 

367 _check_size(len(text.encode("utf-8"))) 

368 try: 

369 return tomllib.loads(text) 

370 # tomllib raises RecursionError on arrays nested a few hundred deep 

371 except (tomllib.TOMLDecodeError, RecursionError) as exc: 

372 raise ProfileFileError(f"Not valid TOML: {exc}") from None 

373 

374 

375def read_profile_text(path: Path) -> str: 

376 """The text of the file at *path*; raises ``ProfileFileError`` when it cannot be read.""" 

377 try: 

378 with path.open("rb") as f: 

379 data = f.read(MAX_PROFILE_BYTES + 1) 

380 except FileNotFoundError: 

381 raise ProfileFileError(f"No file at {path}") from None 

382 except OSError as exc: 

383 raise ProfileFileError(f"Cannot read the file: {exc}") from None 

384 _check_size(len(data)) 

385 try: 

386 return data.decode("utf-8") 

387 except UnicodeDecodeError: 

388 raise ProfileFileError("Not UTF-8 text") from None 

389 

390 

391def _load_toml(path: Path) -> dict[str, Any]: 

392 """The parsed TOML at *path*; raises ``ProfileFileError`` when it cannot be read or parsed.""" 

393 return _parse_toml(read_profile_text(path)) 

394 

395 

396def read_entry(path: Path, folder: ProfileFolder) -> ProfileEntry: 

397 """Read and validate one profile file; a broken file is returned with its reason.""" 

398 try: 

399 data = _load_toml(path) 

400 except ProfileFileError as exc: 

401 return ProfileEntry(path.stem, folder, path, None, str(exc)) 

402 try: 

403 profile = parse_profile(data, path.stem, folder) 

404 except ProfileFileError as exc: 

405 return ProfileEntry(_entry_name(data, path.stem), folder, path, None, str(exc)) 

406 return ProfileEntry(profile.name, folder, path, profile, None) 

407 

408 

409def _entry_name(data: Mapping[str, Any], stem: str) -> str: 

410 """The name a broken file shows under: its own name when readable, else the file stem.""" 

411 meta = data.get(META_TABLE) 

412 # untyped TOML: a broken file's [profile] table may hold anything 

413 name = meta.get("name") if isinstance(meta, dict) else None 

414 return name if isinstance(name, str) else stem 

415 

416 

417def _read_folder(folder: ProfileFolder, path: Path) -> list[ProfileEntry]: 

418 """Every profile file in *path*, sorted by file name; a missing folder is empty.""" 

419 if not path.is_dir(): 

420 return [] 

421 files = sorted(p for p in path.glob(f"*{PROFILE_SUFFIX}") if p.is_file()) 

422 return _mark_duplicates([read_entry(p, folder) for p in files]) 

423 

424 

425def _broken(entry: ProfileEntry, reason: str) -> ProfileEntry: 

426 return replace(entry, file=None, error=reason) 

427 

428 

429def _mark_duplicates(entries: list[ProfileEntry]) -> list[ProfileEntry]: 

430 """Mark every entry whose lookup key another entry in the same folder shares.""" 

431 keys = [profile_key(e.name) for e in entries] 

432 return [ 

433 _broken(e, f"Duplicate name in the {e.folder.value} folder") if keys.count(key) > 1 else e 

434 for e, key in zip(entries, keys, strict=True) 

435 ] 

436 

437 

438def _reserved_reason(name: str) -> str: 

439 return f"Reserved name: {name} is a built-in profile" 

440 

441 

442def _settle_names(entries: list[ProfileEntry]) -> list[ProfileEntry]: 

443 """Break non-builtin entries that take a built-in name; shadow each lower entry by name.""" 

444 reserved = {profile_key(e.name) for e in entries if e.folder is ProfileFolder.BUILTIN} 

445 owner: dict[str, ProfileFolder] = {} 

446 settled: list[ProfileEntry] = [] 

447 for entry in entries: 

448 key = profile_key(entry.name) 

449 if entry.folder is not ProfileFolder.BUILTIN and key in reserved: 

450 entry = _broken(entry, _reserved_reason(entry.name)) 

451 settled.append(replace(entry, shadowed_by=ProfileFolder.BUILTIN)) 

452 continue 

453 winner = owner.setdefault(key, entry.folder) 

454 settled.append(entry if winner is entry.folder else replace(entry, shadowed_by=winner)) 

455 return settled 

456 

457 

458def scan(folders: Sequence[tuple[ProfileFolder, Path]]) -> ProfileCatalog: 

459 """Read every profile file in *folders*, given highest precedence first. 

460 

461 The catalog lists Default, then the other built-ins, then the rest. 

462 """ 

463 entries = [entry for folder, path in folders for entry in _read_folder(folder, path)] 

464 return ProfileCatalog(entries=tuple(sorted(_settle_names(entries), key=_listing_rank))) 

465 

466 

467def _listing_rank(entry: ProfileEntry) -> int: 

468 """Sort key that lists Default first, then the other built-ins, then every other profile.""" 

469 if entry.folder is not ProfileFolder.BUILTIN: 

470 return 2 

471 return 0 if profile_key(entry.name) == profile_key(DEFAULT_PROFILE_NAME) else 1 

472 

473 

474@dataclass(frozen=True) 

475class ProfileStore: 

476 """Scans the profile folders of the current data root on each call.""" 

477 

478 def scan(self) -> ProfileCatalog: 

479 """Every profile visible from ``cfg.data_root`` now.""" 

480 return scan(profile_folders(cfg.data_root)) 

481 

482 

483@dataclass(frozen=True) 

484class ProfileValidation: 

485 """Every problem found in one profile file; empty ``problems`` means it is valid.""" 

486 

487 path: Path 

488 name: str 

489 problems: tuple[str, ...] 

490 

491 @property 

492 def valid(self) -> bool: 

493 """True when the file has no problems.""" 

494 return not self.problems 

495 

496 

497@dataclass(frozen=True) 

498class PlannedWrite: 

499 """A validated profile file and the free path it goes to; nothing is written until ``write``.""" 

500 

501 path: Path 

502 folder: ProfileFolder 

503 profile: ProfileFile 

504 text: str 

505 replacing: Path | None 

506 

507 def write(self) -> Path: 

508 """Write the file atomically, then remove the file it replaces when that is another file.""" 

509 stale = ( 

510 None 

511 if self.replacing is None or _same_file(self.replacing, self.path) 

512 else self.replacing 

513 ) 

514 write_text_atomically(self.path, self.text) 

515 if stale is not None: 

516 stale.unlink(missing_ok=True) 

517 return self.path 

518 

519 

520def _same_file(first: Path, second: Path) -> bool: 

521 """True when both paths exist and name one file, whatever their spelling or case.""" 

522 try: 

523 return first.samefile(second) 

524 except OSError: 

525 return False 

526 

527 

528@cache 

529def _keys_in(folder: Path) -> frozenset[str]: 

530 """The lookup keys of the built-in files in *folder*, which ship with the package.""" 

531 return frozenset(profile_key(e.name) for e in _read_folder(ProfileFolder.BUILTIN, folder)) 

532 

533 

534def builtin_keys() -> frozenset[str]: 

535 """The lookup keys of the built-in profiles, which no other profile may take.""" 

536 return _keys_in(PACKAGE_PROFILES_DIR / BUILTIN_DIRNAME) 

537 

538 

539def validate_file(path: Path, folder: ProfileFolder) -> ProfileValidation: 

540 """Every problem with *path* as a profile file in *folder*.""" 

541 try: 

542 text = read_profile_text(path) 

543 except ProfileFileError as exc: 

544 return ProfileValidation(path, path.stem, (str(exc),)) 

545 return validate_text(text, path, folder) 

546 

547 

548def validate_text(text: str, path: Path, folder: ProfileFolder) -> ProfileValidation: 

549 """Every problem with *text*, the content of the file at *path*, as a profile in *folder*.""" 

550 try: 

551 data = _parse_toml(text) 

552 except ProfileFileError as exc: 

553 return ProfileValidation(path, path.stem, (str(exc),)) 

554 name = _entry_name(data, path.stem) 

555 problems = profile_problems(data, path.stem, folder) 

556 if folder is not ProfileFolder.BUILTIN and profile_key(name) in builtin_keys(): 

557 problems.append(_reserved_reason(name)) 

558 return ProfileValidation(path, name, tuple(problems)) 

559 

560 

561def profile_text(profile: ProfileFile) -> str: 

562 """*profile* as the text of a clean profile file.""" 

563 authors = [ 

564 {k: v for k, v in (("name", a.name), ("github", a.github)) if v is not None} 

565 for a in profile.authors 

566 ] 

567 meta = { 

568 "name": profile.name, 

569 "description": profile.description, 

570 "authors": authors or None, 

571 "tested_on": profile.tested_on, 

572 "format": profile.format, 

573 "min_lilbee": profile.min_lilbee, 

574 "evidence": profile.evidence, 

575 } 

576 table = {key: value for key, value in meta.items() if value is not None} 

577 return tomli_w.dumps({META_TABLE: table, VALUES_TABLE: dict(profile.values)}) 

578 

579 

580def parse_text(text: str, stem: str, folder: ProfileFolder) -> ProfileFile: 

581 """Validate *text* as a profile file named *stem* when it names none; raises on a problem.""" 

582 return parse_profile(_parse_toml(text), stem, folder) 

583 

584 

585def _clash( 

586 directory: Path, folder: ProfileFolder, planned: Path, replacing: Path | None 

587) -> Path | None: 

588 """A file in *directory*, other than *replacing*, at *planned* or holding the same name.""" 

589 if not directory.is_dir(): 

590 return None 

591 key = planned.stem.casefold() 

592 for path in sorted(directory.glob(f"*{PROFILE_SUFFIX}")): 

593 if path != replacing and key in _name_keys(read_entry(path, folder)): 

594 return path 

595 return None 

596 

597 

598def _name_keys(entry: ProfileEntry) -> tuple[str, str]: 

599 """The keys *entry* takes: its name and its file stem, since a file system can fold case.""" 

600 return profile_key(entry.name), entry.path.stem.casefold() 

601 

602 

603def name_problem(name: str, folder: ProfileFolder, catalog: ProfileCatalog) -> NameProblem | None: 

604 """Why ``plan_write`` would refuse *name* in *folder*, judged from *catalog*, or None.""" 

605 if not name.strip(): 

606 return NameProblem.EMPTY 

607 if _bad_name(name): 

608 return NameProblem.INVALID 

609 key = profile_key(name) 

610 if key in RESERVED_NAME_KEYS: 

611 return NameProblem.RESERVED_WORD 

612 taken = {(e.folder, k) for e in catalog.entries for k in _name_keys(e)} 

613 if (ProfileFolder.BUILTIN, key) in taken: 

614 return NameProblem.RESERVED 

615 return NameProblem.TAKEN if (folder, key) in taken else None 

616 

617 

618def plan_write( 

619 directory: Path, folder: ProfileFolder, text: str, *, stem: str, replacing: Path | None = None 

620) -> PlannedWrite: 

621 """Validate *text* and pick its file in *directory*, named by the profile's slug. 

622 

623 Raises ``ProfileFileError`` when the text is invalid or another file there has the name; 

624 *replacing* is the file this write supersedes. 

625 """ 

626 profile = parse_text(text, stem, folder) 

627 key = profile_key(profile.name) 

628 if key in builtin_keys(): 

629 raise ProfileFileError(_reserved_reason(profile.name)) 

630 path = directory / f"{key}{PROFILE_SUFFIX}" 

631 clash = _clash(directory, folder, path, replacing) 

632 if clash is not None: 

633 raise ProfileNameClashError(f"A profile named {profile.name} already exists: {clash}") 

634 return PlannedWrite(path, folder, profile, text, replacing)