Coverage for src/lilbee/app/profiles.py: 100%

330 statements  

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

1"""Profile use cases: list, show, the active profile, diff, apply, and the file operations.""" 

2 

3from __future__ import annotations 

4 

5from collections.abc import Iterable, Mapping, Sequence 

6from dataclasses import dataclass, replace 

7from enum import StrEnum 

8from pathlib import Path 

9from typing import Any 

10 

11import tomli_w 

12 

13from lilbee.app.settings import ( 

14 apply_profile_layer, 

15 index_has_chunks, 

16 inert_reindex_keys, 

17 list_settings, 

18 ocr_off_vision_notice, 

19 reset_settings, 

20) 

21from lilbee.app.status import held_out_sources 

22from lilbee.config_meta import PUBLIC_CONFIG_FIELDS, REINDEX_FIELDS 

23from lilbee.core import settings as persistent_settings 

24from lilbee.core.config import cfg 

25from lilbee.core.config.enums import ProfileScope, SettingSource 

26from lilbee.core.config.resolve import ( 

27 PROFILE_FIELDS, 

28 Resolved, 

29 SettingLayers, 

30 builtin_value, 

31 read_layers, 

32 read_profile_table, 

33 read_table_and_layers, 

34 resolve, 

35) 

36from lilbee.core.profile_files import ( 

37 DEFAULT_PROFILE_NAME, 

38 META_TABLE, 

39 PROFILE_FORMAT, 

40 PROFILE_SUFFIX, 

41 VALUES_TABLE, 

42 PlannedWrite, 

43 ProfileAuthor, 

44 ProfileCatalog, 

45 ProfileEntry, 

46 ProfileFile, 

47 ProfileFolder, 

48 ProfileStore, 

49 ProfileValidation, 

50 normalized_values, 

51 parse_text, 

52 plan_write, 

53 profile_folders, 

54 profile_key, 

55 profile_text, 

56 read_entry, 

57 read_profile_text, 

58 validate_file, 

59 validate_text, 

60) 

61 

62_OVERRIDE_SOURCES = frozenset({SettingSource.ENV, SettingSource.USER}) 

63_SAVE_FOLDERS = frozenset({ProfileFolder.PROJECT, ProfileFolder.GLOBAL}) 

64 

65ANALYZE_DESCRIPTION = "Recommended by lilbee analyze for this corpus." 

66 

67MCP_PROFILES_DISABLED_HINT = ( 

68 "The profile tools are off. Turn them on with settings_set mcp_profiles_enabled true, " 

69 "then reconnect the MCP server." 

70) 

71 

72 

73class ProfileNotFoundError(ValueError): 

74 """No profile has the name asked for.""" 

75 

76 

77class ProfileEffect(StrEnum): 

78 """When a changed profile value takes effect.""" 

79 

80 REINDEX = "reindex" 

81 NEW_FILES_ONLY = "new_files_only" 

82 NOW = "now" 

83 

84 

85class ProfileStatus(StrEnum): 

86 """How the applied profile's file compares with the copy recorded on apply.""" 

87 

88 CURRENT = "current" 

89 CHANGED = "changed" 

90 MISSING = "missing" 

91 BROKEN = "broken" 

92 

93 

94@dataclass(frozen=True) 

95class DiffRow: 

96 """One setting a profile apply changes.""" 

97 

98 key: str 

99 current: Any 

100 current_source: SettingSource 

101 new: Any 

102 effect: ProfileEffect 

103 

104 

105@dataclass(frozen=True) 

106class ProfileDiff: 

107 """What applying a profile changes, which set values it keeps, and how many it never touches.""" 

108 

109 name: str 

110 changes: tuple[DiffRow, ...] 

111 kept: tuple[str, ...] 

112 untouched_count: int 

113 

114 

115@dataclass(frozen=True) 

116class ChangeRow: 

117 """A profile setting config.toml sets, next to the value and source it falls back to.""" 

118 

119 key: str 

120 yours: Any 

121 profile_value: Any 

122 profile_source: SettingSource 

123 effect: ProfileEffect 

124 

125 

126@dataclass(frozen=True) 

127class ActiveProfile: 

128 """The project's applied profile, its recorded values, your changes, and its file's state.""" 

