Coverage for /var/devmt/py/ghmdlib_0.1.0/ghmdlib/ghmd.py: 100%

1#!/usr/bin/env python3

2# -*- coding: utf-8 -*-

3"""

4:App: ghmd(lib)

5:Purpose: This library and command line tool are designed to convert

6 Markdown files into GitHub-style HTML format.

7

8 This module provides *both* the project's command line

9 interface and back-end library.

10

11:Platform: Linux/Windows | Python 3.10+

12:Developer: J Berendt

13:Email: development@s3dev.uk

14

15:Comments: This project is a fork of the `ghmd`_ project and has been

16 updated to include an installable library to interface with

17 your other Python applications. Additionally, the command

18 line interface has been updated to include additional

19 features.

20

21

22.. _ghmd: https://github.com/roman910dev/ghmd

23

24"""

25# pylint: disable=wrong-import-order

26

27 import logging

28 import os

29 import re

30 import sys

31 import webbrowser

32# locals

33 try: # nocover

34 from libs.argparser import argparser

35 from libs._download import Download

36 from libs._offline import Offline

37 from libs._online import Online

38 except ImportError:

39 from ghmdlib.libs.argparser import argparser

40 from ghmdlib.libs._download import Download

41 from ghmdlib.libs._offline import Offline

42 from ghmdlib.libs._online import Online

43

44 logger = logging.getLogger(__name__)

45

46

47 class Converter:

48 """Primary Markdown to HTML document conversion class.

49

50 :Example:

51

52 Convert a Markdown file to GitHub-style HTML::

53

54 >>> from ghmdlib import converter

55

56 >>> converter.convert(path='/path/to/file.md', preview=True)

57

58

59 Convert a Markdown file to GitHub-style HTML, in **offline**

60 mode::

61

62 >>> from ghmdlib import converter

63

64 >>> converter.convert(path='/path/to/file.md', offline=True)

65

66 """

67

68 _TEMPLATE = './resources/md-template.html'

69 _RE_TITLE = re.compile(r'^# (.*)$')

70

71 def __init__(self) -> None:

72 """GitHub-Markdown converter class initialiser."""

73 self._argp = None

74 self._args = None

75 self._css = ''

76 self._headers = ''

77 self._offline = False

78

79 def convert(self,

80 path: str | list[str],

81 *,

82 theme: str='dark',

83 embed_css: bool=False,

84 no_gfm: bool=False,

85 offline: bool=False,

86 preview: bool=False) -> bool:

87 """Convert the given path(s) from Markdown to GitHub-style HTML.

88

89 .. note::

90

91 Once converted, the HTML file is created in the same

92 directory as the source file, with the same filename and an

93 '.html' file extension.

94

95 Args:

96 path (str | list[str]): Full path to the Markdown file

97 (or list of files) to be converted.

98 theme (str, optional): Theme to be used for the HTML.

99 Options: 'dark', 'light'. Defaults to 'dark'.

100 embed_css (bool, optional): Embed the CSS in the HTML file

101 rather than linking. Defaults to False.

102 no_gfm (bool, optional): Disable GitHub Flavoured Markdown

103 (GFM) and use the standard format. Defaults to False.

104 offline (bool, optional): Keep offline. Use cached CSS and

105 local conversion libraries rather than the GitHub API.

106 preview (bool, optional): Open each converted HTML file in

107 a web browser to view the results. Defaults to False.

108

109 Returns:

110 bool: True if the conversion is successful, otherwise False.

111

112 """

113 # pylint: disable=multiple-statements

114 self._offline = offline

115 if isinstance(path, str):

116 path = [path]

117 s = self._set_css(theme=theme, embed_css=embed_css)

118 if s: s = self._set_headers()

119 if s: s = self._convert(path=path, mode='markdown' if no_gfm else 'gfm', preview=preview)

120 return s

121

122 def _convert(self, path: str | list[str], mode: str, preview: bool) -> bool:

123 """Convert Markdown file(s) to HTML.

124

125 Args:

126 path (str | list[str]): Full path to the Markdown file

127 (or list of files) to be converted.

128 mode (str): Mode for Markdown conversion ('markdown' | 'gfm').

129 preview (bool): Open each converted HTML file in a web

130 browser to view the results.

131

132 Returns:

133 bool: True if all files were created, otherwise False.

134

135 """

136 created = []

137 for file in path:

138 logger.debug('Converting: %s', file)

139 with open(file, "r", encoding="utf-8") as f:

140 content = f.read()

