chiark / gitweb /
m.sphinx: new plugin for Python docs via external files.
authorVladimír Vondruš <mosra@centrum.cz>
Sun, 5 May 2019 16:10:49 +0000 (18:10 +0200)
committerVladimír Vondruš <mosra@centrum.cz>
Tue, 21 May 2019 14:51:51 +0000 (16:51 +0200)
Provides a way to document modules, classes and data in the Python doc
theme.

16 files changed:
doc/documentation/python.rst
doc/plugins.rst
doc/plugins/metadata.rst
doc/plugins/sphinx.rst [new file with mode: 0644]
documentation/python.py
documentation/templates/python/class.html
documentation/templates/python/module.html
documentation/test_python/content/classes.html [new file with mode: 0644]
documentation/test_python/content/content.Class.html [new file with mode: 0644]
documentation/test_python/content/content.html [new file with mode: 0644]
documentation/test_python/content/content.py [new file with mode: 0644]
documentation/test_python/content/docs.rst [new file with mode: 0644]
documentation/test_python/test_content.py [new file with mode: 0644]
plugins/m/sphinx.py [new file with mode: 0644]
plugins/m/test/test_sphinx.py [new file with mode: 0644]
site/pelicanconf.py

index ffe9d39bac0cbf1a2aa5f3a401b448811f4f7f8b..f79088beae8cfb93cafcab09a93d746a8021f89d 100644 (file)
@@ -605,8 +605,26 @@ Keyword argument            Content
 :py:`mcss_settings`         Dict containing all m.css settings
 :py:`jinja_environment`     Jinja2 environment. Useful for adding new filters
                             etc.
+:py:`module_doc_contents`   Module documentation contents
+:py:`class_doc_contents`    Class documentation contents
+:py:`data_doc_contents`     Data documentation contents
 =========================== ===================================================
 
+The :py:`module_doc_contents`, :py:`class_doc_contents` and
+:py:`data_doc_contents` variables are :py:`Dict[str, Dict[str, str]]`, where
+the first level is a name and second level are key/value pairs of the actual
+HTML documentation content. Plugins that parse extra documentation inputs (such
+as `m.sphinx`_) are supposed to add to the dict, which is then used to fill the
+actual documentation contents. The following corresponds to the documentation
+source shown in the `External documentation content`_ section below.
+
+.. code:: py
+
+    class_doc_contents['mymodule.sub.Class'] = {
+        'summary': "A pretty class",
+        'details': "This class is *pretty*."
+    }
+
 Registration function for a plugin that needs to query the :py:`OUTPUT` setting
 might look like this --- the remaining keyword arguments will collapse into
 the :py:`**kwargs` parameter. See code of various m.css plugins for actual
@@ -622,6 +640,29 @@ examples.
         global output_dir
         output_dir = mcss_settings['OUTPUT']
 
+`External documentation content`_
+=================================
+
+Because it's often not feasible to have the whole documentation stored in
+Python docstrings, the generator allows you to supply documentation from
+external files. Similarly to `pages`_, the :py:`INPUT_DOCS` setting is a list
+of :abbr:`reST <reStructuredText>` files that contain documentation for
+particular names using custom directives. This is handled by the bundled
+`m.sphinx <{filename}/plugins/sphinx.rst>`_ plugin. See its documentation for
+detailed description of all features, below is a simple example of using it to
+document a class:
+
+.. code:: py
+
+    PLUGINS = ['m.sphinx']
+
+.. code:: rst
+
+    .. py:class:: mymodule.sub.Class
+        :summary: A pretty class
+
+        This class is *pretty*.
+
 `pybind11 compatibility`_
 =========================
 
index b258d04d4dcd89f8e9911176ef74c21172c64d19..4e9ef39fd4e94412b7d95653791949b9262b0ebb 100644 (file)
@@ -57,9 +57,13 @@ below or :gh:`grab the whole Git repository <mosra/m.css>`:
     :gh:`m.gl <mosra/m.css$master/plugins/m/gl.py>`,
     :gh:`m.vk <mosra/m.css$master/plugins/m/vk.py>`,
     :gh:`m.abbr <mosra/m.css$master/plugins/m/abbr.py>`,
-    :gh:`m.filesize <mosra/m.css$master/plugins/m/filesize.py>`,
-    :gh:`m.alias <mosra/m.css$master/plugins/m/alias.py>`
+    :gh:`m.filesize <mosra/m.css$master/plugins/m/filesize.py>`
+-   :gh:`m.alias <mosra/m.css$master/plugins/m/alias.py>`
+    :label-flat-primary:`pelican only`
 -   :gh:`m.metadata <mosra/m.css$master/plugins/m/metadata.py>`
+    :label-flat-primary:`pelican only`
+-   :gh:`m.sphinx <mosra/m.css$master/plugins/m/metadata.py>`
+    :label-flat-warning:`python doc only`
 
 For the `Python doc theme <{filename}/documentation/python.rst>`_ it's enough
 to simply list them in :py:`PLUGINS`. For the `Doxygen theme <{filename}/documentation/doxygen.rst>`_,
