Coverage for pyquickhelper/sphinxext/sphinx_blog_extension.py: 81%

186 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-03 02:21 +0200

1# -*- coding: utf-8 -*- 

2""" 

3@file 

4@brief Defines blogpost directives. 

5See `Tutorial: Writing a simple extension 

6<https://www.sphinx-doc.org/en/master/development/tutorials/helloworld.html>`_, 

7`Creating reStructuredText Directives 

8<https://docutils.sourceforge.io/docs/howto/rst-directives.html>`_ 

9""" 

10import os 

11import sphinx 

12from docutils import nodes 

13from docutils.parsers.rst import Directive 

14from sphinx.locale import _ as _locale 

15from docutils.parsers.rst import directives 

16from docutils.statemachine import StringList 

17from sphinx import addnodes 

18from sphinx.util.nodes import set_source_info, process_index_entry 

19from sphinx.util.nodes import nested_parse_with_titles 

20from .blog_post import BlogPost 

21from ..texthelper.texts_language import TITLES 

22 

23 

24class blogpost_node(nodes.Element): 

25 

26 """ 

27 Defines *blogpost* node. 

28 """ 

29 pass 

30 

31 

32class blogpostagg_node(nodes.Element): 

33 

34 """ 

35 Defines *blogpostagg* node. 

36 """ 

37 pass 

38 

39 

40class BlogPostDirective(Directive): 

41 

42 """ 

43 Extracts information about a blog post described by a directive ``.. blogpost::`` 

44 and modifies the documentation if *env* is not null. The directive handles the following 

45 options: 

46 

47 * *date*: date of the blog (mandatory) 

48 * *title*: title (mandatory) 

49 * *keywords*: keywords, comma separated (mandatory) 

50 * *categories*: categories, comma separated (mandatory) 

51 * *author*: author (optional) 

52 * *blog_background*: can change the blog background (boolean, default is True) 

53 * *lid* or *label*: an id to refer to (optional) 

54 """ 

55 required_arguments = 0 

56 optional_arguments = 0 

57 final_argument_whitespace = True 

58 option_spec = {'date': directives.unchanged, 

59 'title': directives.unchanged, 

60 'keywords': directives.unchanged, 

61 'categories': directives.unchanged, 

62 'author': directives.unchanged, 

63 'blog_background': directives.unchanged, 

64 'lid': directives.unchanged, 

65 'label': directives.unchanged, 

66 } 

67 has_content = True 

68 add_index = True 

69 add_share = True 

70 blogpost_class = blogpost_node 

71 default_config_bg = "blog_background_page" 

72 

73 def suffix_label(self): 

74 """ 

75 returns a suffix to add to a label, 

76 it should not be empty for aggregated pages 

77 

78 @return str 

79 """ 

80 return "" 

81 

82 def run(self): 

83 """ 

84 extracts the information in a dictionary and displays it 

85 if the environment is not null 

86 

87 @return a list of nodes 

88 """ 

89 # settings 

90 sett = self.state.document.settings 

91 language_code = sett.language_code 

92 if hasattr(sett, "out_blogpostlist"): 

93 sett.out_blogpostlist.append(self) 

94 

95 # env 

96 if hasattr(self.state.document.settings, "env"): 

97 env = self.state.document.settings.env 

98 else: 

99 env = None 

100 

101 if env is None: 

102 docname = "___unknown_docname___" 

103 config = None 

104 blog_background = False 

105 sharepost = None 

106 else: 

107 # otherwise, it means sphinx is running 

108 docname = env.docname 

109 # settings and configuration 

110 config = env.config 

111 try: 

112 blog_background = getattr( 

113 config, self.__class__.default_config_bg) 

114 except AttributeError as e: # pragma: no cover 

115 raise AttributeError("Unable to find '{1}' in \n{0}".format( 

116 "\n".join(sorted(config.values)), self.__class__.default_config_bg)) from e 

117 sharepost = config.sharepost if self.__class__.add_share else None 

118 

119 # post 

120 p = { 

121 'docname': docname, 

122 'lineno': self.lineno, 

123 'date': self.options["date"], 

124 'title': self.options["title"], 

125 'keywords': [a.strip() for a in self.options["keywords"].split(",")], 

126 'categories': [a.strip() for a in self.options["categories"].split(",")], 

127 'blog_background': self.options.get("blog_background", str(blog_background)).strip() in ("True", "true", "1"), 

128 'lid': self.options.get("lid", self.options.get("label", None)), 

129 } 

130 