141 s = self._RE_TITLE.search(content, re.MULTILINE)

142 title = s.group(1) if s else ''

143 tmp = self._read_template()

144 if self._offline:

145 html = Offline.convert(content=content)

146 else:

147 html = Online.convert(content=content, headers=self._headers, mode=mode)

148 fn = f'{os.path.splitext(file)[0]}.html'

149 with open(fn, 'w+', encoding='utf-8') as f:

150 f.write(tmp.replace("{{ .CSS }}", self._css)

151 .replace("{{ .Title }}", title)

152 .replace("{{ .Content }}", html))

153 created.append(fn)

154 logger.debug('Created: %s', fn)

155 if preview: # nocover

156 self._preview(paths=created)

157 return all(map(os.path.exists, created)) or not created

158

159 def _main(self) -> None: # nocover # Core functionality is covered by unittests.

160 """Main CLI program entry-point and process controller.

161

162 Exits with exit code 0 if all files were created, or the CSS

163 files were downloaded successfully. Otherwise, the exit code is

164 set to 2.

165

166 .. caution::

167

168 This method is the **command line entry-point** *only* and

169 should *not* be used internally.

170

171 """

172 self._parse_args()

173 if self._args.download:

174 s = Download.download()

175 else:

176 s = self.convert(path=self._args.PATH,

177 theme='dark' if self._args.dark else 'light',

178 embed_css=self._args.embed_css,

179 no_gfm=self._args.no_gfm,

180 offline=self._args.offline,

181 preview=self._args.preview)

182 sys.exit(0 if s else 2)

183

184 def _parse_args(self) -> None: # nocover

185 """Parse CLI arguments into class attributes."""

186 self._argp = argparser

187 self._argp.parse()

188 self._args = self._argp.args

189

190 def _preview(self, paths: list[str]) -> None: # nocover

191 """Preview all created HTML files in a web browser.

192

193 Args:

194 paths (list[str]): A list of file paths to be previewed.

195

196 """

197 for path in paths:

198 logger.debug('Opening preview for: %s', os.path.basename(path))

199 webbrowser.open(path)

200

201 def _read_template(self) -> str:

202 """Read the Markdown HTML template file.

203

204 Returns:

205 str: A string containing the Markdown template.

206

207 """

208 tmp = ''

209 _dir = os.path.dirname(os.path.realpath(__file__))

210 with open(os.path.join(_dir, self._TEMPLATE), 'r', encoding='utf-8') as f:

211 tmp = f.read()

212 return tmp

213

214 def _set_css(self, theme: str, embed_css: bool) -> bool:

215 """Get and set the CSS for the HTML output.

216

217 Args:

218 theme (str): Theme to be used (dark | light).

219 embed_css (bool): Embed the CSS rather than linking.

220

221 Raises:

222 ValueError: If the CSS could not be retrieved.

223

224 Returns:

225 bool: True if the CSS was set successfully, otherwise False.

226

227 """

228 if self._offline:

229 css = Offline.set_css(theme=theme)

230 else:

231 css = Online.set_css(theme=theme, embed_css=embed_css)

232 self._css = css

233 logger.debug('CSS retrieved successfully: %s', bool(css))

234 logger.debug('CSS (truncated): %s ... %s', css[:25], css[-25:])

235 return bool(css)

236

237 def _set_headers(self) -> bool:

238 """Set the HTTP headers.

239

240 Returns:

241 bool: True if the header is set, otherwise False.

242

243 """

244 self._headers = {"Accept": "application/vnd.github+json"}

245 if github_token := os.environ.get("GITHUB_TOKEN"):

246 self._headers["Authorization"] = f"Bearer {github_token}"

247 logger.debug('Headers set successfully: %s', bool(self._headers))

248 logger.debug('Header: %s', self._headers)

249 return bool(self._headers)

250

251

252# Alias for_library imports.

253 converter = Converter()

254

255# %% Prevent from running on module import.

256

257# pylint: disable=protected-access # Converter._main

258# Enable running as either a script (dev/debugging) or as an executable.

259 if __name__ == '__main__': # pragma: nocover

260 c = Converter()

261 c._main()

262 else: # pragma: nocover

263 def main():

264 """Entry-point exposed for the executable.

265

266 The ``"ghmdlib.ghmd:main"`` value is set in ``pyproject.toml``'s

267 ``[project.scripts]`` table as the entry-point for the installed

268 executable.

269

270 """

271 # pylint: disable=redefined-outer-name

272 c = Converter()

273 c._main()