129 

130 name: str 

131 values: Mapping[str, Any] 

132 status: ProfileStatus 

133 changes: tuple[ChangeRow, ...] 

134 error: str | None = None 

135 entry: ProfileEntry | None = None 

136 

137 

138@dataclass(frozen=True) 

139class ProfileLocation: 

140 """A profile file an operation wrote or removed.""" 

141 

142 name: str 

143 folder: ProfileFolder 

144 path: Path 

145 

146 

147@dataclass(frozen=True) 

148class SaveResult: 

149 """A saved profile file, the settings of yours it took over, and any notice the write 

150 raises.""" 

151 

152 location: ProfileLocation 

153 absorbed: tuple[str, ...] 

154 warnings: tuple[str, ...] = () 

155 

156 

157@dataclass(frozen=True) 

158class DiscardResult: 

159 """Your settings removed so the profile's values show through, whether to rebuild, and 

160 any notice the discard raises.""" 

161 

162 dropped: tuple[str, ...] 

163 reindex_required: bool 

164 warnings: tuple[str, ...] = () 

165 

166 

167@dataclass(frozen=True) 

168class ApplyResult: 

169 """The outcome of a profile apply, and any notice it raises.""" 

170 

171 name: str 

172 changes: tuple[DiffRow, ...] 

173 reindex_required: bool 

174 new_files_only: tuple[str, ...] 

175 kept: tuple[str, ...] 

176 warnings: tuple[str, ...] = () 

177 

178 

179def effect_of(key: str) -> ProfileEffect: 

180 """When a change to the profile setting *key* takes effect.""" 

181 if key in REINDEX_FIELDS: 

182 return ProfileEffect.REINDEX 

183 if PROFILE_FIELDS[key] is ProfileScope.INGEST: 

184 return ProfileEffect.NEW_FILES_ONLY 

185 return ProfileEffect.NOW 

186 

187 

188def list_profiles(store: ProfileStore) -> ProfileCatalog: 

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

190 return store.scan() 

191 

192 

193def show(store: ProfileStore, name: str) -> ProfileEntry: 

194 """The entry *name* picks; raises ``ValueError`` when no profile has that name.""" 

195 entry = store.scan().find(name) 

196 if entry is None: 

197 table, _ = read_table_and_layers(cfg.data_root) 

198 if table.name and profile_key(table.name) == profile_key(name): 

199 raise ProfileNotFoundError( 

200 f"No profile named {name!r}. {name!r} is this project's active profile, but " 

201 "no profile file has that name; the copy saved when you applied it stays in use. " 

202 "Save or import a profile with that name, or apply another profile." 

203 ) 

204 raise ProfileNotFoundError(f"No profile named {name!r}") 

205 return entry 

206 

207 

208def _valid_file(entry: ProfileEntry) -> ProfileFile: 

209 """The profile file of *entry*; raises ``ValueError`` when it is broken.""" 

210 if entry.file is None: 

211 raise ValueError(f"Profile {entry.name!r} cannot be used: {entry.error}") 

212 return entry.file 

213 

214 

215def usable(store: ProfileStore, name: str) -> ProfileFile: 

216 """The valid profile file *name* picks; raises ``ValueError`` when missing or broken.""" 

217 return _valid_file(show(store, name)) 

218 

219 

220def _file_status(entry: ProfileEntry | None, recorded: Mapping[str, Any]) -> ProfileStatus: 

221 if entry is None: 

222 return ProfileStatus.MISSING 

223 if entry.file is None: 

224 return ProfileStatus.BROKEN 

225 if dict(entry.file.values) != dict(recorded): 

226 return ProfileStatus.CHANGED 

227 return ProfileStatus.CURRENT 

228 

229 

230def active(store: ProfileStore) -> ActiveProfile: 

231 """The applied profile and whether its file is current, changed, gone or broken.""" 

232 table, layers = read_table_and_layers(cfg.data_root) 

233 name = table.name or DEFAULT_PROFILE_NAME 

234 entry = store.scan().find(name) 

235 changes = _changes_in(layers) 

236 if profile_key(name) == profile_key(DEFAULT_PROFILE_NAME): 