131 tag = BlogPost.build_tag( 

132 p["date"], 

133 p["title"]) if p['lid'] is None else p['lid'] 

134 targetnode = nodes.target(p['title'], '', ids=[tag]) 

135 p["target"] = targetnode 

136 idbp = tag + "-container" 

137 

138 if env is not None: 

139 if not hasattr(env, 'blogpost_all'): 

140 env.blogpost_all = [] 

141 env.blogpost_all.append(p) 

142 

143 # build node 

144 if not docname: 

145 raise RuntimeError( # pragma: no cover 

146 f'docname is missing in blogpost {docname}.') 

147 node = self.__class__.blogpost_class( 

148 ids=[idbp], year=p["date"][:4], 

149 rawfile=self.options.get("rawfile", None), 

150 linktitle=p["title"], lg=language_code, 

151 blog_background=p["blog_background"]) 

152 node.source = docname 

153 if not node.source: 

154 raise RuntimeError( # pragma: no cover 

155 f'node.source is missing in blogpost ' 

156 f'{self.options.get("rawfile", None)}.') 

157 return self.fill_node(node, env, tag, p, language_code, 

158 targetnode, sharepost) 

159 

160 def fill_node(self, node, env, tag, p, language_code, targetnode, sharepost): 

161 """ 

162 Fills the content of the node. 

163 """ 

164 # add a label 

165 suffix_label = self.suffix_label() if not p['lid'] else "" 

166 tag = f"{tag}{suffix_label}" 

167 tnl = [f".. _{tag}:", ""] 

168 title = f"{p['date']} {p['title']}" 

169 tnl.append(title) 

170 tnl.append("=" * len(title)) 

171 tnl.append("") 

172 if sharepost is not None: 

173 tnl.append("") 

174 tnl.append(f":sharenet:`{sharepost}`") 

175 tnl.append('') 

176 tnl.append('') 

177 content = StringList(tnl) 

178 content = content + self.content 

179 try: 

180 nested_parse_with_titles(self.state, content, node) 

181 except Exception as e: # pragma: no cover 

182 from sphinx.util import logging 

183 logger = logging.getLogger("blogpost") 

184 logger.warning( 

185 "[blogpost] unable to parse %r - %s", title, e) 

186 raise e 

187 

188 # final 

189 p['blogpost'] = node 

190 self.exe_class = p.copy() 

191 p["content"] = content 

192 node['classes'] += ["blogpost"] 

193 

194 # for the instruction tocdelay. 

195 node['toctitle'] = title 

196 node['tocid'] = tag 

197 node['tocdoc'] = env.docname 

198 

199 # end. 

200 ns = [node] 

201 return ns 

202 

203 

204class BlogPostDirectiveAgg(BlogPostDirective): 

205 

206 """ 

207 same but for the same post in a aggregated pages 

208 """ 

209 add_index = False 

210 add_share = False 

211 blogpost_class = blogpostagg_node 

212 default_config_bg = "blog_background" 

213 option_spec = {'date': directives.unchanged, 

214 'title': directives.unchanged, 

215 'keywords': directives.unchanged, 

216 'categories': directives.unchanged, 

217 'author': directives.unchanged, 

218 'rawfile': directives.unchanged, 

219 'blog_background': directives.unchanged, 

220 } 

221 

222 def suffix_label(self): 

223 """ 

224 returns a suffix to add to a label, 

225 it should not be empty for aggregated pages 

226 

227 @return str 

228 """ 

229 if hasattr(self.state.document.settings, "env"): 

230 env = self.state.document.settings.env 

231 docname = os.path.split(env.docname)[-1] 

232 docname = os.path.splitext(docname)[0] 

233 else: 

234 env = None 

235 docname = "" 

236 return "-agg" + docname 

237 

238 def fill_node(self, node, env, tag, p, language_code, targetnode, sharepost): 

239 """ 

240 Fill the node of an aggregated page. 

241 """ 

242 # add a label 

243 suffix_label = self.suffix_label() 

244 container = nodes.container() 

245 tnl = [f".. _{tag}{suffix_label}:", ""] 

246 content = StringList(tnl) 

247 self.state.nested_parse(content, self.content_offset, container) 

248 node += container 

249 

250 # id section 

251 if env is not None: 

252 mid = int(env.new_serialno(f"indexblog-u-{p['date'][:4]}")) + 1 

253 else: 

254 mid = -1 

255 

256 # add title 

257 sids = f"y{p['date'][:4]}-{mid}" 

258 section = nodes.section(ids=[sids]) 

