Coverage for src/prosemark/app/materialize_all_placeholders.py: 100%

89 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-09-24 18:08 +0000

1"""MaterializeAllPlaceholders use case for bulk conversion of placeholders to nodes.""" 

2 

3import time 

4from collections.abc import Callable 

5from pathlib import Path 

6from typing import TYPE_CHECKING 

7 

8from prosemark.domain.batch_materialize_result import BatchMaterializeResult 

9from prosemark.domain.materialize_failure import MaterializeFailure 

10from prosemark.domain.materialize_result import MaterializeResult 

11from prosemark.domain.models import Binder, BinderItem 

12from prosemark.domain.placeholder_summary import PlaceholderSummary 

13 

14if TYPE_CHECKING: # pragma: no cover 

15 from prosemark.app.materialize_node import MaterializeNode 

16 from prosemark.ports.binder_repo import BinderRepo 

17 from prosemark.ports.clock import Clock 

18 from prosemark.ports.id_generator import IdGenerator 

19 from prosemark.ports.logger import Logger 

20 from prosemark.ports.node_repo import NodeRepo 

21 

22 

23class MaterializeAllPlaceholders: 

24 """Materialize all placeholder items in a binder to actual content nodes.""" 

25 

26 def __init__( 

27 self, 

28 *, 

29 materialize_node_use_case: 'MaterializeNode', 

30 binder_repo: 'BinderRepo', 

31 node_repo: 'NodeRepo', 

32 id_generator: 'IdGenerator', 

33 clock: 'Clock', 

34 logger: 'Logger', 

35 ) -> None: 

36 """Initialize the MaterializeAllPlaceholders use case. 

37 

38 Args: 

39 materialize_node_use_case: Use case for individual node materialization 

40 binder_repo: Repository for binder operations 

41 node_repo: Repository for node operations 

42 id_generator: Generator for unique node IDs 

43 clock: Clock for timestamps 

44 logger: Logger port 

45 

46 """ 

47 self.materialize_node_use_case = materialize_node_use_case 

48 self.binder_repo = binder_repo 

49 self.node_repo = node_repo 

50 self.id_generator = id_generator 

51 self.clock = clock 

52 self.logger = logger 

53 

54 def execute( 

55 self, 

56 *, 

57 binder: Binder | None = None, 

58 project_path: Path | None = None, 

59 progress_callback: Callable[[str], None] | None = None, 

60 ) -> BatchMaterializeResult: 

61 """Materialize all placeholders in the binder. 

62 

63 Args: 

64 binder: Optional binder to process (if not provided, loads from repo) 

65 project_path: Project directory path 

66 progress_callback: Optional callback for progress reporting 

67 

68 Returns: 

69 BatchMaterializeResult containing success/failure information 

70 

71 """ 

72 start_time = time.time() 

73 project_path = project_path or Path.cwd() 

74 

75 # Load binder if not provided 

76 if binder is None: 

77 binder = self.binder_repo.load() 

78 

79 self.logger.info('Starting batch materialization of all placeholders') 

80 

81 # Discover all placeholders 

82 placeholders = self._discover_placeholders(binder) 

83 total_placeholders = len(placeholders) 

84 

85 self.logger.info('Found %d placeholders to materialize', total_placeholders) 

86 if progress_callback: 

87 progress_callback(f'Found {total_placeholders} placeholders to materialize...') 

88 

89 # If no placeholders, return early 

90 if total_placeholders == 0: 

91 execution_time = time.time() - start_time 

92 return BatchMaterializeResult( 

93 total_placeholders=0, 

94 successful_materializations=[], 

95 failed_materializations=[], 

96 execution_time=execution_time, 

97 ) 

98 

99 # Process each placeholder 

100 successful_materializations: list[MaterializeResult] = [] 

101 failed_materializations: list[MaterializeFailure] = [] 

102 

103 for i, placeholder in enumerate(placeholders, 1): 

104 try: 

105 # Attempt materialization using existing use case 

106 result = self._materialize_single_placeholder(placeholder=placeholder, project_path=project_path) 

107 successful_materializations.append(result) 

108 

109 # Report progress 

110 if progress_callback: 

111 progress_callback(f"✓ Materialized '{result.display_title}' → {result.node_id.value}") 

112 

113 self.logger.info( 

114 'Successfully materialized placeholder %d/%d: %s', 

115 i, 

116 total_placeholders, 

117 placeholder.display_title, 

118 ) 

119 

120 except Exception as e: 

121 # Create failure record 

122 failure = MaterializeAllPlaceholders._create_failure_record(placeholder, e) 

123 failed_materializations.append(failure) 

124 

125 # Report progress 

126 if progress_callback: 

127 progress_callback(f"✗ Failed to materialize '{placeholder.display_title}': {failure.error_message}") 

128 

129 self.logger.exception( 

130 'Failed to materialize placeholder %d/%d: %s', 

131 i, 

132 total_placeholders, 

133 placeholder.display_title, 

134 ) 

135 

136 # Check if we should stop the batch on critical errors 

137 if failure.should_stop_batch: 

138 self.logger.exception('Critical error encountered, stopping batch operation') 

139 break 

140 

141 execution_time = time.time() - start_time 

142 