237 return ActiveProfile(name, table.values, ProfileStatus.CURRENT, changes, entry=entry) 

238 error = entry.error if entry is not None else None 

239 status = _file_status(entry, table.values) 

240 return ActiveProfile(name, table.values, status, changes, error, entry) 

241 

242 

243def _diff(profile: ProfileFile) -> ProfileDiff: 

244 layers = read_layers(cfg.data_root) 

245 current = {key: resolve(key, layers) for key in PROFILE_FIELDS} 

246 kept = tuple(key for key in profile.values if current[key].source in _OVERRIDE_SOURCES) 

247 after = replace(layers, profile=dict(profile.values)) 

248 open_keys = [key for key in PROFILE_FIELDS if current[key].source not in _OVERRIDE_SOURCES] 

249 new_values = normalized_values({key: resolve(key, after).value for key in open_keys}) 

250 changes = tuple( 

251 DiffRow(key, getattr(cfg, key), current[key].source, new_values[key], effect_of(key)) 

252 for key in open_keys 

253 if new_values[key] != getattr(cfg, key) 

254 ) 

255 return ProfileDiff( 

256 name=profile.name, 

257 changes=changes, 

258 kept=kept, 

259 untouched_count=len(PUBLIC_CONFIG_FIELDS - set(PROFILE_FIELDS)), 

260 ) 

261 

262 

263def preview(name: str, values: Mapping[str, Any]) -> ProfileDiff: 

264 """What applying an unsaved profile of *values* changes and which set values it keeps.""" 

265 return _diff(_bare(name, values)) 

266 

267 

268def diff(store: ProfileStore, name: str) -> ProfileDiff: 

269 """What applying *name* changes; raises ``ValueError`` when it is missing or broken.""" 

270 return _diff(usable(store, name)) 

271 

272 

273class RebuildReason(StrEnum): 

274 """Why a rebuild after applying or discarding profile values changes the index.""" 

275 

276 CHANGES = "changes" 

277 SETS_REINDEX = "sets_reindex" 

278 HELD_OUT = "held_out" 

279 

280 

281def rebuild_reason(keys: Iterable[str], reindex_required: bool) -> RebuildReason | None: 

282 """Why a rebuild after the profile's values of *keys* take effect changes the index. 

283 

284 None when it changes nothing. A key whose gating setting is off, such as 

285 ``table_model`` without layout detection, changes no index. A rebuild also 

286 retries each held-out file, the only way a raised limit reaches them: a limit 

287 is not a setting that requires a rebuild. 

288 """ 

289 if index_has_chunks(): 

290 if reindex_required: 

291 return RebuildReason.CHANGES 

292 if (REINDEX_FIELDS - inert_reindex_keys()) & set(keys): 

293 return RebuildReason.SETS_REINDEX 

294 if held_out_sources()[1] > 0: 

295 return RebuildReason.HELD_OUT 

296 return None 

297 

298 

299def apply_needs_rebuild(store: ProfileStore, name: str, reindex_required: bool) -> bool: 

300 """Whether a rebuild after applying profile *name* changes the index.""" 

301 return rebuild_reason(usable(store, name).values, reindex_required) is not None 

302 

303 

304def apply(store: ProfileStore, name: str) -> ApplyResult: 

305 """Record *name* as the project's profile and set cfg from it; user and env values stay.""" 

306 profile = usable(store, name) 

307 planned = _diff(profile) 

308 result = apply_profile_layer(profile.name, profile.values) 

309 return _apply_result(planned, result.warnings) 

310 

311 

312def _apply_result(planned: ProfileDiff, warnings: tuple[str, ...] = ()) -> ApplyResult: 

313 effects = {row.key: row.effect for row in planned.changes} 

314 return ApplyResult( 

315 name=planned.name, 

316 changes=planned.changes, 

317 reindex_required=ProfileEffect.REINDEX in effects.values() and index_has_chunks(), 

318 new_files_only=tuple(k for k, e in effects.items() if e is ProfileEffect.NEW_FILES_ONLY), 

319 kept=planned.kept, 

320 warnings=warnings, 

321 ) 

322 

323 

324def _target_dir(target: ProfileFolder) -> Path: 

325 """The folder a new profile file goes to; only this project's or the global folder.""" 

