Coverage for src/workstack/core/global_config_ops.py: 72%

133 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-19 09:31 -0400

1"""Global configuration operations interface and implementations.""" 

2 

3import tomllib 

4from abc import ABC, abstractmethod 

5from pathlib import Path 

6from typing import Final 

7 

8import click 

9 

10 

11class _UnchangedType: 

12 """Sentinel type for unchanged config values.""" 

13 

14 pass 

15 

16 

17_UNCHANGED: Final = _UnchangedType() 

18 

19 

20class GlobalConfigOps(ABC): 

21 """Abstract interface for global configuration operations. 

22 

23 All implementations (real and fake) must implement this interface. 

24 This interface provides direct access to config fields via getters and a single 

25 setter method using sentinel pattern for optional updates. 

26 """ 

27 

28 @abstractmethod 

29 def get_workstacks_root(self) -> Path: 

30 """Get workstacks root directory. 

31 

32 Returns: 

33 Path to workstacks root directory 

34 

35 Raises: 

36 FileNotFoundError: If config file doesn't exist 

37 """ 

38 ... 

39 

40 @abstractmethod 

41 def get_use_graphite(self) -> bool: 

42 """Get graphite usage preference. 

43 

44 Returns: 

45 True if graphite should be used, False otherwise 

46 

47 Raises: 

48 FileNotFoundError: If config file doesn't exist 

49 """ 

50 ... 

51 

52 @abstractmethod 

53 def get_shell_setup_complete(self) -> bool: 

54 """Get shell setup completion status. 

55 

56 Returns: 

57 True if shell setup is complete, False otherwise 

58 

59 Raises: 

60 FileNotFoundError: If config file doesn't exist 

61 """ 

62 ... 

63 

64 @abstractmethod 

65 def get_show_pr_info(self) -> bool: 

66 """Get whether to show PR information in ls output. 

67 

68 Returns: 

69 True if PR info should be shown, False otherwise 

70 

71 Raises: 

72 FileNotFoundError: If config file doesn't exist 

73 """ 

74 ... 

75 

76 @abstractmethod 

77 def get_show_pr_checks(self) -> bool: 

78 """Get whether to show CI check status in PR info. 

79 

80 When True, fetches CI status from GitHub API (slower). 

81 When False, only shows basic PR info from Graphite cache (faster). 

82 

83 Returns: 

84 True if CI check status should be shown, False otherwise 

85 

86 Raises: 

87 FileNotFoundError: If config file doesn't exist 

88 """ 

89 ... 

90 

91 @abstractmethod 

92 def set( 

93 self, 

94 *, 

95 workstacks_root: Path | _UnchangedType = _UNCHANGED, 

96 use_graphite: bool | _UnchangedType = _UNCHANGED, 

97 shell_setup_complete: bool | _UnchangedType = _UNCHANGED, 

98 show_pr_info: bool | _UnchangedType = _UNCHANGED, 

99 show_pr_checks: bool | _UnchangedType = _UNCHANGED, 

100 ) -> None: 

101 """Update config fields. Only provided fields are changed. 

102 

103 Creates config file if it doesn't exist. Only fields that are not _UNCHANGED 

104 will be updated. At least one field must be provided. 

105 

106 Args: 

107 workstacks_root: New workstacks root, or _UNCHANGED to keep current 

108 use_graphite: New graphite preference, or _UNCHANGED to keep current 

109 shell_setup_complete: New shell setup status, or _UNCHANGED to keep current 

110 show_pr_info: New PR info display preference, or _UNCHANGED to keep current 

111 show_pr_checks: New CI check display preference, or _UNCHANGED to keep current 

112 

113 Raises: 

114 ValueError: If all fields are _UNCHANGED (nothing to update) 

115 """ 

116 ... 

117 

118 @abstractmethod 

119 def exists(self) -> bool: 

120 """Check if global config file exists. 

121 

122 Returns: 

123 True if config exists, False otherwise 

124 """ 

125 ... 

126 

127 @abstractmethod 

128 def get_path(self) -> Path: 

129 """Get the path to the global config file. 

130 

131 Returns: 

132 Path to config file (for error messages and debugging) 

133 """ 

134 ... 

135 

136 

137# ============================================================================ 

138# Production Implementation 

139# ============================================================================ 

140 

141 

142class RealGlobalConfigOps(GlobalConfigOps): 

143 """Production implementation using ~/.workstack/config.toml with lazy loading.""" 

144 

145 def __init__(self) -> None: 

