Coverage for tembo/journal/pages.py: 99%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

141 statements  

1"""Submodule containing the factories & page objects to create Tembo pages.""" 

2 

3from __future__ import annotations 

4 

5import pathlib 

6import re 

7from abc import ABCMeta, abstractmethod 

8from dataclasses import dataclass 

9from typing import Collection, Optional 

10 

11import jinja2 

12import pendulum 

13from jinja2.exceptions import TemplateNotFound 

14 

15import tembo.utils 

16from tembo import exceptions 

17 

18 

19@dataclass 

20class PageCreatorOptions: 

21 """ 

22 Options [dataclass][dataclasses.dataclass] to create a Page. 

23 

24 This is passed to an implemented instance of [PageCreator][tembo.journal.pages.PageCreator] 

25 

26 Attributes: 

27 base_path (str): The base path. 

28 page_path (str): The path of the page relative to the base path. 

29 filename (str): The filename of the page. 

30 extension (str): The extension of the page. 

31 name (str): The name of the scope. 

32 user_input (Collection[str] | None, optional): User input tokens. 

33 example (str | None, optional): User example command. 

34 template_path (str | None, optional): The path which contains the templates. This should 

35 be the full path and not relative to the base path. 

36 template_filename (str | None, optional): The template filename with extension relative 

37 to the template path. 

38 """ 

39 

40 base_path: str 

41 page_path: str 

42 filename: str 

43 extension: str 

44 name: str 

45 user_input: Optional[Collection[str]] = None 

46 example: Optional[str] = None 

47 template_path: Optional[str] = None 

48 template_filename: Optional[str] = None 

49 

50 

51class PageCreator: 

52 """ 

53 A PageCreator factory base class. 

54 

55 This factory should implement methods to create [Page][tembo.journal.pages.Page] objects. 

56 

57 !!! abstract 

58 This factory is an abstract base class and should be implemented for each 

59 [Page][tembo.journal.pages.Page] type. 

60 

61 The private methods 

62 

63 - `_check_base_path_exists()` 

64 - `_convert_base_path_to_path()` 

65 - `_load_template()` 

66 

67 are not abstract and are shared between all [Page][tembo.journal.pages.Page] types. 

68 """ 

69 

70 @abstractmethod 

71 def __init__(self, options: PageCreatorOptions) -> None: 

72 """ 

73 When implemented this should initialise the `PageCreator` factory. 

74 

75 Args: 

76 options (PageCreatorOptions): An instance of 

77 [PageCreatorOptions][tembo.journal.pages.PageCreatorOptions] 

78 

79 !!! abstract 

80 This method is abstract and should be implemented for each 

81 [Page][tembo.journal.pages.Page] type. 

82 """ 

83 raise NotImplementedError 

84 

85 @property 

86 @abstractmethod 

87 def options(self) -> PageCreatorOptions: 

88 """ 

89 When implemented this should return the `PageCreatorOptions` on the class. 

90 

91 Returns: 

92 PageCreatorOptions: the instance of 

93 [PageCreatorOptions][tembo.journal.pages.PageCreatorOptions] set on the class. 

94 

95 !!! abstract 

96 This method is abstract and should be implemented for each 

97 [Page][tembo.journal.pages.Page] type. 

98 """ 

99 raise NotImplementedError 

100 

101 @abstractmethod 

102 def create_page(self) -> Page: 

103 """ 

104 When implemented this should create a `Page` object. 

105 

106 Returns: 

107 Page: an implemented instance of [Page][tembo.journal.pages.Page] such as 

108 [ScopedPage][tembo.journal.pages.ScopedPage]. 

109 

110 !!! abstract 

111 This method is abstract and should be implemented for each 

112 [Page][tembo.journal.pages.Page] type. 

113 """ 

114 raise NotImplementedError 

115 

116 def _check_base_path_exists(self) -> None: 

117 """ 

118 Check that the base path exists. 

119 

120 Raises: 

121 exceptions.BasePathDoesNotExistError: raised if the base path does not exist. 

122 """ 

123 if not pathlib.Path(self.options.base_path).expanduser().exists(): 

124 raise exceptions.BasePathDoesNotExistError( 

125 f"Tembo base path of {self.options.base_path} does not exist." 

126 ) 

127 

128 def _convert_base_path_to_path(self) -> pathlib.Path: 