326 if target not in _SAVE_FOLDERS: 

327 raise ValueError("Profiles are saved to this project or to all projects") 

328 folders = dict(profile_folders(cfg.data_root)) 

329 if target not in folders: 

330 raise ValueError( 

331 "lilbee is using the global data folder, so there is no project folder; " 

332 "save the profile for all projects instead" 

333 ) 

334 return folders[target] 

335 

336 

337def _location(planned: PlannedWrite) -> ProfileLocation: 

338 return ProfileLocation(planned.profile.name, planned.folder, planned.path) 

339 

340 

341def _written(planned: PlannedWrite) -> ProfileLocation: 

342 planned.write() 

343 return _location(planned) 

344 

345 

346def owned(store: ProfileStore, name: str, instead: str = "duplicate it") -> ProfileEntry: 

347 """The project or global profile *name* picks; raises for one that ships with lilbee.""" 

348 entry = show(store, name) 

349 if entry.folder is ProfileFolder.BUILTIN: 

350 raise ValueError(f"{entry.name} ships with lilbee and cannot be changed; {instead} instead") 

351 return entry 

352 

353 

354def _owned_file( 

355 store: ProfileStore, name: str, instead: str = "duplicate it" 

356) -> tuple[ProfileEntry, ProfileFile]: 

357 entry = owned(store, name, instead) 

358 return entry, _valid_file(entry) 

359 

360 

361def _commented(text: str) -> list[str]: 

362 return [f"# {line}" for line in text.strip().splitlines()] 

363 

364 

365def _template_lines(key: str, value: Any, help_text: str) -> list[str]: 

366 """A template entry: the help text, then *value* set, or the default commented out.""" 

367 if value is not None: 

368 return [*_commented(help_text), tomli_w.dumps({key: value}).strip()] 

369 default = builtin_value(key) 

370 if default is None: 

371 return [ 

372 *_commented(help_text), 

373 f"# {key} has no default: leaving it out lets lilbee decide", 

374 ] 

375 return [*_commented(help_text), *_commented(tomli_w.dumps({key: default}))] 

376 

377 

378def _template_text(name: str, values: Mapping[str, Any]) -> str: 

379 """A profile file that lists every profile setting, set from *values* or commented out.""" 

380 help_texts = {info.key: info.help_text for info in list_settings()} 

381 blocks = [ 

382 "\n".join(_template_lines(key, values.get(key), help_texts[key])) for key in PROFILE_FIELDS 

383 ] 

384 head = tomli_w.dumps({META_TABLE: {"name": name, "format": PROFILE_FORMAT}}) 

385 return "\n\n".join([head.strip(), f"[{VALUES_TABLE}]", *blocks]) + "\n" 

386 

387 

388def new( 

389 store: ProfileStore, 

390 name: str, 

391 target: ProfileFolder = ProfileFolder.GLOBAL, 

392 from_name: str | None = None, 

393) -> ProfileLocation: 

394 """Write a template for *name* listing every profile setting; *from_name* sets its values.""" 

395 values = usable(store, from_name).values if from_name is not None else {} 

396 text = _template_text(name, values) 

397 return _written(plan_write(_target_dir(target), target, text, stem=name)) 

398 

399 

400def _your_profile_keys(layers: SettingLayers) -> tuple[str, ...]: 

401 """The profile settings config.toml sets, in profile-field order.""" 

402 return tuple(key for key in PROFILE_FIELDS if key in layers.user) 

403 

404 

405def your_changes() -> tuple[ChangeRow, ...]: 

406 """Your config.toml values of profile settings against the profile's; env-set keys stay out.""" 

407 return _changes_in(read_layers(cfg.data_root)) 

408 

409 

410def _changes_in(layers: SettingLayers) -> tuple[ChangeRow, ...]: 

411 underneath = replace(layers, user={}) 

412 return tuple( 

413 _change_row(key, layers.user[key], resolve(key, underneath)) 

414 for key in _your_profile_keys(layers) 

415 if key not in layers.env 

416 ) 

417 

418 

419def _change_row(key: str, yours: Any, fallback: Resolved) -> ChangeRow: 

420 return ChangeRow(key, yours, fallback.value, fallback.source, effect_of(key)) 