259 section['year'] = p["date"][:4] 

260 section['blogmid'] = mid 

261 node += section 

262 textnodes, messages = self.state.inline_text(p["title"], self.lineno) 

263 section += nodes.title(p["title"], '', *textnodes) 

264 section += messages 

265 

266 # add date and share buttons 

267 tnl = [f":bigger:`::5:{p['date']}`"] 

268 if sharepost is not None: 

269 tnl.append(f":sharenet:`{sharepost}`") 

270 tnl.append('') 

271 content = StringList(tnl) 

272 content = content + self.content 

273 

274 # parse the content into sphinx directive, 

275 # it adds it to section 

276 container = nodes.container() 

277 # nested_parse_with_titles(self.state, content, paragraph) 

278 self.state.nested_parse(content, self.content_offset, container) 

279 section += container 

280 

281 # final 

282 p['blogpost'] = node 

283 self.exe_class = p.copy() 

284 p["content"] = content 

285 node['classes'] += ["blogpost"] 

286 

287 # target 

288 # self.state.add_target(p['title'], '', targetnode, lineno) 

289 

290 # index (see site-packages/sphinx/directives/code.py, class Index) 

291 if self.__class__.add_index: 

292 # it adds an index 

293 # self.state.document.note_explicit_target(targetnode) 

294 indexnode = addnodes.index() 

295 indexnode['entries'] = ne = [] 

296 indexnode['inline'] = False 

297 set_source_info(self, indexnode) 

298 for entry in set(p["keywords"] + p["categories"] + [p["date"]]): 

299 ne.extend(process_index_entry(entry, tag)) # targetid)) 

300 ns = [indexnode, targetnode, node] 

301 else: 

302 ns = [targetnode, node] 

303 

304 return ns 

305 

306 

307def visit_blogpost_node(self, node): 

308 """ 

309 what to do when visiting a node blogpost 

310 the function should have different behaviour, 

311 depending on the format, or the setup should 

312 specify a different function for each. 

313 """ 

314 if node["blog_background"]: 

315 # the node will be in a box 

316 self.visit_admonition(node) 

317 

318 

319def depart_blogpost_node(self, node): 

320 """ 

321 what to do when leaving a node blogpost 

322 the function should have different behaviour, 

323 depending on the format, or the setup should 

324 specify a different function for each. 

325 """ 

326 if node["blog_background"]: 

327 # the node will be in a box 

328 self.depart_admonition(node) 

329 

330 

331def visit_blogpostagg_node(self, node): 

332 """ 

333 what to do when visiting a node blogpost 

334 the function should have different behaviour, 

335 depending on the format, or the setup should 

336 specify a different function for each. 

337 """ 

338 pass 

339 

340 

341def depart_blogpostagg_node(self, node): 

342 """ 

343 what to do when leaving a node blogpost, 

344 the function should have different behaviour, 

345 depending on the format, or the setup should 

346 specify a different function for each. 

347 """ 

348 pass 

349 

350 

351def depart_blogpostagg_node_html(self, node): 

352 """ 

353 what to do when leaving a node blogpost, 

354 the function should have different behaviour, 

355 depending on the format, or the setup should 

356 specify a different function for each. 

357 """ 

358 if node.hasattr("year"): 

359 rawfile = node["rawfile"] 

360 if rawfile is not None: 

361 # there is probably better to do 

362 # module name is something list doctuils.../[xx].py 

363 lg = node["lg"] 

364 name = os.path.splitext(os.path.split(rawfile)[-1])[0] 

365 name += ".html" 

366 year = node["year"] 

367 linktitle = node["linktitle"] 

368 link = """<p><a class="reference internal" href="{0}/{2}" title="{1}">{3}</a></p>""" \ 

369 .format(year, linktitle, name, TITLES[lg]["more"]) 

370 self.body.append(link) 

371 else: 

372 self.body.append( 

373 f"%blogpostagg: link to source only available for HTML: '{type(self)}'\n") 

374 

375 

376###################### 

377# unused, kept as example 

378###################### 

379 

380class blogpostlist_node(nodes.General, nodes.Element): 

381 

382 """ 

383 defines *blogpostlist* node, 

384 unused, kept as example 

385 """ 

386 pass 

387 

388 

389class BlogPostListDirective(Directive): 

390 

391 """ 

392 unused, kept as example 

393 """ 

394 

395 def run(self): 

396 return [BlogPostListDirective.blogpostlist('')] 

397 

398 

399def purge_blogpost(app, env, docname): 