146 self._path = Path.home() / ".workstack" / "config.toml" 

147 self._cache: dict[str, Path | bool] | None = None 

148 

149 def _load_cache(self) -> dict[str, Path | bool]: 

150 """Load config from disk and cache it.""" 

151 if not self._path.exists(): 

152 raise FileNotFoundError(f"Global config not found at {self._path}") 

153 

154 data = tomllib.loads(self._path.read_text(encoding="utf-8")) 

155 root = data.get("workstacks_root") 

156 if not root: 

157 raise ValueError(f"Missing 'workstacks_root' in {self._path}") 

158 

159 return { 

160 "workstacks_root": Path(root).expanduser().resolve(), 

161 "use_graphite": bool(data.get("use_graphite", False)), 

162 "shell_setup_complete": bool(data.get("shell_setup_complete", False)), 

163 "show_pr_info": bool(data.get("show_pr_info", True)), 

164 "show_pr_checks": bool(data.get("show_pr_checks", False)), 

165 } 

166 

167 def _ensure_cache(self) -> dict[str, Path | bool]: 

168 """Ensure cache is loaded and return it.""" 

169 if self._cache is None: 

170 self._cache = self._load_cache() 

171 return self._cache 

172 

173 def _invalidate_cache(self) -> None: 

174 """Invalidate cache after writes.""" 

175 self._cache = None 

176 

177 def get_workstacks_root(self) -> Path: 

178 cache = self._ensure_cache() 

179 result = cache["workstacks_root"] 

180 if not isinstance(result, Path): 

181 raise TypeError(f"Expected Path, got {type(result)}") 

182 return result 

183 

184 def get_use_graphite(self) -> bool: 

185 cache = self._ensure_cache() 

186 result = cache["use_graphite"] 

187 if not isinstance(result, bool): 

188 raise TypeError(f"Expected bool, got {type(result)}") 

189 return result 

190 

191 def get_shell_setup_complete(self) -> bool: 

192 cache = self._ensure_cache() 

193 result = cache["shell_setup_complete"] 

194 if not isinstance(result, bool): 

195 raise TypeError(f"Expected bool, got {type(result)}") 

196 return result 

197 

198 def get_show_pr_info(self) -> bool: 

199 cache = self._ensure_cache() 

200 result = cache["show_pr_info"] 

201 if not isinstance(result, bool): 

202 raise TypeError(f"Expected bool, got {type(result)}") 

203 return result 

204 

205 def get_show_pr_checks(self) -> bool: 

206 cache = self._ensure_cache() 

207 result = cache["show_pr_checks"] 

208 if not isinstance(result, bool): 

209 raise TypeError(f"Expected bool, got {type(result)}") 

210 return result 

211 

212 def set( 

213 self, 

214 *, 

215 workstacks_root: Path | _UnchangedType = _UNCHANGED, 

216 use_graphite: bool | _UnchangedType = _UNCHANGED, 

217 shell_setup_complete: bool | _UnchangedType = _UNCHANGED, 

218 show_pr_info: bool | _UnchangedType = _UNCHANGED, 

219 show_pr_checks: bool | _UnchangedType = _UNCHANGED, 

220 ) -> None: 

221 # Check if at least one field is being updated 

222 if ( 

223 isinstance(workstacks_root, _UnchangedType) 

224 and isinstance(use_graphite, _UnchangedType) 

225 and isinstance(shell_setup_complete, _UnchangedType) 

226 and isinstance(show_pr_info, _UnchangedType) 

227 and isinstance(show_pr_checks, _UnchangedType) 

228 ): 

229 raise ValueError("At least one field must be provided") 

230 

231 # Get current values (if config exists), or use defaults 

232 if self.exists(): 

233 current_root = self.get_workstacks_root() 

234 current_graphite = self.get_use_graphite() 

235 current_shell = self.get_shell_setup_complete() 

236 current_pr_info = self.get_show_pr_info() 

237 current_pr_checks = self.get_show_pr_checks() 

238 else: 

239 # For new config, all fields must be provided (no defaults) 

240 if isinstance(workstacks_root, _UnchangedType): 

241 raise ValueError("workstacks_root must be provided for new config") 

242 current_root = workstacks_root 

243 current_graphite = False 

244 current_shell = False 

245 current_pr_info = True 

246 current_pr_checks = False 

247 

248 # Apply updates 

249 final_root = ( 

250 current_root if isinstance(workstacks_root, _UnchangedType) else workstacks_root 

251 ) 