421 

422 

423def _project_values() -> tuple[dict[str, Any], tuple[str, ...]]: 

424 """The project's profile values with yours laid over them, and which keys are yours.""" 

425 layers = read_layers(cfg.data_root) 

426 yours = _your_profile_keys(layers) 

427 return {**layers.profile, **{key: layers.user[key] for key in yours}}, yours 

428 

429 

430def _switch_to(planned: PlannedWrite, yours: tuple[str, ...]) -> SaveResult: 

431 """Write *planned*, then make it the project's profile, taking *yours* out of config.toml. 

432 

433 A failure after the file is written keeps the file and says so. 

434 """ 

435 written: list[Path] = [] 

436 try: 

437 result = apply_profile_layer( 

438 planned.profile.name, 

439 planned.profile.values, 

440 absorb=yours, 

441 write_first=lambda: written.append(planned.write()), 

442 ) 

443 except (OSError, ValueError) as exc: 

444 if not written: 

445 raise 

446 raise ValueError( 

447 f"Saved the profile to {planned.path}, but switching this project to it failed: {exc}" 

448 ) from exc 

449 return SaveResult(_location(planned), yours, result.warnings) 

450 

451 

452def _bare(name: str, values: Mapping[str, Any]) -> ProfileFile: 

453 return ProfileFile( 

454 name=name, 

455 description=None, 

456 authors=(), 

457 tested_on=None, 

458 format=PROFILE_FORMAT, 

459 min_lilbee=None, 

460 evidence=None, 

461 values=values, 

462 ) 

463 

464 

465def save_as(name: str, target: ProfileFolder = ProfileFolder.GLOBAL) -> SaveResult: 

466 """Save the project's profile values plus yours as *name* and switch the project to it. 

467 

468 ``absorbed`` names your settings the new profile now holds; they leave config.toml. 

469 Environment variables are not saved. 

470 """ 

471 values, yours = _project_values() 

472 text = profile_text(_bare(name, values)) 

473 return _switch_to(plan_write(_target_dir(target), target, text, stem=name), yours) 

474 

475 

476def update(store: ProfileStore, name: str | None = None) -> SaveResult: 

477 """Write the project's profile values plus yours into the active profile's own file. 

478 

479 A given *name* must pick the active profile. 

480 """ 

481 table = read_profile_table(cfg.data_root) 

482 active_name = table.name or DEFAULT_PROFILE_NAME 

483 if name is not None and profile_key(name) != profile_key(active_name): 

484 raise ValueError(f"{name} is not this project's profile; {active_name} is") 

485 name = active_name 

486 entry, profile = _owned_file(store, name, "save your settings as a new profile") 

487 if dict(profile.values) != dict(table.values): 

488 raise ValueError( 

489 f"{entry.name} changed on disk since it was applied; apply it again to use " 

490 "the file, or save your settings as a new profile" 

491 ) 

492 values, yours = _project_values() 

493 text = profile_text(replace(profile, values=values)) 

494 directory = entry.path.parent 

495 planned = plan_write(directory, entry.folder, text, stem=entry.path.stem, replacing=entry.path) 

496 return _switch_to(planned, yours) 

497 

498 

499def default_save_folder() -> ProfileFolder: 

500 """This project's profile folder, or the global one when lilbee uses the global data folder.""" 

501 folders = dict(profile_folders(cfg.data_root)) 

502 return ProfileFolder.PROJECT if ProfileFolder.PROJECT in folders else ProfileFolder.GLOBAL 

503 

504 

505def _analyze_file_to_replace(store: ProfileStore, folder: ProfileFolder, name: str) -> Path | None: 

506 """The analyze-made file named *name* in *folder*; raises when a hand-made one has the name.""" 

507 path = _same_name_in(store, folder, name) 

508 if path is None: 

509 return None 

510 entry = read_entry(path, folder) 

511 if entry.file is None or entry.file.description != ANALYZE_DESCRIPTION: 

512 raise ValueError( 

513 f"A profile named {name} already exists in the {folder.value} folder; " 

514 "save the recommendation under another name with --save NAME" 

515 ) 

516 return path 

517 

518 

