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

45 statements  

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

1"""The project's analyze state in state.toml: when it was analyzed and whether the tip is hidden.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6import tomllib 

7from dataclasses import dataclass 

8from datetime import UTC, datetime 

9from pathlib import Path 

10from typing import Any 

11 

12import tomli_w 

13 

14from lilbee.core.security import file_lock_or_warn, write_private_text 

15 

16log = logging.getLogger(__name__) 

17 

18STATE_FILE_NAME = "state.toml" 

19_ANALYZED_AT_KEY = "analyzed_at" 

20_TIP_DISMISSED_KEY = "tip_dismissed" 

21_STATE_LOCK_TIMEOUT_S = 10.0 

22 

23 

24@dataclass(frozen=True) 

25class ProjectState: 

26 """When analyze last completed (ISO 8601 UTC) and whether the user hid the tip.""" 

27 

28 analyzed_at: str | None = None 

29 tip_dismissed: bool = False 

30 

31 

32def _state_path(root: Path) -> Path: 

33 return root / STATE_FILE_NAME 

34 

35 

36def _read_table(path: Path) -> dict[str, Any]: 

37 """The TOML table at *path*; a missing, unreadable or broken file reads as empty.""" 

38 try: 

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

40 return tomllib.load(f) 

41 except FileNotFoundError: 

42 return {} 

43 except (OSError, tomllib.TOMLDecodeError) as exc: 

44 log.warning("Ignoring unreadable %s: %s", path, exc) 

45 return {} 

46 

47 

48def read_state(root: Path) -> ProjectState: 

49 """The analyze state of the project at *root*; values of the wrong type read as unset.""" 

50 table = _read_table(_state_path(root)) 

51 analyzed_at = table.get(_ANALYZED_AT_KEY) 

52 dismissed = table.get(_TIP_DISMISSED_KEY) 

53 # untyped TOML: a hand-edited file can hold any type under either key 

54 return ProjectState( 

55 analyzed_at=analyzed_at if isinstance(analyzed_at, str) else None, 

56 tip_dismissed=dismissed is True, 

57 ) 

58 

59 

60def _update(root: Path, key: str, value: Any) -> None: 

61 """Set *key* in state.toml under its lock, keeping the other keys.""" 

62 path = _state_path(root) 

63 path.parent.mkdir(parents=True, exist_ok=True) 

64 with file_lock_or_warn(path, _STATE_LOCK_TIMEOUT_S): 

65 table = _read_table(path) 

66 table[key] = value 

67 write_private_text(path, tomli_w.dumps(table)) 

68 

69 

70def mark_analyzed(root: Path) -> None: 

71 """Record that analyze completed now.""" 

72 _update(root, _ANALYZED_AT_KEY, datetime.now(UTC).isoformat(timespec="seconds")) 

73 

74 

75def dismiss_tip(root: Path) -> None: 

76 """Hide the analyze tip for the project at *root*.""" 

77 _update(root, _TIP_DISMISSED_KEY, True)