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

63 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-05 11:32 +0000

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 

27import logging 

28import os 

29import re 

30import sys 

31import webbrowser 

32# locals 

33try: # 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 

38except 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 

44logger = logging.getLogger(__name__) 

45 

46 

47class 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. 

253converter = 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. 

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

260 c = Converter() 

261 c._main() 

262else: # 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()