1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405
|
"""
Basic context for the jinja2 templates.
"Batteries included": It defines standard variables, macros and filters
that a template designer is likely to need.
It contains in particular documentation functions.
Laurent Franceschetti (c) 2020
"""
from urllib.parse import urlparse
import os
import sys
import subprocess
import platform
import traceback
from importlib.metadata import version as package_version
import datetime
from dateutil.parser import parse as date_parse
from functools import partial
import mkdocs
from mkdocs.structure.nav import get_navigation
from mkdocs.structure.files import File
from mkdocs.utils import normalize_url
import jinja2
from jinja2 import Template
from markdown import markdown
# ---------------------------------
# Initialization
# ---------------------------------
# Local directory
SOURCE_DIR = os.path.dirname(os.path.abspath(__file__))
# Name of the package (for version)
PACKAGE_NAME = 'mkdocs-macros-plugin'
# ---------------------------------
# Documentation utilities
# ---------------------------------
def list_items(obj):
"""
Returns a list of key,value pairs for the content of an object
Creates an abstraction layer so that we do not have to worry.
"""
try:
return obj.items()
except AttributeError:
# it's an object
return obj.__dict__.items()
except TypeError:
# it's a list: enumerate
return enumerate(list(obj))
def get_first_para(s) -> str:
"Get the first para of a docstring"
first_lines = []
for row in s.strip().splitlines():
if not row:
break
else:
first_lines.append(row)
r = ' '.join(first_lines).strip()
# fix last character that ends with a semi-colon
if r.endswith(':'):
r = r[:-1] + '.'
return r
def format_value(value):
"Properly format the value, to make it descriptive"
# those classes will be processed as "dictionary type"
# NOTE: using the name does nto force us to import them
LISTED_CLASSES = 'Config', 'File', 'Section'
# those types will be printed without question
SHORT_TYPES = int, float, str, list
if callable(value):
# for functions
docstring = get_first_para(value.__doc__)
# we interpret the markdown in the docstring,
# since both jinja2 and ourselves use markdown,
# and we need to produce a HTML table:
docstring = markdown(docstring)
try:
varnames = ', '.join(value.__code__.co_varnames)
return "(<i>%s</i>)<br/> %s" % (varnames, docstring)
except AttributeError:
# code not available
return docstring
elif (isinstance(value, dict) or
type(value).__name__ in LISTED_CLASSES):
# print("Processing:", type(value).__name__, isinstance(value, SHORT_TYPES))
r_list = []
for key, value in list_items(value):
if isinstance(value, SHORT_TYPES):
r_list.append("%s = %s" % (key, repr(value)))
else:
# object or dict: write minimal info:
r_list.append("<b>%s</b> [<i>%s</i>]" %
(key, type(value).__name__))
return ', '.join(r_list)
else:
return repr(value)
def make_html(rows, header=[], tb_class='macros-tb'):
"Produce an HTML table"
font_color = "#000000" # black
back_color = "#F0FFFF" # light blue
grid_color = "#DCDCDC"
padding = "5px"
style = f"color:{font_color}; border:1px solid {grid_color}; padding: {padding}"
templ = Template("""
<table class="{{ tb_class }}" style="background-color: {{ back_color}}; {{ style }}">
{% for item in header %}
<th style="{{ style }}">{{ item }}</th>
{% endfor %}
{% for row in rows %}
<tr>
{% for item in row %}
<td style="vertical-align:top; {{ style }}">{{ item }}</td>
{% endfor %}
</tr>
{% endfor %}
</table>
""")
return templ.render(locals())
def get_git_info():
"""
Get the abbreviated commit version (not provided by get_git_info())
Returns a dictionary
"""
LAST_COMMIT = ['git', 'log', '-1']
COMMANDS = {
'short_commit': ['git', 'rev-parse', '--short', 'HEAD'],
'commit': ['git', 'rev-parse', 'HEAD'],
'tag': ['git', 'describe', '--tags'],
# With --abbrev set to 0, git will find the closest tagname without any suffix
'short_tag': ['git', 'describe', '--tags', '--abbrev=0'],
'author': LAST_COMMIT + ["--pretty=format:%an"],
'author_email': LAST_COMMIT + ["--pretty=format:%ae"],
'committer': LAST_COMMIT + ["--pretty=format:%cn"],
'committer_email': LAST_COMMIT + ["--pretty=format:%ce"],
# %cd is the commit date
'date_ISO': LAST_COMMIT + ['--pretty=format:%cd'],
'message': LAST_COMMIT + ["--pretty=format:%B"],
'raw': LAST_COMMIT,
'root_dir': ['git', 'rev-parse', '--show-toplevel']
}
# always return a date, even in case of failure
r = {'status': False, 'date': None}
try:
for var, command in COMMANDS.items():
# NOTE: The 'text' argument is clearer,
# but for Python < 3.7, only `universal_newlines`
# is accepted
try:
r[var] = subprocess.check_output(command,
universal_newlines=True,
stderr=subprocess.DEVNULL).strip()
if var == 'date_ISO':
r['date'] = date_parse(r[var])
r['status'] = True
except subprocess.CalledProcessError as e:
if e.returncode == 128:
# generally means "unexpected error"
# git status (no repo),
# git tag (no tag)
r[var] = ''
else:
# should be 1, type whatever that is
r[var] = "# Cannot execute '%s': %s" % (command, e)
except Exception as e:
# any other error, it's probably meaningless at this point
r[var] = "# Unexpected error '%s': %s" % (command, e)
# convert
return r
except FileNotFoundError as e:
# not git command
return r.update(
{'status': False,
'diagnosis': 'Git command not found',
'error': str(e)})
def python_version():
"Get the python version"
try:
return sys.version.split('(')[0].rstrip()
except (AttributeError, IndexError) as e:
return str(e)
def system_name():
"Get the system name"
r = platform.system()
if not r:
# you never know
return "<UNKNOWN>"
# print("Found:", r)
CONV = {'Win': 'Windows', 'Darwin': 'MacOs'}
return CONV.get(r, r)
def system_version():
"Get the system version"
try:
return platform.mac_ver()[0] or platform.release()
except (AttributeError, IndexError) as e:
return str(e)
# for the navigation
class Files(object):
"This helper class is needed to rebuild the navigation"
def __init__(self, config):
self.config = config
self._filenames = []
@property
def filenames(self):
"The list of filenames (not used at the moment"
return self._filenames
def get_file_from_path(self, path):
"Build the filenames"
self._filenames.append(path)
file = File(os.path.basename(path),
os.path.dirname(path),
os.path.dirname(path), True)
return file
def documentation_pages(self):
return []
# ---------------------------------
# Urls
# ---------------------------------
def is_relative(url):
"""
Check whether a url is relative
>>> urlparse("http://www.google.com")
ParseResult(scheme='http', netloc='www.google.com', path='', params='', query='', fragment='')
>>> urlparse("../foo")
ParseResult(scheme='', netloc='', path='../foo', params='', query='', fragment='')
"""
p = urlparse(url)
return (not p.scheme) and p.path
def fix_url(url):
"""
If url is relative, fix it so that it points to the docs directory.
This is necessary because relative links in markdown must be adapted
in html ('img/foo.png' => '../img/img.png').
"""
if is_relative(url):
r = "../" + url
else:
r = url
return r
# ---------------------------------
# Exports to the environment
# ---------------------------------
def define_env(env):
"""
This is the hook for declaring variables, macros and filters
"""
# Get data on the environment (versions)
try:
environment = {
'system': system_name(),
'system_version': system_version(),
'python_version': python_version(),
'mkdocs_version': mkdocs.__version__,
'macros_plugin_version': package_version(PACKAGE_NAME),
'jinja2_version': jinja2.__version__,
# 'site_git_version': site_git_version(),
}
except Exception as e:
# Avoid breaking the system if error in reading the system info:
environment = ("<i><b>Cannot read system info!</b> %s: %s</i>" %
(type(e).__name__, str(e)))
env.variables['environment'] = environment
# configuration of the plugin, in the yaml file:
env.variables['plugin'] = env.config
# git information:
env.variables['git'] = get_git_info()
def render_file(filename):
"""
Render an external page (filename) containing jinja2 code
Do not declare as macro, as this is pointless.
"""
SOURCE_FILE = os.path.join(SOURCE_DIR, filename)
with open(SOURCE_FILE) as f:
s = f.read()
# now we need to render the jinja2 directives,
# always rendering (to skip reasoning about page header)
return env.render(s, force_rendering=True)
@env.macro
def context(obj:dict=None):
"""
*Default Mkdocs-Macro*: List an object
(by default the variables)
"""
if not obj:
obj = env.variables
try:
return [(var, type(value).__name__, format_value(value))
for var, value in list_items(obj)]
except jinja2.exceptions.UndefinedError as e:
return [("<i>Error!</i>", type(e).__name__, str(e))]
except AttributeError:
# Not an object or dictionary (int, str, etc.)
return [(obj, type(obj).__name__, repr(obj))]
@env.filter
def pretty(var_list):
"""
*Default Mkdocs-Macro*: Prettify a dictionary or object
(used for environment documentation, or debugging).
Note: it will work only on the product of the `context()` macro
To prettify any object `obj`, thus use: `context(obj) | pretty`
"""
if not var_list:
return ''
else:
try:
rows = [("<b>%s</b>" % var, "<i>%s</i>" % var_type,
content.replace('\n', '<br/>'))
for var, var_type, content in var_list]
header = ['Variable', 'Type', 'Content']
return make_html(rows, header)
except Exception as e:
# dont make the whole page fail:
return "#%s: %s\n%s" % (type(e).__name__, e,
traceback.format_exc())
@env.macro
def macros_info():
"""
*Test/debug function*:
list useful documentation on the mkdocs_macro environment.
"""
# NOTE: this is template
return render_file('macros_info.md')
@env.macro
def now():
"""
*Default Mkdocs-Macro*:
Get the current time (at the moment of the project build).
It returns a datetime object.
Used alone, it provides a timestamp.
To get the year use `now().year`, for the month number
`now().month`, etc.
"""
return datetime.datetime.now()
# add fix url function as macro
env.macro(fix_url)
# add the normal mkdocs url function
# env.filter(normalize_url)
@env.filter
def relative_url(path: str):
"""
*Default Mkdocs-Macro*:
convert the path of any page according to MkDoc's internal logic,
into a URL relative to the current page
(implements the `normalize_url()` function from `mkdocs.util`).
Typically used to manage custom navigation:
`{{ page.url | relative_url }}`.
"""
return normalize_url(path=path, page=env.page)
|