519def save_recommended( 

520 store: ProfileStore, 

521 name: str, 

522 values: Mapping[str, Any], 

523 target: ProfileFolder, 

524 *, 

525 switch: bool, 

526) -> SaveResult: 

527 """Save analyze's recommended *values* as *name*; *switch* also makes it this project's. 

528 

529 An earlier analyze file with the name is replaced; a hand-made one is refused. 

530 """ 

531 directory = _target_dir(target) 

532 replacing = _analyze_file_to_replace(store, target, name) 

533 text = profile_text(recommended_file(name, values)) 

534 planned = plan_write(directory, target, text, stem=name, replacing=replacing) 

535 if switch: 

536 return _switch_to(planned, ()) 

537 return SaveResult(_written(planned), ()) 

538 

539 

540def recommended_file(name: str, values: Mapping[str, Any]) -> ProfileFile: 

541 """Analyze's recommended profile *name* of *values*, as it is saved.""" 

542 return replace(_bare(name, values), description=ANALYZE_DESCRIPTION) 

543 

544 

545def apply_recommended( 

546 store: ProfileStore, name: str, values: Mapping[str, Any], target: ProfileFolder 

547) -> ApplyResult: 

548 """Save analyze's recommended *values* as *name* and switch this project to it.""" 

549 planned = _diff(recommended_file(name, values)) 

550 result = save_recommended(store, name, values, target, switch=True) 

551 return _apply_result(planned, result.warnings) 

552 

553 

554def discard() -> DiscardResult: 

555 """Remove your settings of profile keys from config.toml; environment variables stay.""" 

556 yours = _your_profile_keys(read_layers(cfg.data_root)) 

557 if not yours: 

558 return DiscardResult((), reindex_required=False) 

559 result = reset_settings(list(yours)) 

560 return DiscardResult(yours, result.reindex_required, ocr_off_vision_notice()) 

561 

562 

563def duplicate( 

564 store: ProfileStore, name: str, new_name: str, target: ProfileFolder = ProfileFolder.GLOBAL 

565) -> ProfileLocation: 

566 """Copy the profile *name* picks, with its metadata, as *new_name*.""" 

567 text = profile_text(replace(usable(store, name), name=new_name)) 

568 return _written(plan_write(_target_dir(target), target, text, stem=new_name)) 

569 

570 

571def rename(store: ProfileStore, name: str, new_name: str) -> ProfileLocation: 

572 """Rename a project or global profile in its folder; the project follows when it is active.""" 

573 entry, profile = _owned_file(store, name) 

574 text = profile_text(replace(profile, name=new_name)) 

575 planned = plan_write(entry.path.parent, entry.folder, text, stem=new_name, replacing=entry.path) 

576 location = _written(planned) 

577 table = read_profile_table(cfg.data_root) 

578 if table.name is not None and profile_key(table.name) == profile_key(entry.name): 

579 persistent_settings.write_profile_table(cfg.data_root, location.name, table.values) 

580 return location 

581 

582 

583def delete(store: ProfileStore, name: str) -> ProfileLocation: 

584 """Remove a project or global profile file; projects keep their recorded copy of it.""" 

585 entry = owned(store, name) 

586 entry.path.unlink() 

587 return ProfileLocation(entry.name, entry.folder, entry.path) 

588 

589 

590@dataclass(frozen=True) 

591class ExportedProfile: 

592 """A profile as the text of a clean file, and the file name it goes under.""" 

593 

594 filename: str 

595 folder: ProfileFolder 

596 profile: ProfileFile 

597 text: str 

598 

599 

600def export_text(store: ProfileStore, name: str) -> ExportedProfile: 

601 """The profile *name* picks as the text of a clean file named by its slug. 

602 

603 A built-in exports as "<name> copy": validate and import refuse the built-in name itself. 

604 """ 

605 entry = show(store, name) 

606 profile = _valid_file(entry) 

607 if entry.folder is ProfileFolder.BUILTIN: 

608 profile = replace(profile, name=f"{profile.name} copy") 

609 filename = f"{profile_key(profile.name)}{PROFILE_SUFFIX}" 

610 return ExportedProfile(filename, entry.folder, profile, profile_text(profile)) 

611 

612 