400 """ 

401 unused, kept as example 

402 """ 

403 if not hasattr(env, 'blogpost_all'): 

404 return 

405 env.blogpost_all = [post for post in env.blogpost_all 

406 if post['docname'] != docname] 

407 

408 

409def process_blogpost_nodes(app, doctree, fromdocname): # pragma: no cover 

410 """ 

411 unused, kept as example 

412 """ 

413 if not app.config.blogpost_include_s: 

414 for node in doctree.traverse(blogpost_node): 

415 node.parent.remove(node) 

416 

417 # Replace all blogpostlist nodes with a list of the collected blogposts. 

418 # Augment each blogpost with a backlink to the original location. 

419 env = app.builder.env 

420 if hasattr(env, "settings") and hasattr(env.settings, "language_code"): 

421 lang = env.settings.language_code 

422 else: 

423 lang = "en" 

424 blogmes = TITLES[lang]["blog_entry"] 

425 

426 for node in doctree.traverse(blogpostlist_node): 

427 if not app.config.blogpost_include_s: 

428 node.replace_self([]) 

429 continue 

430 

431 content = [] 

432 

433 for post_info in env.blogpost_all: 

434 para = nodes.paragraph() 

435 filename = env.doc2path(post_info['docname'], base=None) 

436 description = (_locale(blogmes) % (filename, post_info['lineno'])) 

437 para += nodes.Text(description, description) 

438 

439 # Create a reference 

440 newnode = nodes.reference('', '') 

441 innernode = nodes.emphasis(_locale('here'), _locale('here')) 

442 newnode['refdocname'] = post_info['docname'] 

443 newnode['refuri'] = app.builder.get_relative_uri( 

444 fromdocname, post_info['docname']) 

445 try: 

446 newnode['refuri'] += '#' + post_info['target']['refid'] 

447 except Exception as e: 

448 raise KeyError("refid in not present in '{0}'".format( 

449 post_info['target'])) from e 

450 newnode.append(innernode) 

451 para += newnode 

452 para += nodes.Text('.)', '.)') 

453 

454 # Insert into the blogpostlist 

455 content.append(post_info['blogpost']) 

456 content.append(para) 

457 

458 node.replace_self(content) 

459 

460 

461def setup(app): 

462 """ 

463 setup for ``blogpost`` (sphinx) 

464 """ 

465 # this command enables the parameter blog_background to be part of the 

466 # configuration 

467 app.add_config_value('sharepost', None, 'env') 

468 app.add_config_value('blog_background', True, 'env') 

469 app.add_config_value('blog_background_page', False, 'env') 

470 app.add_config_value('out_blogpostlist', [], 'env') 

471 if hasattr(app, "add_mapping"): 

472 app.add_mapping('blogpost', blogpost_node) 

473 app.add_mapping('blogpostagg', blogpostagg_node) 

474 

475 # app.add_node(blogpostlist) 

476 app.add_node(blogpost_node, 

477 html=(visit_blogpost_node, depart_blogpost_node), 

478 epub=(visit_blogpost_node, depart_blogpost_node), 

479 elatex=(visit_blogpost_node, depart_blogpost_node), 

480 latex=(visit_blogpost_node, depart_blogpost_node), 

481 rst=(visit_blogpost_node, depart_blogpost_node), 

482 md=(visit_blogpost_node, depart_blogpost_node), 

483 text=(visit_blogpost_node, depart_blogpost_node)) 

484 

485 app.add_node(blogpostagg_node, 

486 html=(visit_blogpostagg_node, depart_blogpostagg_node_html), 

487 epub=(visit_blogpostagg_node, depart_blogpostagg_node_html), 

488 elatex=(visit_blogpostagg_node, depart_blogpostagg_node), 

489 latex=(visit_blogpostagg_node, depart_blogpostagg_node), 

490 rst=(visit_blogpostagg_node, depart_blogpostagg_node), 

491 md=(visit_blogpostagg_node, depart_blogpostagg_node), 

492 text=(visit_blogpostagg_node, depart_blogpostagg_node)) 

493 

494 app.add_directive('blogpost', BlogPostDirective) 

495 app.add_directive('blogpostagg', BlogPostDirectiveAgg) 

496 # app.add_directive('blogpostlist', BlogPostListDirective) 

497 # app.connect('doctree-resolved', process_blogpost_nodes) 

498 # app.connect('env-purge-doc', purge_blogpost) 

499 return {'version': sphinx.__display_version__, 'parallel_read_safe': True}