Coverage for pyquickhelper/sphinxext/blog_post.py: 83%

185 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 Helpers to process blog post included in the documentation. 

5""" 

6import os 

7from io import StringIO 

8from contextlib import redirect_stdout, redirect_stderr 

9from docutils import io as docio 

10from docutils.core import publish_programmatically 

11from .._cst.cst_sphinx import get_epkg_dictionary 

12 

13 

14class BlogPostParseError(Exception): 

15 

16 """ 

17 Exception raised when a error comes after 

18 a blogpost was parsed. 

19 """ 

20 pass 

21 

22 

23class BlogPost: 

24 

25 """ 

26 Defines a blog post. 

27 """ 

28 

29 def __init__(self, filename, encoding='utf-8-sig', raise_exception=False, 

30 extensions=None, conf=None, **kwargs_overrides): 

31 """ 

32 Creates an instance of a blog post from a file or a string. 

33 

34 :param filename: filename or string 

35 :param encoding: encoding 

36 :param raise_exception: to raise an exception when the blog cannot 

37 be parsed 

38 :param extensions: list of extension to use to parse 

39 the content of the blog, if None, it will consider 

40 a default list (see @see cl BlogPost and 

41 @see fn get_default_extensions) 

42 :param conf: existing configuration 

43 :param kwargs_overrides: additional parameters for :epkg:`sphinx` 

44 

45 The constructor creates the following members: 

46 

47 * title 

48 * date 

49 * keywords 

50 * categories 

51 * _filename 

52 * _raw 

53 * rst_obj: the object generated by docutils (@see cl BlogPostDirective) 

54 * pub: Publisher 

55 

56 Parameter *raise_exception* catches the standard error. 

57 Option `:process:` of command `.. runpython::` should be 

58 used within a blog post to avoid having the same process use 

59 sphinx at the same time. 