252 final_graphite = ( 

253 current_graphite if isinstance(use_graphite, _UnchangedType) else use_graphite 

254 ) 

255 final_shell = ( 

256 current_shell 

257 if isinstance(shell_setup_complete, _UnchangedType) 

258 else shell_setup_complete 

259 ) 

260 final_pr_info = ( 

261 current_pr_info if isinstance(show_pr_info, _UnchangedType) else show_pr_info 

262 ) 

263 final_pr_checks = ( 

264 current_pr_checks if isinstance(show_pr_checks, _UnchangedType) else show_pr_checks 

265 ) 

266 

267 # Write to disk 

268 self._path.parent.mkdir(parents=True, exist_ok=True) 

269 content = f"""# Global workstack configuration 

270workstacks_root = "{final_root}" 

271use_graphite = {str(final_graphite).lower()} 

272shell_setup_complete = {str(final_shell).lower()} 

273show_pr_info = {str(final_pr_info).lower()} 

274show_pr_checks = {str(final_pr_checks).lower()} 

275""" 

276 self._path.write_text(content, encoding="utf-8") 

277 self._invalidate_cache() 

278 

279 def exists(self) -> bool: 

280 return self._path.exists() 

281 

282 def get_path(self) -> Path: 

283 return self._path 

284 

285 

286# ============================================================================ 

287# Dry-Run Wrapper 

288# ============================================================================ 

289 

290 

291class DryRunGlobalConfigOps(GlobalConfigOps): 

292 """Dry-run wrapper for global config operations. 

293 

294 Read operations are delegated to the wrapped implementation. 

295 Write operations print dry-run messages instead of modifying the config file. 

296 """ 

297 

298 def __init__(self, wrapped: GlobalConfigOps) -> None: 

299 """Initialize dry-run wrapper with a real implementation. 

300 

301 Args: 

302 wrapped: The real global config operations implementation to wrap 

303 """ 

304 self._wrapped = wrapped 

305 

306 def get_workstacks_root(self) -> Path: 

307 """Delegate read operation to wrapped implementation.""" 

308 return self._wrapped.get_workstacks_root() 

309 

310 def get_use_graphite(self) -> bool: 

311 """Delegate read operation to wrapped implementation.""" 

312 return self._wrapped.get_use_graphite() 

313 

314 def get_shell_setup_complete(self) -> bool: 

315 """Delegate read operation to wrapped implementation.""" 

316 return self._wrapped.get_shell_setup_complete() 

317 

318 def get_show_pr_info(self) -> bool: 

319 """Delegate read operation to wrapped implementation.""" 

320 return self._wrapped.get_show_pr_info() 

321 

322 def get_show_pr_checks(self) -> bool: 

323 """Delegate read operation to wrapped implementation.""" 

324 return self._wrapped.get_show_pr_checks() 

325 

326 def set( 

327 self, 

328 *, 

329 workstacks_root: Path | _UnchangedType = _UNCHANGED, 

330 use_graphite: bool | _UnchangedType = _UNCHANGED, 

331 shell_setup_complete: bool | _UnchangedType = _UNCHANGED, 

332 show_pr_info: bool | _UnchangedType = _UNCHANGED, 

333 show_pr_checks: bool | _UnchangedType = _UNCHANGED, 

334 ) -> None: 

335 """Print dry-run message instead of updating config.""" 

336 updates: list[str] = [] 

337 if not isinstance(workstacks_root, _UnchangedType): 

338 updates.append(f"workstacks_root={workstacks_root}") 

339 if not isinstance(use_graphite, _UnchangedType): 

340 updates.append(f"use_graphite={use_graphite}") 

341 if not isinstance(shell_setup_complete, _UnchangedType): 

342 updates.append(f"shell_setup_complete={shell_setup_complete}") 

343 if not isinstance(show_pr_info, _UnchangedType): 

344 updates.append(f"show_pr_info={show_pr_info}") 

345 if not isinstance(show_pr_checks, _UnchangedType): 

346 updates.append(f"show_pr_checks={show_pr_checks}") 

347 

348 if updates: 

349 click.echo(f"[DRY RUN] Would update config: {', '.join(updates)}", err=True) 

350 

351 def exists(self) -> bool: 

352 """Delegate read operation to wrapped implementation.""" 

353 return self._wrapped.exists() 

354 

355 def get_path(self) -> Path: 

356 """Delegate read operation to wrapped implementation.""" 

357 return self._wrapped.get_path()