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
« 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.
8 This module provides *both* the project's command line
9 interface and back-end library.
11:Platform: Linux/Windows | Python 3.10+
12:Developer: J Berendt
13:Email: development@s3dev.uk
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.
22.. _ghmd: https://github.com/roman910dev/ghmd
24"""
25# pylint: disable=wrong-import-order
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
44logger = logging.getLogger(__name__)
47class Converter:
48 """Primary Markdown to HTML document conversion class.
50 :Example:
52 Convert a Markdown file to GitHub-style HTML::
54 >>> from ghmdlib import converter
56 >>> converter.convert(path='/path/to/file.md', preview=True)
59 Convert a Markdown file to GitHub-style HTML, in **offline**
60 mode::
62 >>> from ghmdlib import converter
64 >>> converter.convert(path='/path/to/file.md', offline=True)
66 """
68 _TEMPLATE = './resources/md-template.html'
69 _RE_TITLE = re.compile(r'^# (.*)$')
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
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.
89 .. note::
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.
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.
109 Returns:
110 bool: True if the conversion is successful, otherwise False.
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
122 def _convert(self, path: str | list[str], mode: str, preview: bool) -> bool:
123 """Convert Markdown file(s) to HTML.
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.
132 Returns:
133 bool: True if all files were created, otherwise False.
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
159 def _main(self) -> None: # nocover # Core functionality is covered by unittests.
160 """Main CLI program entry-point and process controller.
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.
166 .. caution::
168 This method is the **command line entry-point** *only* and
169 should *not* be used internally.
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)
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
190 def _preview(self, paths: list[str]) -> None: # nocover
191 """Preview all created HTML files in a web browser.
193 Args:
194 paths (list[str]): A list of file paths to be previewed.
196 """
197 for path in paths:
198 logger.debug('Opening preview for: %s', os.path.basename(path))
199 webbrowser.open(path)
201 def _read_template(self) -> str:
202 """Read the Markdown HTML template file.
204 Returns:
205 str: A string containing the Markdown template.
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
214 def _set_css(self, theme: str, embed_css: bool) -> bool:
215 """Get and set the CSS for the HTML output.
217 Args:
218 theme (str): Theme to be used (dark | light).
219 embed_css (bool): Embed the CSS rather than linking.
221 Raises:
222 ValueError: If the CSS could not be retrieved.
224 Returns:
225 bool: True if the CSS was set successfully, otherwise False.
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)
237 def _set_headers(self) -> bool:
238 """Set the HTTP headers.
240 Returns:
241 bool: True if the header is set, otherwise False.
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)
252# Alias for_library imports.
253converter = Converter()
255# %% Prevent from running on module import.
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.
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.
270 """
271 # pylint: disable=redefined-outer-name
272 c = Converter()
273 c._main()