Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PDF generation via markdown #20

Merged
merged 2 commits into from
Apr 22, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions pdoc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,8 @@ def __init__(self, module: ModuleType, *, docfilter: Callable[[Doc], bool] = Non
global context object will be used.
"""
super().__init__(module.__name__, self, module)
if self.name.endswith('.__init__') and not self.is_package:
self.name = self.name[:-len('.__init__')]

self._context = _global_context if context is None else context
"""
Expand Down Expand Up @@ -913,7 +915,7 @@ def __init__(self, module: ModuleType, *, docfilter: Callable[[Doc], bool] = Non
else:
def is_from_this_module(obj):
mod = inspect.getmodule(obj)
return mod is None or mod.__name__ == self.name
return mod is None or mod.__name__ == self.obj.__name__

public_objs = [(name, inspect.unwrap(obj))
for name, obj in inspect.getmembers(self.obj)
Expand Down Expand Up @@ -1060,7 +1062,7 @@ def find_class(self, cls: type):
or in any of the exported identifiers of the submodules.
"""
# XXX: Is this corrent? Does it always match
# `Class.module.name + Class.qualname`?.
# `Class.module.name + Class.qualname`?. Especially now?
# If not, see what was here before.
return self.find_ident(cls.__module__ + '.' + cls.__qualname__)

Expand Down
52 changes: 52 additions & 0 deletions pdoc/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import os.path as path
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Sequence

import pdoc

Expand Down Expand Up @@ -55,6 +56,11 @@
action="store_true",
help="Overwrites any existing HTML files instead of producing an error.",
)
aa("--pdf",
action="store_true",
help="When set, the specified modules will be printed to standard output, "
"formatted in Markdown-Extra, compatible with most "
"Markdown-(to-HTML-)to-PDF converters.")
aa(
"--external-links",
action="store_true",
Expand Down Expand Up @@ -281,6 +287,18 @@ def write_html_files(m: pdoc.Module):
write_html_files(submodule)


def _flatten_submodules(modules: Sequence[pdoc.Module]):
for module in modules:
yield module
for submodule in module.submodules():
yield from _flatten_submodules((submodule,))


def print_pdf(modules, **kwargs):
modules = list(_flatten_submodules(modules))
print(pdoc._render_template('/pdf.mako', modules=modules, **kwargs))


def main(_args=None):
""" Command-line entry point """
global args
Expand Down Expand Up @@ -338,6 +356,40 @@ def docfilter(obj, _filters=args.filter.strip().split(',')):
for module in args.modules]
pdoc.link_inheritance()

if args.pdf:
print_pdf(modules)
print("""
PDF-ready markdown written to standard output.
^^^^^^^^^^^^^^^
Convert this file to PDF using e.g. Pandoc:

pandoc --metadata=title:"MyProject Documentation" \\
--toc --toc-depth=4 --from=markdown+abbreviations \\
--pdf-engine=xelatex --variable=mainfont:"DejaVu Sans" \\
--output=pdf.pdf pdf.md

or using Python-Markdown and Chrome/Chromium/WkHtmlToPDF:

markdown_py --extension=meta \\
--extension=abbr \\
--extension=attr_list \\
--extension=def_list \\
--extension=fenced_code \\
--extension=footnotes \\
--extension=tables \\
--extension=admonition \\
--extension=smarty \\
--extension=toc \\
pdf.md > pdf.html

chromium --headless --disable-gpu --print-to-pdf=pdf.pdf pdf.html

wkhtmltopdf -s A4 --print-media-type pdf.html pdf.pdf

or similar, at your own discretion.""",
file=sys.stderr)
sys.exit(0)

for module in modules:
if args.html:
_quit_if_exists(module)
Expand Down
25 changes: 19 additions & 6 deletions pdoc/html_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,16 +292,29 @@ def raw_urls(text):


def to_html(text: str, docformat: str = 'numpy,google', *,
module: pdoc.Module = None, link: Callable[..., str] = None,
# Matches markdown code spans not +directly+ within links.
# E.g. `code` and [foo is `bar`]() but not [`code`](...)
# Also skips \-escaped grave quotes.
_code_refs=re.compile(r'(?<![\[\\])`(?!])(?:[^`]|(?<=\\)`)+`').sub):
module: pdoc.Module = None, link: Callable[..., str] = None):
"""
Returns HTML of `text` interpreted as `docformat`.
By default, Numpydoc and Google-style docstrings are assumed,
as well as pure Markdown.

`module` should be the documented module (so the references can be
resolved) and `link` is the hyperlinking function like the one in the
example template.
"""
md = to_markdown(text, docformat=docformat, module=module, link=link)
return _md.reset().convert(md)


def to_markdown(text: str, docformat: str = 'numpy,google', *,
module: pdoc.Module = None, link: Callable[..., str] = None,
# Matches markdown code spans not +directly+ within links.
# E.g. `code` and [foo is `bar`]() but not [`code`](...)
# Also skips \-escaped grave quotes.
_code_refs=re.compile(r'(?<![\[\\])`(?!])(?:[^`]|(?<=\\)`)+`').sub):
"""
Returns `text`, assumed to be a docstring in `docformat`, converted to markdown.

`module` should be the documented module (so the references can be
resolved) and `link` is the hyperlinking function like the one in the
example template.
Expand Down Expand Up @@ -342,7 +355,7 @@ def linkify(match, _is_pyident=re.compile(r'^[a-zA-Z_]\w*(\.\w+)+$').match):

text = _code_refs(linkify, text)

return _md.reset().convert(text)
return text


def extract_toc(text: str):
Expand Down
167 changes: 167 additions & 0 deletions pdoc/templates/pdf.mako
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
---
description: |
API documentation for modules: ${', '.join(m.name for m in modules)}.

lang: en

classoption: oneside
geometry: margin=1in
papersize: a4

linkcolor: blue
links-as-notes: true
...
<%!
import re
import pdoc
from pdoc.html_helpers import to_markdown

def link(d, fmt='{}'):
name = fmt.format(d.qualname + ('()' if isinstance(d, pdoc.Function) else ''))
if isinstance(d, pdoc.External):
return name
return '[{}](#{})'.format(name, d.refname)

def _to_md(text, module):
text = to_markdown(text, module=module, link=link)
# Setext H2 headings to atx H2 headings
text = re.sub(r'\n(.+)\n-{3,}\n', r'\n## \1\n\n', text)
# Convert admonitions into simpler paragraphs, dedent contents
text = re.sub(r'^(?P<indent>( *))!!! \w+ \"([^\"]*)\"(.*(?:\n(?P=indent) +.*)*)',
lambda m: '{}**{}:** {}'.format(m.group(2), m.group(3),
re.sub('\n {,4}', '\n', m.group(4))),
text, flags=re.MULTILINE)
return text

def subh(text, level=2):
# Deepen heading levels so H2 becomes H4 etc.
return re.sub(r'\n(#+) +(.+)\n', r'\n%s\1 \2\n' % ('#' * level), text)
%>

<%def name="title(level, string, id=None)">
<% id = ' {#%s}' % id if id is not None else '' %>
${('#' * level) + ' ' + string + id}
</%def>

<%def name="funcdef(f)">
> `${f.funcdef()} ${f.name}(${', '.join(f.params())})`
</%def>

<%def name="classdef(c)">
> `class ${c.name}(${', '.join(c.params())})`
</%def>

% for module in modules:
<%
submodules = module.submodules()
variables = module.variables()
functions = module.functions()
classes = module.classes()

def to_md(text):
return _to_md(text, module)
%>
${title(1, 'Module `%s`' % module.name, module.refname)}
${module.docstring | to_md}

% if submodules:
${title(2, 'Sub-modules')}
% for m in submodules:
* [${m.name}](#${m.refname})
% endfor
% endif

% if variables:
${title(2, 'Variables')}
% for v in variables:
${title(3, 'Variable `%s`' % v.name, v.refname)}
${v.docstring | to_md, subh, subh}
% endfor
% endif

% if functions:
${title(2, 'Functions')}
% for f in functions:
${title(3, 'Function `%s`' % f.name, f.refname)}

${funcdef(f)}

${f.docstring | to_md, subh, subh}
% endfor
% endif

% if classes:
${title(2, 'Classes')}
% for cls in classes:
${title(3, 'Class `%s`' % cls.name, cls.refname)}

${classdef(cls)}

${cls.docstring | to_md, subh}
<%
class_vars = cls.class_variables()
static_methods = cls.functions()
inst_vars = cls.instance_variables()
methods = cls.methods()
mro = cls.mro()
subclasses = cls.subclasses()
%>
% if mro:
${title(4, 'Ancestors (in MRO)')}
% for c in mro:
* [${c.refname}](#${c.refname})
% endfor
% endif

% if subclasses:
${title(4, 'Descendants')}
% for c in subclasses:
* [${c.refname}](#${c.refname})
% endfor
% endif

% if class_vars:
${title(4, 'Class variables')}
% for v in class_vars:
${title(5, 'Variable `%s`' % v.name, v.refname)}
${v.docstring | to_md, subh, subh}
% endfor
% endif

% if inst_vars:
${title(4, 'Instance variables')}
% for v in inst_vars:
${title(5, 'Variable `%s`' % v.name, v.refname)}
${v.docstring | to_md, subh, subh}
% endfor
% endif

% if static_methods:
${title(4, 'Static methods')}
% for f in static_methods:
${title(5, '`Method %s`' % f.name, f.refname)}

${funcdef(f)}

${f.docstring | to_md, subh, subh}
% endfor
% endif

% if methods:
${title(4, 'Methods')}
% for f in methods:
${title(5, 'Method `%s`' % f.name, f.refname)}

${funcdef(f)}

${f.docstring | to_md, subh, subh}
% endfor
% endif
% endfor
% endif

##\## for module in modules:
% endfor

-----
Generated by *pdoc* ${pdoc.__version__} (<https://pdoc3.github.io>).
18 changes: 17 additions & 1 deletion pdoc/test/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,17 @@ def test_text_identifier(self):
self.assertNotIn('CONST', out)
self.assertNotIn('B docstring', out)

def test_pdf(self):
with redirect_streams() as (stdout, stderr):
run('pdoc', pdf=None)
out = stdout.getvalue()
err = stderr.getvalue()
self.assertIn('pdoc3.github.io', out)
self.assertIn('pandoc', err)

self.assertIn(str(inspect.signature(pdoc.Doc.__init__)).replace('self, ', ''),
out)


class ApiTest(unittest.TestCase):
"""
Expand Down Expand Up @@ -659,6 +670,11 @@ def test_sorting(self):
self.assertNotEqual(sorted_methods, unsorted_methods)
self.assertEqual(sorted_methods, sorted(unsorted_methods))

def test_module_init(self):
mod = pdoc.Module(pdoc.import_module('pdoc.__init__'))
self.assertEqual(mod.name, 'pdoc')
self.assertIn('Module', mod.doc)


class HtmlHelpersTest(unittest.TestCase):
"""
Expand Down Expand Up @@ -865,7 +881,7 @@ def test_reST_directives(self):
<div class="admonition admonition">
<p class="admonition-title">Example</p>
<p>Image shows something.</p>
<p><img alt="" src="/logo.png"></p>
<p><img alt="" src="https://www.debian.org/logos/openlogo-nd-100.png"></p>
<div class="admonition note">
<p class="admonition-title">Note</p>
<p>Can only nest admonitions two levels.</p>
Expand Down
2 changes: 1 addition & 1 deletion pdoc/test/example_pkg/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ def reST_directives(self):

Image shows something.

.. image:: /logo.png
.. image:: https://www.debian.org/logos/openlogo-nd-100.png

.. note::
Can only nest admonitions two levels.
Expand Down