613def export( 

614 store: ProfileStore, name: str, dest: Path, *, overwrite: bool = False 

615) -> ProfileLocation: 

616 """Write the profile *name* picks as a clean file at *dest*, or in it when it is a folder. 

617 

618 Refuses when the destination file exists unless *overwrite*. Not a project or global save: 

619 there is no name-clash check against the profile store, only against the destination itself. 

620 """ 

621 exported = export_text(store, name) 

622 path = dest / exported.filename if dest.is_dir() else dest 

623 if path.exists() and not overwrite: 

624 raise ValueError(f"{path} already exists") 

625 planned = PlannedWrite( 

626 path, 

627 exported.folder, 

628 parse_text(exported.text, path.stem, exported.folder), 

629 exported.text, 

630 None, 

631 ) 

632 return _written(planned) 

633 

634 

635def _same_name_in(store: ProfileStore, folder: ProfileFolder, name: str) -> Path | None: 

636 key = profile_key(name) 

637 return next( 

638 (e.path for e in store.scan().entries if e.folder is folder and profile_key(e.name) == key), 

639 None, 

640 ) 

641 

642 

643def import_profile( 

644 store: ProfileStore, 

645 source: Path, 

646 target: ProfileFolder = ProfileFolder.GLOBAL, 

647 *, 

648 overwrite: bool = False, 

649) -> ProfileLocation: 

650 """Validate the file at *source* and copy it into *target*; a taken name needs *overwrite*. 

651 

652 A file that names no profile takes the slug of its file name as its name. 

653 """ 

654 return import_text(store, read_profile_text(source), source.name, target, overwrite=overwrite) 

655 

656 

657def import_text( 

658 store: ProfileStore, 

659 text: str, 

660 filename: str, 

661 target: ProfileFolder = ProfileFolder.GLOBAL, 

662 *, 

663 overwrite: bool = False, 

664) -> ProfileLocation: 

665 """Validate *text*, a profile file named *filename*, and copy it into *target*.""" 

666 directory = _target_dir(target) 

667 stem = profile_key(Path(filename).stem) 

668 profile = parse_text(text, stem, target) 

669 replacing = _same_name_in(store, target, profile.name) if overwrite else None 

670 return _written(plan_write(directory, target, text, stem=stem, replacing=replacing)) 

671 

672 

673def validate(path: Path, folder: ProfileFolder = ProfileFolder.GLOBAL) -> ProfileValidation: 

674 """Every problem with the file at *path* as a profile in *folder*.""" 

675 return validate_file(path, folder) 

676 

677 

678def validate_content( 

679 text: str, filename: str, folder: ProfileFolder = ProfileFolder.GLOBAL 

680) -> ProfileValidation: 

681 """Every problem with *text*, a profile file named *filename*, as a profile in *folder*.""" 

682 return validate_text(text, Path(filename), folder) 

683 

684 

685_STATUS_NOTES = { 

686 ProfileStatus.CURRENT: None, 

687 ProfileStatus.CHANGED: "Its file changed since it was applied; apply it again to use the file.", 

688 ProfileStatus.MISSING: "Its file is gone; the copy saved when you applied it stays in use.", 

689 ProfileStatus.BROKEN: "Its file is broken; the copy saved when you applied it stays in use.", 

690} 

691 

692 

693def status_note(status: ProfileStatus) -> str | None: 

694 """What an applied profile's file state means for the project; None when it is current.""" 

695 return _STATUS_NOTES[status] 

696 

697 

698def credit_line(authors: Sequence[ProfileAuthor]) -> str | None: 

699 """The authors as "by Jane Doe (@janedoe), Sam Roe"; None when the profile names none.""" 

700 if not authors: 

701 return None 

702 names = [f"{a.name} (@{a.github})" if a.github else a.name for a in authors] 

703 return f"by {', '.join(names)}" 

704 

705 

706def tested_on_line(tested_on: str | None, template: str) -> str | None: 

707 """*template* filled with *tested_on*; None when there is nothing to show.""" 

708 return template.format(text=tested_on) if tested_on else None 

709 

710 

711def file_failure_message(exc: OSError) -> str: 

712 """User-facing text for a profile or config file lilbee could not write or remove.""" 

713 return f"Could not save the change: {exc}"