patch_autodoc.py 7.6 KB
Newer Older
qianyj's avatar
qianyj committed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
"""Hack autodoc to get more fine-grained docstring rendering contol.

autodoc and autosummary didn't expose many of their controls to sphinx users via config.
To customize them, the "correct" approach seems to copy and paste all their code and rewrite some part.
To avoid doing this, I monkey-patched some of the functions to keep the changes minimal.

Note that some of them are related to sphinx internal APIs, which can be broken when sphinx got upgraded.
Try to keep them updated, or pin to a particular sphinx version.
"""

import inspect
import os
from typing import List, Tuple, List

import sphinx
from docutils import nodes
from docutils.nodes import Node


class ClassNewBlacklistPatch:
    """Force some classes to skip ``__new__`` when generating signature."""

    original = None

    def restore(self, *args, **kwargs):
        assert self.original is not None
        sphinx.ext.autodoc._CLASS_NEW_BLACKLIST = self.original

    def patch(self, *args, **kwargs):
        self.original = sphinx.ext.autodoc._CLASS_NEW_BLACKLIST

        blacklist = []

        import nni.retiarii.nn.pytorch
        for name in dir(nni.retiarii.nn.pytorch):
            obj = getattr(nni.retiarii.nn.pytorch, name)
            if inspect.isclass(obj):
                new_name = "{0.__module__}.{0.__qualname__}".format(obj.__new__)
                if new_name not in blacklist:
                    blacklist.append(new_name)

        sphinx.ext.autodoc._CLASS_NEW_BLACKLIST = self.original + blacklist


def disable_trace_patch(*args, **kwargs):
    """Disable trace by setting an environment variable."""
    os.environ['NNI_TRACE_FLAG'] = 'DISABLE'


def trial_tool_import_patch(*args, **kwargs):
    """Insert dummy trial tool variable to ensure trial_tool can be imported.
    See nni/tools/trial_tool/constants.py
    """
    os.environ.update({
        'NNI_OUTPUT_DIR': '/tmp',
        'NNI_PLATFORM': 'unittest',
        'NNI_SYS_DIR': '/tmp',
        'NNI_TRIAL_JOB_ID': 'dummy',
        'NNI_EXP_ID': 'dummy',
        'MULTI_PHASE': 'dummy'
    })


class AutoSummaryPatch:
    """Ignore certain files as they are completely un-importable. It patches:

    - find_autosummary_in_files: Some modules cannot be imported at all due to dependency issues or some special design.
      They need to skipped when running autosummary generate.
    - Autosummary.get_table: The original autosummary creates an index for each module, and the module links in autosummary table
      points to the corresponding generated module page (by using ``:py:module:xxx``). This doesn't work for us,
      because we have used automodule else (other than autosummary) in our docs, and to avoid duplicate index,
      we have to set ``:noindex:`` in autosummary template (see docs/templates/autosummary/module.rst).
      This breaks most of the links, where they fail to link to generated module page by using index.
      We here update the python domain role, to a general domain role (``:doc:``), and link to the page directly.
    """

    find_autosummary_original = None
    get_table_original = None

    def restore(self, *args, **kwargs):
        assert self.find_autosummary_original is not None and self.get_table_original is not None
        sphinx.ext.autosummary.generate.find_autosummary_in_files = self.find_autosummary_original
        sphinx.ext.autosummary.Autosummary.get_table = self.get_table_original

    def patch(self, app, config):
        from sphinx.ext.autosummary import Autosummary
        from sphinx.ext.autosummary.generate import AutosummaryEntry

        self.find_autosummary_original = sphinx.ext.autosummary.generate.find_autosummary_in_files
        self.get_table_original = Autosummary.get_table

        def find_autosummary_in_files(filenames: List[str]) -> List[AutosummaryEntry]:
            items: List[AutosummaryEntry] = self.find_autosummary_original(filenames)
            items = [item for item in items if item.name not in config.autosummary_mock_imports]
            return items

        def get_table(autosummary, items: List[Tuple[str, str, str, str]]) -> List[Node]:
            col_spec, autosummary_table = self.get_table_original(autosummary, items)
            if 'toctree' in autosummary.options:
                # probably within modules
                table = autosummary_table[0]
                tgroup = table[0]
                tbody = tgroup[-1]
                for row in tbody:
                    entry = row[0]
                    paragraph = entry[0]
                    pending_xref = paragraph[0]

                    # get the reference path and check whether it has been generated
                    # if path to reference is changed, this should also be changed
                    reftarget_path = 'reference/_modules/' + pending_xref['reftarget']

                    if reftarget_path in autosummary.env.found_docs:
                        # make :py:obj:`xxx` looks like a :doc:`xxx`
                        pending_xref['refdomain'] = 'std'
                        pending_xref['reftype'] = 'doc'
                        pending_xref['refexplicit'] = False
                        pending_xref['refwarn'] = True
                        pending_xref['reftarget'] = '/' + reftarget_path
                        # a special tag to enable `ResolveDocPatch`
                        pending_xref['refkeepformat'] = True

            return [col_spec, autosummary_table]

        sphinx.ext.autosummary.generate.find_autosummary_in_files = find_autosummary_in_files
        sphinx.ext.autosummary.Autosummary.get_table = get_table


class ResolveDocPatch:
    """Original :doc: role throws away all the format, and keep raw text only.
    We wish to keep module names literal. This patch is to keep literal format in :doc: resolver."""

    original = None

    def restore(self, *args, **kwargs):
        assert self.original is not None
        sphinx.domains.std.StandardDomain._resolve_doc_xref = self.original

    def patch(self, *args, **kwargs):
        self.original = sphinx.domains.std.StandardDomain._resolve_doc_xref

        def doc_xref_resolver(std_domain, env, fromdocname, builder, typ, target, node, contnode):
            if not node.get('refkeepformat'):
                # redirect to original implementation to make it safer
                return self.original(std_domain, env, fromdocname, builder, typ, target, node, contnode)

            # directly reference to document by source name; can be absolute or relative
            from sphinx.domains.std import docname_join, make_refnode
            refdoc = node.get('refdoc', fromdocname)
            docname = docname_join(refdoc, node['reftarget'])
            if docname not in env.all_docs:
                return None
            else:
                innernode = node[0]  # no astext here, to keep literal intact
                return make_refnode(builder, fromdocname, docname, None, innernode)

        sphinx.domains.std.StandardDomain._resolve_doc_xref = doc_xref_resolver


def setup(app):
    # See life-cycle of sphinx app here:
    # https://www.sphinx-doc.org/en/master/extdev/appapi.html#sphinx-core-events
    patch = ClassNewBlacklistPatch()
    app.connect('env-before-read-docs', patch.patch)
    app.connect('env-merge-info', patch.restore)

    patch = ResolveDocPatch()
    app.connect('env-before-read-docs', patch.patch)
    app.connect('env-merge-info', patch.restore)

    app.connect('env-before-read-docs', disable_trace_patch)

    # autosummary generate happens at builder-inited
    app.connect('config-inited', trial_tool_import_patch)

    autosummary_patch = AutoSummaryPatch()
    app.connect('config-inited', autosummary_patch.patch)
    app.connect('env-merge-info', autosummary_patch.restore)