129 """ 

130 Convert the `base_path` from a `str` to a `pathlib.Path` object. 

131 

132 Returns: 

133 pathlib.Path: the `base_path` as a `pathlib.Path` object. 

134 """ 

135 path_to_file = ( 

136 pathlib.Path(self.options.base_path).expanduser() 

137 / pathlib.Path(self.options.page_path.replace(" ", "_")).expanduser() 

138 / self.options.filename.replace(" ", "_") 

139 ) 

140 # check for existing `.` in the extension 

141 extension = ( 

142 self.options.extension[1:] 

143 if self.options.extension[0] == "." 

144 else self.options.extension 

145 ) 

146 # return path with a file 

147 return path_to_file.with_suffix(f".{extension}") 

148 

149 def _load_template(self) -> str: 

150 """ 

151 Load the template file. 

152 

153 Raises: 

154 exceptions.TemplateFileNotFoundError: raised if the template file is specified but 

155 not found. 

156 

157 Returns: 

158 str: the contents of the template file. 

159 """ 

160 if self.options.template_filename is None: 

161 return "" 

162 if self.options.template_path is not None: 

163 converted_template_path = pathlib.Path(self.options.template_path).expanduser() 

164 else: 

165 converted_template_path = ( 

166 pathlib.Path(self.options.base_path).expanduser() / ".templates" 

167 ) 

168 

169 file_loader = jinja2.FileSystemLoader(converted_template_path) 

170 env = jinja2.Environment(loader=file_loader, autoescape=True) 

171 

172 try: 

173 loaded_template = env.get_template(self.options.template_filename) 

174 except TemplateNotFound as template_not_found: 

175 _template_file = f"{converted_template_path}/{template_not_found.args[0]}" 

176 raise exceptions.TemplateFileNotFoundError( 

177 f"Template file {_template_file} does not exist." 

178 ) from template_not_found 

179 return loaded_template.render() 

180 

181 

182class ScopedPageCreator(PageCreator): 

183 """ 

184 Factory to create a scoped page. 

185 

186 Attributes: 

187 base_path (str): base path of tembo. 

188 page_path (str): path of the page relative to the base path. 

189 filename (str): filename relative to the page path. 

190 extension (str): extension of file. 

191 """ 

192 

193 def __init__(self, options: PageCreatorOptions) -> None: 

194 """ 

195 Initialise a `ScopedPageCreator` factory. 

196 

197 Args: 

198 options (PageCreatorOptions): An instance of 

199 [PageCreatorOptions][tembo.journal.pages.PageCreatorOptions]. 

200 """ 

201 self._all_input_tokens: list[str] = [] 

202 self._options = options 

203 

204 @property 

205 def options(self) -> PageCreatorOptions: 

206 """ 

207 Return the `PageCreatorOptions` instance set on the factory. 

208 

209 Returns: 

210 PageCreatorOptions: 

211 An instance of [PageCreatorOptions][tembo.journal.pages.PageCreatorOptions]. 

212 """ 

213 return self._options 

214 

215 def create_page(self) -> Page: 

216 """ 

217 Create a [ScopedPage][tembo.journal.pages.ScopedPage] object. 

218 

219 This method will 

220 

221 - Check the `base_path` exists 

222 - Verify the input tokens match the number defined in the `config.yml` 

223 - Substitue the input tokens in the filepath 

224 - Load the template contents and substitue the input tokens 

225 

226 Raises: 

227 exceptions.MismatchedTokenError: Raises 

228 [MismatchedTokenError][tembo.exceptions.MismatchedTokenError] if the number of 

229 input tokens does not match the number of unique input tokens defined. 

230 exceptions.BasePathDoesNotExistError: Raises 

231 [BasePathDoesNotExistError][tembo.exceptions.BasePathDoesNotExistError] if the 

232 base path does not exist. 

233 exceptions.TemplateFileNotFoundError: Raises 

234 [TemplateFileNotFoundError][tembo.exceptions.TemplateFileNotFoundError] if the 

235 template file is specified but not found. 

236 

237 

238 Returns: 

239 Page: A [ScopedPage][tembo.journal.pages.ScopedPage] object using the 

240 `PageCreatorOptions`. 

241 """ 

242 try: 

243 self._check_base_path_exists() 

244 except exceptions.BasePathDoesNotExistError as base_path_does_not_exist_error: 

245 raise base_path_does_not_exist_error 

246 self._all_input_tokens = self._get_input_tokens() 

247 try: 

248 self._verify_input_tokens() 