60 """ 

61 if os.path.exists(filename): 

62 with open(filename, "r", encoding=encoding) as f: 

63 try: 

64 content = f.read() 

65 except UnicodeDecodeError as e: # pragma: no cover 

66 raise RuntimeError( 

67 'Unable to read filename (encoding issue):\n ' 

68 'File "{0}", line 1'.format(filename)) from e 

69 self._filename = filename 

70 else: 

71 content = filename 

72 self._filename = None 

73 

74 self._raw = content 

75 

76 overrides = {} 

77 overrides["out_blogpostlist"] = [] 

78 overrides["blog_background"] = True 

79 overrides["blog_background_page"] = False 

80 overrides["sharepost"] = None 

81 if conf is None or not getattr(conf, 'epkg_dictionary'): 

82 overrides['epkg_dictionary'] = get_epkg_dictionary() 

83 else: 

84 overrides['epkg_dictionary'] = conf.epkg_dictionary 

85 overrides.update(kwargs_overrides) 

86 

87 overrides.update({ # 'warning_stream': StringIO(), 

88 'out_blogpostlist': [], 

89 'out_runpythonlist': [], 

90 'master_doc': 'stringblog'}) 

91 

92 if "extensions" not in overrides: 

93 if extensions is None: 

94 # To avoid circular references. 

95 from . import get_default_extensions 

96 extensions = get_default_extensions() 

97 overrides["extensions"] = extensions 

98 

99 from ..helpgen.sphinxm_mock_app import MockSphinxApp 

100 app = MockSphinxApp.create(confoverrides=overrides) 

101 env = app[0].env 

102 config = env.config 

103 

104 if 'blog_background' not in config: 

105 raise AttributeError( # pragma: no cover 

106 "Unable to find 'blog_background' in config:\n{0}".format( 

107 "\n".join(sorted(config.values)))) 

108 if 'blog_background_page' not in config: 

109 raise AttributeError( # pragma: no cover 

110 "Unable to find 'blog_background_page' in config:\n{0}".format( 

111 "\n".join(sorted(config.values)))) 

112 if 'epkg_dictionary' in config: 

113 if len(config.epkg_dictionary) > 0: 

114 overrides['epkg_dictionary'].update(config.epkg_dictionary) 

115 else: 

116 overrides['epkg_dictionary'].update(get_epkg_dictionary()) 

117 

118 env.temp_data["docname"] = "stringblog" 

119 overrides["env"] = env 

120 

121 config.add('doctitle_xform', True, False, bool) 

122 config.add('initial_header_level', 2, False, int) 

123 config.add('input_encoding', encoding, False, str) 

124 

125 keepout = StringIO() 

126 keeperr = StringIO() 

127 with redirect_stdout(keepout): 

128 with redirect_stderr(keeperr): 

129 _, pub = publish_programmatically( 

130 source_class=docio.StringInput, source=content, 

131 source_path=None, destination_class=docio.StringOutput, destination=None, 

132 destination_path=None, reader=None, reader_name='standalone', parser=None, 

133 parser_name='restructuredtext', writer=None, writer_name='null', settings=None, 

134 settings_spec=None, settings_overrides=overrides, config_section=None, 

135 enable_exit_status=None) 

136 

137 all_err = keeperr.getvalue() 

138 if len(all_err) > 0: 

139 lines = all_err.strip(' \n\r').split('\n') 

140 lines = [_ for _ in lines 

141 if ("in epkg_dictionary" not in _ and 

142 "to be local relative or absolute" not in _)] 

143 std = keepout.getvalue().strip('\n\r\t ') 

144 if len(lines) > 0 and raise_exception: 

145 raise BlogPostParseError( # pragma: no cover 

146 "Unable to parse a blogpost:\n[sphinxerror]-F\n{0}" 

147 "\nFILE\n{1}\nCONTENT\n{2}\n--OUT--\n{3}".format( 

148 all_err, self._filename, content, keepout.getvalue())) 

149 if len(lines) > 0: 

150 print(all_err) 

151 if len(std) > 3: 

152 print(std) 

153 else: 

154 for _ in all_err.strip(' \n\r').split('\n'): 

155 print(" ", _) 

156 if len(std) > 3: 

157 print(std) 

158 # we assume we just need the content, raising a warnings 

159 # might make some process fail later 

160 # warnings.warn("Raw rst was caught but unable to fully parse 

161 # a blogpost:\n[sphinxerror]-H\n{0}\nFILE\n{1}\nCONTENT\n{2}".format( 

162 # all_err, self._filename, content)) 

163 

164 # document = pub.writer.document 

165 objects = pub.settings.out_blogpostlist 

166 

167 if len(objects) != 1: 

168 raise BlogPostParseError( # pragma: no cover 

169 f'no blog post (#={len(objects)}) in\n File "{filename}", line 1') 

170 

171 post = objects[0] 

172 for k in post.options: 

173 setattr(self, k, post.options[k]) 

174 self.rst_obj = post 

175 self.pub = pub 

176 self._content = post.content 

177 

178 def __cmp__(self, other): 

179 """ 

180 This method avoids to get the following error 

181 ``TypeError: unorderable types: BlogPost() < BlogPost()``. 

182 

183 @param other other @see cl BlogPost 

184 @return -1, 0, or 1 

185 """ 

186 if self.Date < other.Date: 

187 return -1 

188 if self.Date > other.Date: 

189 return 1 

190 if self.Tag < other.Tag: 

191 return -1 

192 if self.Tag > other.Tag: 

193 return 1 

194 raise ValueError( # pragma: no cover 

195 f"same tag for two BlogPost: {self.Tag}") 

196 

197 def __lt__(self, other): 

198 """ 

199 Tells if this blog should be placed before *other*. 

200 """ 

201 if self.Date < other.Date: 

202 return True 

203 if self.Date > other.Date: 

204 return False 

205 if self.Tag < other.Tag: 

206 return True 

207 return False 

208 

209 @property 

210 def Fields(self): 

211 """ 

212 Returns the fields as a dictionary. 

213 """ 

214 res = dict(title=self.title, 

215 date=self.date, 

216 keywords=self.Keywords, 

217 categories=self.Categories) 

218 if self.BlogBackground is not None: 

219 res["blog_ground"] = self.BlogBackground 

220 if self.Author is not None: 

221 res["author"] = self.Author 

222 return res 

223 

224 @property 

225 def Tag(self): 

226 """ 

227 Produces a tag for the blog post. 

228 """ 

229 return BlogPost.build_tag(self.Date, self.Title) 

230 

231 @staticmethod 

232 def build_tag(date, title): 

233 """ 

234 Builds the tag for a post. 

235 

236 @param date date 

