chiark / gitweb /
documentation: initial version of the Python doc generator.
authorVladimír Vondruš <mosra@centrum.cz>
Fri, 19 Apr 2019 15:47:39 +0000 (17:47 +0200)
committerVladimír Vondruš <mosra@centrum.cz>
Mon, 22 Apr 2019 15:53:36 +0000 (17:53 +0200)
Inspecting modules, functions, classes, methods and properties, taking
the first line of the docstring for a brief docs. Nothing more at the
moment, no index or search is hooked up yet.

38 files changed:
doc/build-status.html.in
doc/documentation.rst
doc/documentation/doxygen.rst
doc/documentation/python.rst [new file with mode: 0644]
documentation/.gitignore
documentation/python.py [new file with mode: 0755]
documentation/templates/python/base.html [new file with mode: 0644]
documentation/templates/python/class.html [new file with mode: 0644]
documentation/templates/python/entry-class.html [new file with mode: 0644]
documentation/templates/python/entry-data.html [new file with mode: 0644]
documentation/templates/python/entry-function.html [new file with mode: 0644]
documentation/templates/python/entry-module.html [new file with mode: 0644]
documentation/templates/python/entry-property.html [new file with mode: 0644]
documentation/templates/python/module.html [new file with mode: 0644]
documentation/templates/python/page.html [new file with mode: 0644]
documentation/test_python/__init__.py [new file with mode: 0644]
documentation/test_python/inspect_all_property/inspect_all_property.html [new file with mode: 0644]
documentation/test_python/inspect_all_property/inspect_all_property/__init__.py [new file with mode: 0644]
documentation/test_python/inspect_all_property/inspect_all_property/_private_but_exposed.py [new file with mode: 0644]
documentation/test_python/inspect_all_property/inspect_all_property/hidden.py [new file with mode: 0644]
documentation/test_python/inspect_annotations/inspect_annotations.Foo.html [new file with mode: 0644]
documentation/test_python/inspect_annotations/inspect_annotations.html [new file with mode: 0644]
documentation/test_python/inspect_annotations/inspect_annotations.py [new file with mode: 0644]
documentation/test_python/inspect_annotations/math.html [new file with mode: 0644]
documentation/test_python/inspect_string/inspect_string.Foo.html [new file with mode: 0644]
documentation/test_python/inspect_string/inspect_string.Specials.html [new file with mode: 0644]
documentation/test_python/inspect_string/inspect_string.another_module.html [new file with mode: 0644]
documentation/test_python/inspect_string/inspect_string.html [new file with mode: 0644]
documentation/test_python/inspect_string/inspect_string/__init__.py [new file with mode: 0644]
documentation/test_python/inspect_string/inspect_string/_private_module.py [new file with mode: 0644]
documentation/test_python/inspect_string/inspect_string/another_module.py [new file with mode: 0644]
documentation/test_python/inspect_string/inspect_string/subpackage/__init__.py [new file with mode: 0644]
documentation/test_python/layout/index.html [new file with mode: 0644]
documentation/test_python/layout/sitemap.xml [new file with mode: 0644]
documentation/test_python/test_inspect.py [new file with mode: 0644]
documentation/test_python/test_layout.py [new file with mode: 0644]
package/ci/travis.yml
site/pelicanconf.py

index 3d28d7f643360ecf0659042832de41a08576a8db..a3447f51797817182c4feebd9445974f0cdc924f 100644 (file)
       <td class="m-dim"></td>
     </tr>
     <tr>
-      <th class="m-text-right">Doxygen theme</th>
+      <th class="m-text-right">Documentation themes</th>
       <td class="m-dim"></td>
       <td class="m-dim"></td>
     </tr>
     <tr>
-      <th class="m-text-right">Doxygen theme<br/>client search</th>
+      <th class="m-text-right">Documentation themes<br/>client search</th>
       <td class="m-dim"></td>
       <td class="m-dim"></td>
       <td id="mcss-js"><a>&nbsp;<br/><span class="m-text m-small">&nbsp;</span></a></td>
index 8f7d653fe5560a90f76f9a82a78bbd1d14605896..6c0de820626e08ca2a78c457acbb6a2005df01d5 100644 (file)
@@ -78,3 +78,11 @@ it up, reducing the autogenerated clutter, while making it mobile-friendly and
 extending it with better content layouting capabilities and improved support
 for C++11 and beyond. Fully compatible with Doxygen URL format and tag files to
 avoid broken links once you switch.
+
+`Python docs » <{filename}/documentation/python.rst>`_
+======================================================
+
+All features you're used to from either the m.css Pelican theme or the Doxygen
+C++ theme, only for Python documentation. Extracting Python APIs using
+reflection, *not* parsing Python sources itself. With dedicated support for
+pybind11 projects.
index b6b36e9aebb46a8d8acb2e14b7af027e63db8ffb..fb11e35a42321ff7540b051e5dcebf91b4ed3a7b 100644 (file)
@@ -29,6 +29,11 @@ Doxygen C++ theme
 :breadcrumb: {filename}/documentation.rst Doc generators
 :summary: A modern, mobile-friendly drop-in replacement for the stock Doxygen
     HTML output with a first-class search functionality
+:footer:
+    .. note-dim::
+        :class: m-text-center
+
+        `Doc generators <{filename}/documentation.rst>`_ | `Python doc theme » <{filename}/documentation/python.rst>`_
 
 .. role:: cpp(code)
     :language: cpp
@@ -245,7 +250,9 @@ amount of generated content for no added value.
 ================
 
 The script takes most of the configuration from the ``Doxyfile`` itself,
-(ab)using the following builtin options:
+(ab)using the following builtin options. The used options are similar to the
+`Python config <{filename}python.rst#configuration>`_, but with Doxygen-imposed
+naming and constraints.
 
 .. class:: m-table m-fullwidth
 
