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
« 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
14class BlogPostParseError(Exception):
16 """
17 Exception raised when a error comes after
18 a blogpost was parsed.
19 """
20 pass
23class BlogPost:
25 """
26 Defines a blog post.
27 """
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.
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`
45 The constructor creates the following members:
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
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
74 self._raw = content
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)
87 overrides.update({ # 'warning_stream': StringIO(),
88 'out_blogpostlist': [],
89 'out_runpythonlist': [],
90 'master_doc': 'stringblog'})
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
99 from ..helpgen.sphinxm_mock_app import MockSphinxApp
100 app = MockSphinxApp.create(confoverrides=overrides)
101 env = app[0].env
102 config = env.config
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())
118 env.temp_data["docname"] = "stringblog"
119 overrides["env"] = env
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)
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)
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))
164 # document = pub.writer.document
165 objects = pub.settings.out_blogpostlist
167 if len(objects) != 1:
168 raise BlogPostParseError( # pragma: no cover
169 f'no blog post (#={len(objects)}) in\n File "{filename}", line 1')
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
178 def __cmp__(self, other):
179 """
180 This method avoids to get the following error
181 ``TypeError: unorderable types: BlogPost() < BlogPost()``.
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}")
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
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
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)
231 @staticmethod
232 def build_tag(date, title):
233 """
234 Builds the tag for a post.
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"])
243 @property
244 def FileName(self):
245 """
246 Returns the filename.
247 """
248 return self._filename
250 @property
251 def Title(self):
252 """
253 Returns the title.
254 """
255 return self.title
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
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
271 @property
272 def Date(self):
273 """
274 Returns the date.
275 """
276 return self.date
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]
285 @property
286 def Keywords(self):
287 """
288 Returns the keywords.
289 """
290 return [_.strip() for _ in self.keywords.split(",")]
292 @property
293 def Categories(self):
294 """
295 Returns the categories.
296 """
297 return [_.strip() for _ in self.categories.split(",")]
299 @property
300 def Content(self):
301 """
302 Returns the content of the blogpost.
303 """
304 return self._content
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.
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("")
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
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
360 rows.extend(["", ""])
361 return "\n".join(rows)
363 image_tag = ".. image:: "
365 def _update_link(self, row):
366 """
367 Changes a link to an image if the page contains one into
368 *year/img.png*.
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