143 # Create final result 

144 batch_result = BatchMaterializeResult( 

145 total_placeholders=total_placeholders, 

146 successful_materializations=successful_materializations, 

147 failed_materializations=failed_materializations, 

148 execution_time=execution_time, 

149 ) 

150 

151 self.logger.info( 

152 'Batch materialization complete: %d successes, %d failures in %.2f seconds', 

153 len(successful_materializations), 

154 len(failed_materializations), 

155 execution_time, 

156 ) 

157 

158 return batch_result 

159 

160 def _discover_placeholders(self, binder: Binder) -> list[PlaceholderSummary]: 

161 """Discover all placeholders in the binder hierarchy. 

162 

163 Args: 

164 binder: Binder to scan for placeholders 

165 

166 Returns: 

167 List of placeholder summaries in hierarchical order 

168 

169 """ 

170 placeholders: list[PlaceholderSummary] = [] 

171 self._collect_placeholders_recursive(binder.roots, placeholders, parent_title=None, depth=0, position_path=[]) 

172 return placeholders 

173 

174 def _collect_placeholders_recursive( 

175 self, 

176 items: list[BinderItem], 

177 placeholders: list[PlaceholderSummary], 

178 parent_title: str | None, 

179 depth: int, 

180 position_path: list[int] | None = None, 

181 ) -> None: 

182 """Recursively collect placeholders from binder hierarchy. 

183 

184 Args: 

185 items: Current level items to process 

186 placeholders: List to append discovered placeholders to 

187 parent_title: Title of parent item (if any) 

188 depth: Current nesting depth 

189 position_path: Hierarchical position path from root 

190 

191 """ 

192 for i, item in enumerate(items): 

193 # Create position string based on index 

194 position_path = position_path or [] 

195 position = '[' + ']['.join(str(idx) for idx in [*position_path, i]) + ']' 

196 

197 if item.node_id is None: # This is a placeholder 

198 placeholder = PlaceholderSummary( 

199 display_title=item.display_title, 

200 position=position, 

201 parent_title=parent_title, 

202 depth=depth, 

203 ) 

204 placeholders.append(placeholder) 

205 

206 # Recursively process children 

207 if item.children: 

208 self._collect_placeholders_recursive( 

209 items=item.children, 

210 placeholders=placeholders, 

211 parent_title=item.display_title, 

212 depth=depth + 1, 

213 position_path=[*position_path, i], 

214 ) 

215 

216 def _materialize_single_placeholder( 

217 self, *, placeholder: PlaceholderSummary, project_path: Path 

218 ) -> MaterializeResult: 

219 """Materialize a single placeholder using the existing MaterializeNode use case. 

220 

221 Args: 

222 placeholder: Placeholder to materialize 

223 project_path: Project directory path 

224 

225 Returns: 

226 MaterializeResult with the outcome 

227 

228 Raises: 

229 Various exceptions from the underlying materialization process 

230 

231 """ 

232 # Use the existing MaterializeNode use case 

233 node_id = self.materialize_node_use_case.execute(title=placeholder.display_title, project_path=project_path) 

234 

235 # Create file paths 

236 file_paths = [f'{node_id.value}.md', f'{node_id.value}.notes.md'] 

237 

238 return MaterializeResult( 

239 display_title=placeholder.display_title, 

240 node_id=node_id, 

241 file_paths=file_paths, 

242 position=placeholder.position, 

243 ) 

244 

245 @staticmethod 

246 def _create_failure_record(placeholder: PlaceholderSummary, error: Exception) -> MaterializeFailure: 

247 """Create a MaterializeFailure record from an exception. 

248 

249 Args: 

250 placeholder: Placeholder that failed to materialize 

251 error: Exception that occurred during materialization 

252 

253 Returns: 

254 MaterializeFailure record with categorized error information 

255 

256 """ 

257 # Categorize the error type based on exception 

258 error_type = MaterializeAllPlaceholders._categorize_error(error) 

259 error_message = str(error) 

260 

261 return MaterializeFailure( 

262 display_title=placeholder.display_title, 

263 error_type=error_type, 

264 error_message=error_message, 

265 position=placeholder.position, 

266 ) 

267 

268 @staticmethod 

269 def _categorize_error(error: Exception) -> str: 

270 """Categorize an exception into a known error type. 

271 

272 Args: 

273 error: Exception to categorize 

274 

275 Returns: 

276 Error type string from MaterializeFailure.VALID_ERROR_TYPES 

277 

278 """ 

279 error_type_name = type(error).__name__ 

280 

281 # Map common exception types to our error categories 

282 if 'FileSystem' in error_type_name or 'Permission' in error_type_name or 'OSError' in error_type_name: 

283 return 'filesystem' 

284 if 'Validation' in error_type_name or 'ValueError' in error_type_name: 

285 return 'validation' 

286 if 'AlreadyMaterialized' in error_type_name: 

287 return 'already_materialized' 

288 if 'Binder' in error_type_name or 'Integrity' in error_type_name: 

289 return 'binder_integrity' 

290 if 'UUID' in error_type_name or 'Id' in error_type_name: 

291 return 'id_generation' 

292 # Default to filesystem for unknown errors 

293 return 'filesystem'