249 except exceptions.MismatchedTokenError as mismatched_token_error: 

250 raise mismatched_token_error 

251 

252 path = self._convert_base_path_to_path() 

253 path = pathlib.Path(self._substitute_tokens(str(path))) 

254 

255 try: 

256 template_contents = self._load_template() 

257 except exceptions.TemplateFileNotFoundError as template_not_found_error: 

258 raise template_not_found_error 

259 if self.options.template_filename is not None: 

260 template_contents = self._substitute_tokens(template_contents) 

261 

262 return ScopedPage(path, template_contents) 

263 

264 def _get_input_tokens(self) -> list[str]: 

265 """Get the input tokens from the path & user template.""" 

266 path = str( 

267 pathlib.Path( 

268 self.options.base_path, 

269 self.options.page_path, 

270 self.options.filename, 

271 ) 

272 .expanduser() 

273 .with_suffix(f".{self.options.extension}") 

274 ) 

275 template_contents = self._load_template() 

276 # get the input tokens from both the path and the template 

277 all_input_tokens = [] 

278 for tokenified_string in (path, template_contents): 

279 all_input_tokens.extend(re.findall(r"(\{input\d*\})", tokenified_string)) 

280 return sorted(list(set(all_input_tokens))) 

281 

282 def _verify_input_tokens(self) -> None: 

283 """ 

284 Verify the input tokens. 

285 

286 The number of input tokens should match the number of unique input tokens defined in the 

287 path and the user's template. 

288 

289 Raises: 

290 exceptions.MismatchedTokenError: Raises 

291 [MismatchedTokenError][tembo.exceptions.MismatchedTokenError] if the number of 

292 input tokens does not match the number of unique input tokens defined. 

293 """ 

294 if len(self._all_input_tokens) > 0 and self.options.user_input is None: 

295 raise exceptions.MismatchedTokenError(expected=len(self._all_input_tokens), given=0) 

296 if self.options.user_input is None: 

297 return 

298 if len(self._all_input_tokens) != len(self.options.user_input): 

299 raise exceptions.MismatchedTokenError( 

300 expected=len(self._all_input_tokens), 

301 given=len(self.options.user_input), 

302 ) 

303 

304 def _substitute_tokens(self, tokenified_string: str) -> str: 

305 """For a tokened string, substitute input, name and date tokens.""" 

306 tokenified_string = self.__substitute_input_tokens(tokenified_string) 

307 tokenified_string = self.__substitute_name_tokens(tokenified_string) 

308 tokenified_string = self.__substitute_date_tokens(tokenified_string) 

309 return tokenified_string 

310 

311 def __substitute_input_tokens(self, tokenified_string: str) -> str: 

312 """ 

313 Substitue the input tokens in a `str` with the user input. 

314 

315 Args: 

316 tokenified_string (str): a string with input tokens. 

317 

318 Returns: 

319 str: the string with the input tokens replaced by the user input. 

320 

321 Examples: 

322 A `user_input` of `("monthly_meeting",)` with a `tokenified_string` of 

323 `/meetings/{input0}/` results in a string of `/meetings/monthly_meeting/` 

324 """ 

325 if self.options.user_input is not None: 

326 for input_value, extracted_token in zip( 

327 self.options.user_input, self._all_input_tokens 

328 ): 

329 tokenified_string = tokenified_string.replace( 

330 extracted_token, input_value.replace(" ", "_") 

331 ) 

332 return tokenified_string 

333 

334 def __substitute_name_tokens(self, tokenified_string: str) -> str: 

335 """Find any `{name}` tokens and substitute for the name value in a `str`.""" 

336 name_extraction = re.findall(r"(\{name\})", tokenified_string) 

337 for extracted_input in name_extraction: 

338 tokenified_string = tokenified_string.replace(extracted_input, self.options.name) 

339 return tokenified_string 

340 

341 @staticmethod 

342 def __substitute_date_tokens(tokenified_string: str) -> str: 

343 """Find any {d:%d-%M-%Y} tokens in a `str`.""" 

344 # extract the full token string 

345 date_extraction_token = re.findall(r"(\{d\:[^}]*\})", tokenified_string) 

346 for extracted_token in date_extraction_token: 

347 # extract the inner %d-%M-%Y only 

348 strftime_value = re.match(r"\{d\:([^\}]*)\}", extracted_token) 

349 if strftime_value is not None: 

350 strftime_value = strftime_value.group(1) 