@@ -122,3 +126,9 @@ With the :py:`m.metadata` plugin it's possible to assign additional description
 and images to authors, categories and tags. The information can then appear on
 article listing page, as a badge under the article or be added to social meta
 tags.
+
+`Sphinx » <{filename}/plugins/sphinx.rst>`_
+===========================================
+
+The :py:`m.sphinx` plugin brings Sphinx-compatible directives for documenting
+modules, classes and other to the `Python doc theme`_.
index 59d19a8b14845dac4221c85421de0164aca493fb..b2cb7a88fd28084f9afa387d849996071423c5b2 100644 (file)
@@ -32,7 +32,7 @@ Metadata
     .. note-dim::
         :class: m-text-center
 
-        `« Links and other <{filename}/plugins/links.rst>`_ | `Plugins <{filename}/plugins.rst>`_
+        `« Links and other <{filename}/plugins/links.rst>`_ | `Plugins <{filename}/plugins.rst>`_ | `Sphinx » <{filename}/plugins/sphinx.rst>`_
 
 .. role:: html(code)
     :language: html
diff --git a/doc/plugins/sphinx.rst b/doc/plugins/sphinx.rst
new file mode 100644 (file)
index 0000000..836ccec
--- /dev/null
@@ -0,0 +1,100 @@
+..
+    This file is part of m.css.
+
+    Copyright © 2017, 2018, 2019 Vladimír Vondruš <mosra@centrum.cz>
+
+    Permission is hereby granted, free of charge, to any person obtaining a
+    copy of this software and associated documentation files (the "Software"),
+    to deal in the Software without restriction, including without limitation
+    the rights to use, copy, modify, merge, publish, distribute, sublicense,
+    and/or sell copies of the Software, and to permit persons to whom the
+    Software is furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included
+    in all copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+    THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+    FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+    DEALINGS IN THE SOFTWARE.
+..
+
+Sphinx
+######
+
+:breadcrumb: {filename}/plugins.rst Plugins
+:footer:
+    .. note-dim::
+        :class: m-text-center
+
+        `« Metadata <{filename}/plugins/metadata.rst>`_ | `Plugins <{filename}/plugins.rst>`_
+
+.. role:: html(code)
+    :language: html
+.. role:: py(code)
+    :language: py
+.. role:: rst(code)
+    :language: rst
+
+Makes it possible to document APIs with the `Python doc theme <{filename}/documentation/python.rst>`_
+using external files in a way similar to `Sphinx <https://www.sphinx-doc.org/>`_.
+
+.. contents::
+    :class: m-block m-default
+
+`How to use`_
+=============
+
+`Pelican`_
+----------
+
+List the plugin in your :py:`PLUGINS`.
+
+.. code:: py
+
+    PLUGINS += ['m.sphinx']
+
+`Module, class and data docs`_
+==============================
+
+The :rst:`.. py:module::`, :rst:`.. py:class::` and :rst:`.. py:data::`
+directives provide a way to supply module, class and data documentation
+content. Directive option is the name to document, directive contents are
+the actual contents; in addition the :py:`:summary:` option can override the
+docstring extracted using inspection. No restrictions are made on the contents,
+it's possible to make use of any additional plugins in the markup. Example:
+
+.. code:: rst
+
+    .. py:module:: mymodule
+        :summary: A top-level module.
+
+        This is the top-level module.
+
+        Usage
+        -----
+
+        .. code:: pycon
+
+            >>> import mymodule
+            >>> mymodule.foo()
+            Hello world!
+
+    .. py:data:: mymodule.ALMOST_PI
+        :summary: :math:`\pi`, but *less precise*.
+
+Compared to docstrings, the :py:`:summary:` is interpreted as
+:abbr:`reST <reStructuredText>`, which means you can keep the docstring
+formatting simpler (for display inside IDEs or via the builtin :py:`help()`),
+while supplying an alternative and more complex-formatted summary for the
+actual rendered docs.
+
+.. note-info::
+
+    Modules, classes and data described using these directives have to actually
+    exist (i.e., accessible via inspection) in given module. If given name
+    doesn't exist, a warning will be printed during processing and the
+    documentation ignored.
index 3fea6eca150b6b4e3a0b835160cc5dfd7c9f21fd..de7b62223fd5be4e2f30a6073b2afce07bff2d80 100755 (executable)
@@ -60,6 +60,8 @@ default_config = {
     'OUTPUT': 'output',
     'INPUT_MODULES': [],
     'INPUT_PAGES': [],
+    'INPUT_DOCS': [],
+    'OUTPUT': 'output',
     'THEME_COLOR': '#22272e',
     'FAVICON': 'favicon-dark.png',
     'STYLESHEETS': [
@@ -118,6 +120,9 @@ class State:
         self.class_index: List[IndexEntry] = []
         self.page_index: List[IndexEntry] = []
         self.module_mapping: Dict[str, str] = {}
+        self.module_docs: Dict[str, Dict[str, str]] = {}
+        self.class_docs: Dict[str, Dict[str, str]] = {}
+        self.data_docs: Dict[str, Dict[str, str]] = {}
 
 def is_internal_function_name(name: str) -> bool:
     """If the function name is internal.
@@ -247,7 +252,7 @@ def parse_pybind_signature(state: State, signature: str) -> Tuple[str, str, List
             end = original_signature.find('\n')
             logging.warning("cannot parse pybind11 function signature %s", original_signature[:end if end != -1 else None])
             if end != -1 and len(original_signature) > end + 1 and original_signature[end + 1] == '\n':
-                summary = extract_summary(original_signature[end + 1:])
+                summary = extract_summary({}, [], original_signature[end + 1:])
             else:
                 summary = ''
             return (name, summary, [('…', None, None)], None)
@@ -268,13 +273,13 @@ def parse_pybind_signature(state: State, signature: str) -> Tuple[str, str, List
         end = original_signature.find('\n')
         logging.warning("cannot parse pybind11 function signature %s", original_signature[:end if end != -1 else None])
         if end != -1 and len(original_signature) > end + 1 and original_signature[end + 1] == '\n':
-            summary = extract_summary(original_signature[end + 1:])
+            summary = extract_summary({}, [], original_signature[end + 1:])
         else:
             summary = ''
         return (name, summary, [('…', None, None)], None)
 
     if len(signature) > 1 and signature[1] == '\n':
-        summary = extract_summary(signature[2:])
+        summary = extract_summary({}, [], signature[2:])
     else:
         summary = ''
 
@@ -306,7 +311,12 @@ def parse_pybind_docstring(state: State, name: str, doc: str) -> List[Tuple[str,
     else:
         return [parse_pybind_signature(state, doc)]
 
-def extract_summary(doc: str) -> str:
+def extract_summary(external_docs, path: List[str], doc: str) -> str:
+    # Prefer external docs, if available
+    path_str = '.'.join(path)
+    if path_str in external_docs:
+        return render_inline_rst(external_docs[path_str]['summary'])
+
     if not doc: return '' # some modules (xml.etree) have that :(
     doc = inspect.cleandoc(doc)
     end = doc.find('\n\n')
@@ -344,22 +354,22 @@ def render(config, template: str, page, env: jinja2.Environment):
         # TODO could keep_trailing_newline fix this better?
         f.write(b'\n')
 
-def extract_module_doc(path: List[str], module):
+def extract_module_doc(state: State, path: List[str], module):
     assert inspect.ismodule(module)
 
     out = Empty()
     out.url = make_url(path)
     out.name = path[-1]
-    out.summary = extract_summary(module.__doc__)
+    out.summary = extract_summary(state.class_docs, path, module.__doc__)
     return out
 
-def extract_class_doc(path: List[str], class_):
+def extract_class_doc(state: State, path: List[str], class_):
     assert inspect.isclass(class_)
 
     out = Empty()
     out.url = make_url(path)
     out.name = path[-1]
-    out.summary = extract_summary(class_.__doc__)
+    out.summary = extract_summary(state.class_docs, path, class_.__doc__)
     return out
 
 def extract_enum_doc(state: State, path: List[str], enum_):
@@ -375,7 +385,8 @@ def extract_enum_doc(state: State, path: List[str], enum_):
         if enum_.__doc__ == 'An enumeration.':
             out.summary = ''
         else:
-            out.summary = extract_summary(enum_.__doc__)
+            # TODO: external summary for enums
+            out.summary = extract_summary({}, [], enum_.__doc__)
 
         out.base = extract_type(enum_.__base__)
 
@@ -388,7 +399,8 @@ def extract_enum_doc(state: State, path: List[str], enum_):
             if i.__doc__ == enum_.__doc__:
                 value.summary = ''
             else:
-                value.summary = extract_summary(i.__doc__)
+                # TODO: external summary for enum values
+                value.summary = extract_summary({}, [], i.__doc__)
 
             if value.summary:
                 out.has_details = True
@@ -399,7 +411,8 @@ def extract_enum_doc(state: State, path: List[str], enum_):
     elif state.config['PYBIND11_COMPATIBILITY']:
         assert hasattr(enum_, '__members__')
 
-        out.summary = extract_summary(enum_.__doc__)
+        # TODO: external summary for enums
+        out.summary = extract_summary({}, [], enum_.__doc__)
         out.base = None
 
         for name, v in enum_.__members__.items():
@@ -431,6 +444,7 @@ def extract_function_doc(state: State, parent, path: List[str], function) -> Lis
             out.params = []
             out.has_complex_params = False
             out.has_details = False
+            # TODO: external summary for functions
             out.summary = summary
 
             # Don't show None return type for void functions
@@ -508,7 +522,8 @@ def extract_function_doc(state: State, parent, path: List[str], function) -> Lis
         out.params = []
         out.has_complex_params = False
         out.has_details = False
-        out.summary = extract_summary(function.__doc__)
+        # TODO: external summary for functions
+        out.summary = extract_summary({}, [], function.__doc__)
 
         # Decide if classmethod or staticmethod in case this is a method
         if inspect.isclass(parent):
@@ -549,7 +564,8 @@ def extract_property_doc(state: State, path: List[str], property):
 
     out = Empty()
     out.name = path[-1]
-    out.summary = extract_summary(property.__doc__)
+    # TODO: external summary for properties
+    out.summary = extract_summary({}, [], property.__doc__)
     out.is_settable = property.fset is not None
     out.is_deletable = property.fdel is not None
     out.has_details = False
@@ -585,6 +601,13 @@ def extract_data_doc(state: State, parent, path: List[str], data):
     else:
         out.value = None
 
+    # External data summary, if provided
+    path_str = '.'.join(path)
+    if path_str in state.data_docs:
+        # TODO: use also the contents
+        out.summary = render_inline_rst(state.data_docs[path_str]['summary'])
+        del state.data_docs[path_str]
+
     return out
 
 def render_module(state: State, path, module, env):
@@ -597,7 +620,7 @@ def render_module(state: State, path, module, env):
         breadcrumb += [(i, url_base + 'html')]
 
     page = Empty()
-    page.summary = extract_summary(module.__doc__)
+    page.summary = extract_summary(state.module_docs, path, module.__doc__)
     page.url = breadcrumb[-1][1]
     page.breadcrumb = breadcrumb
     page.prefix_wbr = '.<wbr />'.join(path + [''])
@@ -608,6 +631,12 @@ def render_module(state: State, path, module, env):
     page.data = []
     page.has_enum_details = False
 
+    # External page content, if provided
+    path_str = '.'.join(path)
+    if path_str in state.module_docs:
+        page.content = render_rst(state.module_docs[path_str]['content'])
+        state.module_docs[path_str]['used'] = True
+
     # Index entry for this module, returned together with children at the end
     index_entry = IndexEntry()
     index_entry.kind = 'module'
@@ -657,10 +686,10 @@ def render_module(state: State, path, module, env):
             # standard lib), but not undocumented classes etc. Render the
             # submodules and subclasses recursively.
             if inspect.ismodule(object):
-                page.modules += [extract_module_doc(subpath, object)]
+                page.modules += [extract_module_doc(state, subpath, object)]
                 index_entry.children += [render_module(state, subpath, object, env)]
             elif inspect.isclass(object) and not is_enum(state, object):
-                page.classes += [extract_class_doc(subpath, object)]
+                page.classes += [extract_class_doc(state, subpath, object)]
                 index_entry.children += [render_class(state, subpath, object, env)]
             elif inspect.isclass(object) and is_enum(state, object):
                 enum_ = extract_enum_doc(state, subpath, object)
@@ -686,7 +715,7 @@ def render_module(state: State, path, module, env):
             if is_internal_or_imported_module_member(state, module, path, name, object): continue
 
             subpath = path + [name]
-            page.modules += [extract_module_doc(subpath, object)]
+            page.modules += [extract_module_doc(state, subpath, object)]
             index_entry.children += [render_module(state, subpath, object, env)]
 
         # Get (and render) inner classes
@@ -696,7 +725,7 @@ def render_module(state: State, path, module, env):
             subpath = path + [name]
             if not object.__doc__: logging.warning("%s is undocumented", '.'.join(subpath))
 
-            page.classes += [extract_class_doc(subpath, object)]
+            page.classes += [extract_class_doc(state, subpath, object)]
             index_entry.children += [render_class(state, subpath, object, env)]
 
         # Get enums
@@ -791,7 +820,7 @@ def render_class(state: State, path, class_, env):
         breadcrumb += [(i, url_base + 'html')]
 
     page = Empty()
-    page.summary = extract_summary(class_.__doc__)
+    page.summary = extract_summary(state.class_docs, path, class_.__doc__)
     page.url = breadcrumb[-1][1]
     page.breadcrumb = breadcrumb
     page.prefix_wbr = '.<wbr />'.join(path + [''])
@@ -805,6 +834,12 @@ def render_class(state: State, path, class_, env):
     page.data = []
     page.has_enum_details = False
 
+    # External page content, if provided
+    path_str = '.'.join(path)
+    if path_str in state.class_docs:
+        page.content = render_rst(state.class_docs[path_str]['content'])
+        state.class_docs[path_str]['used'] = True
+
     # Index entry for this module, returned together with children at the end
     index_entry = IndexEntry()
     index_entry.kind = 'class'
@@ -820,7 +855,7 @@ def render_class(state: State, path, class_, env):
         subpath = path + [name]
         if not object.__doc__: logging.warning("%s is undocumented", '.'.join(subpath))
 
-        page.classes += [extract_class_doc(subpath, object)]
+        page.classes += [extract_class_doc(state, subpath, object)]
         index_entry.children += [render_class(state, subpath, object, env)]
 
     # Get enums
@@ -877,21 +912,42 @@ def render_class(state: State, path, class_, env):
     render(state.config, 'class.html', page, env)
     return index_entry
 
-def publish_rst(source):
+def publish_rst(source, translator_class=m.htmlsanity.SaneHtmlTranslator):
     pub = docutils.core.Publisher(
         writer=m.htmlsanity.SaneHtmlWriter(),
         source_class=docutils.io.StringInput,
         destination_class=docutils.io.StringOutput)
     pub.set_components('standalone', 'restructuredtext', 'html')
-    #pub.writer.translator_class = m.htmlsanity.SaneHtmlTranslator
+    pub.writer.translator_class = translator_class
     pub.process_programmatic_settings(None, m.htmlsanity.docutils_settings, None)
     # Docutils uses a deprecated U mode for opening files, so instead of
     # monkey-patching docutils.io.FileInput to not do that (like Pelican does),
     # I just read the thing myself.
+    # TODO *somehow* need to supply the filename to it for better error
+    # reporting, this is too awful
     pub.set_source(source=source)
     pub.publish()
     return pub
 
+def render_rst(source):
+    return publish_rst(source).writer.parts.get('body').rstrip()
+
+class _SaneInlineHtmlTranslator(m.htmlsanity.SaneHtmlTranslator):
+    # Unconditionally force compact paragraphs. This means the inline HTML
+    # won't be wrapped in a <p> which is exactly what we want.
+    def should_be_compact_paragraph(self, node):
+        return True
+
+def render_inline_rst(source):
+    return publish_rst(source, _SaneInlineHtmlTranslator).writer.parts.get('body').rstrip()
+
+def render_doc(state: State, filename):
+    logging.debug("parsing docs from %s", filename)
+
+    # Render the file. The directives should take care of everything, so just
+    # discard the output afterwards.
+    with open(filename, 'r') as f: publish_rst(f.read())
+
 def render_page(state: State, path, filename, env):
     logging.debug("generating %s.html", '.'.join(path))
 
@@ -982,7 +1038,17 @@ def run(basedir, config, templates):
     # Import plugins
     for plugin in ['m.htmlsanity'] + config['PLUGINS']:
         module = importlib.import_module(plugin)
-        module.register_mcss(mcss_settings=config, jinja_environment=env)
+        module.register_mcss(
+            mcss_settings=config,
+            jinja_environment=env,
+            module_doc_contents=state.module_docs,
+            class_doc_contents=state.class_docs,
+            data_doc_contents=state.data_docs)
+
+    # First process the doc input files so we have all data for rendering
+    # module pages
+    for file in config['INPUT_DOCS']:
+        render_doc(state, os.path.join(basedir, file))
 
     for module in config['INPUT_MODULES']:
         if isinstance(module, str):
@@ -993,6 +1059,17 @@ def run(basedir, config, templates):
 
         state.class_index += [render_module(state, [module_name], module, env)]
 
+    # Warn if there are any unused contents left after processing everything
+    unused_module_docs = [key for key, value in state.module_docs.items() if not 'used' in value]
+    unused_class_docs = [key for key, value in state.class_docs.items() if not 'used' in value]
+    unused_data_docs = [key for key, value in state.data_docs.items() if not 'used' in value]
+    if unused_module_docs:
+        logging.warning("The following module doc contents were unused: %s", unused_module_docs)
+    if unused_class_docs:
+        logging.warning("The following class doc contents were unused: %s", unused_class_docs)
+    if unused_data_docs:
+        logging.warning("The following data doc contents were unused: %s", unused_data_docs)
+
     for page in config['INPUT_PAGES']:
         state.page_index += render_page(state, [os.path.splitext(os.path.basename(page))[0]], os.path.join(config['INPUT'], page), env)
 
index be622826a6305989aca22d3d892dc0fe6fbc8e66..b26078a3350f5c6390e009b8caf1e990b708b0dc 100644 (file)
@@ -52,6 +52,9 @@
             </li>
           </ul>
         </div>
+        {% endif %}
+        {% if page.content %}
+{{ page.content }}
         {% endif %}
         {% if page.classes %}
         <section id="classes">
index 440b8f36b78b9a5425219837e9102bf5d0efe693..9cf77d456d7560efd802d1f349942300b57366cc 100644 (file)
@@ -43,6 +43,9 @@
             </li>
           </ul>
         </div>
+        {% endif %}
+        {% if page.content %}
+{{ page.content }}
         {% endif %}
         {% if page.modules %}
         <section id="namespaces">
diff --git a/documentation/test_python/content/classes.html b/documentation/test_python/content/classes.html
new file mode 100644 (file)
index 0000000..9472999
--- /dev/null
@@ -0,0 +1,52 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>My Python Project</title>
+  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,400i,600,600i%7CSource+Code+Pro:400,400i,600" />
+  <link rel="stylesheet" href="m-dark+documentation.compiled.css" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+</head>
+<body>
+<header><nav id="navigation">
+  <div class="m-container">
+    <div class="m-row">
+      <a href="index.html" id="m-navbar-brand" class="m-col-t-8 m-col-m-none m-left-m">My Python Project</a>
+    </div>
+  </div>
+</nav></header>
+<main><article>
+  <div class="m-container m-container-inflatable">
+    <div class="m-row">
+      <div class="m-col-l-10 m-push-l-1">
+        <h1>Classes</h2>
+        <ul class="m-doc">
+          <li class="m-doc-collapsible">
+            <a href="#" onclick="return toggle(this)">module</a> <a href="content.html" class="m-doc">content</a> <span class="m-doc">This overwrites the docstring for <code>content</code>.</span>
+            <ul class="m-doc">
+              <li>class <a href="content.Class.html" class="m-doc">Class</a> <span class="m-doc">This overwrites the docstring for <code>content.Class</code>.</span></li>
+            </ul>
+          </li>
+        </ul>
+        <script>
+        function toggle(e) {
+            e.parentElement.className = e.parentElement.className == 'm-doc-collapsible' ?
+                'm-doc-expansible' : 'm-doc-collapsible';
+            return false;
+        }
+        /* Collapse all nodes marked as such. Doing it via JS instead of
+           directly in markup so disabling it doesn't harm usability. The list
+           is somehow regenerated on every iteration and shrinks as I change
+           the classes. It's not documented anywhere and I'm not sure if this
+           is the same across browsers, so I am going backwards in that list to
+           be sure. */
+        var collapsed = document.getElementsByClassName("collapsed");
+        for(var i = collapsed.length - 1; i >= 0; --i)
+            collapsed[i].className = 'm-doc-expansible';
+        </script>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/content/content.Class.html b/documentation/test_python/content/content.Class.html
new file mode 100644 (file)
index 0000000..6643207
--- /dev/null
@@ -0,0 +1,33 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>content.Class | My Python Project</title>
+  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,400i,600,600i%7CSource+Code+Pro:400,400i,600" />
+  <link rel="stylesheet" href="m-dark+documentation.compiled.css" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+</head>
+<body>
+<header><nav id="navigation">
+  <div class="m-container">
+    <div class="m-row">
+      <a href="index.html" id="m-navbar-brand" class="m-col-t-8 m-col-m-none m-left-m">My Python Project</a>
+    </div>
+  </div>
+</nav></header>
+<main><article>
+  <div class="m-container m-container-inflatable">
+    <div class="m-row">
+      <div class="m-col-l-10 m-push-l-1">
+        <h1>
+          <span class="m-breadcrumb"><a href="content.html">content</a>.<wbr/></span>Class <span class="m-thin">class</span>
+        </h1>
+        <p>This overwrites the docstring for <code>content.Class</code>.</p>
+<p>This is detailed class docs. Here I <em>also</em> hate how it needs to be
+indented.</p>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/content/content.html b/documentation/test_python/content/content.html
new file mode 100644 (file)
index 0000000..a44c917
--- /dev/null
@@ -0,0 +1,61 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>content | My Python Project</title>
+  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,400i,600,600i%7CSource+Code+Pro:400,400i,600" />
+  <link rel="stylesheet" href="m-dark+documentation.compiled.css" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+</head>
+<body>
+<header><nav id="navigation">
+  <div class="m-container">
+    <div class="m-row">
+      <a href="index.html" id="m-navbar-brand" class="m-col-t-8 m-col-m-none m-left-m">My Python Project</a>
+    </div>
+  </div>
+</nav></header>
+<main><article>
+  <div class="m-container m-container-inflatable">
+    <div class="m-row">
+      <div class="m-col-l-10 m-push-l-1">
+        <h1>
+          content <span class="m-thin">module</span>
+        </h1>
+        <p>This overwrites the docstring for <code>content</code>.</p>
+        <div class="m-block m-default">
+          <h3>Contents</h3>
+          <ul>
+            <li>
+              Reference
+              <ul>
+                <li><a href="#classes">Classes</a></li>
+                <li><a href="#data">Data</a></li>
+              </ul>
+            </li>
+          </ul>
+        </div>
+<p>This is detailed module docs. I kinda <em>hate</em> how it needs to be indented,
+tho.</p>
+        <section id="classes">
+          <h2><a href="#classes">Classes</a></h2>
+          <dl class="m-doc">
+            <dt>class <a href="content.Class.html" class="m-doc">Class</a></dt>
+            <dd>This overwrites the docstring for <code>content.Class</code>.</dd>
+          </dl>
+        </section>
+        <section id="data">
+          <h2><a href="#data">Data</a></h2>
+          <dl class="m-doc">
+            <dt>
+              <a href="" class="m-doc-self">CONSTANT</a>: float = 3.14
+            </dt>
+            <dd>This is finally a docstring for <code>content.CONSTANT</code></dd>
+          </dl>
+        </section>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/content/content.py b/documentation/test_python/content/content.py
new file mode 100644 (file)
index 0000000..ea2d6f1
--- /dev/null
@@ -0,0 +1,6 @@
+"""Yes so this is module summary, not shown in the output"""
+
+class Class:
+    """And this class summary, not shown either"""
+
+CONSTANT: float = 3.14
diff --git a/documentation/test_python/content/docs.rst b/documentation/test_python/content/docs.rst
new file mode 100644 (file)
index 0000000..3c6ceb0
--- /dev/null
@@ -0,0 +1,14 @@
+.. py:module:: content
+    :summary: This overwrites the docstring for ``content``.
+
+    This is detailed module docs. I kinda *hate* how it needs to be indented,
+    tho.
+
+.. py:class:: content.Class
+    :summary: This overwrites the docstring for ``content.Class``.
+
+    This is detailed class docs. Here I *also* hate how it needs to be
+    indented.
+
+.. py:data:: content.CONSTANT
+    :summary: This is finally a docstring for ``content.CONSTANT``
diff --git a/documentation/test_python/test_content.py b/documentation/test_python/test_content.py
new file mode 100644 (file)
index 0000000..bdb7772
--- /dev/null
@@ -0,0 +1,40 @@
+#
+#   This file is part of m.css.
+#
+#   Copyright © 2017, 2018, 2019 Vladimír Vondruš <mosra@centrum.cz>
+#
+#   Permission is hereby granted, free of charge, to any person obtaining a
+#   copy of this software and associated documentation files (the "Software"),
+#   to deal in the Software without restriction, including without limitation
+#   the rights to use, copy, modify, merge, publish, distribute, sublicense,
+#   and/or sell copies of the Software, and to permit persons to whom the
+#   Software is furnished to do so, subject to the following conditions:
+#
+#   The above copyright notice and this permission notice shall be included
+#   in all copies or substantial portions of the Software.
+#
+#   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+#   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+#   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+#   THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+#   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+#   FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+#   DEALINGS IN THE SOFTWARE.
+#
+
+import os
+
+from . import BaseInspectTestCase
+
+class Content(BaseInspectTestCase):
+    def __init__(self, *args, **kwargs):
+        super().__init__(__file__, '', *args, **kwargs)
+
+    def test(self):
+        self.run_python({
+            'PLUGINS': ['m.sphinx'],
+            'INPUT_DOCS': ['docs.rst']
+        })
+        self.assertEqual(*self.actual_expected_contents('classes.html'))
+        self.assertEqual(*self.actual_expected_contents('content.html'))
+        self.assertEqual(*self.actual_expected_contents('content.Class.html'))
diff --git a/plugins/m/sphinx.py b/plugins/m/sphinx.py
new file mode 100644 (file)
index 0000000..99aece3
--- /dev/null
@@ -0,0 +1,82 @@
+#
+#   This file is part of m.css.
+#
+#   Copyright © 2017, 2018, 2019 Vladimír Vondruš <mosra@centrum.cz>
+#
+#   Permission is hereby granted, free of charge, to any person obtaining a
+#   copy of this software and associated documentation files (the "Software"),
+#   to deal in the Software without restriction, including without limitation
+#   the rights to use, copy, modify, merge, publish, distribute, sublicense,
+#   and/or sell copies of the Software, and to permit persons to whom the
+#   Software is furnished to do so, subject to the following conditions:
+#
+#   The above copyright notice and this permission notice shall be included
+#   in all copies or substantial portions of the Software.
+#
+#   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+#   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+#   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+#   THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+#   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+#   FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+#   DEALINGS IN THE SOFTWARE.
+#
+
+from docutils.parsers import rst
+from docutils.parsers.rst import directives
+
+module_doc_output = None
+class_doc_output = None
+data_doc_output = None
+
+class PyModule(rst.Directive):
+    final_argument_whitespace = True
+    has_content = True
+    required_arguments = 1
+    option_spec = {'summary': directives.unchanged}
+
+    def run(self):
+        module_doc_output[self.arguments[0]] = {
+            'summary': self.options.get('summary', ''),
+            'content': '\n'.join(self.content)
+        }
+        return []
+
+class PyClass(rst.Directive):
+    final_argument_whitespace = True
+    has_content = True
+    required_arguments = 1
+    option_spec = {'summary': directives.unchanged}
+
+    def run(self):
+        class_doc_output[self.arguments[0]] = {
+            'summary': self.options.get('summary', ''),
+            'content': '\n'.join(self.content)
+        }
+        return []
+
+class PyData(rst.Directive):
+    final_argument_whitespace = True
+    has_content = True
+    required_arguments = 1
+    option_spec = {'summary': directives.unchanged}
+
+    def run(self):
+        data_doc_output[self.arguments[0]] = {
+            'summary': self.options.get('summary', ''),
+            'content': '\n'.join(self.content)
+        }
+        return []
+
+def register_mcss(module_doc_contents, class_doc_contents, data_doc_contents, **kwargs):
+    global module_doc_output, class_doc_output, data_doc_output
+    module_doc_output = module_doc_contents
+    class_doc_output = class_doc_contents
+    data_doc_output = data_doc_contents
+
+    rst.directives.register_directive('py:module', PyModule)
+    rst.directives.register_directive('py:class', PyClass)
+    rst.directives.register_directive('py:data', PyData)
+
+def register(): # for Pelican
+    assert not "This plugin is for the m.css Doc theme only" # pragma: no cover
diff --git a/plugins/m/test/test_sphinx.py b/plugins/m/test/test_sphinx.py
new file mode 100644 (file)
index 0000000..5a5e8fa
--- /dev/null
@@ -0,0 +1,25 @@
+#
+#   This file is part of m.css.
+#
+#   Copyright © 2017, 2018, 2019 Vladimír Vondruš <mosra@centrum.cz>
+#
+#   Permission is hereby granted, free of charge, to any person obtaining a
+#   copy of this software and associated documentation files (the "Software"),
+#   to deal in the Software without restriction, including without limitation
+#   the rights to use, copy, modify, merge, publish, distribute, sublicense,
+#   and/or sell copies of the Software, and to permit persons to whom the
+#   Software is furnished to do so, subject to the following conditions:
+#
+#   The above copyright notice and this permission notice shall be included
+#   in all copies or substantial portions of the Software.
+#
+#   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+#   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+#   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+#   THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+#   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+#   FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+#   DEALINGS IN THE SOFTWARE.
+#
+
+# This module gets tested inside documentation/test_python/.
index 706ad5b6947de16902fab87c61dc5189812be7dd..d7fee31a4cde00b2ec3040b29df42e7438cd8615 100644 (file)
@@ -80,19 +80,20 @@ M_LINKS_NAVBAR1 = [('Why?', 'why/', 'why', []),
                         ('Themes', 'css/themes/', 'css/themes')]),
                    ('Themes', 'themes/', 'themes', [
                         ('Writing reST content', 'themes/writing-rst-content/', 'pelican/writing-content'),
-                        ('Pelican', 'themes/pelican/', 'themes/pelican')])]
-
-M_LINKS_NAVBAR2 = [('Doc generators', 'documentation/', 'documentation', [
+                        ('Pelican', 'themes/pelican/', 'themes/pelican')]),
+                   ('Doc generators', 'documentation/', 'documentation', [
                         ('Doxygen C++ theme', 'documentation/doxygen/', 'documentation/doxygen'),
-                        ('Python doc theme', 'documentation/python/', 'documentation/python')]),
-                   ('Plugins', 'plugins/', 'plugins', [
+                        ('Python doc theme', 'documentation/python/', 'documentation/python')])]
+
+M_LINKS_NAVBAR2 = [('Plugins', 'plugins/', 'plugins', [
                         ('HTML sanity', 'plugins/htmlsanity/', 'plugins/htmlsanity'),
                         ('Components', 'plugins/components/', 'plugins/components'),
                         ('Images', 'plugins/images/', 'plugins/images'),
                         ('Math and code', 'plugins/math-and-code/', 'plugins/math-and-code'),
                         ('Links and other', 'plugins/links/', 'plugins/links'),
                         ('Plots and graphs', 'plugins/plots-and-graphs/', 'plugins/plots-and-graphs'),
-                        ('Metadata', 'plugins/metadata/', 'plugins/metadata')]),
+                        ('Metadata', 'plugins/metadata/', 'plugins/metadata'),
+                        ('Sphinx', 'plugins/sphinx/', 'plugins/sphinx')]),
                    ('GitHub', 'https://github.com/mosra/m.css', '', [])]
 
 M_LINKS_FOOTER1 = [('m.css', '/'),
@@ -125,7 +126,8 @@ M_LINKS_FOOTER4 = [('Plugins', 'plugins/'),
                    ('Math and code', 'plugins/math-and-code/'),
                    ('Plots and graphs', 'plugins/plots-and-graphs/'),
                    ('Links and other', 'plugins/links/'),
-                   ('Metadata', 'plugins/metadata/')]
+                   ('Metadata', 'plugins/metadata/'),
+                   ('Sphinx', 'plugins/sphinx/')]
 
 M_FINE_PRINT = """
 | m.css. Copyright © `Vladimír Vondruš <http://mosra.cz>`_, 2017--2019. Site