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
« 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."""
3from __future__ import annotations
5from collections.abc import Iterable, Mapping, Sequence
6from dataclasses import dataclass, replace
7from enum import StrEnum
8from pathlib import Path
9from typing import Any
11import tomli_w
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)
62_OVERRIDE_SOURCES = frozenset({SettingSource.ENV, SettingSource.USER})
63_SAVE_FOLDERS = frozenset({ProfileFolder.PROJECT, ProfileFolder.GLOBAL})
65ANALYZE_DESCRIPTION = "Recommended by lilbee analyze for this corpus."
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)
73class ProfileNotFoundError(ValueError):
74 """No profile has the name asked for."""
77class ProfileEffect(StrEnum):
78 """When a changed profile value takes effect."""
80 REINDEX = "reindex"
81 NEW_FILES_ONLY = "new_files_only"
82 NOW = "now"
85class ProfileStatus(StrEnum):
86 """How the applied profile's file compares with the copy recorded on apply."""
88 CURRENT = "current"
89 CHANGED = "changed"
90 MISSING = "missing"
91 BROKEN = "broken"
94@dataclass(frozen=True)
95class DiffRow:
96 """One setting a profile apply changes."""
98 key: str
99 current: Any
100 current_source: SettingSource
101 new: Any
102 effect: ProfileEffect
105@dataclass(frozen=True)
106class ProfileDiff:
107 """What applying a profile changes, which set values it keeps, and how many it never touches."""
109 name: str
110 changes: tuple[DiffRow, ...]
111 kept: tuple[str, ...]
112 untouched_count: int
115@dataclass(frozen=True)
116class ChangeRow:
117 """A profile setting config.toml sets, next to the value and source it falls back to."""
119 key: str
120 yours: Any
121 profile_value: Any
122 profile_source: SettingSource
123 effect: ProfileEffect
126@dataclass(frozen=True)
127class ActiveProfile:
128 """The project's applied profile, its recorded values, your changes, and its file's state."""
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
138@dataclass(frozen=True)
139class ProfileLocation:
140 """A profile file an operation wrote or removed."""
142 name: str
143 folder: ProfileFolder
144 path: Path
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."""
152 location: ProfileLocation
153 absorbed: tuple[str, ...]
154 warnings: tuple[str, ...] = ()
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."""
162 dropped: tuple[str, ...]
163 reindex_required: bool
164 warnings: tuple[str, ...] = ()
167@dataclass(frozen=True)
168class ApplyResult:
169 """The outcome of a profile apply, and any notice it raises."""
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, ...] = ()
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
188def list_profiles(store: ProfileStore) -> ProfileCatalog:
189 """Every profile file: the Default profile, the other built-ins, then the rest."""
190 return store.scan()
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
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
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))
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
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)
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 )
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))
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))
273class RebuildReason(StrEnum):
274 """Why a rebuild after applying or discarding profile values changes the index."""
276 CHANGES = "changes"
277 SETS_REINDEX = "sets_reindex"
278 HELD_OUT = "held_out"
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.
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
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
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)
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 )
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]
337def _location(planned: PlannedWrite) -> ProfileLocation:
338 return ProfileLocation(planned.profile.name, planned.folder, planned.path)
341def _written(planned: PlannedWrite) -> ProfileLocation:
342 planned.write()
343 return _location(planned)
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
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)
361def _commented(text: str) -> list[str]:
362 return [f"# {line}" for line in text.strip().splitlines()]
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}))]
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"
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))
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)
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))
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 )
419def _change_row(key: str, yours: Any, fallback: Resolved) -> ChangeRow:
420 return ChangeRow(key, yours, fallback.value, fallback.source, effect_of(key))
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
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.
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)
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 )
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.
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)
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.
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)
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
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
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.
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), ())
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)
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)
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())
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))
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
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)
590@dataclass(frozen=True)
591class ExportedProfile:
592 """A profile as the text of a clean file, and the file name it goes under."""
594 filename: str
595 folder: ProfileFolder
596 profile: ProfileFile
597 text: str
600def export_text(store: ProfileStore, name: str) -> ExportedProfile:
601 """The profile *name* picks as the text of a clean file named by its slug.
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))
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.
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)
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 )
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*.
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)
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))
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)
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)
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}
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]
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)}"
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
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}"