351 if isinstance(strftime_value, str): 

352 tokenified_string = tokenified_string.replace( 

353 extracted_token, pendulum.now().strftime(strftime_value) 

354 ) 

355 return tokenified_string 

356 

357 

358class Page(metaclass=ABCMeta): 

359 """ 

360 Abstract Page class. 

361 

362 This interface is used to define a `Page` object. 

363 

364 A `Page` represents a note/page that will be saved to disk. 

365 

366 !!! abstract 

367 This object is an abstract base class and should be implemented for each `Page` type. 

368 """ 

369 

370 @abstractmethod 

371 def __init__(self, path: pathlib.Path, page_content: str) -> None: 

372 """ 

373 When implemented this should initalise a Page object. 

374 

375 Args: 

376 path (pathlib.Path): the full path of the page including the filename as a 

377 [Path][pathlib.Path]. 

378 page_content (str): the contents of the page. 

379 

380 !!! abstract 

381 This method is abstract and should be implemented for each `Page` type. 

382 """ 

383 raise NotImplementedError 

384 

385 @property 

386 @abstractmethod 

387 def path(self) -> pathlib.Path: 

388 """ 

389 When implemented this should return the full path of the page including the filename. 

390 

391 Returns: 

392 pathlib.Path: the path as a [Path][pathlib.Path] object. 

393 

394 !!! abstract 

395 This property is abstract and should be implemented for each `Page` type. 

396 """ 

397 raise NotImplementedError 

398 

399 @abstractmethod 

400 def save_to_disk(self) -> tembo.utils.Success: 

401 """ 

402 When implemented this should save the page to disk. 

403 

404 Returns: 

405 tembo.utils.Success: A Tembo [Success][tembo.utils.__init__.Success] object. 

406 

407 !!! abstract 

408 This method is abstract and should be implemented for each `Page` type. 

409 """ 

410 raise NotImplementedError 

411 

412 

413class ScopedPage(Page): 

414 """ 

415 A page that uses substitute tokens. 

416 

417 Attributes: 

418 path (pathlib.Path): a [Path][pathlib.Path] object of the page's filepath including the 

419 filename. 

420 page_content (str): the content of the page from the template. 

421 """ 

422 

423 def __init__(self, path: pathlib.Path, page_content: str) -> None: 

424 """ 

425 Initalise a scoped page object. 

426 

427 Args: 

428 path (pathlib.Path): a [Path][pathlib.Path] object of the page's filepath including 

429 the filename. 

430 page_content (str): the content of the page from the template. 

431 """ 

432 self._path = path 

433 self.page_content = page_content 

434 

435 def __str__(self) -> str: 

436 """ 

437 Return a `str` representation of a `ScopedPage`. 

438 

439 Examples: 

440 ``` 

441 >>> str(ScopedPage(Path("/home/bob/tembo/meetings/my_meeting_0.md"), "")) 

442 ScopedPage("/home/bob/tembo/meetings/my_meeting_0.md") 

443 ``` 

444 

445 Returns: 

446 str: The `ScopedPage` as a `str`. 

447 """ 

448 return f'ScopedPage("{self.path}")' 

449 

450 @property 

451 def path(self) -> pathlib.Path: 

452 """ 

453 Return the full path of the page. 

454 

455 Returns: 

456 pathlib.path: The full path of the page as a [Path][pathlib.Path] object. 

457 """ 

458 return self._path 

459 

460 def save_to_disk(self) -> tembo.utils.Success: 

461 """ 

462 Save the scoped page to disk and write the `page_content`. 

463 

464 Raises: 

465 exceptions.ScopedPageAlreadyExists: If the page already exists a 

466 [ScopedPageAlreadyExists][tembo.exceptions.ScopedPageAlreadyExists] exception 

467 is raised. 

468 

469 Returns: 

470 tembo.utils.Success: A [Success][tembo.utils.__init__.Success] with the path of the 

471 ScopedPage as the message. 

472 """ 

473 # create the parent directories 

474 scoped_page_file = pathlib.Path(self.path) 

475 scoped_page_file.parents[0].mkdir(parents=True, exist_ok=True) 

476 if scoped_page_file.exists(): 

477 raise exceptions.ScopedPageAlreadyExists(f"{self.path} already exists") 

478 with scoped_page_file.open("w", encoding="utf-8") as scoped_page: 

479 scoped_page.write(self.page_content) 

480 return tembo.utils.Success(str(self.path))