diff --git a/doc/documentation/python.rst b/doc/documentation/python.rst
new file mode 100644 (file)
index 0000000..d9b2047
--- /dev/null
@@ -0,0 +1,754 @@
+..
+    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.
+..
+
+Python docs
+###########
+
+:breadcrumb: {filename}/documentation.rst Doc generators
+:summary: A modern, mobile-friendly Sphinx-compatible Python documentation
+    generator with a first-class search functionality
+:footer:
+    .. note-dim::
+        :class: m-text-center
+
+        `« Doxygen C++ theme <{filename}/documentation/doxygen.rst>`_ | `Doc generators <{filename}/documentation.rst>`_
+
+.. role:: cpp(code)
+    :language: cpp
+.. role:: js(code)
+    :language: js
+.. role:: py(code)
+    :language: py
+
+A modern, mobile-friendly Sphinx-compatible Python documentation generator with
+a first-class search functionality. Generated by inspecting Python modules and
+using either embedded docstrings or external :abbr:`reST <reStructuredText>`
+files to populate the documentation.
+
+One of the design goals is providing a similar user experience to the
+`Doxygen documentation theme <{filename}doxygen.rst>`_.
+
+.. note-danger:: Heavily experimental
+
+    This functionality is *heavily* experimental at the moment. It's being used
+    for the upcoming `Magnum Engine <https://magnum.graphics>`_ Python bindings
+    and evolves solely based on that project needs. Note that not everything
+    listed below is fully implemented yet --- in particular, the search
+    functionality and :abbr:`reST <reStructuredText>` input processing is
+    currently not yet implemented.
+
+.. contents::
+    :class: m-block m-default
+
+`Basic usage`_
+==============
+
+The base is contained in a single Python script and related style/template
+files, for advanced features such as math rendering it'll make use of internals
+of some `m.css plugins <{filename}/plugins.rst>`_. Clone
+:gh:`the m.css GitHub repository <mosra/m.css$master/documentation>` and look
+into the ``documentation/`` directory:
+
+.. code:: sh
+
+    git clone git://github.com/mosra/m.css
+    cd m.css/documentation
+
+The script requires Python 3.6 and depends on `Jinja2 <http://jinja.pocoo.org/>`_
+for templating and docutils for :abbr:`reST <reStructuredText>` markup
+rendering. You can install the dependencies via ``pip`` or your distribution
+package manager, in most cases you'll probably have them already installed:
+
+.. code:: sh
+
+    # You may need sudo here
+    pip3 install docutils jinja2
+
+Next, you need a configuration file which tells the script what modules to
+inspect, how to name the project and where to put the output. In this example,
+we'll generate documentation for the Python builtin ``math`` module:
+
+.. code:: py
+
+    PROJECT_TITLE = "Python math"
+    INPUT_MODULES = ['math']
+
+Now, run the script and pass path to the configuration file to it:
+
+.. code:: sh
+
+    ./python.py path/to/conf.py
+
+This will generate an ``output/`` directory next to the ``conf.py`` file and
+fill it with the generated output. Open ``index.html`` to see the result.
+
+`Features`_
+===========
+
+-   Theme tailored from scratch for Python-specific language features
+-   Uses code inspection to query modules, classes, data, functions and their
+    signatures, does not rely on error-prone source code parsing
+-   Does not force the documentation writer to explicitly list all symbols in
+    order to have them documented
+-   Can use both in-code docstrings and external :abbr:`reST <reStructuredText>`
+    files to describe the APIs, giving the user a control over the code size vs
+    documentation verbosity tradeoff
+
+`Configuration`_
+================
+
+Together with the above :py:`PROJECT_TITLE` and :py:`INPUT_MODULES` variables
+mentioned above, the configuration file supports the following variables. The
+options are similar to the `Doxygen config <{filename}doxygen.rst#configuration>`_,
+but free of the Doxygen-specific naming and constraints.
+
+.. class:: m-table m-fullwidth
+
+=================================== ===========================================
+Variable                            Description
+=================================== ===========================================
+:py:`PROJECT_TITLE: str`            Project title. Rendered in top navbar, page
+                                    title and fine print. If not set,
+                                    :py:`"My Python Project"` is used.
+:py:`PROJECT_SUBTITLE: str`         Project subtitle. If set, appended in a
+                                    thinner font to :py:`PROJECT_TITLE`.
+:py:`MAIN_PROJECT_URL: str`         If set and :py:`PROJECT_SUBTITLE` is also
+                                    set, then :py:`PROJECT_TITLE` in the top
+                                    navbar will link to this URL and
+                                    :py:`PROJECT_SUBTITLE` to the documentation
+                                    main page, similarly as
+                                    `shown here <{filename}/css/page-layout.rst#link-back-to-main-site-from-a-subsite>`_.
+:py:`INPUT_MODULES: List[Any]`      List of modules to generate the docs from.
+                                    Values can be either strings or module
+                                    objects.
+:py:`OUTPUT: str`                   Where to save the output. Relative paths
+                                    relative are to the config file base dir;
+                                    if not set, ``output/`` is used.
+:py:`THEME_COLOR: str`              Color for :html:`<meta name="theme-color" />`,
+                                    corresponding to the CSS style. If empty,
+                                    no :html:`<meta>` tag is rendered. See
+                                    `Theme selection`_ for more information.
+:py:`FAVICON: str`                  Favicon URL, used to populate
+                                    :html:`<link rel="icon" />`. If empty, no
+                                    :html:`<link>` tag is rendered. Relative
+                                    paths are searched relative to the config
+                                    file base dir and to the ``python.py``
+                                    script dir as a fallback. See
+                                    `Theme selection`_ for more information.
+:py:`STYLESHEETS: List[str]`        List of CSS files to include. Relative
+                                    paths are searched relative to the config
+                                    file base dir and to the ``python.py``
+                                    script dir as a fallback. See `Theme selection`_
+                                    for more information.
+:py:`HTML_HEADER: str`              HTML code to put at the end of the
+                                    :html:`<head>` element. Useful for linking
+                                    arbitrary JavaScript code or, for example,
+                                    adding :html:`<link>` CSS stylesheets with
+                                    additional properties and IDs that are
+                                    otherwise not possible with just
+                                    :py:`STYLESHEETS`.
+:py:`EXTRA_FILES: List[str]`        List of extra files to copy (for example
+                                    additional CSS files that are :css:`@import`\ ed
+                                    from the primary one). Relative paths are
+                                    searched relative to the config file base
+                                    dir and to the ``python.py`` script dir as
+                                    a fallback.
+:py:`LINKS_NAVBAR1: List[Any]`      Left navbar column links. See
+                                    `Navbar links`_ for more information.
+:py:`LINKS_NAVBAR2: List[Any]`      Right navbar column links. See
+                                    `Navbar links`_ for more information.
+:py:`PAGE_HEADER: str`              :abbr:`reST <reStructuredText>` markup to
+                                    put at the top of every page. If not set,
+                                    nothing is added anywhere. The
+                                    ``{filename}`` placeholder is replaced with
+                                    current file name.
+:py:`FINE_PRINT: str`               :abbr:`reST <reStructuredText>` markup to
+                                    put into the footer. If not set, a default
+                                    generic text is used. If empty, no footer
+                                    is rendered at all.
+:py:`SEARCH_DISABLED: bool`         Disable search functionality. If this
+                                    option is set, no search data is compiled
+                                    and the rendered HTML does not contain
+                                    search-related UI or support. If not set,
+                                    :py:`False` is used.
+:py:`SEARCH_DOWNLOAD_BINARY: bool`  Download search data as a binary to save
+                                    bandwidth and initial processing time. If
+                                    not set, :py:`False` is used. See `Search options`_
+                                    for more information.
+:py:`SEARCH_HELP: str`              :abbr:`reST <reStructuredText>` markup to
+                                    display as help text on empty search popup.
+                                    If not set, a default message is used. Has
+                                    effect only if :py:`SEARCH_DISABLED` is not
+                                    :py:`True`.
+:py:`SEARCH_BASE_URL: str`          Base URL for OpenSearch-based search engine
+                                    suggestions for web browsers. See
+                                    `Search options`_ for more information. Has
+                                    effect only if :py:`SEARCH_DISABLED` is not
+                                    :py:`True`.
+:py:`SEARCH_EXTERNAL_URL: str`      URL for external search. The ``{query}``
+                                    placeholder is replaced with urlencoded
+                                    search string. If not set, no external
+                                    search is offered. See `Search options`_
+                                    for more information. Has effect only if
+                                    :py:`SEARCH_DISABLED` is not :py:`True`.
+:py:`DOCUTILS_SETTINGS: Dict[Any]`  Additional docutils settings. Key/value
+                                    pairs as described in `the docs <http://docutils.sourceforge.net/docs/user/config.html>`_.
+=================================== ===========================================
+
+`Theme selection`_
+------------------
+
+By default, the `dark m.css theme <{filename}/css/themes.rst#dark>`_ together
+with documentation-theme-specific additions is used, which corresponds to the
+following configuration:
+
+.. code:: py
+
+    STYLESHEETS = [
+        'https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,400i,600,600i%7CSource+Code+Pro:400,400i,600',
+        '../css/m-dark+documentation.compiled.css']
+    THEME_COLOR = '#22272e'
+    FAVICON = 'favicon-dark.png'
+
+If you have a site already using the ``m-dark.compiled.css`` file, there's
+another file called ``m-dark.documentation.compiled.css``, which contains just
+the documentation-theme-specific additions so you can reuse the already cached
+``m-dark.compiled.css`` file from your main site:
+
+.. code:: ini
+
+    STYLESHEETS = [
+        'https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,400i,600,600i%7CSource+Code+Pro:400,400i,600',
+        '../css/m-dark.compiled.css',
+        '../css/m-dark.documentation.compiled.css']
+    THEME_COLOR = '#22272e'
+    FAVICON = 'favicon-dark.png'
+
+If you prefer the `light m.css theme <{filename}/css/themes.rst#light>`_
+instead, use the following configuration (and, similarly, you can use
+``m-light.compiled.css`` together with ``m-light.documentation.compiled-css``
+in place of ``m-light+documentation.compiled.css``:
+
+.. code:: ini
+
+    STYLESHEETS = [
+        'https://fonts.googleapis.com/css?family=Libre+Baskerville:400,400i,700,700i%7CSource+Code+Pro:400,400i,600',
+        '../css/m-light+documentation.compiled.css']
+    THEME_COLOR = '#cb4b16'
+    FAVICON = 'favicon-light.png'
+
+See the `CSS files`_ section below for more information about customizing the
+CSS files.
+
+`Navbar links`_
+---------------
+
+The :py:`LINKS_NAVBAR1` and :py:`LINKS_NAVBAR2` options define which links are
+shown on the top navbar, split into left and right column on small screen
+sizes. These options take a list of :py:`(title, path, sub)` tuples ---
+``title`` is the link title, ``path`` is path to a particular page or
+module/class (in the form of ``module.sub.ClassName``, for example) and ``sub``
+is an optional submenu, containing :py:`(title, path)` tuples. The ``path`` can
+be also one of ``pages``, ``modules`` or ``classes``, linking to the page /
+module / class index. When rendering, the path is converted to an actual URL to
+the destination file.
+
+By default the variables are defined like following --- there's just three
+items in the left column, with no submenus and the right column is empty:
+
+.. code:: py
+
+    LINKS_NAVBAR1 = [
+        ('Pages', 'pages', []),
+        ('Modules', 'modules', []),
+        ('Classes', 'classes', [])]
+    LINKS_NAVBAR2 = []
+
+A menu item is highlighted if a page with the same path is the current page.
+The ``path`` can be also a full URL --- if it contains a scheme prefix (such as
+``https://``), then it's taken as-is, without conversion.
+
+`Search options`_
+-----------------
+
+Symbol search is implemented using JavaScript Typed Arrays and does not need
+any server-side functionality to perform well --- the client automatically
+downloads a tightly packed binary containing search data and performs search
+directly on it.
+
+However, due to `restrictions of Chromium-based browsers <https://bugs.chromium.org/p/chromium/issues/detail?id=40787&q=ajax%20local&colspec=ID%20Stars%20Pri%20Area%20Feature%20Type%20Status%20Summary%20Modified%20Owner%20Mstone%20OS>`_,
+it's not possible to download data using :js:`XMLHttpRequest` when served from
+a local file-system. Because of that, the search defaults to producing a
+Base85-encoded representation of the search binary and loading that
+asynchronously as a plain JavaScript file. This results in the search data
+being 25% larger, but since this is for serving from a local filesystem, it's
+not considered a problem. If your docs are accessed through a server (or you
+don't need Chrome support), enable the :py:`SEARCH_DOWNLOAD_BINARY` option.
+
+The site can provide search engine metadata using the `OpenSearch <http://www.opensearch.org/>`_
+specification. On supported browsers this means you can add the search field to
+search engines and search directly from the address bar. To enable search
+engine metadata, point :py:`M_SEARCH_BASE_URL` to base URL of your
+documentation, for example:
+
+.. code:: py
+
+    SEARCH_BASE_URL = 'https://doc.magnum.graphics/magnum/'
+
+In general, even without the above setting, appending ``?q={query}#search`` to
+the URL will directly open the search popup with results for ``{query}``.
+
+.. note-info::
+
+    OpenSearch also makes it possible to have autocompletion and search results
+    directly in the browser address bar. However that requires a server-side
+    search implementation and is not supported at the moment.
+
+If :py:`SEARCH_EXTERNAL_URL` is specified, full-text search using an external
+search engine is offered if nothing is found for given string or if the user
+has JavaScript disabled. It's recommended to restrict the search to a
+particular domain or add additional keywords to the search query to filter out
+irrelevant results. Example, using Google search engine and restricting the
+search to a subdomain:
+
+.. code:: py
+
+    SEARCH_EXTERNAL_URL = 'https://google.com/search?q=site:doc.magnum.graphics+{query}'
+
+`Content`_
+==========
+
+By default, if a module contains the :py:`__all__` attribute, all names listed
+there are exposed in the documentation. Otherwise, all module (and class)
+members are extracted using :py:`inspect.getmembers()`, skipping names
+:py:`import`\ ed from elsewhere and underscored names.
+
+Detecting if a module is a submodule of the current package or if it's
+:py:`import`\ ed from elsewhere is tricky, the script thus includes only
+submodules that have their :py:`__package__` property the same or one level below
+the parent package. If a module's :py:`__package__` is empty, it's assumed to
+be a plain module (instead of a package) and since those can't have submodules,
+all found submodules in it are ignored.
+
+.. block-success:: Overriding the set of included names
+
+    In case the autodetection includes more than you want or you need to
+    include names from other modules as part of the module you need, you can
+    temporarily override the :py:`__all__` attribute when generating the docs.
+    For example, the following will list just the :py:`pow()` and :py:`log()`
+    funtions from the :py:`math` module, ignoring the rest:
+
+    .. code:: py
+
+        import math
+        math.__all__ = ['pow', 'log']
+
+        INPUT_MODULES = [math]
+
+`Docstrings`_
+-------------
+
+The first paragraph of a module-level, class-level and function-level docstring
+is used as a brief documentation, copied as-is to the output without formatting
+it in any way.
+
+.. code:: py
+
+    """Module brief docs"""
+
+    class Foo:
+        """Class brief docs"""
+
+        def bar(self):
+            """Function brief docs"""
+
+.. block-warning:: Limitations
+
+    With the current approach, there are a few limitations:
+
+    -   Everything after the first paragraph is ignored (there's no way to have
+        detailed documentation yet)
+    -   Class and module-level variables can't have a docstring attached due to
+        how Python works
+    -   Because not every Python API can be documented using docstrings, the
+        output contains everything, including undocumented names
+
+`Function and variable annotations`_
+------------------------------------
+
+The script uses :py:`inspect.signature()` to query function parameter / return
+type annotations together with default values and displays them in the output.
+Similar is for module and class variables, extracted from the
+:py:`__annotations__` property. If a variable type implements :py:`__repr__()`,
+a :py:`repr()` of it is printed as the value, otherwise the value is omitted.
+
+.. code:: py
+
+    from typing import Tuple, List
+
+    def foo(a: str, be_nice: bool = True) -> Tuple[int, str]:
+        pass
+
+    SETTINGS: List[Tuple[str, bool]] = []
+
+For better readability, if the function signature contains type annotations or
+a default value, the arguments are printed each on one line. Otherwise, to
+avoid wasting vertical space, the arguments are listed on a single line.
+
+Similarly to how the builtin :py:`help()` in Python 3.7 started annotating
+boundaries between position-only, position-or-keyword and keyword-only
+arguments with ``/`` and ``*``, the same is done here --- it's especially
+helpful for native functions, where you can for example call :py:`math.sin(0.3)`
+but not :py:`math.sin(x=0.3)`, because the ``x`` argument is positional-only.
+Currently, positional-only arguments are possible only with native functions,
+`PEP570 <https://www.python.org/dev/peps/pep-0570/>`_ adds them for pure Python
+functions as well.
+
+In some cases, especially when documenting native functions, the signature
+can't be extracted and the function signature shows just an ellipsis (``…``)
+instead of the actual argument list.
+
+`Class methods, static methods, dunder methods, properties`_
+------------------------------------------------------------
+
+Methods decorated with :py:`@classmethod` are put into a "Class methods"
+section, :py:`@staticmethod`\ s into a "Static methods" section.
+Double-underscored methods explicitly implemented in the class are put into a
+"Special methods" section, otherwise they're ignored --- by default, Python
+adds a large collection of dunder methods to each class and the only way to
+know if the method is user-provided or implicit is by checking the docstring.
+
+.. code:: py
+
+    class MyClass:
+        @classmethod
+        def a_classmethod(cls):
+            """A class method"""
+
+        @staticmethod
+        def a_staticmethod():
+            """A static method"""
+
+        def __init__(self, foo, bar):
+            """A constructor"""
+
+Properties added to classes either using the :py:`@property` decorator or
+created with the :py:`property()` builtin are added to the "Properties"
+section. Each property is annotated with :label-flat-success:`get set del` if
+it has a getter, a setter and a :py:`del`\ eter or with :label-flat-warning:`get`
+and other variants if it has just some. The docstring and type annotation is
+extracted from the property getter.
+
+.. code:: py
+
+    from typing import Tuple
+
+    class MyClass:
+        @property
+        def a_read_write_property(self) -> Tuple[int, int]:
+            """A read-write tuple property"""
+
+        @a_read_write_property.setter
+        def a_read_write_property(self, a):
+            # Docstring and type annotation taken from the getter, no need to
+            # have it repeated here too
+            pass
+
+.. block-warning:: Limitations
+
+    Instance variables added inside :py:`__init__()` are not extracted, as this
+    would require parsing Python code directly (which is what Sphinx has to do
+    to support these).
+
+`Command-line options`_
+=======================
+
+.. code:: sh
+
+    ./python.py [-h] [--templates TEMPLATES] [--debug] conf
+
+Arguments:
+
+-   ``conf`` --- configuration file
+
+Options:
+
+-   ``-h``, ``--help`` --- show this help message and exit
+-   ``--templates TEMPLATES`` --- template directory. Defaults to the
+    ``templates/python/`` subdirectory if not set.
+-   ``--debug`` --- verbose logging output. Useful for debugging.
+
+`Customizing the template`_
+===========================
+
+The rest of the documentation explains how to customize the builtin template to
+better suit your needs. Each documentation file is generated from one of the
+template files that are bundled with the script. However, it's possible to
+provide your own Jinja2 template files for customized experience as well as
+modify the CSS styling.
+
+`CSS files`_
+------------
+
+By default, compiled CSS files are used to reduce amount of HTTP requests and
+bandwidth needed for viewing the documentation. However, for easier
+customization and debugging it's better to use the unprocessed stylesheets. The
+:py:`STYLESHEETS` option lists all files that go to the
+:html:`<link rel="stylesheet" />` in the resulting HTML markup, while
+:py:`EXTRA_FILES` list the indirectly referenced files that need to be copied
+to the output as well. Below is an example configuration corresponding to the
+dark theme:
+
+.. code:: py
+
+    STYLESHEETS = [
+        'https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,400i,600,600i%7CSource+Code+Pro:400,400i,600',
+        '../css/m-dark.css',
+        '../css/m-documentation.css']
+    EXTRA_FILES = [
+        '../css/m-grid.css',
+        '../css/m-components.css',
+        '../css/pygments-dark.css',
+        '../css/pygments-console.css']
+    THEME_COLOR = '#22272e'
+
+After making desired changes to the source files, it's possible to postprocess
+them back to the compiled version using the ``postprocess.py`` utility as
+explained in the `CSS themes <{filename}/css/themes.rst#make-your-own>`_
+documentation. In case of the dark theme, the ``m-dark+documentation.compiled.css``
+and ``m-dark.documentation.compiled.css`` files are produced like this:
+
+.. code:: sh
+
+    cd css
+    ./postprocess.py m-dark.css m-documentation.css -o m-dark+documentation.compiled.css
+    ./postprocess.py m-dark.css m-documentation.css --no-import -o m-dark.documentation.compiled.css
+
+`Module documentation template`_
+--------------------------------
+
+Each output file is rendered with one of these templates:
+
+.. class:: m-table m-fullwidth
+
+======================= =======================================================
+Filename                Use
+======================= =======================================================
+``module.html``         Module documentation. See `Module properties`_ for more
+                        information.
+``class.html``          Class documentation. See `Class properties`_ for more
+                        information.
+======================= =======================================================
+
+Each template gets passed all configuration values from the `Configuration`_
+table as-is, together with a :py:`FILENAME` variable with name of given output
+file. In addition to builtin Jinja2 filters, the ``basename_or_url`` filter
+returns either a basename of file path, if the path is relative; or a full URL,
+if the argument is an absolute URL. It's useful in cases like this:
+
+.. code:: html+jinja
+
+  {% for css in HTML_EXTRA_STYLESHEET %}
+  <link rel="stylesheet" href="{{ css|basename_or_url }}" />
+  {% endfor %}
+
+The actual page contents are provided in a :py:`page` object, which has the
+following properties. All exposed data are meant to be passed directly to the
+HTML markup without any additional escaping.
+
+.. class:: m-table m-fullwidth
+
+======================================= =======================================
+Property                                Description
+======================================= =======================================
+:py:`page.brief`                        Brief docs
+:py:`page.url`                          File URL
+:py:`page.breadcrumb`                   List of :py:`(title, URL)` tuples for
+                                        breadcrumb navigation.
+======================================= =======================================
+
+Each module page has the following additional properties:
+
+.. class:: m-table m-fullwidth
+
+======================================= =======================================
+Property                                Description
+======================================= =======================================
+:py:`page.modules`                      List of inner modules. See
+                                        `Module properties`_ for details.
+:py:`page.classes`                      List of classes. See
+                                        `Class properties`_ for details.
+:py:`page.functions`                    List of module-level functions. See
+                                        `Function properties`_ for details.
+:py:`page.data`                         List of module-level data. See
+                                        `Data properties`_ for details.
+======================================= =======================================
+
+Each class page has the following additional properties:
+
+.. class:: m-table m-fullwidth
+
+======================================= =======================================
+Property                                Description
+======================================= =======================================
+:py:`page.classes`                      List of classes. See
+                                        `Class properties`_ for details.
+:py:`page.classmethods`                 List of class methods (annotated with
+                                        :py:`@classmethod`). See
+                                        `Function properties`_ for details.
+:py:`page.staticmethods`                List of static methods (annotated with
+                                        :py:`@staticmethod`). See
+                                        `Function properties`_ for details.
+:py:`page.methods`                      List of methods. See
+                                        `Function properties`_ for details.
+:py:`page.dunder_methods`               List of double-underscored special
+                                        functions. See
+                                        `Function properties`_ for details.
+:py:`page.properties`                   List of properties. See
+                                        `Property properties`_ for details.
+:py:`page.data`                         List of data. See `Data properties`_
+                                        for details.
+======================================= =======================================
+
+`Module properties`_
+````````````````````
+
+.. class:: m-table m-fullwidth
+
+======================================= =======================================
+Property                                Description
+======================================= =======================================
+:py:`module.url`                        URL of detailed module documentation
+:py:`module.name`                       Module name
+:py:`module.brief`                      Brief docs
+======================================= =======================================
+
+`Class properties`_
+```````````````````
+
+.. class:: m-table m-fullwidth
+
+======================================= =======================================
+Property                                Description
+======================================= =======================================
+:py:`class_.url`                        URL of detailed class documentation
+:py:`class_.name`                       Class name
+:py:`class_.brief`                      Brief docs
+======================================= =======================================
+
+`Function properties`_
+``````````````````````
+
+.. class:: m-table m-fullwidth
+
+=================================== ===========================================
+Property                            Description
+=================================== ===========================================
+:py:`function.name`                 Function name
+:py:`function.brief`                Brief docs
+:py:`function.type`                 Function return type annotation [1]_
+:py:`function.params`               List of function parameters. See below for
+                                    details.
+:py:`function.has_complex_params`   Set to :py:`True` if the parameter list
+                                    should be wrapped on several lines for
+                                    better readability (for example when it
+                                    contains type annotations or default
+                                    arguments). Set to :py:`False` when
+                                    wrapping on multiple lines would only
+                                    occupy too much vertical space.
+:py:`function.has_details`          If there is enough content for the full
+                                    description block. Currently always set to
+                                    :py:`False`. [2]_
+:py:`function.is_classmethod`       Set to :py:`True` if the function is
+                                    annotated with :py:`@classmethod`,
+                                    :py:`False` otherwise.
+:py:`function.is_staticmethod`      Set to :py:`True` if the function is
+                                    annotated with :py:`@staticmethod`,
+                                    :py:`False` otherwise.
+=================================== ===========================================
+
+The :py:`func.params` is a list of function parameters and their description.
+Each item has the following properties:
+
+.. class:: m-table m-fullwidth
+
+=========================== ===================================================
+Property                    Description
+=========================== ===================================================
+:py:`param.name`            Parameter name
+:py:`param.type`            Parameter type annotation [1]_
+:py:`param.default`         Default parameter value, if any
+:py:`param.kind`            Parameter kind, a string equivalent to one of the
+                            `inspect.Parameter.kind <https://docs.python.org/3/library/inspect.html#inspect.Parameter.kind>`_
+                            values
+=========================== ===================================================
+
+In some cases (for example in case of native APIs), the parameters can't be
+introspected. In that case, the parameter list is a single entry with ``name``
+set to :py:`"..."` and the rest being empty.
+
+`Property properties`_
+``````````````````````
+
+.. class:: m-table m-fullwidth
+
+=================================== ===========================================
+Property                            Description
+=================================== ===========================================
+:py:`property.name`                 Property name
+:py:`property.type`                 Property getter return type annotation [1]_
+:py:`property.brief`                Brief docs
+:py:`property.is_writable`          If the property is writable
+:py:`property.is_deletable`         If the property is deletable with :py:`del`
+:py:`property.has_details`          If there is enough content for the full
+                                    description block. Currently always set to
+                                    :py:`False`. [2]_
+=================================== ===========================================
+
+`Data properties`_
+``````````````````
+
+.. class:: m-table m-fullwidth
+
+=================================== ===========================================
+Property                            Description
+=================================== ===========================================
+:py:`data.name`                     Data name
+:py:`data.type`                     Data type
+:py:`data.brief`                    Brief docs. Currently always empty.
+:py:`data.value`                    Data value representation
+:py:`data.has_details`              If there is enough content for the full
+                                    description block. Currently always set to
+                                    :py:`False`. [2]_
+=================================== ===========================================
+
+-------------------------------
+
+.. [1] :py:`i.type` is extracted out of function annotation. If the types
+    aren't annotated, the annotation is empty.
+.. [2] :py:`page.has_*_details` and :py:`i.has_details` are :py:`True` if
+    there is detailed description, function parameter documentation or
+    *documented* enum value listing that makes it worth to render the full
+    description block. If :py:`False`, the member should be included only in
+    the brief listing on top of the page to avoid unnecessary repetition.
index 0adb54dbf1c5ade16cc69e04b20bd98a99cbef58..aabcaf98afaf315745c7a441affb46cef73dac87 100644 (file)
@@ -5,3 +5,4 @@ test_doxygen/layout_generated_doxyfile/Doxyfile
 node_modules/
 package-lock.json
 test_doxygen/package-lock.json
+test_python/*/output/
diff --git a/documentation/python.py b/documentation/python.py
new file mode 100755 (executable)
index 0000000..4d81ae1
--- /dev/null
@@ -0,0 +1,542 @@
+#!/usr/bin/env python3
+
+#
+#   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 argparse
+import copy
+import urllib.parse
+import html
+import importlib
+import inspect
+import logging
+import mimetypes
+import os
+import sys
+import shutil
+
+from types import SimpleNamespace as Empty
+from importlib.machinery import SourceFileLoader
+from typing import Tuple, Dict, Any, List
+from urllib.parse import urljoin
+
+import jinja2
+
+sys.path.append(os.path.join(os.path.dirname(os.path.realpath(__file__)), '../plugins'))
+import m.htmlsanity
+
+default_templates = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'templates/python/')
+
+default_config = {
+    'PROJECT_TITLE': 'My Python Project',
+    'PROJECT_SUBTITLE': None,
+    'MAIN_PROJECT_URL': None,
+    'INPUT_MODULES': [],
+    'OUTPUT': 'output',
+    'THEME_COLOR': '#22272e',
+    'FAVICON': 'favicon-dark.png',
+    'STYLESHEETS': [
+        'https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,400i,600,600i%7CSource+Code+Pro:400,400i,600',
+        '../css/m-dark+documentation.compiled.css'],
+    'EXTRA_FILES': [],
+    'LINKS_NAVBAR1': [
+        ('Pages', 'pages', []),
+        ('Modules', 'modules', []),
+        ('Classes', 'classes', [])],
+    'LINKS_NAVBAR2': [],
+    'PAGE_HEADER': None,
+    'FINE_PRINT': '[default]',
+    'SEARCH_DISABLED': False,
+    'SEARCH_DOWNLOAD_BINARY': False,
+    'SEARCH_HELP': """.. raw:: html
+
+    <p class="m-noindent">Search for modules, classes, functions and other
+    symbols. You can omit any prefix from the symbol path; adding a <code>.</code>
+    suffix lists all members of given symbol.</p>
+    <p class="m-noindent">Use <span class="m-label m-dim">&darr;</span>
+    / <span class="m-label m-dim">&uarr;</span> to navigate through the list,
+    <span class="m-label m-dim">Enter</span> to go.
+    <span class="m-label m-dim">Tab</span> autocompletes common prefix, you can
+    copy a link to the result using <span class="m-label m-dim">⌘</span>
+    <span class="m-label m-dim">L</span> while <span class="m-label m-dim">⌘</span>
+    <span class="m-label m-dim">M</span> produces a Markdown link.</p>
+""",
+    'SEARCH_BASE_URL': None,
+    'SEARCH_EXTERNAL_URL': None,
+}
+
+def is_internal_function_name(name: str) -> bool:
+    """If the function name is internal.
+
+    Skips underscored functions but keeps special functions such as __init__.
+    """
+    return name.startswith('_') and not (name.startswith('__') and name.endswith('__'))
+
+def is_internal_or_imported_module_member(parent, path: str, name: str, object) -> bool:
+    """If the module member is internal or imported."""
+
+    if name.startswith('_'): return True
+
+    # If this is not a module, check if the enclosing module of the object is
+    # what expected. If not, it's a class/function/... imported from elsewhere
+    # and we don't want those.
+    # TODO: xml.dom.domreg says the things from it should be imported as
+    #   xml.dom.foo() and this check discards them, can it be done without
+    #   manually adding __all__?
+    if not inspect.ismodule(object):
+        # Variables don't have the __module__ attribute, so check for its
+        # presence. Right now *any* variable will be present in the output, as
+        # there is no way to check where it comes from.
+        if hasattr(object, '__module__') and object.__module__ != '.'.join(path):
+            return True
+
+    # If this is a module, then things get complicated again and we need to
+    # handle modules and packages differently. See also for more info:
+    # https://stackoverflow.com/a/7948672
+    else:
+        # The parent is a single-file module (not a package), these don't have
+        # submodules so this is most definitely an imported module. Source:
+        # https://docs.python.org/3/reference/import.html#packages
+        if not parent.__package__: return True
+
+        # The parent is a package and this is either a submodule or a
+        # subpackage. Check that the __package__ of parent and child is either
+        # the same or it's parent + child name
+        if object.__package__ not in [parent.__package__, parent.__package__ + '.' + name]: return True
+
+    # If nothing of the above matched, then it's a thing we want to document
+    return False
+
+def make_url(path: List[str]) -> str:
+    return '.'.join(path) + '.html'
+
+def extract_brief(doc: str) -> str:
+    if not doc: return '' # some modules (xml.etree) have that :(
+    doc = inspect.cleandoc(doc)
+    end = doc.find('\n\n')
+    return html.escape(doc if end == -1 else doc[:end])
+
+def extract_type(type) -> str:
+    # For types we concatenate the type name with its module unless it's
+    # builtins (i.e., we want re.Match but not builtins.int).
+    return (type.__module__ + '.' if type.__module__ != 'builtins' else '') + type.__name__
+
+def extract_annotation(annotation) -> str:
+    # TODO: why this is not None directly?
+    if annotation is inspect.Signature.empty: return None
+
+    # To avoid getting <class 'foo.bar'> for classes (and getting foo.bar
+    # instead) but getting the actual type for types annotated with e.g.
+    # List[int], we need to branch on isclass()
+    if inspect.isclass(annotation): return extract_type(annotation)
+    return str(annotation)
+
+def render(config, template: str, page, env: jinja2.Environment):
+    template = env.get_template(template)
+    rendered = template.render(page=page, FILENAME=page.url, **config)
+    with open(os.path.join(config['OUTPUT'], page.url), 'wb') as f:
+        f.write(rendered.encode('utf-8'))
+        # Add back a trailing newline so we don't need to bother with
+        # patching test files to include a trailing newline to make Git
+        # happy
+        # TODO could keep_trailing_newline fix this better?
+        f.write(b'\n')
+
+def extract_module_doc(path: List[str], module):
+    assert inspect.ismodule(module)
+
+    out = Empty()
+    out.url = make_url(path)
+    out.name = path[-1]
+    out.brief = extract_brief(module.__doc__)
+    return out
+
+def extract_class_doc(path: List[str], class_):
+    assert inspect.isclass(class_)
+
+    out = Empty()
+    out.url = make_url(path)
+    out.name = path[-1]
+    out.brief = extract_brief(class_.__doc__)
+    return out
+
+def extract_function_doc(path: List[str], function):
+    assert inspect.isfunction(function) or inspect.ismethod(function) or inspect.isroutine(function)
+
+    out = Empty()
+    out.name = path[-1]
+    out.brief = extract_brief(function.__doc__)
+    out.params = []
+    out.has_complex_params = False
+    out.has_details = False
+
+    try:
+        signature = inspect.signature(function)
+        out.type = extract_annotation(signature.return_annotation)
+        for i in signature.parameters.values():
+            param = Empty()
+            param.name = i.name
+            param.type = extract_annotation(i.annotation)
+            if param.type:
+                out.has_complex_params = True
+            if i.default is inspect.Signature.empty:
+                param.default = None
+            else:
+                param.default = repr(i.default)
+                out.has_complex_params = True
+            param.kind = str(i.kind)
+            out.params += [param]
+
+    # In CPython, some builtin functions (such as math.log) do not provide
+    # metadata about their arguments. Source:
+    # https://docs.python.org/3/library/inspect.html#inspect.signature
+    except ValueError:
+        param = Empty()
+        param.name = '...'
+        param.name_type = param.name
+        out.params = [param]
+
+    return out
+
+def extract_property_doc(path: List[str], property):
+    assert inspect.isdatadescriptor(property)
+
+    out = Empty()
+    out.name = path[-1]
+    out.brief = extract_brief(property.__doc__)
+    out.is_settable = property.fset is not None
+    out.is_deletable = property.fdel is not None
+    out.has_details = False
+
+    try:
+        signature = inspect.signature(property.fget)
+        out.type = extract_annotation(signature.return_annotation)
+    except ValueError:
+        out.type = None
+
+    return out
+
+def extract_data_doc(parent, path: List[str], data):
+    assert not inspect.ismodule(data) and not inspect.isclass(data) and not inspect.isroutine(data) and not inspect.isframe(data) and not inspect.istraceback(data) and not inspect.iscode(data)
+
+    out = Empty()
+    out.name = path[-1]
+    # Welp. https://stackoverflow.com/questions/8820276/docstring-for-variable
+    out.brief = ''
+    out.has_details = False
+    if hasattr(parent, '__annotations__') and out.name in parent.__annotations__:
+        out.type = extract_annotation(parent.__annotations__[out.name])
+    else:
+        out.type = None
+    # The autogenerated <foo.bar at 0xbadbeef> is useless, so provide the value
+    # only if __repr__ is implemented for given type
+    if '__repr__' in type(data).__dict__:
+        out.value = html.escape(repr(data))
+    else:
+        out.value = None
+
+    return out
+
+def render_module(config, path, module, env):
+    logging.debug("generating %s.html", '.'.join(path))
+
+    url_base = ''
+    breadcrumb = []
+    for i in path:
+        url_base += i + '.'
+        breadcrumb += [(i, url_base + 'html')]
+
+    page = Empty()
+    page.brief = extract_brief(module.__doc__)
+    page.url = breadcrumb[-1][1]
+    page.breadcrumb = breadcrumb
+    page.modules = []
+    page.classes = []
+    page.functions = []
+    page.data = []
+
+    # This is actually complicated -- if the module defines __all__, use that.
+    # The __all__ is meant to expose the public API, so we don't filter out
+    # underscored things.
+    if hasattr(module, '__all__'):
+        for name in module.__all__:
+            # Everything available in __all__ is already imported, so get those
+            # directly
+            object = getattr(module, name)
+            subpath = path + [name]
+
+            # We allow undocumented submodules (since they're often in the
+            # standard lib), but not undocumented classes etc. Render the
+            # submodules and subclasses recursively.
+            if inspect.ismodule(object):
+                page.modules += [extract_module_doc(subpath, object)]
+                render_module(config, subpath, object, env)
+            elif inspect.isclass(object):
+                page.classes += [extract_class_doc(subpath, object)]
+                render_class(config, subpath, object, env)
+            elif inspect.isfunction(object) or inspect.isbuiltin(object):
+                page.functions += [extract_function_doc(subpath, object)]
+            # Assume everything else is data. The builtin help help() (from
+            # pydoc) does the same:
+            # https://github.com/python/cpython/blob/d29b3dd9227cfc4a23f77e99d62e20e063272de1/Lib/pydoc.py#L113
+            # TODO: unify this query
+            elif not inspect.isframe(object) and not inspect.istraceback(object) and not inspect.iscode(object):
+                page.data += [extract_data_doc(module, subpath, object)]
+            else: # pragma: no cover
+                logging.warning("unknown symbol %s in %s", name, '.'.join(path))
+
+    # Otherwise, enumerate the members using inspect. However, inspect lists
+    # also imported modules, functions and classes, so take only those which
+    # have __module__ equivalent to `path`.
+    else:
+        # Get (and render) inner modules
+        for name, object in inspect.getmembers(module, inspect.ismodule):
+            if is_internal_or_imported_module_member(module, path, name, object): continue
+
+            subpath = path + [name]
+            page.modules += [extract_module_doc(subpath, object)]
+            render_module(config, subpath, object, env)
+
+        # Get (and render) inner classes
+        for name, object in inspect.getmembers(module, inspect.isclass):
+            if is_internal_or_imported_module_member(module, path, name, object): continue
+
+            subpath = path + [name]
+            if not object.__doc__: logging.warning("%s is undocumented", '.'.join(subpath))
+
+            page.classes += [extract_class_doc(subpath, object)]
+            render_class(config, subpath, object, env)
+
+        # Get inner functions
+        for name, object in inspect.getmembers(module, lambda o: inspect.isfunction(o) or inspect.isbuiltin(o)):
+            if is_internal_or_imported_module_member(module, path, name, object): continue
+
+            subpath = path + [name]
+            if not object.__doc__: logging.warning("%s() is undocumented", '.'.join(subpath))
+
+            page.functions += [extract_function_doc(subpath, object)]
+
+        # Get data
+        # TODO: unify this query
+        for name, object in inspect.getmembers(module, lambda o: not inspect.ismodule(o) and not inspect.isclass(o) and not inspect.isroutine(o) and not inspect.isframe(o) and not inspect.istraceback(o) and not inspect.iscode(o)):
+            if is_internal_or_imported_module_member(module, path, name, object): continue
+
+            page.data += [extract_data_doc(module, path + [name], object)]
+
+    render(config, 'module.html', page, env)
+
+# Builtin dunder functions have hardcoded docstrings. This is totally useless
+# to have in the docs, so filter them out. Uh... kinda ugly.
+_filtered_builtin_functions = set([
+    ('__delattr__', "Implement delattr(self, name)."),
+    ('__dir__', "Default dir() implementation."),
+    ('__eq__', "Return self==value."),
+    ('__format__', "Default object formatter."),
+    ('__ge__', "Return self>=value."),
+    ('__getattribute__', "Return getattr(self, name)."),
+    ('__gt__', "Return self>value."),
+    ('__hash__', "Return hash(self)."),
+    ('__init__', "Initialize self.  See help(type(self)) for accurate signature."),
+    ('__init_subclass__',
+        "This method is called when a class is subclassed.\n\n"
+        "The default implementation does nothing. It may be\n"
+        "overridden to extend subclasses.\n"),
+    ('__le__', "Return self<=value."),
+    ('__lt__', "Return self<value."),
+    ('__ne__', "Return self!=value."),
+    ('__new__',
+        "Create and return a new object.  See help(type) for accurate signature."),
+    ('__reduce__', "Helper for pickle."),
+    ('__reduce_ex__', "Helper for pickle."),
+    ('__repr__', "Return repr(self)."),
+    ('__setattr__', "Implement setattr(self, name, value)."),
+    ('__sizeof__', "Size of object in memory, in bytes."),
+    ('__str__', "Return str(self)."),
+    ('__subclasshook__',
+        "Abstract classes can override this to customize issubclass().\n\n"
+        "This is invoked early on by abc.ABCMeta.__subclasscheck__().\n"
+        "It should return True, False or NotImplemented.  If it returns\n"
+        "NotImplemented, the normal algorithm is used.  Otherwise, it\n"
+        "overrides the normal algorithm (and the outcome is cached).\n")
+])
+
+_filtered_builtin_properties = set([
+    ('__weakref__', "list of weak references to the object (if defined)")
+])
+
+def render_class(config, path, class_, env):
+    logging.debug("generating %s.html", '.'.join(path))
+
+    url_base = ''
+    breadcrumb = []
+    for i in path:
+        url_base += i + '.'
+        breadcrumb += [(i, url_base + 'html')]
+
+    page = Empty()
+    page.brief = extract_brief(class_.__doc__)
+    page.url = breadcrumb[-1][1]
+    page.breadcrumb = breadcrumb
+    page.classes = []
+    page.classmethods = []
+    page.staticmethods = []
+    page.dunder_methods = []
+    page.methods = []
+    page.properties = []
+    page.data = []
+
+    # Get inner classes
+    for name, object in inspect.getmembers(class_, inspect.isclass):
+        if name in ['__base__', '__class__']: continue # TODO
+        if name.startswith('_'): continue
+
+        subpath = path + [name]
+        if not object.__doc__: logging.warning("%s is undocumented", '.'.join(subpath))
+
+        page.classes += [extract_class_doc(subpath, object)]
+        render_class(config, subpath, object, env)
+
+    # Get methods
+    for name, object in inspect.getmembers(class_, inspect.isroutine):
+        # Filter out underscored methods (but not dunder methods)
+        if is_internal_function_name(name): continue
+
+        # Filter out dunder methods that don't have their own docs
+        if name.startswith('__') and (name, object.__doc__) in _filtered_builtin_functions: continue
+
+        subpath = path + [name]
+        if not object.__doc__: logging.warning("%s() is undocumented", '.'.join(subpath))
+
+        function = extract_function_doc(subpath, object)
+        function.is_classmethod = inspect.ismethod(object)
+        function.is_staticmethod = name in class_.__dict__ and isinstance(class_.__dict__[name], staticmethod)
+
+        if name.startswith('__'):
+            page.dunder_methods += [function]
+        elif function.is_classmethod:
+            page.classmethods += [function]
+        elif function.is_staticmethod:
+            page.staticmethods += [function]
+        else:
+            page.methods += [function]
+
+    # Get properties
+    for name, object in inspect.getmembers(class_, inspect.isdatadescriptor):
+        if (name, object.__doc__) in _filtered_builtin_properties:
+            continue
+        if name.startswith('_'): continue # TODO: are there any dunder props?
+
+        subpath = path + [name]
+        if not object.__doc__: logging.warning("%s is undocumented", '.'.join(subpath))
+
+        page.properties += [extract_property_doc(subpath, object)]
+
+    # Get data
+    # TODO: unify this query
+    for name, object in inspect.getmembers(class_, lambda o: not inspect.ismodule(o) and not inspect.isclass(o) and not inspect.isroutine(o) and not inspect.isframe(o) and not inspect.istraceback(o) and not inspect.iscode(o) and not inspect.isdatadescriptor(o)):
+        if name.startswith('_'): continue
+
+        subpath = path + [name]
+        page.data += [extract_data_doc(class_, subpath, object)]
+
+    render(config, 'class.html', page, env)
+
+def run(basedir, config, templates):
+    # Prepare Jinja environment
+    env = jinja2.Environment(
+        loader=jinja2.FileSystemLoader(templates), trim_blocks=True,
+        lstrip_blocks=True, enable_async=True)
+    # Filter to return file basename or the full URL, if absolute
+    def basename_or_url(path):
+        if urllib.parse.urlparse(path).netloc: return path
+        return os.path.basename(path)
+    # Filter to return URL for given symbol or the full URL, if absolute
+    def path_to_url(path):
+        if urllib.parse.urlparse(path).netloc: return path
+        return path + '.html'
+    env.filters['basename_or_url'] = basename_or_url
+    env.filters['path_to_url'] = path_to_url
+    env.filters['urljoin'] = urljoin
+    env.filters['render_rst'] = m.htmlsanity.render_rst
+
+    # Make the output dir absolute
+    config['OUTPUT'] = os.path.join(basedir, config['OUTPUT'])
+    if not os.path.exists(config['OUTPUT']): os.makedirs(config['OUTPUT'])
+
+    # Guess MIME type of the favicon
+    if config['FAVICON']:
+        config['FAVICON'] = (config['FAVICON'], mimetypes.guess_type(config['FAVICON'])[0])
+
+    for module in config['INPUT_MODULES']:
+        if isinstance(module, str):
+            module_name = module
+            module = importlib.import_module(module)
+        else:
+            module_name = module.__name__
+
+        render_module(config, [module_name], module, env)
+
+    # Create index.html
+    # TODO: use actual reST source and have this just as a fallback
+    page = Empty()
+    page.breadcrumb = [(config['PROJECT_TITLE'], 'index.html')]
+    page.url = page.breadcrumb[-1][1]
+    render(config, 'page.html', page, env)
+
+    # Copy referenced files
+    for i in config['STYLESHEETS'] + config['EXTRA_FILES'] + ([config['FAVICON'][0]] if config['FAVICON'] else []) + ([] if config['SEARCH_DISABLED'] else ['search.js']):
+        # Skip absolute URLs
+        if urllib.parse.urlparse(i).netloc: continue
+
+        # If file is found relative to the conf file, use that
+        if os.path.exists(os.path.join(basedir, i)):
+            i = os.path.join(basedir, i)
+
+        # Otherwise use path relative to script directory
+        else:
+            i = os.path.join(os.path.dirname(os.path.realpath(__file__)), i)
+
+        logging.debug("copying %s to output", i)
+        shutil.copy(i, os.path.join(config['OUTPUT'], os.path.basename(i)))
+
+if __name__ == '__main__': # pragma: no cover
+    parser = argparse.ArgumentParser()
+    parser.add_argument('conf', help="configuration file")
+    parser.add_argument('--templates', help="template directory", default=default_templates)
+    parser.add_argument('--debug', help="verbose debug output", action='store_true')
+    args = parser.parse_args()
+
+    # Load configuration from a file, update the defaults with it
+    config = copy.deepcopy(default_config)
+    name, _ = os.path.splitext(os.path.basename(args.conf))
+    module = SourceFileLoader(name, args.conf).load_module()
+    if module is not None:
+        config.update((k, v) for k, v in inspect.getmembers(module) if k.isupper())
+
+    if args.debug:
+        logging.basicConfig(level=logging.DEBUG)
+    else:
+        logging.basicConfig(level=logging.INFO)
+
+    run(os.path.dirname(os.path.abspath(args.conf)), config, os.path.abspath(args.templates))
diff --git a/documentation/templates/python/base.html b/documentation/templates/python/base.html
new file mode 100644 (file)
index 0000000..1f6aa4b
--- /dev/null
@@ -0,0 +1,157 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>{% block title %}{{ PROJECT_TITLE }}{% if PROJECT_SUBTITLE %} {{ PROJECT_SUBTITLE }}{% endif %}{% endblock %}</title>
+  {% for css in STYLESHEETS %}
+  <link rel="stylesheet" href="{{ css|basename_or_url|e }}" />
+  {% endfor %}
+  {% if FAVICON %}
+  <link rel="icon" href="{{ FAVICON[0]|basename_or_url|e }}" type="{{ FAVICON[1] }}" />
+  {% endif %}
+  {% if not SEARCH_DISABLED and SEARCH_BASE_URL %}
+  <link rel="search" type="application/opensearchdescription+xml" href="opensearch.xml" title="Search {{ PROJECT_TITLE }} documentation" />
+  {% endif %}
+  {% block header_links %}
+  {% endblock %}
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  {% if THEME_COLOR %}
+  <meta name="theme-color" content="{{ THEME_COLOR }}" />
+  {% endif %}
+  {% if HTML_HEADER %}
+  {{ HTML_HEADER|indent(2) }}
+  {% endif %}
+</head>
+<body>
+<header><nav id="navigation">
+  <div class="m-container">
+    <div class="m-row">
+      {% if MAIN_PROJECT_URL and PROJECT_TITLE %}
+      <span id="m-navbar-brand" class="m-col-t-8 m-col-m-none m-left-m">
+        <a href="{{ MAIN_PROJECT_URL }}">{{ PROJECT_TITLE }}</a> <span class="m-breadcrumb">|</span> <a href="index.html" class="m-thin">{{ PROJECT_SUBTITLE }}</a>
+      </span>
+      {% else %}
+      <a href="index.html" id="m-navbar-brand" class="m-col-t-8 m-col-m-none m-left-m">{{ PROJECT_TITLE }}{% if PROJECT_SUBTITLE %} <span class="m-thin">{{ PROJECT_SUBTITLE }}</span>{% endif %}</a>
+      {% endif %}
+      {% if LINKS_NAVBAR1 or LINKS_NAVBAR2 or not SEARCH_DISABLED %}
+      <div class="m-col-t-4 m-hide-m m-text-right m-nopadr">
+        {% if not SEARCH_DISABLED %}
+        <a href="#search" class="m-doc-search-icon" title="Search" onclick="return showSearch()"><svg style="height: 0.9rem;" viewBox="0 0 16 16">
+          <path d="m6 0c-3.3144 0-6 2.6856-6 6 0 3.3144 2.6856 6 6 6 1.4858 0 2.8463-0.54083 3.8945-1.4355-0.0164 0.33797 0.14734 0.75854 0.5 1.1504l3.2227 3.7891c0.55185 0.6139 1.4517 0.66544 2.002 0.11524 0.55022-0.55022 0.49866-1.4501-0.11524-2.002l-3.7891-3.2246c-0.39184-0.35266-0.81242-0.51469-1.1504-0.5 0.89472-1.0482 1.4355-2.4088 1.4355-3.8945 0-3.3128-2.6856-5.998-6-5.998zm0 1.5625a4.4375 4.4375 0 0 1 4.4375 4.4375 4.4375 4.4375 0 0 1-4.4375 4.4375 4.4375 4.4375 0 0 1-4.4375-4.4375 4.4375 4.4375 0 0 1 4.4375-4.4375z"/>
+        </svg></a>
+        {% endif %}
+        <a id="m-navbar-show" href="#navigation" title="Show navigation"></a>
+        <a id="m-navbar-hide" href="#" title="Hide navigation"></a>
+      </div>
+      <div id="m-navbar-collapse" class="m-col-t-12 m-show-m m-col-m-none m-right-m">
+        <div class="m-row">
+          <ol class="{% if M_LINKS_NAVBAR2 %}m-col-t-6{% else %}m-col-t-12{% endif %} m-col-m-none">
+            {% for title, path, sub in LINKS_NAVBAR1 %}
+            {% if not sub %}
+            <li><a href="{{ path|path_to_url }}"{% if (page and page.path == path) or navbar_current == path %} id="m-navbar-current"{% endif %}>{{ title }}</a></li>
+            {% else %}
+            <li>
+              <a href="{{ path|path_to_url }}"{% if (page and page.path == path) or navbar_current == path %} id="m-navbar-current"{% endif %}>{{ title }}</a>
+              <ol>
+                {% for title, path in sub %}
+                <li><a href="{{ path|path_to_url }}"{% if (page and page.path == path) or navbar_current == path %} id="m-navbar-current"{% endif %}>{{ title }}</a></li>
+                {% endfor %}
+              </ol>
+            </li>
+            {% endif %}
+            {% endfor %}
+          </ol>
+          {% if LINKS_NAVBAR2 or not SEARCH_DISABLED %}
+          {% set start = LINKS_NAVBAR1|length + 1 %}
+          <ol class="m-col-t-6 m-col-m-none" start="{{ start }}">
+            {% for title, path, sub in LINKS_NAVBAR2 %}
+            {% if not sub %}
+            <li><a href="{{ path|path_to_url }}"{% if (page and page.path == path) or navbar_current == path %} id="m-navbar-current"{% endif %}>{{ title }}</a></li>
+            {% else %}
+            <li>
+              <a href="{{ path|path_to_url }}"{% if (page and page.path == path) or navbar_current == path %} id="m-navbar-current"{% endif %}>{{ title }}</a>
+              <ol>
+                {% for title, path in sub %}
+                <li><a href="{{ path|path_to_url }}"{% if (page and page.path == path) or navbar_current == path %} id="m-navbar-current"{% endif %}>{{ title }}</a></li>
+                {% endfor %}
+              </ol>
+            </li>
+            {% endif %}
+            {% endfor %}
+            {% if not SEARCH_DISABLED %}
+            <li class="m-show-m"><a href="#search" class="m-doc-search-icon" title="Search" onclick="return showSearch()"><svg style="height: 0.9rem;" viewBox="0 0 16 16">
+              <path d="m6 0c-3.3144 0-6 2.6856-6 6 0 3.3144 2.6856 6 6 6 1.4858 0 2.8463-0.54083 3.8945-1.4355-0.0164 0.33797 0.14734 0.75854 0.5 1.1504l3.2227 3.7891c0.55185 0.6139 1.4517 0.66544 2.002 0.11524 0.55022-0.55022 0.49866-1.4501-0.11524-2.002l-3.7891-3.2246c-0.39184-0.35266-0.81242-0.51469-1.1504-0.5 0.89472-1.0482 1.4355-2.4088 1.4355-3.8945 0-3.3128-2.6856-5.998-6-5.998zm0 1.5625a4.4375 4.4375 0 0 1 4.4375 4.4375 4.4375 4.4375 0 0 1-4.4375 4.4375 4.4375 4.4375 0 0 1-4.4375-4.4375 4.4375 4.4375 0 0 1 4.4375-4.4375z"/>
+            </svg></a></li>
+            {% endif %}
+          </ol>
+          {% endif %}
+        </div>
+      </div>
+      {% endif %}
+    </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">
+        {% if PAGE_HEADER %}
+        {{ PAGE_HEADER|render_rst|replace('{filename}', FILENAME) }}
+        {% endif %}
+{% block main %}
+{% endblock %}
+      </div>
+    </div>
+  </div>
+</article></main>
+{% if not SEARCH_DISABLED %}
+<div class="m-doc-search" id="search">
+  <a href="#!" onclick="return hideSearch()"></a>
+  <div class="m-container">
+    <div class="m-row">
+      <div class="m-col-m-8 m-push-m-2">
+        <div class="m-doc-search-header m-text m-small">
+          <div><span class="m-label m-default">Tab</span> / <span class="m-label m-default">T</span> to search, <span class="m-label m-default">Esc</span> to close</div>
+          <div id="search-symbolcount">&hellip;</div>
+        </div>
+        <div class="m-doc-search-content">
+          <form{% if SEARCH_BASE_URL %} action="{{ SEARCH_BASE_URL }}#search"{% endif %}>
+            <input type="search" name="q" id="search-input" placeholder="Loading &hellip;" disabled="disabled" autofocus="autofocus" autocomplete="off" spellcheck="false" />
+          </form>
+          <noscript class="m-text m-danger m-text-center">Unlike everything else in the docs, the search functionality <em>requires</em> JavaScript.{% if SEARCH_EXTERNAL_URL %} Enable it or <a href="{{ SEARCH_EXTERNAL_URL|replace('{query}', '') }}">use an external search engine</a>.{% endif %}</noscript>
+          <div id="search-help" class="m-text m-dim m-text-center">
+            {{ SEARCH_HELP|render_rst|indent(12) }}
+          </div>
+          <div id="search-notfound" class="m-text m-warning m-text-center">Sorry, nothing was found.{% if SEARCH_EXTERNAL_URL %}<br />Maybe try a full-text <a href="#" id="search-external" data-search-engine="{{ SEARCH_EXTERNAL_URL }}">search with external engine</a>?{% endif %}</div>
+          <ul id="search-results"></ul>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+<script src="search.js"></script>
+{% if SEARCH_DOWNLOAD_BINARY %}
+<script>
+  Search.download(window.location.pathname.substr(0, window.location.pathname.lastIndexOf('/') + 1) + "searchdata.bin");
+</script>
+{% else %}
+<script src="searchdata.js" async="async"></script>
+{% endif %}
+{% endif %}
+{% if FINE_PRINT %}
+<footer><nav>
+  <div class="m-container">
+    <div class="m-row">
+      <div class="m-col-l-10 m-push-l-1">
+        {% if FINE_PRINT == '[default]' %}
+        <p>{{ PROJECT_TITLE }}{% if PROJECT_SUBTITLE %} {{ PROJECT_SUBTITLE }}{% endif %}. Created by <a href="https://mcss.mosra.cz/documentation/python/">m.css Python doc generator</a>.</p>
+        {% else %}
+        {{ FINE_PRINT|render_rst|indent(8) }}
+        {% endif %}
+      </div>
+    </div>
+  </div>
+</nav></footer>
+{% endif %}
+</body>
+</html>
diff --git a/documentation/templates/python/class.html b/documentation/templates/python/class.html
new file mode 100644 (file)
index 0000000..27d15f6
--- /dev/null
@@ -0,0 +1,120 @@
+{% extends 'base.html' %}
+
+{% macro entry_class(class) %}{% include 'entry-class.html' %}{% endmacro %}
+{% macro entry_function(function) %}{% include 'entry-function.html' %}{% endmacro %}
+{% macro entry_property(property) %}{% include 'entry-property.html' %}{% endmacro %}
+{% macro entry_data(data) %}{% include 'entry-data.html' %}{% endmacro %}
+
+{% block title %}{% set j = joiner('.') %}{% for name, _ in page.breadcrumb %}{{ j() }}{{ name }}{% endfor %} | {{ super() }}{% endblock %}
+
+{% block main %}
+        <h1>
+          {%+ for name, target in page.breadcrumb[:-1] %}<span class="m-breadcrumb"><a href="{{ target }}">{{ name }}</a>.<wbr/></span>{% endfor %}{{ page.breadcrumb[-1][0] }} <span class="m-thin">class</span>
+        </h1>
+        {% if page.brief %}
+        <p>{{ page.brief }}</p>
+        {% endif %}
+        {% if page.classes or page.classmethods or page.staticmethods or page.methods or page.dunder_methods or page.properties or page.data %}
+        <div class="m-block m-default">
+          <h3>Contents</h3>
+          <ul>
+            <li>
+              Reference
+              <ul>
+                {% if page.classes %}
+                <li><a href="#classes">Classes</a></li>
+                {% endif %}
+                {% if page.classmethods %}
+                <li><a href="#classmethods">Class methods</a></li>
+                {% endif %}
+                {% if page.staticmethods %}
+                <li><a href="#staticmethods">Static methods</a></li>
+                {% endif %}
+                {% if page.methods %}
+                <li><a href="#methods">Methods</a></li>
+                {% endif %}
+                {% if page.dunder_methods %}
+                <li><a href="#dunder-methods">Special methods</a></li>
+                {% endif %}
+                {% if page.properties %}
+                <li><a href="#properties">Properties</a></li>
+                {% endif %}
+                {% if page.data %}
+                <li><a href="#data">Data</a></li>
+                {% endif %}
+              </ul>
+            </li>
+          </ul>
+        </div>
+        {% endif %}
+        {% if page.classes %}
+        <section id="classes">
+          <h2><a href="#classes">Classes</a></h2>
+          <dl class="m-doc">
+            {% for class in page.classes %}
+{{ entry_class(class) }}
+            {% endfor %}
+          </dl>
+        </section>
+        {% endif %}
+        {% if page.classmethods %}
+        <section id="classmethods">
+          <h2><a href="#classmethods">Class methods</a></h2>
+          <dl class="m-doc">
+            {% for function in page.classmethods %}
+{{ entry_function(function) }}
+            {% endfor %}
+          </dl>
+        </section>
+        {% endif %}
+        {% if page.staticmethods %}
+        <section id="staticmethods">
+          <h2><a href="#staticmethods">Static methods</a></h2>
+          <dl class="m-doc">
+            {% for function in page.staticmethods %}
+{{ entry_function(function) }}
+            {% endfor %}
+          </dl>
+        </section>
+        {% endif %}
+        {% if page.methods %}
+        <section id="methods">
+          <h2><a href="#methods">Methods</a></h2>
+          <dl class="m-doc">
+            {% for function in page.methods %}
+{{ entry_function(function) }}
+            {% endfor %}
+          </dl>
+        </section>
+        {% endif %}
+        {% if page.dunder_methods %}
+        <section id="dunder-methods">
+          <h2><a href="#dunder-methods">Special methods</a></h2>
+          <dl class="m-doc">
+            {% for function in page.dunder_methods %}
+{{ entry_function(function) }}
+            {% endfor %}
+          </dl>
+        </section>
+        {% endif %}
+        {% if page.properties %}
+        <section id="properties">
+          <h2><a href="#properties">Properties</a></h2>
+          <dl class="m-doc">
+            {% for property in page.properties %}
+{{ entry_property(property) }}
+            {% endfor %}
+          </dl>
+        </section>
+        {% endif %}
+        {% if page.data %}
+        <section id="data">
+          <h2><a href="#data">Data</a></h2>
+          <dl class="m-doc">
+            {% for data in page.data %}
+{{ entry_data(data) }}
+            {% endfor %}
+          </dl>
+        </section>
+        {% endif %}
+{% endblock %}
diff --git a/documentation/templates/python/entry-class.html b/documentation/templates/python/entry-class.html
new file mode 100644 (file)
index 0000000..a294f48
--- /dev/null
@@ -0,0 +1,2 @@
+            <dt>class <a href="{{ class.url }}" class="m-doc">{{ class.name }}</a>{% if class.is_deprecated %} <span class="m-label m-danger">deprecated</span>{% endif %}</dt>
+            <dd>{{ class.brief }}</dd>
diff --git a/documentation/templates/python/entry-data.html b/documentation/templates/python/entry-data.html
new file mode 100644 (file)
index 0000000..d4e4d88
--- /dev/null
@@ -0,0 +1,6 @@
+            <dt>
+              <a href="" class="m-doc{% if not data.has_details %}-self{% endif %}">{{ data.name }}</a>{% if data.type %}: {{ data.type }}{% endif %}{% if data.value %} = {{ data.value }}{% endif %}
+              {# This has to be here to avoid the newline being eaten #}
+
+            </dt>
+            <dd>{{ data.brief }}</dd>
diff --git a/documentation/templates/python/entry-function.html b/documentation/templates/python/entry-function.html
new file mode 100644 (file)
index 0000000..c5f43ab
--- /dev/null
@@ -0,0 +1,5 @@
+            <dt>
+              {% set j = joiner('\n              ' if function.has_complex_params else ' ') %}
+              <span class="m-doc-wrap-bumper">def <a href="" class="m-doc{% if not function.has_details %}-self{% endif %}">{{ function.name }}</a>(</span><span class="m-doc-wrap">{% for param in function.params %}{% if loop.index0 %}{% if function.params[loop.index0 - 1].kind == 'POSITIONAL_OR_KEYWORD' and param.kind == 'KEYWORD_ONLY' %},<span class="m-text m-dim"> *,</span>{% else %},{% endif %}{% endif %}{{ j() }}{% if param.kind == 'VAR_POSITIONAL' %}*{% elif param.kind == 'VAR_KEYWORD' %}**{% endif %}{{ param.name }}{% if param.type %}: {{ param.type }}{% endif %}{% if param.default %} = {{ param.default }}{% endif %}{% if param.kind == 'POSITIONAL_ONLY' and (loop.last or function.params[loop.index0 + 1].kind != 'POSITIONAL_ONLY') %}<span class="m-text m-dim">, /</span>{% endif %}{% endfor %}){% if function.type %} -&gt; {{ function.type }}{% endif %}</span>
+            </dt>
+            <dd>{{ function.brief }}</dd>
diff --git a/documentation/templates/python/entry-module.html b/documentation/templates/python/entry-module.html
new file mode 100644 (file)
index 0000000..504066d
--- /dev/null
@@ -0,0 +1,2 @@
+            <dt>module <a href="{{ module.url }}" class="m-doc">{{ module.name }}</a>{% if module.is_deprecated %} <span class="m-label m-danger">deprecated</span>{% endif %}</dt>
+            <dd>{{ module.brief }}</dd>
diff --git a/documentation/templates/python/entry-property.html b/documentation/templates/python/entry-property.html
new file mode 100644 (file)
index 0000000..8abe114
--- /dev/null
@@ -0,0 +1,4 @@
+            <dt>
+              <a href="" class="m-doc{% if not property.has_details %}-self{% endif %}">{{ property.name }}</a>{% if property.type %}: {{ property.type }}{% endif %} <span class="m-label m-flat {% if property.is_settable %}m-success{% else %}m-warning{% endif %}">get{% if property.is_settable %} set{% endif %}{% if property.is_deletable %} del{% endif %}</span>
+            </dt>
+            <dd>{{ property.brief }}</dd>
diff --git a/documentation/templates/python/module.html b/documentation/templates/python/module.html
new file mode 100644 (file)
index 0000000..09eada7
--- /dev/null
@@ -0,0 +1,81 @@
+{% extends 'base.html' %}
+
+{% macro entry_module(module) %}{% include 'entry-module.html' %}{% endmacro %}
+{% macro entry_class(class) %}{% include 'entry-class.html' %}{% endmacro %}
+{% macro entry_function(function) %}{% include 'entry-function.html' %}{% endmacro %}
+{% macro entry_data(data) %}{% include 'entry-data.html' %}{% endmacro %}
+
+{% block title %}{% set j = joiner('.') %}{% for name, _ in page.breadcrumb %}{{ j() }}{{ name }}{% endfor %} | {{ super() }}{% endblock %}
+
+{% block main %}
+        <h1>
+          {%+ for name, target in page.breadcrumb[:-1] %}<span class="m-breadcrumb"><a href="{{ target }}">{{ name }}</a>.<wbr/></span>{% endfor %}{{ page.breadcrumb[-1][0] }} <span class="m-thin">module</span>
+        </h1>
+        {% if page.brief %}
+        <p>{{ page.brief }}</p>
+        {% endif %}
+        {% if page.modules or page.classes or page.functions or page.data %}
+        <div class="m-block m-default">
+          <h3>Contents</h3>
+          <ul>
+            <li>
+              Reference
+              <ul>
+                {% if page.modules %}
+                <li><a href="#packages">Modules</a></li>
+                {% endif %}
+                {% if page.classes %}
+                <li><a href="#classes">Classes</a></li>
+                {% endif %}
+                {% if page.functions %}
+                <li><a href="#functions">Functions</a></li>
+                {% endif %}
+                {% if page.data %}
+                <li><a href="#data">Data</a></li>
+                {% endif %}
+              </ul>
+            </li>
+          </ul>
+        </div>
+        {% endif %}
+        {% if page.modules %}
+        <section id="namespaces">
+          <h2><a href="#namespaces">Modules</a></h2>
+          <dl class="m-doc">
+            {% for module in page.modules %}
+{{ entry_module(module) }}
+            {% endfor %}
+          </dl>
+        </section>
+        {% endif %}
+        {% if page.classes %}
+        <section id="classes">
+          <h2><a href="#classes">Classes</a></h2>
+          <dl class="m-doc">
+            {% for class in page.classes %}
+{{ entry_class(class) }}
+            {% endfor %}
+          </dl>
+        </section>
+        {% endif %}
+        {% if page.functions %}
+        <section id="functions">
+          <h2><a href="#functions">Functions</a></h2>
+          <dl class="m-doc">
+            {% for function in page.functions %}
+{{ entry_function(function) }}
+            {% endfor %}
+          </dl>
+        </section>
+        {% endif %}
+        {% if page.data %}
+        <section id="data">
+          <h2><a href="#data">Data</a></h2>
+          <dl class="m-doc">
+            {% for data in page.data %}
+{{ entry_data(data) }}
+            {% endfor %}
+          </dl>
+        </section>
+        {% endif %}
+{% endblock %}
diff --git a/documentation/templates/python/page.html b/documentation/templates/python/page.html
new file mode 100644 (file)
index 0000000..41bc697
--- /dev/null
@@ -0,0 +1,9 @@
+{% extends 'base.html' %}
+
+{% block title %}{% set j = joiner('.') %}{% for name, _ in page.breadcrumb %}{{ j() }}{{ name }}{% endfor %} | {{ super() }}{% endblock %}
+
+{% block main %}
+        <h1>
+          {%+ for name, target in page.breadcrumb[:-1] %}<span class="m-breadcrumb"><a href="{{ target }}">{{ name }}</a>.<wbr/></span>{% endfor %}{{ page.breadcrumb[-1][0] }}
+        </h1>
+{% endblock %}
diff --git a/documentation/test_python/__init__.py b/documentation/test_python/__init__.py
new file mode 100644 (file)
index 0000000..1c9a737
--- /dev/null
@@ -0,0 +1,79 @@
+#
+#   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 copy
+import sys
+import os
+import inspect
+import shutil
+import unittest
+
+from python import run, default_templates, default_config
+
+class BaseTestCase(unittest.TestCase):
+    def __init__(self, path, dir, *args, **kwargs):
+        unittest.TestCase.__init__(self, *args, **kwargs)
+        # Full directory name (for test_something.py the directory is something_dir{}
+        self.dirname = os.path.splitext(os.path.basename(path))[0][5:] + ('_' + dir if dir else '')
+        # Absolute path to this directory
+        self.path = os.path.join(os.path.dirname(os.path.realpath(path)), self.dirname)
+
+        # Display ALL THE DIFFS
+        self.maxDiff = None
+
+    def setUp(self):
+        if os.path.exists(os.path.join(self.path, 'output')): shutil.rmtree(os.path.join(self.path, 'output'))
+
+    def run_python(self, config_overrides={}, templates=default_templates):
+        # Defaults that make sense for the tests
+        config = copy.deepcopy(default_config)
+        config.update({
+            'FINE_PRINT': None,
+            'THEME_COLOR': None,
+            'FAVICON': None,
+            'LINKS_NAVBAR1': None,
+            'LINKS_NAVBAR2': None,
+            # None instead of [] so we can detect even an empty override
+            'INPUT_MODULES': None,
+            'SEARCH_DISABLED': True,
+            'OUTPUT': os.path.join(self.path, 'output')
+        })
+
+        # Update it with config overrides, specify the input module if not
+        # already
+        config.update(config_overrides)
+        if config['INPUT_MODULES'] is None:
+            sys.path.append(self.path)
+            config['INPUT_MODULES'] = [self.dirname]
+
+        run(self.path, config, templates=templates)
+
+    def actual_expected_contents(self, actual, expected = None):
+        if not expected: expected = actual
+
+        with open(os.path.join(self.path, expected)) as f:
+            expected_contents = f.read().strip()
+        with open(os.path.join(self.path, 'output', actual)) as f:
+            actual_contents = f.read().strip()
+        return actual_contents, expected_contents
diff --git a/documentation/test_python/inspect_all_property/inspect_all_property.html b/documentation/test_python/inspect_all_property/inspect_all_property.html
new file mode 100644 (file)
index 0000000..771b037
--- /dev/null
@@ -0,0 +1,76 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>inspect_all_property | 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>
+          inspect_all_property <span class="m-thin">module</span>
+        </h1>
+        <div class="m-block m-default">
+          <h3>Contents</h3>
+          <ul>
+            <li>
+              Reference
+              <ul>
+                <li><a href="#packages">Modules</a></li>
+                <li><a href="#classes">Classes</a></li>
+                <li><a href="#functions">Functions</a></li>
+                <li><a href="#data">Data</a></li>
+              </ul>
+            </li>
+          </ul>
+        </div>
+        <section id="namespaces">
+          <h2><a href="#namespaces">Modules</a></h2>
+          <dl class="m-doc">
+            <dt>module <a href="inspect_all_property._private_but_exposed.html" class="m-doc">_private_but_exposed</a></dt>
+            <dd></dd>
+          </dl>
+        </section>
+        <section id="classes">
+          <h2><a href="#classes">Classes</a></h2>
+          <dl class="m-doc">
+            <dt>class <a href="inspect_all_property._PrivateButExposedClass.html" class="m-doc">_PrivateButExposedClass</a></dt>
+            <dd></dd>
+          </dl>
+        </section>
+        <section id="functions">
+          <h2><a href="#functions">Functions</a></h2>
+          <dl class="m-doc">
+            <dt>
+              <span class="m-doc-wrap-bumper">def <a href="" class="m-doc-self">_private_but_exposed_func</a>(</span><span class="m-doc-wrap">)</span>
+            </dt>
+            <dd></dd>
+          </dl>
+        </section>
+        <section id="data">
+          <h2><a href="#data">Data</a></h2>
+          <dl class="m-doc">
+            <dt>
+              <a href="" class="m-doc-self">_private_data</a> = &#x27;Hey!&#x27;
+            </dt>
+            <dd></dd>
+          </dl>
+        </section>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/inspect_all_property/inspect_all_property/__init__.py b/documentation/test_python/inspect_all_property/inspect_all_property/__init__.py
new file mode 100644 (file)
index 0000000..cd670b6
--- /dev/null
@@ -0,0 +1,24 @@
+from . import hidden
+from . import _private_but_exposed
+
+class HiddenClass:
+    """A documented class not exposed in __all__"""
+    pass
+
+class _PrivateButExposedClass:
+    # An undocumented class but exposed to
+    pass
+
+def hidden_func(a, b, c):
+    """A documented function not exposed in __all__"""
+    pass
+
+def _private_but_exposed_func():
+    # A private thing but exposed in __all__
+    pass
+
+hidden_data = 34
+
+_private_data = 'Hey!'
+
+__all__ = ['_private_but_exposed', '_PrivateButExposedClass', '_private_but_exposed_func', '_private_data']
diff --git a/documentation/test_python/inspect_all_property/inspect_all_property/_private_but_exposed.py b/documentation/test_python/inspect_all_property/inspect_all_property/_private_but_exposed.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/documentation/test_python/inspect_all_property/inspect_all_property/hidden.py b/documentation/test_python/inspect_all_property/inspect_all_property/hidden.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/documentation/test_python/inspect_annotations/inspect_annotations.Foo.html b/documentation/test_python/inspect_annotations/inspect_annotations.Foo.html
new file mode 100644 (file)
index 0000000..6954a78
--- /dev/null
@@ -0,0 +1,51 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>inspect_annotations.Foo | 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="inspect_annotations.html">inspect_annotations</a>.<wbr/></span>Foo <span class="m-thin">class</span>
+        </h1>
+        <p>A class with properties</p>
+        <div class="m-block m-default">
+          <h3>Contents</h3>
+          <ul>
+            <li>
+              Reference
+              <ul>
+                <li><a href="#properties">Properties</a></li>
+              </ul>
+            </li>
+          </ul>
+        </div>
+        <section id="properties">
+          <h2><a href="#properties">Properties</a></h2>
+          <dl class="m-doc">
+            <dt>
+              <a href="" class="m-doc-self">a_property</a>: typing.List[bool] <span class="m-label m-flat m-warning">get</span>
+            </dt>
+            <dd>A property with a type annotation</dd>
+          </dl>
+        </section>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/inspect_annotations/inspect_annotations.html b/documentation/test_python/inspect_annotations/inspect_annotations.html
new file mode 100644 (file)
index 0000000..1d11437
--- /dev/null
@@ -0,0 +1,105 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>inspect_annotations | 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>
+          inspect_annotations <span class="m-thin">module</span>
+        </h1>
+        <div class="m-block m-default">
+          <h3>Contents</h3>
+          <ul>
+            <li>
+              Reference
+              <ul>
+                <li><a href="#classes">Classes</a></li>
+                <li><a href="#functions">Functions</a></li>
+                <li><a href="#data">Data</a></li>
+              </ul>
+            </li>
+          </ul>
+        </div>
+        <section id="classes">
+          <h2><a href="#classes">Classes</a></h2>
+          <dl class="m-doc">
+            <dt>class <a href="inspect_annotations.Foo.html" class="m-doc">Foo</a></dt>
+            <dd>A class with properties</dd>
+          </dl>
+        </section>
+        <section id="functions">
+          <h2><a href="#functions">Functions</a></h2>
+          <dl class="m-doc">
+            <dt>
+              <span class="m-doc-wrap-bumper">def <a href="" class="m-doc-self">annotated_positional_keyword</a>(</span><span class="m-doc-wrap">bar = False,<span class="m-text m-dim"> *,</span>
+              foo: str,
+              **kwargs)</span>
+            </dt>
+            <dd>Function with explicitly delimited keyword args and type annotations</dd>
+            <dt>
+              <span class="m-doc-wrap-bumper">def <a href="" class="m-doc-self">annotation</a>(</span><span class="m-doc-wrap">param: typing.List[int],
+              another: bool,
+              third: str = 'hello') -&gt; inspect_annotations.Foo</span>
+            </dt>
+            <dd>An annotated function</dd>
+            <dt>
+              <span class="m-doc-wrap-bumper">def <a href="" class="m-doc-self">args_kwargs</a>(</span><span class="m-doc-wrap">a, b, *args, **kwargs)</span>
+            </dt>
+            <dd>Function with args and kwargs</dd>
+            <dt>
+              <span class="m-doc-wrap-bumper">def <a href="" class="m-doc-self">no_annotation</a>(</span><span class="m-doc-wrap">a, b, z)</span>
+            </dt>
+            <dd>Non-annotated function</dd>
+            <dt>
+              <span class="m-doc-wrap-bumper">def <a href="" class="m-doc-self">no_annotation_default_param</a>(</span><span class="m-doc-wrap">param,
+              another,
+              third = 'hello')</span>
+            </dt>
+            <dd>Non-annotated function</dd>
+            <dt>
+              <span class="m-doc-wrap-bumper">def <a href="" class="m-doc-self">partial_annotation</a>(</span><span class="m-doc-wrap">foo,
+              param: typing.Tuple[int, int],
+              unannotated,
+              cls: inspect_annotations.Foo)</span>
+            </dt>
+            <dd>Partially annotated function</dd>
+            <dt>
+              <span class="m-doc-wrap-bumper">def <a href="" class="m-doc-self">positional_keyword</a>(</span><span class="m-doc-wrap">positional_kw,<span class="m-text m-dim"> *,</span> kw_only)</span>
+            </dt>
+            <dd>Function with explicitly delimited keyword args</dd>
+          </dl>
+        </section>
+        <section id="data">
+          <h2><a href="#data">Data</a></h2>
+          <dl class="m-doc">
+            <dt>
+              <a href="" class="m-doc-self">ANNOTATED_VAR</a>: typing.Tuple[bool, str] = (False, &#x27;No.&#x27;)
+            </dt>
+            <dd></dd>
+            <dt>
+              <a href="" class="m-doc-self">UNANNOTATED_VAR</a> = 3.45
+            </dt>
+            <dd></dd>
+          </dl>
+        </section>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/inspect_annotations/inspect_annotations.py b/documentation/test_python/inspect_annotations/inspect_annotations.py
new file mode 100644 (file)
index 0000000..f2e2052
--- /dev/null
@@ -0,0 +1,46 @@
+from typing import List, Tuple
+
+class Foo:
+    """A class with properties"""
+
+    @property
+    def a_property(self) -> List[bool]:
+        """A property with a type annotation"""
+        pass
+
+def annotation(param: List[int], another: bool, third: str = "hello") -> Foo:
+    """An annotated function"""
+    pass
+
+def no_annotation(a, b, z):
+    """Non-annotated function"""
+    pass
+
+def no_annotation_default_param(param, another, third = "hello"):
+    """Non-annotated function"""
+    pass
+
+def partial_annotation(foo, param: Tuple[int, int], unannotated, cls: Foo):
+    """Partially annotated function"""
+    pass
+
+# Only possible with native code now, https://www.python.org/dev/peps/pep-0570/
+#def positionals_only(positional_only, /, positional_kw):
+    #"""Function with explicitly delimited positional args"""
+    #pass
+
+def args_kwargs(a, b, *args, **kwargs):
+    """Function with args and kwargs"""
+    pass
+
+def positional_keyword(positional_kw, *, kw_only):
+    """Function with explicitly delimited keyword args"""
+    pass
+
+def annotated_positional_keyword(bar = False, *, foo: str, **kwargs):
+    """Function with explicitly delimited keyword args and type annotations"""
+    pass
+
+UNANNOTATED_VAR = 3.45
+
+ANNOTATED_VAR: Tuple[bool, str] = (False, 'No.')
diff --git a/documentation/test_python/inspect_annotations/math.html b/documentation/test_python/inspect_annotations/math.html
new file mode 100644 (file)
index 0000000..90c1e2f
--- /dev/null
@@ -0,0 +1,57 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>math | 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>
+          math <span class="m-thin">module</span>
+        </h1>
+        <p>This module is always available.  It provides access to the
+mathematical functions defined by the C standard.</p>
+        <div class="m-block m-default">
+          <h3>Contents</h3>
+          <ul>
+            <li>
+              Reference
+              <ul>
+                <li><a href="#functions">Functions</a></li>
+              </ul>
+            </li>
+          </ul>
+        </div>
+        <section id="functions">
+          <h2><a href="#functions">Functions</a></h2>
+          <dl class="m-doc">
+            <dt>
+              <span class="m-doc-wrap-bumper">def <a href="" class="m-doc-self">pow</a>(</span><span class="m-doc-wrap">x, y<span class="m-text m-dim">, /</span>)</span>
+            </dt>
+            <dd>Return x**y (x to the power of y).</dd>
+            <dt>
+              <span class="m-doc-wrap-bumper">def <a href="" class="m-doc-self">log</a>(</span><span class="m-doc-wrap">...)</span>
+            </dt>
+            <dd>log(x, [base=math.e])
+Return the logarithm of x to the given base.</dd>
+          </dl>
+        </section>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/inspect_string/inspect_string.Foo.html b/documentation/test_python/inspect_string/inspect_string.Foo.html
new file mode 100644 (file)
index 0000000..b7cdce3
--- /dev/null
@@ -0,0 +1,107 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>inspect_string.Foo | 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="inspect_string.html">inspect_string</a>.<wbr/></span>Foo <span class="m-thin">class</span>
+        </h1>
+        <p>The foo class</p>
+        <div class="m-block m-default">
+          <h3>Contents</h3>
+          <ul>
+            <li>
+              Reference
+              <ul>
+                <li><a href="#classes">Classes</a></li>
+                <li><a href="#classmethods">Class methods</a></li>
+                <li><a href="#staticmethods">Static methods</a></li>
+                <li><a href="#methods">Methods</a></li>
+                <li><a href="#properties">Properties</a></li>
+                <li><a href="#data">Data</a></li>
+              </ul>
+            </li>
+          </ul>
+        </div>
+        <section id="classes">
+          <h2><a href="#classes">Classes</a></h2>
+          <dl class="m-doc">
+            <dt>class <a href="inspect_string.Foo.Subclass.html" class="m-doc">Subclass</a></dt>
+            <dd>A subclass of Foo</dd>
+          </dl>
+        </section>
+        <section id="classmethods">
+          <h2><a href="#classmethods">Class methods</a></h2>
+          <dl class="m-doc">
+            <dt>
+              <span class="m-doc-wrap-bumper">def <a href="" class="m-doc-self">func_on_class</a>(</span><span class="m-doc-wrap">a)</span>
+            </dt>
+            <dd>A class method</dd>
+          </dl>
+        </section>
+        <section id="staticmethods">
+          <h2><a href="#staticmethods">Static methods</a></h2>
+          <dl class="m-doc">
+            <dt>
+              <span class="m-doc-wrap-bumper">def <a href="" class="m-doc-self">static_func</a>(</span><span class="m-doc-wrap">a)</span>
+            </dt>
+            <dd>A static method</dd>
+          </dl>
+        </section>
+        <section id="methods">
+          <h2><a href="#methods">Methods</a></h2>
+          <dl class="m-doc">
+            <dt>
+              <span class="m-doc-wrap-bumper">def <a href="" class="m-doc-self">func</a>(</span><span class="m-doc-wrap">self, a, b)</span>
+            </dt>
+            <dd>A method</dd>
+          </dl>
+        </section>
+        <section id="properties">
+          <h2><a href="#properties">Properties</a></h2>
+          <dl class="m-doc">
+            <dt>
+              <a href="" class="m-doc-self">a_property</a> <span class="m-label m-flat m-warning">get</span>
+            </dt>
+            <dd>A property</dd>
+            <dt>
+              <a href="" class="m-doc-self">deletable_property</a> <span class="m-label m-flat m-warning">get del</span>
+            </dt>
+            <dd>Deletable property</dd>
+            <dt>
+              <a href="" class="m-doc-self">writable_property</a> <span class="m-label m-flat m-success">get set</span>
+            </dt>
+            <dd>Writable property</dd>
+          </dl>
+        </section>
+        <section id="data">
+          <h2><a href="#data">Data</a></h2>
+          <dl class="m-doc">
+            <dt>
+              <a href="" class="m-doc-self">A_DATA</a> = &#x27;BOO&#x27;
+            </dt>
+            <dd></dd>
+          </dl>
+        </section>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/inspect_string/inspect_string.Specials.html b/documentation/test_python/inspect_string/inspect_string.Specials.html
new file mode 100644 (file)
index 0000000..23e8674
--- /dev/null
@@ -0,0 +1,59 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>inspect_string.Specials | 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="inspect_string.html">inspect_string</a>.<wbr/></span>Specials <span class="m-thin">class</span>
+        </h1>
+        <p>Special class members</p>
+        <div class="m-block m-default">
+          <h3>Contents</h3>
+          <ul>
+            <li>
+              Reference
+              <ul>
+                <li><a href="#dunder-methods">Special methods</a></li>
+              </ul>
+            </li>
+          </ul>
+        </div>
+        <section id="dunder-methods">
+          <h2><a href="#dunder-methods">Special methods</a></h2>
+          <dl class="m-doc">
+            <dt>
+              <span class="m-doc-wrap-bumper">def <a href="" class="m-doc-self">__add__</a>(</span><span class="m-doc-wrap">self, other)</span>
+            </dt>
+            <dd>Add a thing</dd>
+            <dt>
+              <span class="m-doc-wrap-bumper">def <a href="" class="m-doc-self">__and__</a>(</span><span class="m-doc-wrap">self, other)</span>
+            </dt>
+            <dd></dd>
+            <dt>
+              <span class="m-doc-wrap-bumper">def <a href="" class="m-doc-self">__init__</a>(</span><span class="m-doc-wrap">self)</span>
+            </dt>
+            <dd>The constructor</dd>
+          </dl>
+        </section>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/inspect_string/inspect_string.another_module.html b/documentation/test_python/inspect_string/inspect_string.another_module.html
new file mode 100644 (file)
index 0000000..7805349
--- /dev/null
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>inspect_string.another_module | 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="inspect_string.html">inspect_string</a>.<wbr/></span>another_module <span class="m-thin">module</span>
+        </h1>
+        <p>Another module</p>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/inspect_string/inspect_string.html b/documentation/test_python/inspect_string/inspect_string.html
new file mode 100644 (file)
index 0000000..eb283b0
--- /dev/null
@@ -0,0 +1,85 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>inspect_string | 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>
+          inspect_string <span class="m-thin">module</span>
+        </h1>
+        <p>A module</p>
+        <div class="m-block m-default">
+          <h3>Contents</h3>
+          <ul>
+            <li>
+              Reference
+              <ul>
+                <li><a href="#packages">Modules</a></li>
+                <li><a href="#classes">Classes</a></li>
+                <li><a href="#functions">Functions</a></li>
+                <li><a href="#data">Data</a></li>
+              </ul>
+            </li>
+          </ul>
+        </div>
+        <section id="namespaces">
+          <h2><a href="#namespaces">Modules</a></h2>
+          <dl class="m-doc">
+            <dt>module <a href="inspect_string.another_module.html" class="m-doc">another_module</a></dt>
+            <dd>Another module</dd>
+            <dt>module <a href="inspect_string.subpackage.html" class="m-doc">subpackage</a></dt>
+            <dd>A subpackage</dd>
+          </dl>
+        </section>
+        <section id="classes">
+          <h2><a href="#classes">Classes</a></h2>
+          <dl class="m-doc">
+            <dt>class <a href="inspect_string.Foo.html" class="m-doc">Foo</a></dt>
+            <dd>The foo class</dd>
+            <dt>class <a href="inspect_string.Specials.html" class="m-doc">Specials</a></dt>
+            <dd>Special class members</dd>
+          </dl>
+        </section>
+        <section id="functions">
+          <h2><a href="#functions">Functions</a></h2>
+          <dl class="m-doc">
+            <dt>
+              <span class="m-doc-wrap-bumper">def <a href="" class="m-doc-self">function</a>(</span><span class="m-doc-wrap">)</span>
+            </dt>
+            <dd>A function</dd>
+          </dl>
+        </section>
+        <section id="data">
+          <h2><a href="#data">Data</a></h2>
+          <dl class="m-doc">
+            <dt>
+              <a href="" class="m-doc-self">A_CONSTANT</a> = 3.24
+            </dt>
+            <dd></dd>
+            <dt>
+              <a href="" class="m-doc-self">foo</a>
+            </dt>
+            <dd></dd>
+          </dl>
+        </section>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/inspect_string/inspect_string/__init__.py b/documentation/test_python/inspect_string/inspect_string/__init__.py
new file mode 100644 (file)
index 0000000..987df07
--- /dev/null
@@ -0,0 +1,121 @@
+"""A module"""
+
+# This one is a module that shouldn't be exposed
+import enum
+
+# This one is a package that shouldn't be exposed
+import xml
+
+# These are descendant packages / modules that should be exposed if not
+# underscored
+from . import subpackage, another_module, _private_module
+
+# These are variables from an external modules, shouldn't be exposed either
+from re import I
+
+# TODO: there's no way to tell where a variable of a builtin type comes from,
+#   so this would be exposed. The only solution is requiring a presence of an
+#   external docstring I'm afraid.
+#from math import pi
+
+class Foo:
+    """The foo class"""
+
+    A_DATA = 'BOO'
+
+    class Subclass:
+        """A subclass of Foo"""
+        pass
+
+    class _PrivateSubclass:
+        """A private subclass"""
+        pass
+
+    def func(self, a, b):
+        """A method"""
+        pass
+
+    def _private_func(self, a, b):
+        """A private function"""
+        pass
+
+    @classmethod
+    def func_on_class(cls, a):
+        """A class method"""
+        pass
+
+    @classmethod
+    def _private_func_on_class(cls, a):
+        """A private class method"""
+        pass
+
+    @staticmethod
+    def static_func(a):
+        """A static method"""
+        pass
+
+    @staticmethod
+    def _private_static_func(a):
+        """A private static method"""
+        pass
+
+    @property
+    def a_property(self):
+        """A property"""
+        pass
+
+    @property
+    def writable_property(self):
+        """Writable property"""
+        pass
+
+    @writable_property.setter
+    def writable_property(self, a):
+        pass
+
+    @property
+    def deletable_property(self):
+        """Deletable property"""
+        pass
+
+    @deletable_property.deleter
+    def deletable_property(self):
+        pass
+
+    @property
+    def _private_property(self):
+        """A private property"""
+        pass
+
+class Specials:
+    """Special class members"""
+
+    def __init__(self):
+        """The constructor"""
+        pass
+
+    def __add__(self, other):
+        """Add a thing"""
+        pass
+
+    def __and__(self, other):
+        # Undocumented, but should be present
+        pass
+
+class _PrivateClass:
+    """Private class"""
+    pass
+
+def function():
+    """A function"""
+    pass
+
+def _private_function():
+    """A private function"""
+    pass
+
+A_CONSTANT = 3.24
+
+_PRIVATE_CONSTANT = -3
+
+foo = Foo()
diff --git a/documentation/test_python/inspect_string/inspect_string/_private_module.py b/documentation/test_python/inspect_string/inspect_string/_private_module.py
new file mode 100644 (file)
index 0000000..f2ec18f
--- /dev/null
@@ -0,0 +1 @@
+"""Private module."""
diff --git a/documentation/test_python/inspect_string/inspect_string/another_module.py b/documentation/test_python/inspect_string/inspect_string/another_module.py
new file mode 100644 (file)
index 0000000..1681ee4
--- /dev/null
@@ -0,0 +1,15 @@
+"""Another module"""
+
+# This one is a module that shouldn't be exposed
+import enum
+
+# This one is a package that shouldn't be exposed
+import xml
+
+# These are variables from an external modules, shouldn't be exposed either
+from re import I
+
+# TODO: there's no way to tell where a variable of a builtin type comes from,
+#   so this would be exposed. The only solution is requiring a presence of an
+#   external docstring I'm afraid.
+#from math import pi
diff --git a/documentation/test_python/inspect_string/inspect_string/subpackage/__init__.py b/documentation/test_python/inspect_string/inspect_string/subpackage/__init__.py
new file mode 100644 (file)
index 0000000..1fc06e4
--- /dev/null
@@ -0,0 +1 @@
+"""A subpackage"""
diff --git a/documentation/test_python/layout/index.html b/documentation/test_python/layout/index.html
new file mode 100644 (file)
index 0000000..e17777f
--- /dev/null
@@ -0,0 +1,106 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>A project | A project is cool</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" />
+  <link rel="icon" href="favicon-light.png" type="image/png" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <meta name="theme-color" content="#00ffff" />
+  <!-- this is extra in the header -->
+    <!-- and more, indented -->
+  <!-- yes. -->
+</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">A project <span class="m-thin">is cool</span></a>
+      <div class="m-col-t-4 m-hide-m m-text-right m-nopadr">
+        <a href="#search" class="m-doc-search-icon" title="Search" onclick="return showSearch()"><svg style="height: 0.9rem;" viewBox="0 0 16 16">
+          <path d="m6 0c-3.3144 0-6 2.6856-6 6 0 3.3144 2.6856 6 6 6 1.4858 0 2.8463-0.54083 3.8945-1.4355-0.0164 0.33797 0.14734 0.75854 0.5 1.1504l3.2227 3.7891c0.55185 0.6139 1.4517 0.66544 2.002 0.11524 0.55022-0.55022 0.49866-1.4501-0.11524-2.002l-3.7891-3.2246c-0.39184-0.35266-0.81242-0.51469-1.1504-0.5 0.89472-1.0482 1.4355-2.4088 1.4355-3.8945 0-3.3128-2.6856-5.998-6-5.998zm0 1.5625a4.4375 4.4375 0 0 1 4.4375 4.4375 4.4375 4.4375 0 0 1-4.4375 4.4375 4.4375 4.4375 0 0 1-4.4375-4.4375 4.4375 4.4375 0 0 1 4.4375-4.4375z"/>
+        </svg></a>
+        <a id="m-navbar-show" href="#navigation" title="Show navigation"></a>
+        <a id="m-navbar-hide" href="#" title="Hide navigation"></a>
+      </div>
+      <div id="m-navbar-collapse" class="m-col-t-12 m-show-m m-col-m-none m-right-m">
+        <div class="m-row">
+          <ol class="m-col-t-12 m-col-m-none">
+            <li>
+              <a href="pages.html">Pages</a>
+              <ol>
+                <li><a href="getting-started.html">Getting started</a></li>
+                <li><a href="troubleshooting.html">Troubleshooting</a></li>
+              </ol>
+            </li>
+            <li><a href="modules.html">Modules</a></li>
+          </ol>
+          <ol class="m-col-t-6 m-col-m-none" start="3">
+            <li><a href="classes.html">Classes</a></li>
+            <li>
+              <a href="https://github.com/mosra/m.css">GitHub</a>
+              <ol>
+                <li><a href="about.html">About</a></li>
+              </ol>
+            </li>
+            <li class="m-show-m"><a href="#search" class="m-doc-search-icon" title="Search" onclick="return showSearch()"><svg style="height: 0.9rem;" viewBox="0 0 16 16">
+              <path d="m6 0c-3.3144 0-6 2.6856-6 6 0 3.3144 2.6856 6 6 6 1.4858 0 2.8463-0.54083 3.8945-1.4355-0.0164 0.33797 0.14734 0.75854 0.5 1.1504l3.2227 3.7891c0.55185 0.6139 1.4517 0.66544 2.002 0.11524 0.55022-0.55022 0.49866-1.4501-0.11524-2.002l-3.7891-3.2246c-0.39184-0.35266-0.81242-0.51469-1.1504-0.5 0.89472-1.0482 1.4355-2.4088 1.4355-3.8945 0-3.3128-2.6856-5.998-6-5.998zm0 1.5625a4.4375 4.4375 0 0 1 4.4375 4.4375 4.4375 4.4375 0 0 1-4.4375 4.4375 4.4375 4.4375 0 0 1-4.4375-4.4375 4.4375 4.4375 0 0 1 4.4375-4.4375z"/>
+            </svg></a></li>
+          </ol>
+        </div>
+      </div>
+    </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">
+        <p><a href="index.html">A self link</a></p>
+        <h1>
+          A project <span class="m-thin">module</span>
+        </h1>
+      </div>
+    </div>
+  </div>
+</article></main>
+<div class="m-doc-search" id="search">
+  <a href="#!" onclick="return hideSearch()"></a>
+  <div class="m-container">
+    <div class="m-row">
+      <div class="m-col-m-8 m-push-m-2">
+        <div class="m-doc-search-header m-text m-small">
+          <div><span class="m-label m-default">Tab</span> / <span class="m-label m-default">T</span> to search, <span class="m-label m-default">Esc</span> to close</div>
+          <div id="search-symbolcount">&hellip;</div>
+        </div>
+        <div class="m-doc-search-content">
+          <form>
+            <input type="search" name="q" id="search-input" placeholder="Loading &hellip;" disabled="disabled" autofocus="autofocus" autocomplete="off" spellcheck="false" />
+          </form>
+          <noscript class="m-text m-danger m-text-center">Unlike everything else in the docs, the search functionality <em>requires</em> JavaScript. Enable it or <a href="https://google.com/search?q=site:mcss.mosra.cz+{}">use an external search engine</a>.</noscript>
+          <div id="search-help" class="m-text m-dim m-text-center">
+            <p>Some <em>help</em>.
+            On multiple lines.</p>
+          </div>
+          <div id="search-notfound" class="m-text m-warning m-text-center">Sorry, nothing was found.<br />Maybe try a full-text <a href="#" id="search-external" data-search-engine="https://google.com/search?q=site:mcss.mosra.cz+{}">search with external engine</a>?</div>
+          <ul id="search-results"></ul>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+<script src="search.js"></script>
+<script src="searchdata.js" async="async"></script>
+<footer><nav>
+  <div class="m-container">
+    <div class="m-row">
+      <div class="m-col-l-10 m-push-l-1">
+        <p>This beautiful thing is done thanks to
+        <a href="https://mcss.mosra.cz">m.css</a>.</p>
+      </div>
+    </div>
+  </div>
+</nav></footer>
+</body>
+</html>
diff --git a/documentation/test_python/layout/sitemap.xml b/documentation/test_python/layout/sitemap.xml
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/documentation/test_python/test_inspect.py b/documentation/test_python/test_inspect.py
new file mode 100644 (file)
index 0000000..09a34a3
--- /dev/null
@@ -0,0 +1,91 @@
+#
+#   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
+import sys
+import math
+
+from . import BaseTestCase
+
+class String(BaseTestCase):
+    def __init__(self, *args, **kwargs):
+        super().__init__(__file__, 'string', *args, **kwargs)
+
+    def test(self):
+        self.run_python()
+        self.assertEqual(*self.actual_expected_contents('inspect_string.html'))
+        self.assertEqual(*self.actual_expected_contents('inspect_string.another_module.html'))
+        self.assertEqual(*self.actual_expected_contents('inspect_string.Foo.html'))
+        self.assertEqual(*self.actual_expected_contents('inspect_string.Specials.html'))
+
+class Object(BaseTestCase):
+    def __init__(self, *args, **kwargs):
+        super().__init__(__file__, 'object', *args, **kwargs)
+
+    def test(self):
+        # Reuse the stuff from inspect_string, but this time reference it via
+        # an object and not a string
+        sys.path.append(os.path.join(os.path.dirname(self.path), 'inspect_string'))
+        import inspect_string
+        self.run_python({
+            'INPUT_MODULES': [inspect_string]
+        })
+
+        # The output should be the same as when inspecting a string
+        self.assertEqual(*self.actual_expected_contents('inspect_string.html', '../inspect_string/inspect_string.html'))
+        self.assertEqual(*self.actual_expected_contents('inspect_string.another_module.html', '../inspect_string/inspect_string.another_module.html'))
+        self.assertEqual(*self.actual_expected_contents('inspect_string.Foo.html', '../inspect_string/inspect_string.Foo.html'))
+        self.assertEqual(*self.actual_expected_contents('inspect_string.Specials.html', '../inspect_string/inspect_string.Specials.html'))
+
+class AllProperty(BaseTestCase):
+    def __init__(self, *args, **kwargs):
+        super().__init__(__file__, 'all_property', *args, **kwargs)
+
+    def test(self):
+        self.run_python()
+        self.assertEqual(*self.actual_expected_contents('inspect_all_property.html'))
+
+class Annotations(BaseTestCase):
+    def __init__(self, *args, **kwargs):
+        super().__init__(__file__, 'annotations', *args, **kwargs)
+
+    def test(self):
+        self.run_python()
+        self.assertEqual(*self.actual_expected_contents('inspect_annotations.html'))
+        self.assertEqual(*self.actual_expected_contents('inspect_annotations.Foo.html'))
+
+    def test_math(self):
+        # From math export only pow() so we have the verification easier, and
+        # in addition log() because it doesn't provide any signature metadata
+        assert not hasattr(math, '__all__')
+        math.__all__ = ['pow', 'log']
+
+        self.run_python({
+            'INPUT_MODULES': [math]
+        })
+
+        del math.__all__
+        assert not hasattr(math, '__all__')
+
+        self.assertEqual(*self.actual_expected_contents('math.html'))
diff --git a/documentation/test_python/test_layout.py b/documentation/test_python/test_layout.py
new file mode 100644 (file)
index 0000000..1d7ca6b
--- /dev/null
@@ -0,0 +1,65 @@
+#
+#   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 BaseTestCase
+
+class Layout(BaseTestCase):
+    def __init__(self, *args, **kwargs):
+        super().__init__(__file__, '', *args, **kwargs)
+
+    def test(self):
+        self.run_python({
+            'PROJECT_TITLE': "A project",
+            'PROJECT_SUBTITLE': "is cool",
+
+            'INPUT_MODULES': [], # Explicitly none
+
+            'THEME_COLOR': '#00ffff',
+            'FAVICON': 'favicon-light.png',
+            'PAGE_HEADER': "`A self link <{filename}>`_",
+            'FINE_PRINT': "This beautiful thing is done thanks to\n`m.css <https://mcss.mosra.cz>`_.",
+            'LINKS_NAVBAR1': [
+                ('Pages', 'pages', [
+                    ('Getting started', 'getting-started'),
+                    ('Troubleshooting', 'troubleshooting')]),
+                ('Modules', 'modules', [])],
+            'LINKS_NAVBAR2': [
+                ('Classes', 'classes', []),
+                ('GitHub', 'https://github.com/mosra/m.css', [
+                    ('About', 'about')])],
+            'SEARCH_DISABLED': False,
+            'SEARCH_EXTERNAL_URL': 'https://google.com/search?q=site:mcss.mosra.cz+{}',
+            'SEARCH_HELP': "Some *help*.\nOn multiple lines.",
+            'HTML_HEADER':
+"""<!-- this is extra in the header -->
+  <!-- and more, indented -->
+<!-- yes. -->""",
+            'EXTRA_FILES': ['sitemap.xml']
+        })
+        self.assertEqual(*self.actual_expected_contents('index.html'))
+        self.assertTrue(os.path.exists(os.path.join(self.path, 'output/m-dark+documentation.compiled.css')))
+        self.assertTrue(os.path.exists(os.path.join(self.path, 'output/favicon-light.png')))
+        self.assertTrue(os.path.exists(os.path.join(self.path, 'output/sitemap.xml')))
index c90b178248991f6823b0f2d8fc4bfcfbc7e049c5..0be5829ffa8a2dca8a931c9e4c1ef1e6302b4662 100644 (file)
@@ -8,7 +8,7 @@ matrix:
       python: 3.4
       env:
       - WITH_THEME=ON
-      - WITH_DOXYGEN=OFF
+      - WITH_DOCUMENTATION=OFF
       - WITH_NODE=OFF
       - JOBID=py34
     - language: python
@@ -19,7 +19,7 @@ matrix:
           - graphviz
       env:
       - WITH_THEME=ON
-      - WITH_DOXYGEN=OFF
+      - WITH_DOCUMENTATION=OFF
       - WITH_NODE=OFF
       - JOBID=py35
     - language: python
@@ -30,7 +30,7 @@ matrix:
           - graphviz
       env:
       - WITH_THEME=ON
-      - WITH_DOXYGEN=ON
+      - WITH_DOCUMENTATION=ON
       - WITH_NODE=OFF
       - TRAVIS_BROKEN_MATPLOTLIB_SEED=YES
       - JOBID=py36
@@ -38,7 +38,7 @@ matrix:
       node_js: 8
       env:
       - WITH_THEME=OFF
-      - WITH_DOXYGEN=OFF
+      - WITH_DOCUMENTATION=OFF
       - WITH_NODE=ON
       - JOBID=js
 
@@ -50,21 +50,22 @@ install:
   - if [ "$WITH_NODE" == "ON" ]; then npm install istanbul codecov; fi
 
   # Needed for doxygen binaries
-  - if [ "$WITH_DOXYGEN" == "ON" ]; then mkdir -p $HOME/bin && export PATH=$HOME/bin:$PATH; fi
+  - if [ "$WITH_DOCUMENTATION" == "ON" ]; then mkdir -p $HOME/bin && export PATH=$HOME/bin:$PATH; fi
 
   # Download newer Doxygen, as I don't want to care for the old bugs
-  - if [ "$WITH_DOXYGEN" == "ON" ] && [ ! -f $HOME/bin/doxygen ]; then wget "http://doxygen.nl/files/doxygen-1.8.15.linux.bin.tar.gz" && tar -xzf doxygen-1.8.15.linux.bin.tar.gz && cp doxygen-1.8.15/bin/doxygen $HOME/bin && doxygen -v; fi
+  - if [ "$WITH_DOCUMENTATION" == "ON" ] && [ ! -f $HOME/bin/doxygen ]; then wget "http://doxygen.nl/files/doxygen-1.8.15.linux.bin.tar.gz" && tar -xzf doxygen-1.8.15.linux.bin.tar.gz && cp doxygen-1.8.15/bin/doxygen $HOME/bin && doxygen -v; fi
 
 script:
   # Test the theme. No code coverage there.
   - if [ "$WITH_THEME" == "ON" ]; then cd $TRAVIS_BUILD_DIR/pelican-theme && python -m unittest; fi
 
-  # Test plugins. Math plugin is not tested as dvisvgm on 14.04 is unusable.
+  # Test plugins. Math plugin is not tested as dvisvgm is unusable even on
+  # 16.04.
   - if [ "$WITH_THEME" == "ON" ]; then cd $TRAVIS_BUILD_DIR/plugins && coverage run -m unittest && cp .coverage ../.coverage.plugins; fi
 
-  # Test doxygen theme. Math rendering is not tested as dvisvgm on 14.04 is
-  # unusable.
-  - if [ "$WITH_DOXYGEN" == "ON" ]; then cd $TRAVIS_BUILD_DIR/documentation && coverage run -m unittest && cp .coverage ../.coverage.doxygen; fi
+  # Test documentation themes. Math rendering is not tested as dvisvgm is
+  # unusable even on 16.04.
+  - if [ "$WITH_DOCUMENTATION" == "ON" ]; then cd $TRAVIS_BUILD_DIR/documentation && coverage run -m unittest && cp .coverage ../.coverage.doxygen; fi
 
   # Test client doxygen JS
   - if [ "$WITH_NODE" == "ON" ]; then cd $TRAVIS_BUILD_DIR/documentation && node ../node_modules/istanbul/lib/cli.js cover test_doxygen/test-search.js; fi
@@ -89,5 +90,5 @@ notifications:
     on_start: never
 
 after_success:
-  - if [ "$WITH_THEME" == "ON" ] || [ "$WITH_DOXYGEN" == "ON" ]; then cd $TRAVIS_BUILD_DIR && coverage combine && codecov; fi
+  - if [ "$WITH_THEME" == "ON" ] || [ "$WITH_DOCUMENTATION" == "ON" ]; then cd $TRAVIS_BUILD_DIR && coverage combine && codecov; fi
   - if [ "$WITH_NODE" == "ON" ]; then cd $TRAVIS_BUILD_DIR && codecov; fi
index 4eed970155eaabe51f838a7809f214d1de3aabf2..706ad5b6947de16902fab87c61dc5189812be7dd 100644 (file)
@@ -83,7 +83,8 @@ M_LINKS_NAVBAR1 = [('Why?', 'why/', 'why', []),
                         ('Pelican', 'themes/pelican/', 'themes/pelican')])]
 
 M_LINKS_NAVBAR2 = [('Doc generators', 'documentation/', 'documentation', [
-                        ('Doxygen C++ theme', 'documentation/doxygen/', 'documentation/doxygen')]),
+                        ('Doxygen C++ theme', 'documentation/doxygen/', 'documentation/doxygen'),
+                        ('Python doc theme', 'documentation/python/', 'documentation/python')]),
                    ('Plugins', 'plugins/', 'plugins', [
                         ('HTML sanity', 'plugins/htmlsanity/', 'plugins/htmlsanity'),
                         ('Components', 'plugins/components/', 'plugins/components'),
@@ -114,7 +115,8 @@ M_LINKS_FOOTER3 = [('Themes', 'themes/'),
                    ('Pelican', 'themes/pelican/'),
                    ('', ''),
                    ('Doc generators', 'documentation/'),
-                   ('Doxygen C++ theme', 'documentation/doxygen/')]
+                   ('Doxygen C++ theme', 'documentation/doxygen/'),
+                   ('Python documentation', 'documentation/python/')]
 
 M_LINKS_FOOTER4 = [('Plugins', 'plugins/'),
                    ('HTML sanity', 'plugins/htmlsanity/'),