237 @param title title 

238 @return tag or label 

239 """ 

240 return "post-" + date + "-" + \ 

241 "".join([c for c in title.lower() if "a" <= c <= "z"]) 

242 

243 @property 

244 def FileName(self): 

245 """ 

246 Returns the filename. 

247 """ 

248 return self._filename 

249 

250 @property 

251 def Title(self): 

252 """ 

253 Returns the title. 

254 """ 

255 return self.title 

256 

257 @property 

258 def BlogBackground(self): 

259 """ 

260 Returns the blog background or None if not defined. 

261 """ 

262 return self.blog_ground if hasattr(self, "blog_ground") else None 

263 

264 @property 

265 def Author(self): 

266 """ 

267 Returns the author or None if not defined. 

268 """ 

269 return self.author if hasattr(self, "author") else None 

270 

271 @property 

272 def Date(self): 

273 """ 

274 Returns the date. 

275 """ 

276 return self.date 

277 

278 @property 

279 def Year(self): 

280 """ 

281 Returns the year, we assume ``self.date`` is a string like ``YYYY-MM-DD``. 

282 """ 

283 return self.date[:4] 

284 

285 @property 

286 def Keywords(self): 

287 """ 

288 Returns the keywords. 

289 """ 

290 return [_.strip() for _ in self.keywords.split(",")] 

291 

292 @property 

293 def Categories(self): 

294 """ 

295 Returns the categories. 

296 """ 

297 return [_.strip() for _ in self.categories.split(",")] 

298 

299 @property 

300 def Content(self): 

301 """ 

302 Returns the content of the blogpost. 

303 """ 

304 return self._content 

305 

306 def post_as_rst(self, language, directive="blogpostagg", cut=False): 

307 """ 

308 Reproduces the text of the blog post, 

309 updates the image links. 

310 

311 @param language language 

312 @param directive to specify a different behavior based on 

313 @param cut truncate the post after the first paragraph 

314 @return blog post as RST 

315 """ 

316 rows = [] 

317 rows.append(f".. {directive}::") 

318 for f, v in self.Fields.items(): 

319 if isinstance(v, str): 

320 rows.append(f" :{f}: {v}") 

321 else: 

322 rows.append(f" :{f}: {','.join(v)}") 

323 if self._filename is not None: 

324 spl = self._filename.replace("\\", "/").split("/") 

325 name = "/".join(spl[-2:]) 

326 rows.append(f" :rawfile: {name}") 

327 rows.append("") 

328 

329 def can_cut(i, r, rows_stack): 

330 rs = r.lstrip() 

331 indent = len(r) - len(rs) 

332 if len(rows_stack) == 0: 

333 if len(rs) > 0: 

334 rows_stack.append(r) 

335 else: 

336 indent2 = len(rows_stack[0]) - len(rows_stack[0].lstrip()) 

337 last = rows_stack[-1] 

338 if len(last) > 0: 

339 last = last[-1] 

340 if (indent == indent2 and len(rs) == 0 and 

341 last in {'.', ';', ',', ':', '!', '?'}): 

342 return True 

343 rows_stack.append(r) 

344 return False 

345 

346 rows_stack = [] 

347 if directive == "blogpostagg": 

348 for i, r in enumerate(self.Content): 

349 rows.append(" " + self._update_link(r)) 

350 if cut and can_cut(i, r, rows_stack): 

351 rows.extend(["", " ..."]) 

352 break 

353 else: 

354 for i, r in enumerate(self.Content): 

355 rows.append(" " + r) 

356 if cut and can_cut(i, r, rows_stack): 

357 rows.extend(["", " ..."]) 

358 break 

359 

360 rows.extend(["", ""]) 

361 return "\n".join(rows) 

362 

363 image_tag = ".. image:: " 

364 

365 def _update_link(self, row): 

366 """ 

367 Changes a link to an image if the page contains one into 

368 *year/img.png*. 

369 

370 @param row row 

371 @return new row 

372 """ 

373 r = row.strip("\r\t ") 

374 if r.startswith(BlogPost.image_tag): 

375 i = len(BlogPost.image_tag) 

376 r2 = row[i:] 

377 if "/" in r2: 

378 return row 

379 row = f"{row[:i]}{self.Year}/{r2}" 

380 return row 

381 return row