chiark / gitweb /
documentation/python: initial support for generating Python stubs.
authorVladimír Vondruš <mosra@centrum.cz>
Thu, 26 Sep 2024 13:57:09 +0000 (15:57 +0200)
committerVladimír Vondruš <mosra@centrum.cz>
Sat, 28 Sep 2024 03:02:03 +0000 (05:02 +0200)
So far just aiming to produce valid code, skipping docstrings altogether.
Thus -- generating one file per module, and putting code that closely
resembles what's in the documentation, but in a way that is actual
Python, together with importing dependency modules.

To support incomplete or broken annotations, the tyoe_relative template
variable got specialized to type_quoted. In certain cases, such as base
classes for enums or (data / default) values, it's left as *_relative, as
in those cases no quoting is necessary. What isn't handled so far is
quoting forward references for types that were not yet listed in the
output.

57 files changed:
doc/documentation/python.rst
documentation/python.py
documentation/templates/python/stub.pyi [new file with mode: 0644]
documentation/test_python/CMakeLists.txt
documentation/test_python/__init__.py
documentation/test_python/content_html_escape/stubs/content_html_escape/__init__.pyi [new file with mode: 0644]
documentation/test_python/content_html_escape/stubs/content_html_escape/pybind.pyi [new file with mode: 0644]
documentation/test_python/inspect_annotations/stubs/inspect_annotations-py36.pyi [new file with mode: 0644]
documentation/test_python/inspect_annotations/stubs/inspect_annotations-py37+38.pyi [new file with mode: 0644]
documentation/test_python/inspect_annotations/stubs/inspect_annotations.pyi [new file with mode: 0644]
documentation/test_python/inspect_attrs/stubs/inspect_attrs.pyi [new file with mode: 0644]
documentation/test_python/inspect_builtin/stubs/inspect_builtin-310.pyi [new file with mode: 0644]
documentation/test_python/inspect_builtin/stubs/inspect_builtin-36.pyi [new file with mode: 0644]
documentation/test_python/inspect_builtin/stubs/inspect_builtin.pyi [new file with mode: 0644]
documentation/test_python/inspect_name_mapping/stubs/inspect_name_mapping/__init__.py [new file with mode: 0644]
documentation/test_python/inspect_name_mapping/stubs/inspect_name_mapping/submodule.py [new file with mode: 0644]
documentation/test_python/inspect_string/stubs/inspect_string/__init__-310.pyi [new file with mode: 0644]
documentation/test_python/inspect_string/stubs/inspect_string/__init__.pyi [new file with mode: 0644]
documentation/test_python/inspect_string/stubs/inspect_string/another_module.pyi [new file with mode: 0644]
documentation/test_python/inspect_string/stubs/inspect_string/subpackage/inner.pyi [new file with mode: 0644]
documentation/test_python/inspect_value_formatting/stubs/inspect_value_formatting.pyi [new file with mode: 0644]
documentation/test_python/pybind_enums/stubs/pybind_enums.pyi [new file with mode: 0644]
documentation/test_python/pybind_name_mapping/stubs/pybind_name_mapping/__init__.py [new file with mode: 0644]
documentation/test_python/pybind_name_mapping/stubs/pybind_name_mapping/submodule.py [new file with mode: 0644]
documentation/test_python/pybind_signatures/pybind_signatures.cpp
documentation/test_python/pybind_signatures/pybind_signatures.html
documentation/test_python/pybind_signatures/stubs/pybind_signatures/__init__-pybind22.pyi [new file with mode: 0644]
documentation/test_python/pybind_signatures/stubs/pybind_signatures/__init__-pybind25.pyi [new file with mode: 0644]
documentation/test_python/pybind_signatures/stubs/pybind_signatures/__init__.pyi [new file with mode: 0644]
documentation/test_python/pybind_signatures/stubs/pybind_signatures/just_overloads.pyi [new file with mode: 0644]
documentation/test_python/stubs_custom_header_extension/stubs/stubs_custom_header_extension/__init__.custom.py [new file with mode: 0644]
documentation/test_python/stubs_custom_header_extension/stubs/stubs_custom_header_extension/sub.custom.py [new file with mode: 0644]
documentation/test_python/stubs_custom_header_extension/stubs_custom_header_extension/__init__.py [new file with mode: 0644]
documentation/test_python/stubs_custom_header_extension/stubs_custom_header_extension/sub.py [new file with mode: 0644]
documentation/test_python/stubs_module_dependencies/another_module.py [new file with mode: 0644]
documentation/test_python/stubs_module_dependencies/stubs/stubs_module_dependencies/__init__.py [new file with mode: 0644]
documentation/test_python/stubs_module_dependencies/stubs/stubs_module_dependencies/pybind/__init__.py [new file with mode: 0644]
documentation/test_python/stubs_module_dependencies/stubs/stubs_module_dependencies/sub/inner.py [new file with mode: 0644]
documentation/test_python/stubs_module_dependencies/stubs_module_dependencies/__init__.py [new file with mode: 0644]
documentation/test_python/stubs_module_dependencies/stubs_module_dependencies/pybind.cpp [new file with mode: 0644]
documentation/test_python/stubs_module_dependencies/stubs_module_dependencies/sub/__init__.py [new file with mode: 0644]
documentation/test_python/stubs_module_dependencies/stubs_module_dependencies/sub/inner.py [new file with mode: 0644]
documentation/test_python/stubs_module_dependencies/unparsed_enum_module.py [new file with mode: 0644]
documentation/test_python/stubs_module_dependencies/unparsed_module.py [new file with mode: 0644]
documentation/test_python/stubs_nested_classes/stubs_nested_classes.Class.Inner.html [new file with mode: 0644]
documentation/test_python/stubs_nested_classes/stubs_nested_classes.Class.InnerAnother.AndAnother.YetAnother.html [new file with mode: 0644]
documentation/test_python/stubs_nested_classes/stubs_nested_classes.Class.InnerAnother.AndAnother.html [new file with mode: 0644]
documentation/test_python/stubs_nested_classes/stubs_nested_classes.Class.html [new file with mode: 0644]
documentation/test_python/stubs_nested_classes/stubs_nested_classes.html [new file with mode: 0644]
documentation/test_python/stubs_nested_classes/stubs_nested_classes.py [new file with mode: 0644]
documentation/test_python/stubs_spacing/empty_module.py [new file with mode: 0644]
documentation/test_python/stubs_spacing/stubs_spacing.py [new file with mode: 0644]
documentation/test_python/stubs_spacing/stubs_spacing.rst [new file with mode: 0644]
documentation/test_python/test_content.py
documentation/test_python/test_inspect.py
documentation/test_python/test_pybind.py
documentation/test_python/test_stubs.py [new file with mode: 0644]

index 5e7f6b80d25daaa5dc68300d5267288cf28cfe7e..8820c1ad489ef5417b6849ee9e43b910a3ca76be 100644 (file)
@@ -55,8 +55,9 @@ Python docs
 .. |wink| replace:: 😉
 
 A modern, mobile-friendly Sphinx-alike Python documentation generator with a
-first-class search functionality. Generated by inspecting Python modules and
-using either embedded docstrings or external :abbr:`reST <reStructuredText>`
+first-class search functionality and an ability to provide Python stubs for
+IDE autocompletion and type checking. 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
@@ -124,6 +125,11 @@ fill it with the generated output. Open ``index.html`` to see the result.
 -   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
+-   Opt-in specialized behavior for understanding native bindings written with
+    `pybind11`_ and Python code using `attrs`_
+-   Ability to generate lightweight Python stubs for IDE autocompletion and
+    type checking that exactly matches the documentation, both for pure Python
+    and native bindings.
 
 `Configuration`_
 ================
@@ -154,9 +160,15 @@ Variable                            Description
 :py:`INPUT: str`                    Base input directory. If not set, config
                                     file base dir is used. Relative paths are
                                     relative to config file base dir.
-:py:`OUTPUT: str`                   Where to save the output. Relative paths
-                                    are relative to :py:`INPUT`; if not set,
-                                    ``output/`` is used.
+:py:`OUTPUT: Optional[str]`         Where to save the HTML output. Relative
+                                    paths are relative to :py:`INPUT`. If not
+                                    set, ``output/`` is used, if set to
+                                    :py:`None`, no HTML output is generated.
+:py:`OUTPUT_STUBS: Optional[str]`   Where to save generated Python stubs. See
+                                    `Stub generation`_ for more information.
+                                    Relative paths are relative to :py:`INPUT`.
+                                    If set to :py:`None`, no stubs are
+                                    generated, default is :py:`None`.
 :py:`INPUT_MODULES: List[Any]`      List of modules to generate the docs from.
                                     Values can be either strings or module
                                     objects. See `Module inspection`_ for more
@@ -285,6 +297,16 @@ Variable                            Description
                                     module and class members. See
                                     `Custom URL formatters`_ for more
                                     information.
+:py:`STUB_EXTENSION: str`           Extension to use for generated Python
+                                    stub files. If not set, ``.pyi`` is used.
+                                    See `Stub generation`_ for more
+                                    information.
+:py:`STUB_HEADER: str`              Comment block or arbitrary other code to
+                                    put at the top generated Python stub files.
+                                    If not set, a default generic text is used.
+                                    If empty, no header is present in the files
+                                    at all. See `Stub generation`_ for more
+                                    information.
 =================================== ===========================================
 
 `Theme selection`_
@@ -875,6 +897,37 @@ attrs.*" in their docstring are implicitly hidden from the output if this
 option is enabled. In order to show them again, override the docstring to
 something meaningful.
 
+`Stub generation`_
+==================
+
+By default, the tool produces just a HTML documentation. In many cases, and
+especially when native bindings are involved, it's useful to provide so-called
+*stubs* for the IDE in order for it to provide useful autocompletion and
+perform type checking. Another use case is when the amount of code in the
+actual implementation is too large for the IDE to handle.
+
+Stubs can be opted in by specifying a path where to generate them in
+:py:`OUTPUT_STUBS`. They can be generated either together with the HTML output,
+or alone, if :py:`OUTPUT` is set to :py:`None` instead. A common setup in that
+case is to create a second file, named ``conf-stubs.py``, that inherits
+everything from ``conf.py`` but enables only the stub output:
+
+.. code:: py
+
+    # Inherit everything from conf.py
+    import os, sys; sys.path.append(os.path.dirname(os.path.realpath(__file__)))
+    from conf import *
+
+    OUTPUT = None
+    OUTPUT_STUBS = 'stubs/'
+
+Now, when you run the script, the output directory will contain ``*.pyi`` files
+that you can supply to your IDE.
+
+.. code:: sh
+
+    ./python.py path/to/conf-stubs.py
+
 `Command-line options`_
 =======================
 
@@ -1176,6 +1229,14 @@ Property                                Description
 :py:`page.prefix_wbr`                   Fully-qualified symbol prefix for given
                                         compound with trailing ``.`` with
                                         :html:`<wbr/>` tag after every ``.``.
+:py:`page.dependencies`                 List of :py:`(prefix, module)`
+                                        tuples with module dependencies. To
+                                        match relative type names, it's either
+                                        :py:`import module` if ``prefix`` is
+                                        empty, :py:`from prefix import *` if
+                                        ``module`` is empty or
+                                        :py:`from prefix import module` if both
+                                        are non-empty.
 :py:`page.modules`                      List of inner modules. See
                                         `Module properties`_ for details.
 :py:`page.classes`                      List of classes. See
@@ -1218,6 +1279,8 @@ Property                                Description
                                         `Property properties`_ for details.
 :py:`page.has_property_details`         If there is at least one property with
                                         full description block [3]_
+:py:`page.has_members`                  If the class contains at least one
+                                        member or is completely empty
 ======================================= =======================================
 
 Explicit documentation pages rendered with ``class.html`` have additional
@@ -1308,10 +1371,14 @@ Property                            Description
 :py:`function.content`              Detailed documentation, if any
 :py:`function.type`                 Fully qualified function return type
                                     annotation [2]_
-:py:`function.type_relative`        Like :py:`function.type`, but relative,
+:py:`function.type_quoted`          Like :py:`function.type`, but relative,
+                                    i.e. with a common prefix of the type and
+                                    containing module / class omitted, and with
+                                    parts quoted if type inspection failed to
+                                    match the annotation to an actual name
+:py:`function.type_link`            Like :py:`function.type`, but relative,
                                     i.e. with a common prefix of the type and
-                                    containing module / class omitted
-:py:`function.type_link`            Like :py:`function.type_relative`, but with
+                                    containing module / class omitted, and with
                                     cross-linked types
 :py:`function.params`               List of function parameters. See below for
                                     details.
@@ -1328,12 +1395,18 @@ Property                            Description
 :py:`function.return_value`         Return value documentation. Can be empty.
 :py:`function.has_details`          If there is enough content for the full
                                     description block [3]_
+:py:`function.is_method`            Set to :py:`True` if the function is a
+                                    class method, :py:`False` if it's
+                                    standalone.
 :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.
+:py:`function.is_overloaded`        Set to :py:`True` if the function has
+                                    multiple overloads with the same name,
+                                    :py:`False` otherwise.
 =================================== ===========================================
 
 The :py:`function.params` is a list of function parameters and their
@@ -1346,10 +1419,14 @@ Property                        Description
 =============================== ===============================================
 :py:`param.name`                Parameter name
 :py:`param.type`                Fully qualified parameter type annotation [2]_
-:py:`param.type_relative`       Like :py:`param.type`, but relative, i.e. with
+:py:`param.type_quoted`         Like :py:`param.type`, but relative, i.e. with
                                 a common prefix of the type and containing
-                                module / class omitted
-:py:`param.type_link`           Like :py:`param.type_relative`, but with
+                                module / class omitted, and with parts quoted
+                                if type inspection failed to match the
+                                annotation to an actual name
+:py:`param.type_link`           Like :py:`param.type_relative`, but relative,
+                                i.e. with a common prefix of the type and
+                                containing module / class omitted, and with
                                 cross-linked types
 :py:`param.default`             Default parameter value, if any. If
                                 :py:`param.type` is an enum, is a
@@ -1402,11 +1479,15 @@ Property                        Description
 :py:`property.id`               Property ID [5]_
 :py:`property.type`             Fully qualified property getter return type
                                 annotation [2]_
-:py:`property.type_relative`    Like :py:`property.type`, but relative, i.e.
+:py:`property.type_quoted`      Like :py:`property.type`, but relative, i.e.
                                 with a common prefix of the type and containing
-                                module / class omitted
-:py:`property.type_link`        Like :py:`property.type_relative`, but with
-                                cross-linked types
+                                module / class omitted, and with parts quoted
+                                if type inspection failed to match the
+                                annotation to an actual name
+:py:`property.type_link`        Like :py:`property.type`, but relative, i.e.
+                                with a common prefix of the type and containing
+                                module / class omitted, and with cross-linked
+                                types
 :py:`property.summary`          Doc summary
 :py:`property.content`          Detailed documentation, if any
 :py:`property.exceptions`       List of exceptions raised when accessing this
@@ -1430,11 +1511,15 @@ Property                        Description
 :py:`data.name`                 Data name
 :py:`data.id`                   Data ID [5]_
 :py:`data.type`                 Fully qualified data type annotation [2]_
-:py:`data.type_relative`        Like :py:`data.type`, but relative, i.e. with a
+:py:`data.type_quoted`          Like :py:`data.type`, but relative, i.e. with a
                                 common prefix of the type and containing module
-                                / class omitted
-:py:`data.type_link`            Like :py:`data.type_relative`, but with
-                                cross-linked types
+                                / class omitted, and with parts quoted
+                                if type inspection failed to match the
+                                annotation to an actual name
+:py:`data.type_link`            Like :py:`data.type`, but relative, i.e.
+                                with a common prefix of the type and containing
+                                module / class omitted, and with cross-linked
+                                types
 :py:`data.summary`              Doc summary
 :py:`data.content`              Detailed documentation, if any
 :py:`data.value`                Data value representation
index 2465dc3acd6764fb0b06024db4d3bf127a64a24d..6b6c46083d0243dbcd6168b2fbc25c093592301f 100755 (executable)
@@ -48,7 +48,7 @@ import typing
 from enum import Enum
 from types import SimpleNamespace as Empty
 from importlib.machinery import SourceFileLoader
-from typing import Tuple, Dict, Set, Any, List, Callable, Optional
+from typing import Tuple, Dict, Set, Any, List, Callable, Optional, Union
 from urllib.parse import urljoin
 from docutils.transforms import Transform
 
@@ -138,6 +138,7 @@ default_config = {
 
     'INPUT': None,
     'OUTPUT': 'output',
+    'OUTPUT_STUBS': None,
     'INPUT_MODULES': [],
     'INPUT_PAGES': [],
     'INPUT_DOCS': [],
@@ -192,7 +193,10 @@ default_config = {
     'SEARCH_EXTERNAL_URL': None,
 
     'URL_FORMATTER': default_url_formatter,
-    'ID_FORMATTER': default_id_formatter
+    'ID_FORMATTER': default_id_formatter,
+
+    'STUB_EXTENSION': '.pyi',
+    'STUB_HEADER': "# This file is a stub generated by m.css out of actual Python code. Don't edit\n# directly, modify the original code and regenerate.",
 }
 
 class State:
@@ -220,6 +224,24 @@ class State:
 
         self.crawled: Set[object] = set()
 
+        # For collecting module dependencies (i.e., what to import to have all
+        # used types known). The `current_module` gets set to the module name
+        # at start of render_module() and render_class(), is cleared again when
+        # the functions exit.
+        #
+        # Cannot be just `current_module_dependencies`, because
+        self.current_module: Optional[str] = None
+        # Should be filled only through add_module_dependency_for()
+        self.module_dependencies: Dict[str, Set[str]] = {}
+
+        # If we're genearating stubs, parsed classes have to be saved and then
+        # rendered together with the rest of the module
+        if self.config['OUTPUT_STUBS']:
+            # Key is path including the class name, value is a parsed class
+            self.parsed_classes: Dict[List[str], Empty] = {}
+        else:
+            self.parsed_classes = None
+
 def map_name_prefix(state: State, type: str) -> str:
     for prefix, replace in state.name_mapping.items():
         if type == prefix or type.startswith(prefix + '.'):
@@ -869,6 +891,64 @@ def make_name_relative_link(state: State, referrer_path: List[str], name) -> Tup
     entry = state.name_map[name]
     return relative_name, '<a href="{}" class="{}">{}</a>'.format(entry.url, ' '.join(entry.css_classes), relative_name)
 
+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). We need to use
+    # __qualname__ instead of __name__ because __name__ doesn't take nested
+    # classes into account.
+    return (type.__module__ + '.' if type.__module__ != 'builtins' else '') + type.__qualname__
+
+def enclosing_module_for(state: State, name: str) -> Optional[str]:
+    path = name.split('.')
+    for i in reversed(range(len(path))):
+        name = '.'.join(path[:i])
+        if name in state.name_map and state.name_map[name].type == EntryType.MODULE:
+            return name
+    return None
+
+def add_module_dependency_for(state: State, object: Union[Any, str]):
+    assert state.current_module
+
+    # If not a string and not a module, try looking if its name-mapped type is
+    # in our name map. If it is, and its enclosing module is as well, use that
+    # to have name mapping correctly applied for it.
+    name = None
+    if not isinstance(object, str) and not inspect.ismodule(object):
+        name_candidate = map_name_prefix(state, extract_type(object))
+        if name_candidate in state.name_map:
+            name = enclosing_module_for(state, name_candidate)
+
+    # If the above didn't succeed, try other options
+    if not name:
+        # If it's a string, assume it's a parsed name that's already in the
+        # name map, find the leaf module name and add it
+        if isinstance(object, str):
+            # We should get string names only for pybind11 types, nothing else
+            # TODO er wait, what about unknown annotations? those probably
+            #   shouldn't get here at all?
+            assert state.config['PYBIND11_COMPATIBILITY']
+            name = enclosing_module_for(state, object)
+            # If there's no enclosing module, it's a builtin, for which we add
+            # no dependency. Given that str is passed only from pybind, all
+            # referenced names should be either builtin or known.
+            if not name:
+                assert '.' not in object
+                return
+
+        # If it's directly a module (such as `typing` or `enum` passed from
+        # certain parts of the codebase), apply name mapping to it
+        elif inspect.ismodule(object):
+            name = map_name_prefix(state, object.__name__)
+
+        # Otherwise it's a class/function/enum/..., extract module name from
+        # it, apply name mapping
+        else:
+            name = map_name_prefix(state, object.__module__)
+
+    # Add it only if it's not a module self-reference and if it's not builtins
+    if name != 'builtins' and name != state.current_module:
+        state.module_dependencies[state.current_module].add(name)
+
 _pybind_name_rx = re.compile('[a-zA-Z0-9_]*')
 _pybind_arg_name_rx = re.compile('[/*a-zA-Z0-9_]+')
 _pybind_type_rx = re.compile('[a-zA-Z0-9_.]+')
@@ -930,9 +1010,16 @@ def _pybind_map_name_prefix_or_add_typing_suffix(state: State, input_type: str):
     if input_type_lowercase in ['dict', 'list', 'set', 'tuple']:
         return input_type_lowercase
     if input_type in ['Callable', 'Iterator', 'Iterable', 'Optional', 'Union']:
+        # current_module might be unset when calling this from unittests etc.
+        if state.current_module:
+            add_module_dependency_for(state, typing)
         return 'typing.' + input_type
     else:
-        return map_name_prefix(state, input_type)
+        type = map_name_prefix(state, input_type)
+        # current_module might be unset when calling this from unittests etc.
+        if state.current_module:
+            add_module_dependency_for(state, type)
+        return type
 
 def parse_pybind_type(state: State, referrer_path: List[str], signature: str) -> Tuple[str, str, str, str]:
     match = _pybind_type_rx.match(signature)
@@ -1099,8 +1186,12 @@ def format_value(state: State, referrer_path: List[str], value) -> Optional[Tupl
     # pybind enums don't inherit from enum.Enum but have the __members__
     # attribute instead
     if isinstance(value, enum.Enum) or (state.config['PYBIND11_COMPATIBILITY'] and hasattr(value.__class__, '__members__')):
+        name = '.'.join([value.__class__.__module__, value.__class__.__qualname__, value.name])
+        # Adding the `enum` module as a dependency isn't enough, here we need
+        # the actual enum value definitions
+        add_module_dependency_for(state, value.__class__)
         # TODO Python 3.8+ supports `a, *b`, switch to that once 3.7 is dropped
-        return (value.name, ) + make_name_relative_link(state, referrer_path, '.'.join([value.__class__.__module__, value.__class__.__qualname__, value.name]))
+        return (value.name, ) + make_name_relative_link(state, referrer_path, name)
     # isbuiltin returns true if object is a builtin _function_ or _method_, not
     # just any builtin such as the False literal
     elif inspect.isfunction(value) or inspect.isbuiltin(value):
@@ -1230,13 +1321,6 @@ def extract_docs(state: State, external_docs, type: EntryType, path: List[str],
     external_doc_entry['used'] = True
     return summary, content
 
-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). We need to use
-    # __qualname__ instead of __name__ because __name__ doesn't take nested
-    # classes into account.
-    return (type.__module__ + '.' if type.__module__ != 'builtins' else '') + type.__qualname__
-
 def get_type_hints_or_nothing(state: State, path: List[str], object) -> Dict:
     # Calling get_type_hints on a pybind11 type (from extract_data_doc())
     # results in KeyError because there's no sys.modules['pybind11_builtins'].
@@ -1253,6 +1337,9 @@ def get_type_hints_or_nothing(state: State, path: List[str], object) -> Dict:
         logging.warning("failed to dereference type hints for %s (%s), falling back to non-dereferenced", '.'.join(path), e.__class__.__name__)
         return {}
 
+def quoted_annotation(annotation: str) -> str:
+    return '\'{}\''.format(annotation.replace('\'', '\\\''))
+
 def extract_annotation(state: State, referrer_path: List[str], annotation) -> Tuple[str, str, str]:
     # Empty annotation, as opposed to a None annotation, handled below
     if annotation is inspect.Signature.empty:
@@ -1260,19 +1347,20 @@ def extract_annotation(state: State, referrer_path: List[str], annotation) -> Tu
 
     # If dereferencing with typing.get_type_hints() failed, we might end up
     # with forward-referenced types being plain strings. Keep them as is, since
-    # those are most probably an error.
+    # those are most probably an error, but quote for stubs.
     if type(annotation) == str:
-        return (annotation, )*3
+        return annotation, quoted_annotation(annotation), annotation
 
     # Or the plain strings might be inside (e.g. List['Foo']), which gets
     # converted by Python to ForwardRef. Hammer out the actual string and again
-    # leave it as-is, since it's most probably an error.
+    # leave it as-is, since it's most probably an error. Quote for stubs.
     elif isinstance(annotation, typing.ForwardRef if sys.version_info >= (3, 7) else typing._ForwardRef):
-        return (annotation.__forward_arg__, )*3
+        return annotation.__forward_arg__, quoted_annotation(annotation.__forward_arg__), annotation.__forward_arg__
 
     # Generic type names -- use their name directly
+    # TODO define the TypeVar somewhere somehow instead of lazy quoting
     elif isinstance(annotation, typing.TypeVar):
-        return annotation.__name__, annotation.__name__, annotation.__name__
+        return annotation.__name__, quoted_annotation(annotation.__name__), annotation.__name__
 
     # Ellipsis -- print a literal `...`
     # TODO: any chance to link this to python official docs?
@@ -1283,6 +1371,8 @@ def extract_annotation(state: State, referrer_path: List[str], annotation) -> Tu
     # could be a "bracketed" type, in which case we want to recurse to its
     # types as well.
     elif (hasattr(annotation, '__module__') and annotation.__module__ == 'typing'):
+        add_module_dependency_for(state, typing)
+
         # Optional or Union, handle those first
         if hasattr(annotation, '__origin__') and annotation.__origin__ is typing.Union:
             # FOR SOME REASON `annotation.__args__[1] is None` is always False,
@@ -1335,14 +1425,14 @@ def extract_annotation(state: State, referrer_path: List[str], annotation) -> Tu
                 assert len(args) >= 1
 
                 nested_types = []
-                nested_types_relative = []
+                nested_types_quoted = []
                 nested_type_links = []
                 for i in args[:-1]:
-                    nested_type, nested_type_relative, nested_type_link = extract_annotation(state, referrer_path, i)
+                    nested_type, nested_type_quoted, nested_type_link = extract_annotation(state, referrer_path, i)
                     nested_types += [nested_type]
-                    nested_types_relative += [nested_type_relative]
+                    nested_types_quoted += [nested_type_quoted]
                     nested_type_links += [nested_type_link]
-                nested_return_type, nested_return_type_relative, nested_return_type_link = extract_annotation(state, referrer_path, args[-1])
+                nested_return_type, nested_return_type_quoted, nested_return_type_link = extract_annotation(state, referrer_path, args[-1])
 
                 # If nested parsing failed (the invalid annotation below),
                 # fail the whole thing
@@ -1351,18 +1441,18 @@ def extract_annotation(state: State, referrer_path: List[str], annotation) -> Tu
 
                 return (
                     '{}[[{}], {}]'.format(name, ', '.join(nested_types), nested_return_type),
-                    '{}[[{}], {}]'.format(name, ', '.join(nested_types_relative), nested_return_type_relative),
+                    '{}[[{}], {}]'.format(name, ', '.join(nested_types_quoted), nested_return_type_quoted),
                     '{}[[{}], {}]'.format(name_link, ', '.join(nested_type_links), nested_return_type_link)
                 )
 
             else:
                 nested_types = []
-                nested_types_relative = []
+                nested_types_quoted = []
                 nested_type_links = []
                 for i in args:
-                    nested_type, nested_type_relative, nested_type_link = extract_annotation(state, referrer_path, i)
+                    nested_type, nested_type_quoted, nested_type_link = extract_annotation(state, referrer_path, i)
                     nested_types += [nested_type]
-                    nested_types_relative += [nested_type_relative]
+                    nested_types_quoted += [nested_type_quoted]
                     nested_type_links += [nested_type_link]
 
                 # If nested parsing failed (the invalid annotation below),
@@ -1372,7 +1462,7 @@ def extract_annotation(state: State, referrer_path: List[str], annotation) -> Tu
 
                 return (
                     '{}[{}]'.format(name, ', '.join(nested_types)),
-                    '{}[{}]'.format(name_relative, ', '.join(nested_types_relative)),
+                    '{}[{}]'.format(name_relative, ', '.join(nested_types_quoted)),
                     '{}[{}]'.format(name_link, ', '.join(nested_type_links)),
                 )
 
@@ -1392,6 +1482,7 @@ def extract_annotation(state: State, referrer_path: List[str], annotation) -> Tu
         return ('None', ) + make_name_relative_link(state, referrer_path, 'None')
 
     # Otherwise it's a plain type. Turn it into a link.
+    add_module_dependency_for(state, annotation)
     name = map_name_prefix(state, extract_type(annotation))
     # TODO Python 3.8+ supports `a, *b`, switch to that once 3.7 is dropped
     return (name, ) + make_name_relative_link(state, referrer_path, name)
@@ -1433,6 +1524,8 @@ def extract_class_doc(state: State, entry: Empty):
     return out
 
 def extract_enum_doc(state: State, entry: Empty):
+    assert state.current_module
+
     out = Empty()
     out.name = entry.path[-1]
     out.id = state.config['ID_FORMATTER'](EntryType.ENUM, entry.path[-1:])
@@ -1448,7 +1541,10 @@ def extract_enum_doc(state: State, entry: Empty):
         else:
             docstring = entry.object.__doc__
 
+        # TODO should probably do some name mapping here also?
         out.base = extract_type(entry.object.__base__)
+        # Add the base as a dependency so the stubs can derive from it
+        add_module_dependency_for(state, entry.object.__base__)
         out.base_relative, out.base_link = make_name_relative_link(state, entry.path, out.base)
 
         for i in entry.object:
@@ -1477,6 +1573,9 @@ def extract_enum_doc(state: State, entry: Empty):
         docstring = entry.object.__doc__.partition('\n\n')[0]
 
         out.base = None
+        # Add the enum module as a dependency, the stub template will make
+        # enum.Enum a base to make it recognizable as an actual enum
+        add_module_dependency_for(state, enum)
 
         for name, v in entry.object.__members__.items():
             value = Empty()
@@ -1531,6 +1630,7 @@ def extract_enum_doc(state: State, entry: Empty):
     return out
 
 def extract_function_doc(state: State, parent, entry: Empty) -> List[Any]:
+    assert state.current_module
     assert inspect.isfunction(entry.object) or inspect.ismethod(entry.object) or inspect.isroutine(entry.object)
 
     # Enclosing page URL for search
@@ -1557,18 +1657,22 @@ def extract_function_doc(state: State, parent, entry: Empty) -> List[Any]:
             out.params = []
             out.has_complex_params = False
             out.has_details = False
-            out.type, out.type_relative, out.type_link = type, type_relative, type_link
+            # The parsed pybind11 annotation either works as a whole, or not at
+            # all, so it's never quoted, only relative
+            out.type, out.type_quoted, out.type_link = type, type_relative, type_link
 
             # There's no other way to check staticmethods than to check for
             # self being the name of first parameter :( No support for
             # classmethods, as C++11 doesn't have that
             out.is_classmethod = False
             if inspect.isclass(parent):
+                out.is_method = True
                 if args and args[0][0] == 'self':
                     out.is_staticmethod = False
                 else:
                     out.is_staticmethod = True
             else:
+                out.is_method = False
                 out.is_staticmethod = False
 
             # If the arguments contain a literal * or / (which is only if
@@ -1635,11 +1739,13 @@ def extract_function_doc(state: State, parent, entry: Empty) -> List[Any]:
 
                 # Don't include redundant type for the self argument
                 if i == 0 and arg_name == 'self':
-                    param.type, param.type_relative, param.type_link = None, None, None
+                    param.type, param.type_quoted, param.type_link = None, None, None
                     param_types += [None]
                     signature += ['self']
                 else:
-                    param.type, param.type_relative, param.type_link = arg_type, arg_type_relative, arg_type_link
+                    # The parsed pybind11 annotation either works as a whole,
+                    # or not at all, so it's never quoted, only relative
+                    param.type, param.type_quoted, param.type_link = arg_type, arg_type_relative, arg_type_link
                     param_types += [arg_type]
                     signature += ['{}: {}'.format(arg_name, arg_type)]
 
@@ -1705,8 +1811,11 @@ def extract_function_doc(state: State, parent, entry: Empty) -> List[Any]:
 
         # Decide if classmethod or staticmethod in case this is a method
         if inspect.isclass(parent):
+            out.is_method = True
             out.is_classmethod = inspect.ismethod(entry.object)
             out.is_staticmethod = out.name in parent.__dict__ and isinstance(parent.__dict__[out.name], staticmethod)
+        else:
+            out.is_method = False
 
         # First try to get fully dereferenced type hints (with strings
         # converted to actual annotations). If that fails (e.g. because a type
@@ -1718,18 +1827,18 @@ def extract_function_doc(state: State, parent, entry: Empty) -> List[Any]:
             signature = inspect.signature(entry.object)
 
             if 'return' in type_hints:
-                out.type, out.type_relative, out.type_link = extract_annotation(state, entry.path, type_hints['return'])
+                out.type, out.type_quoted, out.type_link = extract_annotation(state, entry.path, type_hints['return'])
             else:
-                out.type, out.type_relative, out.type_link = extract_annotation(state, entry.path, signature.return_annotation)
+                out.type, out.type_quoted, out.type_link = extract_annotation(state, entry.path, signature.return_annotation)
             param_names = []
             for i in signature.parameters.values():
                 param = Empty()
                 param.name = i.name
                 param_names += [i.name]
                 if i.name in type_hints:
-                    param.type, param.type_relative, param.type_link = extract_annotation(state, entry.path, type_hints[i.name])
+                    param.type, param.type_quoted, param.type_link = extract_annotation(state, entry.path, type_hints[i.name])
                 else:
-                    param.type, param.type_relative, param.type_link = extract_annotation(state, entry.path, i.annotation)
+                    param.type, param.type_quoted, param.type_link = extract_annotation(state, entry.path, i.annotation)
                 if param.type:
                     out.has_complex_params = True
                 if i.default is inspect.Signature.empty:
@@ -1746,10 +1855,10 @@ def extract_function_doc(state: State, parent, entry: Empty) -> List[Any]:
         except ValueError:
             param = Empty()
             param.name = None
-            param.type, param.type_relative, param.type_link = None, None, None
+            param.type, param.type_quoted, param.type_link = None, None, None
             param.default, param.default_relative, param.default_link = None, None, None
             out.params = [param]
-            out.type, out.type_relative, out.type_link = None, None, None
+            out.type, out.type_quoted, out.type_link = None, None, None
             param_names = []
 
         # Call all scope enter hooks
@@ -1767,6 +1876,13 @@ def extract_function_doc(state: State, parent, entry: Empty) -> List[Any]:
 
         overloads = [out]
 
+    # Mark the functions as overloaded if there's more than one overload
+    for out in overloads:
+        out.is_overloaded = len(overloads) != 1
+    # The stub template will decorate the function with @typing.overload
+    if len(overloads) != 1:
+        add_module_dependency_for(state, typing)
+
     # Common path for parameter / exception / return value docs and search
     path_str = '.'.join(entry.path)
     for out in overloads:
@@ -1843,17 +1959,21 @@ def extract_function_doc(state: State, parent, entry: Empty) -> List[Any]:
             result.prefix = entry.path[:-1]
             result.name = entry.path[-1]
             result.params = []
-            # If the function has multiple overloads (e.g. pybind functions),
-            # add arguments to each to distinguish between them
+            # If the function is overloaded, add arguments to each to
+            # distinguish between them
             if len(overloads) != 1:
                 for i in range(len(out.params)):
                     param = out.params[i]
-                    result.params += ['{}: {}'.format(param.name, param.type_relative) if param.type_relative else param.name]
+                    # TODO use param.type_relative if it ever exists again in
+                    #   addition to param.type_quoted
+                    result.params += ['{}: {}'.format(param.name, make_relative_name(state, entry.path, param.type)) if param.type else param.name]
             state.search += [result]
 
     return overloads
 
 def extract_property_doc(state: State, parent, entry: Empty):
+    assert state.current_module
+
     out = Empty()
     out.name = entry.path[-1]
     out.id = state.config['ID_FORMATTER'](EntryType.PROPERTY, entry.path[-1:])
@@ -1871,7 +1991,7 @@ def extract_property_doc(state: State, parent, entry: Empty):
         out.is_gettable = True
         out.is_settable = True
         out.is_deletable = True
-        out.type, out.type_relative, out.type_link = extract_annotation(state, entry.path, entry.object.type)
+        out.type, out.type_quoted, out.type_link = extract_annotation(state, entry.path, entry.object.type)
 
     # If this is a slot, there won't be any fget / fset / fdel. Assume they're
     # gettable and settable (couldn't find any way to make them *inspectably*
@@ -1894,11 +2014,11 @@ def extract_property_doc(state: State, parent, entry: Empty):
         type_hints = get_type_hints_or_nothing(state, entry.path, parent)
 
         if out.name in type_hints:
-            out.type, out.type_relative, out.type_link = extract_annotation(state, entry.path, type_hints[out.name])
+            out.type, out.type_quoted, out.type_link = extract_annotation(state, entry.path, type_hints[out.name])
         elif hasattr(parent, '__annotations__') and out.name in parent.__annotations__:
-            out.type, out.type_relative, out.type_link = extract_annotation(state, entry.path, parent.__annotations__[out.name])
+            out.type, out.type_quoted, out.type_link = extract_annotation(state, entry.path, parent.__annotations__[out.name])
         else:
-            out.type, out.type_relative, out.type_link = None, None, None
+            out.type, out.type_quoted, out.type_link = None, None, None
 
     # The properties can be defined using the low-level descriptor protocol
     # instead of the higher-level property() decorator. That means there's no
@@ -1914,13 +2034,15 @@ def extract_property_doc(state: State, parent, entry: Empty):
         out.is_gettable = True
         out.is_settable = False
         out.is_deletable = False
-        out.type, out.type_relative, out.type_link = None, None, None
+        out.type, out.type_quoted, out.type_link = None, None, None
 
     # Otherwise it's a classic property
     else:
         assert inspect.isdatadescriptor(entry.object)
         is_classic_property = True
 
+        # TODO figure out how to do pybind11 writeonly properties in the stub
+        #   template
         out.is_gettable = entry.object.fget is not None
         out.is_settable = entry.object.fset is not None
         out.is_deletable = entry.object.fdel is not None
@@ -1949,9 +2071,9 @@ def extract_property_doc(state: State, parent, entry: Empty):
                 type_hints = get_type_hints_or_nothing(state, entry.path, entry.object.fget)
 
                 if 'return' in type_hints:
-                    out.type, out.type_relative, out.type_link = extract_annotation(state, entry.path, type_hints['return'])
+                    out.type, out.type_quoted, out.type_link = extract_annotation(state, entry.path, type_hints['return'])
                 else:
-                    out.type, out.type_relative, out.type_link = extract_annotation(state, entry.path, signature.return_annotation)
+                    out.type, out.type_quoted, out.type_link = extract_annotation(state, entry.path, signature.return_annotation)
             else:
                 assert entry.object.fset
                 signature = inspect.signature(entry.object.fset)
@@ -1964,25 +2086,25 @@ def extract_property_doc(state: State, parent, entry: Empty):
                 # non-dereferenced version
                 value_parameter = list(signature.parameters.values())[1]
                 if value_parameter.name in type_hints:
-                    out.type, out.type_relative, out.type_link = extract_annotation(state, entry.path, type_hints[value_parameter.name])
+                    out.type, out.type_quoted, out.type_link = extract_annotation(state, entry.path, type_hints[value_parameter.name])
                 else:
-                    out.type, out.type_relative, out.type_link = extract_annotation(state, entry.path, value_parameter.annotation)
+                    out.type, out.type_quoted, out.type_link = extract_annotation(state, entry.path, value_parameter.annotation)
 
         except ValueError:
             # pybind11 properties have the type in the docstring
             if state.config['PYBIND11_COMPATIBILITY']:
                 if entry.object.fget:
-                    out.type, out.type_relative, out.type_link = parse_pybind_signature(state, entry.path, entry.object.fget.__doc__)[3:]
+                    out.type, out.type_quoted, out.type_link = parse_pybind_signature(state, entry.path, entry.object.fget.__doc__)[3:]
                 else:
                     assert entry.object.fset
                     parsed_args = parse_pybind_signature(state, entry.path, entry.object.fset.__doc__)[2]
                     # If argument parsing failed, we're screwed
                     if len(parsed_args) == 1:
-                        out.type, out.type_relative, out.type_link = None, None, None
+                        out.type, out.type_quoted, out.type_link = None, None, None
                     else:
-                        out.type, out.type_relative, out.type_link = parsed_args[1][1:4]
+                        out.type, out.type_quoted, out.type_link = parsed_args[1][1:4]
             else:
-                out.type, out.type_relative, out.type_link = None, None, None
+                out.type, out.type_quoted, out.type_link = None, None, None
 
     # Call all scope enter hooks before rendering the docs
     for hook in state.hooks_pre_scope:
@@ -2020,6 +2142,7 @@ def extract_property_doc(state: State, parent, entry: Empty):
     return out
 
 def extract_data_doc(state: State, parent, entry: Empty):
+    assert state.current_module
     assert not inspect.ismodule(entry.object) and not inspect.isclass(entry.object) and not inspect.isroutine(entry.object) and not inspect.isframe(entry.object) and not inspect.istraceback(entry.object) and not inspect.iscode(entry.object)
 
     # Call all scope enter hooks before rendering the docs
@@ -2043,11 +2166,11 @@ def extract_data_doc(state: State, parent, entry: Empty):
     type_hints = get_type_hints_or_nothing(state, entry.path, parent)
 
     if out.name in type_hints:
-        out.type, out.type_relative, out.type_link = extract_annotation(state, entry.path, type_hints[out.name])
+        out.type, out.type_quoted, out.type_link = extract_annotation(state, entry.path, type_hints[out.name])
     elif hasattr(parent, '__annotations__') and out.name in parent.__annotations__:
-        out.type, out.type_relative, out.type_link = extract_annotation(state, entry.path, parent.__annotations__[out.name])
+        out.type, out.type_quoted, out.type_link = extract_annotation(state, entry.path, parent.__annotations__[out.name])
     else:
-        out.type, out.type_relative, out.type_link = None, None, None
+        out.type, out.type_quoted, out.type_link = None, None, None
 
     out.value, out.value_relative, out.value_link = format_value(state, entry.path, entry.object) or (None, None, None)
 
@@ -2075,11 +2198,23 @@ def render(*, config, template: str, url: str, filename: str, env: jinja2.Enviro
         # patching test files to include a trailing newline to make Git
         # happy. Can't use keep_trailing_newline because that'd add it
         # also for nested templates :( The rendered file should never contain a
-        # trailing newline on its own.
-        assert not rendered.endswith('\n')
-        f.write(b'\n')
+        # trailing newline on its own. Also add it only in case the file isn't
+        # empty, which can happen with generated stubs. If non-empty, it should
+        # never contain a trailing newline on its own.
+        if rendered:
+            assert not rendered.endswith('\n')
+            f.write(b'\n')
 
 def render_module(state: State, path, module, env):
+    # Save name of current module for populating module dependencies and
+    # initialize the dependency set. It could already be present in
+    # module_dependencies if render_class() for an inner class was called
+    # before, don't overwrite in that case.
+    assert not state.current_module
+    path_str = '.'.join(path)
+    state.current_module = path_str
+    state.module_dependencies.setdefault(path_str, set())
+
     # Call all scope enter hooks first
     for hook in state.hooks_pre_scope:
         hook(type=EntryType.MODULE, path=path)
@@ -2114,7 +2249,7 @@ def render_module(state: State, path, module, env):
     page.has_data_details = False
 
     # Find itself in the global map, save the summary back there for index
-    entry = state.name_map['.'.join(path)]
+    entry = state.name_map[path_str]
     entry.summary = page.summary
 
     # Extract docs for all members
@@ -2129,7 +2264,15 @@ def render_module(state: State, path, module, env):
         if member_entry.type == EntryType.MODULE:
             page.modules += [extract_module_doc(state, member_entry)]
         elif member_entry.type == EntryType.CLASS:
-            page.classes += [extract_class_doc(state, member_entry)]
+            # If we're generating stubs, add the whole parsed class instead of
+            # just a name, summary and reference. All classes inside given
+            # module are parsed before the module itself because crawl_module()
+            # first puts all nested names into state.name_map and only then the
+            # module itself.
+            if state.config['OUTPUT_STUBS']:
+                page.classes += [state.parsed_classes[subpath_str]]
+            else:
+                page.classes += [extract_class_doc(state, member_entry)]
         elif member_entry.type == EntryType.ENUM:
             enum_ = extract_enum_doc(state, member_entry)
             page.enums += [enum_]
@@ -2149,6 +2292,33 @@ def render_module(state: State, path, module, env):
         else: # pragma: no cover
             assert False
 
+    # At this point the module dependencies should be filled for everything in
+    # this module as well as all (recursive) classes. To verify that's indeed
+    # the case, remove current module name from the dict afterwards -- anything
+    # that'd attempt to insert afterwards would fail with a KeyError.
+    page.dependencies = []
+    for dependency in state.module_dependencies[path_str]:
+        dependency_path = dependency.split('.')
+        common_prefix_length = len(os.path.commonprefix([dependency_path, path]))
+        # If there's no common prefix, it's an unrelated module
+        if not common_prefix_length:
+            page.dependencies += [('', dependency)]
+        # If the common prefix is the whole module path, the dependency is from
+        # a submodule (so this file is a __init__.py)
+        elif len(path) == common_prefix_length:
+            page.dependencies += [('.', '.'.join(dependency_path[common_prefix_length:]))]
+        # Otherwise the dependency is a sibling module or siblings of parents,
+        # add one dot for each. Yes, this can also produce a single dot as
+        # above, but the difference is that above the file is a __init__.py so
+        # `from . import sub` refers to a submodule, while here
+        # `from . import sub` refers to the enclosing __init__.py so a sibling.
+        else:
+            page.dependencies += [('.'*(len(path) - common_prefix_length), '.'.join(dependency_path[common_prefix_length:]))]
+    # Make the list independent from the order in which the dependencies were
+    # discovered
+    page.dependencies.sort()
+    del state.module_dependencies[path_str]
+
     if not state.config['SEARCH_DISABLED']:
         result = Empty()
         result.flags = ResultFlag.from_type(ResultFlag.NONE, EntryType.MODULE)
@@ -2157,37 +2327,75 @@ def render_module(state: State, path, module, env):
         result.name = path[-1]
         state.search += [result]
 
-    # Perform HTML escaping for everything going into the template. Done here
-    # instead of inside extract_*_doc() to avoid having to unescape in certain
-    # cases.
-    for enum in page.enums:
-        for value in enum.values:
-            value.value = html.escape(value.value)
-    for function in page.functions:
-        for param in function.params:
-            if param.default:
-                param.default = html.escape(param.default)
-                param.default_relative = html.escape(param.default_relative)
-                # param.default_link may contain HTML and thus had to be
-                # escaped early
-    for data in page.data:
-        if data.value:
-            data.value = html.escape(data.value)
-            data.value_relative = html.escape(data.value_relative)
-            # data.value_link may contain HTML and thus had to be escaped early
+    # Render also the python stub file if requested
+    if state.config['OUTPUT_STUBS'] is not None:
+        # If the module has submodules, put it into <module>/__init__.pyi so
+        # submodules can be next to it. It could be done always, but writing
+        # just <module>.pyi if there are no submodules makes the output
+        # cleaner.
+        if page.modules:
+            stub_filename = os.path.join(*path, '__init__' + state.config['STUB_EXTENSION'])
+        else:
+            stub_filename = os.path.join(*path[:-1], path[-1] + state.config['STUB_EXTENSION'])
 
-    render(config=state.config,
-        template='module.html',
-        filename=os.path.join(state.config['OUTPUT'], filename),
-        url=url,
-        env=env,
-        page=page)
+        render(config=state.config,
+            template='stub.pyi',
+            filename=os.path.join(state.config['OUTPUT_STUBS'], stub_filename),
+            url=url,
+            env=env,
+            page=page)
+
+    # Render the regular HTML output, unless disabled
+    if state.config['OUTPUT'] is not None:
+        # Perform HTML escaping for everything going into the template. Done
+        # here instead of inside extract_*_doc() to avoid having to unescape in
+        # certain cases.
+        for enum in page.enums:
+            for value in enum.values:
+                value.value = html.escape(value.value)
+        for function in page.functions:
+            for param in function.params:
+                if param.default:
+                    param.default = html.escape(param.default)
+                    param.default_relative = html.escape(param.default_relative)
+                    # param.default_link may contain HTML and thus had to be
+                    # escaped early
+        for data in page.data:
+            if data.value:
+                data.value = html.escape(data.value)
+                data.value_relative = html.escape(data.value_relative)
+                # data.value_link may contain HTML and thus had to be escaped
+                # early
+
+        render(config=state.config,
+            template='module.html',
+            filename=os.path.join(state.config['OUTPUT'], filename),
+            url=url,
+            env=env,
+            page=page)
 
     # Call all scope exit hooks last
     for hook in state.hooks_post_scope:
         hook(type=EntryType.MODULE, path=path)
 
+    # Reset name of current module to ensure it isn't mistakenly filled with
+    # something unrelated
+    state.current_module = None
+
 def render_class(state: State, path, class_, env):
+    # Save name of the enclosing module for populating module dependencies and
+    # initialize the dependency set. It could already be present in
+    # module_dependencies if render_class() for an inner class was called
+    # before, don't overwrite in that case.
+    #
+    # Can't use class_.__module__ as that may not respect name mapping either
+    # from config or from the __all__ members, have to iterate backwards until
+    # the path prefix is a module.
+    assert not state.current_module
+    path_str = '.'.join(path)
+    state.current_module = enclosing_module_for(state, path_str)
+    state.module_dependencies.setdefault(state.current_module, set())
+
     # Call all scope enter hooks first
     for hook in state.hooks_pre_scope:
         hook(type=EntryType.CLASS, path=path)
@@ -2222,13 +2430,14 @@ def render_class(state: State, path, class_, env):
     page.methods = []
     page.properties = []
     page.data = []
+    page.has_members = False
     page.has_enum_details = False
     page.has_function_details = False
     page.has_property_details = False
     page.has_data_details = False
 
     # Find itself in the global map, save the summary back there for index
-    entry = state.name_map['.'.join(path)]
+    entry = state.name_map[path_str]
     entry.summary = page.summary
 
     # Extract docs for all members
@@ -2242,12 +2451,21 @@ def render_class(state: State, path, class_, env):
             logging.warning("%s is undocumented", subpath_str)
 
         if member_entry.type == EntryType.CLASS:
-            page.classes += [extract_class_doc(state, member_entry)]
+            # If we're generating stubs, add the whole parsed class instead of
+            # just a name, summary and reference. All inner classes are parsed
+            # before the class itself because crawl_class() first puts all
+            # nested names into state.name_map and only then the class itself.
+            if state.config['OUTPUT_STUBS']:
+                page.classes += [state.parsed_classes[subpath_str]]
+            else:
+                page.classes += [extract_class_doc(state, member_entry)]
+            page.has_members = True
         elif member_entry.type == EntryType.ENUM:
             enum_ = extract_enum_doc(state, member_entry)
             page.enums += [enum_]
             if enum_.has_details:
                 page.has_enum_details = True
+            page.has_members = True
         elif member_entry.type in [EntryType.FUNCTION, EntryType.OVERLOADED_FUNCTION]:
             for function in extract_function_doc(state, class_, member_entry):
                 if name.startswith('__'):
@@ -2260,16 +2478,19 @@ def render_class(state: State, path, class_, env):
                     page.methods += [function]
                 if function.has_details:
                     page.has_function_details = True
+            page.has_members = True
         elif member_entry.type == EntryType.PROPERTY:
             property = extract_property_doc(state, class_, member_entry)
             page.properties += [property]
             if property.has_details:
                 page.has_property_details = True
+            page.has_members = True
         elif member_entry.type == EntryType.DATA:
             data = extract_data_doc(state, class_, member_entry)
             page.data += [data]
             if data.has_details:
                 page.has_data_details = True
+            page.has_members = True
         else: # pragma: no cover
             assert False
 
@@ -2281,36 +2502,53 @@ def render_class(state: State, path, class_, env):
         result.name = path[-1]
         state.search += [result]
 
-    # Perform HTML escaping for everything going into the template. Done here
-    # instead of inside extract_*_doc() to avoid having to unescape in certain
-    # cases.
-    for enum in page.enums:
-        for value in enum.values:
-            value.value = html.escape(value.value)
-    for function in page.classmethods + page.staticmethods + page.dunder_methods + page.methods:
-        for param in function.params:
-            if param.default:
-                param.default = html.escape(param.default)
-                param.default_relative = html.escape(param.default_relative)
-                # param.default_link may contain HTML and thus had to be
-                # escaped early
-    for data in page.data:
-        if data.value:
-            data.value = html.escape(data.value)
-            data.value_relative = html.escape(data.value_relative)
-            # data.value_link may contain HTML and thus had to be escaped early
-
-    render(config=state.config,
-        template='class.html',
-        filename=os.path.join(state.config['OUTPUT'], filename),
-        url=url,
-        env=env,
-        page=page)
+    # If we're generating stubs, the parsed data gets used in render_module()
+    # instead of it using just the output of extract_class_doc(). It
+    # additionally needs a name member to be a superset of what
+    # extract_class_doc() produces so it works with regular HTML output as
+    # well.
+    if state.config['OUTPUT_STUBS']:
+        parsed_class = copy.deepcopy(page)
+        parsed_class.name = path[-1]
+        state.parsed_classes[path_str] = parsed_class
+
+    # Render the regular HTML output, unless disabled
+    if state.config['OUTPUT'] is not None:
+        # Perform HTML escaping for everything going into the template. Done
+        # here instead of inside extract_*_doc() to avoid having to unescape in
+        # certain cases.
+        for enum in page.enums:
+            for value in enum.values:
+                value.value = html.escape(value.value)
+        for function in page.classmethods + page.staticmethods + page.dunder_methods + page.methods:
+            for param in function.params:
+                if param.default:
+                    param.default = html.escape(param.default)
+                    param.default_relative = html.escape(param.default_relative)
+                    # param.default_link may contain HTML and thus had to be
+                    # escaped early
+        for data in page.data:
+            if data.value:
+                data.value = html.escape(data.value)
+                data.value_relative = html.escape(data.value_relative)
+                # data.value_link may contain HTML and thus had to be escaped
+                # early
+
+        render(config=state.config,
+            template='class.html',
+            filename=os.path.join(state.config['OUTPUT'], filename),
+            url=url,
+            env=env,
+            page=page)
 
     # Call all scope exit hooks last
     for hook in state.hooks_post_scope:
         hook(type=EntryType.CLASS, path=path)
 
+    # Reset name of current module to ensure it isn't mistakenly filled with
+    # something unrelated
+    state.current_module = None
+
 # Extracts image paths and transforms them to just the filenames
 class ExtractImages(Transform):
     # Max Docutils priority is 990, be sure that this is applied at the very
@@ -2464,6 +2702,9 @@ def render_doc(state: State, filename):
         docutils.utils.assemble_option_dict = prev_assemble_option_dict
 
 def render_page(state: State, path, input_filename, env):
+    # If not generating the regular HTML output, we shouldn't even be here
+    assert state.config['OUTPUT'] is not None
+
     filename, url = state.config['URL_FORMATTER'](EntryType.PAGE, path)
 
     logging.debug("generating %s", filename)
@@ -2641,10 +2882,15 @@ def run(basedir, config, *, templates=default_templates, search_add_lookahead_ba
     if config['INPUT'] is None: config['INPUT'] = basedir
     else: config['INPUT'] = os.path.join(basedir, config['INPUT'])
 
-    # Make the output dir absolute
-    config['OUTPUT'] = os.path.join(config['INPUT'], config['OUTPUT'])
-    if not os.path.exists(config['OUTPUT']):
-        os.makedirs(config['OUTPUT'])
+    # Make the output dirs absolute
+    if config['OUTPUT'] is not None:
+        config['OUTPUT'] = os.path.join(config['INPUT'], config['OUTPUT'])
+        if not os.path.exists(config['OUTPUT']):
+            os.makedirs(config['OUTPUT'])
+    if config['OUTPUT_STUBS'] is not None:
+        config['OUTPUT_STUBS'] = os.path.join(config['INPUT'], config['OUTPUT_STUBS'])
+        if not os.path.exists(config['OUTPUT_STUBS']):
+            os.makedirs(config['OUTPUT_STUBS'])
 
     # Guess MIME type of the favicon
     if config['FAVICON']:
@@ -2792,131 +3038,142 @@ def run(basedir, config, *, templates=default_templates, search_add_lookahead_ba
         if unused_docs:
             logging.warning("The following %s doc contents were unused: %s", docs, unused_docs)
 
-    # Create module and class index from the toplevel name list. Recursively go
-    # from the top-level index list and gather all class/module children.
-    def fetch_class_index(entry):
-        index_entry = Empty()
-        index_entry.kind = 'module' if entry.type == EntryType.MODULE else 'class'
-        index_entry.name = entry.path[-1]
-        index_entry.url = state.config['URL_FORMATTER'](entry.type, entry.path)[1]
-        index_entry.summary = entry.summary
-        index_entry.has_nestable_children = False
-        index_entry.children = []
-
-        # Module children should go before class children, put them in a
-        # separate list and then concatenate at the end
-        class_children = []
-        for member in entry.members:
-            member_entry = state.name_map['.'.join(entry.path + [member])]
-            if member_entry.type == EntryType.MODULE:
-                index_entry.has_nestable_children = True
-                index_entry.children += [fetch_class_index(state.name_map['.'.join(member_entry.path)])]
-            elif member_entry.type == EntryType.CLASS:
-                class_children += [fetch_class_index(state.name_map['.'.join(member_entry.path)])]
-        index_entry.children += class_children
-
-        return index_entry
-
-    for i in range(len(class_index)):
-        class_index[i] = fetch_class_index(state.name_map[class_index[i]])
-
-    # Create page index from the toplevel name list
-    # TODO: rework when we have nested page support
-    for i in range(len(page_index)):
-        entry = state.name_map[page_index[i]]
-        assert entry.type == EntryType.PAGE, "page %s already used as %s (%s)" % (page_index[i], entry.type, entry.url)
-
-        index_entry = Empty()
-        index_entry.kind = 'page'
-        index_entry.name = entry.name
-        index_entry.url = entry.url
-        index_entry.summary = entry.summary
-        index_entry.has_nestable_children = False
-        index_entry.children = []
-
-        page_index[i] = index_entry
-
-    index = Empty()
-    index.classes = class_index
-    index.pages = page_index
-    for file in special_pages[1:]: # exclude index
-        filename, url = config['URL_FORMATTER'](EntryType.SPECIAL, [file])
-        render(config=config,
-            template=file + '.html',
-            filename=os.path.join(config['OUTPUT'], filename),
-            url=url,
-            env=env,
-            index=index)
+    # All collected module dependencies should be consumed and removed by
+    # render_module() at this point. If not, it might be because the same class
+    # appears in two distinct modules, or a module somewhere in the path isn't
+    # crawled. Neither of those is an error.
+    if state.module_dependencies:
+        logging.debug("Some module dependencies were not consumed: {}".format(state.module_dependencies))
+
+    # The following is all relevant to the HTML output only, skip if disabled
+    if state.config['OUTPUT'] is not None:
+        # Create module and class index from the toplevel name list.
+        # Recursively go from the top-level index list and gather all
+        # class/module children.
+        def fetch_class_index(entry):
+            index_entry = Empty()
+            index_entry.kind = 'module' if entry.type == EntryType.MODULE else 'class'
+            index_entry.name = entry.path[-1]
+            index_entry.url = state.config['URL_FORMATTER'](entry.type, entry.path)[1]
+            index_entry.summary = entry.summary
+            index_entry.has_nestable_children = False
+            index_entry.children = []
+
+            # Module children should go before class children, put them in a
+            # separate list and then concatenate at the end
+            class_children = []
+            for member in entry.members:
+                member_entry = state.name_map['.'.join(entry.path + [member])]
+                if member_entry.type == EntryType.MODULE:
+                    index_entry.has_nestable_children = True
+                    index_entry.children += [fetch_class_index(state.name_map['.'.join(member_entry.path)])]
+                elif member_entry.type == EntryType.CLASS:
+                    class_children += [fetch_class_index(state.name_map['.'.join(member_entry.path)])]
+            index_entry.children += class_children
+
+            return index_entry
+
+        for i in range(len(class_index)):
+            class_index[i] = fetch_class_index(state.name_map[class_index[i]])
+
+        # Create page index from the toplevel name list
+        # TODO: rework when we have nested page support
+        for i in range(len(page_index)):
+            entry = state.name_map[page_index[i]]
+            assert entry.type == EntryType.PAGE, "page %s already used as %s (%s)" % (page_index[i], entry.type, entry.url)
+
+            index_entry = Empty()
+            index_entry.kind = 'page'
+            index_entry.name = entry.name
+            index_entry.url = entry.url
+            index_entry.summary = entry.summary
+            index_entry.has_nestable_children = False
+            index_entry.children = []
+
+            page_index[i] = index_entry
+
+        index = Empty()
+        index.classes = class_index
+        index.pages = page_index
+        for file in special_pages[1:]: # exclude index
+            filename, url = config['URL_FORMATTER'](EntryType.SPECIAL, [file])
+            render(config=config,
+                template=file + '.html',
+                filename=os.path.join(config['OUTPUT'], filename),
+                url=url,
+                env=env,
+                index=index)
 
-    # Create index.html if it was not provided by the user
-    if 'index.rst' not in [os.path.basename(i) for i in config['INPUT_PAGES']]:
-        logging.debug("writing index.html for an empty main page")
+        # Create index.html if it was not provided by the user
+        if 'index.rst' not in [os.path.basename(i) for i in config['INPUT_PAGES']]:
+            logging.debug("writing index.html for an empty main page")
 
-        filename, url = config['URL_FORMATTER'](EntryType.SPECIAL, ['index'])
+            filename, url = config['URL_FORMATTER'](EntryType.SPECIAL, ['index'])
 
-        page = Empty()
-        page.filename = filename
-        page.url = url
-        page.breadcrumb = [(config['PROJECT_TITLE'], url)]
-        render(config=config,
-            template='page.html',
-            filename=os.path.join(config['OUTPUT'], filename),
-            url=url,
-            env=env,
-            page=page)
+            page = Empty()
+            page.filename = filename
+            page.url = url
+            page.breadcrumb = [(config['PROJECT_TITLE'], url)]
+            render(config=config,
+                template='page.html',
+                filename=os.path.join(config['OUTPUT'], filename),
+                url=url,
+                env=env,
+                page=page)
 
-    if not state.config['SEARCH_DISABLED']:
-        logging.debug("building search data for {} symbols".format(len(state.search)))
-
-        data = build_search_data(state, add_lookahead_barriers=search_add_lookahead_barriers, merge_subtrees=search_merge_subtrees, merge_prefixes=search_merge_prefixes)
-
-        # Joining twice, first before passing those to the URL formatter and
-        # second after. If SEARCH_DOWNLOAD_BINARY is a string, use that as a
-        # filename.
-        # TODO: any chance we could write the file *before* it gets ever passed
-        # to URL formatters so we can add cache buster hashes to its URL?
-        if state.config['SEARCH_DOWNLOAD_BINARY']:
-            with open(os.path.join(config['OUTPUT'], config['URL_FORMATTER'](EntryType.STATIC, [os.path.join(config['OUTPUT'], state.config['SEARCH_DOWNLOAD_BINARY'] if isinstance(state.config['SEARCH_DOWNLOAD_BINARY'], str) else searchdata_filename.format(search_filename_prefix=state.config['SEARCH_FILENAME_PREFIX']))])[0]), 'wb') as f:
-                f.write(data)
-        else:
-            with open(os.path.join(config['OUTPUT'], config['URL_FORMATTER'](EntryType.STATIC, [os.path.join(config['OUTPUT'], searchdata_filename_b85.format(search_filename_prefix=state.config['SEARCH_FILENAME_PREFIX']))])[0]), 'wb') as f:
-                f.write(base85encode_search_data(data))
-
-        # OpenSearch metadata, in case we have the base URL
-        if state.config['SEARCH_BASE_URL']:
-            logging.debug("writing OpenSearch metadata file")
-
-            template = env.get_template('opensearch.xml')
-            rendered = template.render(**state.config)
-            output = os.path.join(config['OUTPUT'], 'opensearch.xml')
-            with open(output, '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. Can't use keep_trailing_newline because that'd add it
-                # also for nested templates :( The rendered file should never
-                # contain a trailing newline on its own.
-                assert not rendered.endswith('\n')
-                f.write(b'\n')
-
-    # Copy referenced files
-    for i in config['STYLESHEETS'] + config['EXTRA_FILES'] + ([config['PROJECT_LOGO']] if config['PROJECT_LOGO'] else []) + ([config['FAVICON'][0]] if config['FAVICON'] else []) + list(state.external_data) + ([] if config['SEARCH_DISABLED'] else ['search.js']):
-        # Skip absolute URLs
-        if urllib.parse.urlparse(i).netloc: continue
+        if not state.config['SEARCH_DISABLED']:
+            logging.debug("building search data for {} symbols".format(len(state.search)))
+
+            data = build_search_data(state, add_lookahead_barriers=search_add_lookahead_barriers, merge_subtrees=search_merge_subtrees, merge_prefixes=search_merge_prefixes)
+
+            # Joining twice, first before passing those to the URL formatter
+            # and second after. If SEARCH_DOWNLOAD_BINARY is a string, use that
+            # as a filename.
+            # TODO: any chance we could write the file *before* it gets ever
+            # passed to URL formatters so we can add cache buster hashes to its
+            # URL?
+            if state.config['SEARCH_DOWNLOAD_BINARY']:
+                with open(os.path.join(config['OUTPUT'], config['URL_FORMATTER'](EntryType.STATIC, [os.path.join(config['OUTPUT'], state.config['SEARCH_DOWNLOAD_BINARY'] if isinstance(state.config['SEARCH_DOWNLOAD_BINARY'], str) else searchdata_filename.format(search_filename_prefix=state.config['SEARCH_FILENAME_PREFIX']))])[0]), 'wb') as f:
+                    f.write(data)
+            else:
+                with open(os.path.join(config['OUTPUT'], config['URL_FORMATTER'](EntryType.STATIC, [os.path.join(config['OUTPUT'], searchdata_filename_b85.format(search_filename_prefix=state.config['SEARCH_FILENAME_PREFIX']))])[0]), 'wb') as f:
+                    f.write(base85encode_search_data(data))
+
+            # OpenSearch metadata, in case we have the base URL
+            if state.config['SEARCH_BASE_URL']:
+                logging.debug("writing OpenSearch metadata file")
+
+                template = env.get_template('opensearch.xml')
+                rendered = template.render(**state.config)
+                output = os.path.join(config['OUTPUT'], 'opensearch.xml')
+                with open(output, '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. Can't use keep_trailing_newline because
+                    # that'd add it also for nested templates :( The rendered
+                    # file should never contain a trailing newline on its own.
+                    assert not rendered.endswith('\n')
+                    f.write(b'\n')
+
+        # Copy referenced files
+        for i in config['STYLESHEETS'] + config['EXTRA_FILES'] + ([config['PROJECT_LOGO']] if config['PROJECT_LOGO'] else []) + ([config['FAVICON'][0]] if config['FAVICON'] else []) + list(state.external_data) + ([] 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(config['INPUT'], i)):
-            i = os.path.join(config['INPUT'], i)
+            # If file is found relative to the conf file, use that
+            if os.path.exists(os.path.join(config['INPUT'], i)):
+                i = os.path.join(config['INPUT'], i)
 
-        # Otherwise use path relative to script directory
-        else:
-            i = os.path.join(os.path.dirname(os.path.realpath(__file__)), i)
+            # Otherwise use path relative to script directory
+            else:
+                i = os.path.join(os.path.dirname(os.path.realpath(__file__)), i)
 
-        output = os.path.join(config['OUTPUT'], config['URL_FORMATTER'](EntryType.STATIC, [i])[0])
-        output_dir = os.path.dirname(output)
-        if not os.path.exists(output_dir): os.makedirs(output_dir)
-        logging.debug("copying %s to output", i)
-        shutil.copy(i, output)
+            output = os.path.join(config['OUTPUT'], config['URL_FORMATTER'](EntryType.STATIC, [i])[0])
+            output_dir = os.path.dirname(output)
+            if not os.path.exists(output_dir): os.makedirs(output_dir)
+            logging.debug("copying %s to output", i)
+            shutil.copy(i, output)
 
     # Call all registered finalization hooks
     for hook in state.hooks_post_run: hook()
diff --git a/documentation/templates/python/stub.pyi b/documentation/templates/python/stub.pyi
new file mode 100644 (file)
index 0000000..9431c0e
--- /dev/null
@@ -0,0 +1,97 @@
+{% set pj = joiner('\n\n') %}
+{% if STUB_HEADER %}{{ pj() }}{{ STUB_HEADER|trim }}{% endif %}
+{% if page.dependencies %}{{ pj() }}{% for prefix, module in page.dependencies %}
+{% if not loop.first %}
+
+{% endif %}
+{% if prefix %}from {{ prefix }} import {{ module or '*' }}{% else %}import {{ module }}{% endif %}
+{% endfor %}{% endif %}
+{% macro render_enums(enums) %}
+{% for enum in enums %}
+{% if not loop.first %}
+
+
+{% endif %}
+class {{ enum.name }}({% if enum.base_relative %}{{ enum.base_relative }}{% else %}enum.Enum{% endif %}):
+{% if not enum.values %}
+    ...
+{%- else %}
+    {% for value in enum.values %}
+    {% if not loop.first %}
+
+    {% endif %}
+    {{ value.name }} = {{ value.value }}{% endfor %}
+{% endif %}
+{% endfor %}{% endmacro %}
+{% macro render_functions(functions) %}
+{% for function in functions %}
+{% if not loop.first %}
+
+
+{% endif %}
+{% if function.is_classmethod %}
+@classmethod
+{% elif function.is_staticmethod %}
+@staticmethod
+{% endif %}
+{% if function.is_overloaded %}
+@typing.overload
+{% endif %}
+{% if function.params|length == 1 and not function.params[0].name %}
+def {{ function.name }}({% if function.is_classmethod %}cls, {% elif function.is_method and not function.is_staticmethod %}self, {% endif %}*args):
+{% else %}
+def {{ function.name }}({% for param in function.params %}{% if loop.index0 and function.params[loop.index0 - 1].kind == 'POSITIONAL_OR_KEYWORD' and param.kind == 'KEYWORD_ONLY' %}, *{% endif %}{% if not loop.first %}, {% endif %}{% if param.kind == 'VAR_POSITIONAL' %}*{% elif param.kind == 'VAR_KEYWORD' %}**{% endif %}{{ param.name }}{% if param.type_quoted %}: {{ param.type_quoted }}{% endif %}{% if param.default_relative %} = {{ param.default_relative }}{% endif %}{% if param.kind == 'POSITIONAL_ONLY' and (loop.last or function.params[loop.index0 + 1].kind != 'POSITIONAL_ONLY') %}, /{% endif %}{% endfor %}){% if function.type_quoted %} -> {{ function.type_quoted }}{% endif %}:
+{% endif %}
+    ...
+{%- endfor %}{% endmacro %}
+{% macro render_properties(properties) %}
+{% for property in properties %}
+{% if not loop.first %}
+
+
+{% endif %}
+@property
+def {{ property.name }}(self){% if property.type_quoted %} -> {{ property.type_quoted }}{% endif %}:
+    ...
+{%- if property.is_settable +%}
+@{{ property.name }}.setter
+def {{ property.name }}(self, value{% if property.type_quoted %}: {{ property.type_quoted }}{% endif %}):
+    ...
+{%- endif %}
+{%- if property.is_deletable +%}
+@{{ property.name }}.deleter
+def {{ property.name }}(self):
+    ...
+{%- endif %}
+{%- endfor %}{% endmacro %}
+{% macro render_data(data_) %}
+{% for data in data_ %}
+{% if not loop.first %}
+
+
+{% endif %}
+{{ data.name }}{% if data.type_quoted or data.value_relative %}{% if data.type_quoted %}: {{ data.type_quoted }}{% endif %}{% if data.value_relative %} = {{ data.value_relative }}{% endif %}{% else %}: ...{% endif %}
+{% endfor %}{% endmacro %}
+{% if page.enums %}{{ pj() }}{{ render_enums(page.enums) }}{% endif %}
+{% if page.classes %}{{ pj() -}}
+{% for class in page.classes recursive %}
+{% if not loop.first %}
+
+
+{% endif %}
+class {{ class.name }}:
+{% if not class.has_members %}
+    ...
+{%- endif %}
+{% set mj = joiner('\n\n') %}
+{% if class.enums %}{{ mj() }}{{ render_enums(class.enums)|indent(4, first=True) }}{% endif %}
+{% if class.classes %}{{ mj() }}{{ loop(class.classes)|indent(4, first=True) }}{% endif %}
+{% if class.data %}{{ mj() }}{{ render_data(class.data)|indent(4, first=True) }}{% endif %}
+{% if class.staticmethods %}{{ mj() }}{{ render_functions(class.staticmethods)|indent(4, first=True) -}}{% endif %}
+{% if class.classmethods %}{{ mj() }}{{ render_functions(class.classmethods)|indent(4, first=True) }}{% endif %}
+{% if class.methods %}{{ mj() }}{{ render_functions(class.methods)|indent(4, first=True) }}{% endif %}
+{% if class.dunder_methods %}{{ mj() }}{{ render_functions(class.dunder_methods)|indent(4, first=True) }}{% endif %}
+{% if class.properties %}{{ mj() }}{{ render_properties(class.properties)|indent(4, first=True) }}{% endif %}
+{%- endfor %}{% endif %}
+{% if page.data %}{{ pj() }}{{ render_data(page.data) }}{% endif %}
+{% if page.functions %}{{ pj() }}{{ render_functions(page.functions) }}{% endif %}
index bb7457abbfa62aa28fd66d1e9125e57eadeec387..cb3cb9afa51fcd72e57dae966cf3a51dd4588dce 100644 (file)
@@ -68,3 +68,9 @@ pybind11_add_module(pybind_submodules_package pybind_submodules_package/sub.cpp)
 set_target_properties(pybind_submodules_package PROPERTIES
     OUTPUT_NAME sub
     LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/pybind_submodules_package/pybind_submodules_package)
+
+# Need a special name for this one
+pybind11_add_module(pybind_stubs_module_dependencies stubs_module_dependencies/stubs_module_dependencies/pybind.cpp)
+set_target_properties(pybind_stubs_module_dependencies PROPERTIES
+    OUTPUT_NAME pybind
+    LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/stubs_module_dependencies/stubs_module_dependencies)
index 16b62a1fc1fe072d67c631b35433882348d57453..50ed76d1096450986f20f1a42e269427fe1d87fb 100644 (file)
@@ -97,7 +97,7 @@ class BaseTestCase(unittest.TestCase):
     def actual_expected_contents(self, actual, expected = None):
         if not expected: expected = actual
 
-        with open(os.path.join(self.path, expected)) as f:
+        with open(os.path.join(self.path, 'stubs', expected) if expected.endswith('.pyi') and not expected.startswith('../') else os.path.join(self.path, expected)) as f:
             expected_contents = f.read()
         with open(os.path.join(self.path, 'output', actual)) as f:
             actual_contents = f.read()
@@ -117,3 +117,27 @@ class BaseInspectTestCase(BaseTestCase):
             config_overrides = config
 
         BaseTestCase.run_python(self, config_overrides, templates)
+
+    def run_python_stubs(self, config_overrides={}, templates=default_templates):
+        # Defaults that make sense for stub-only tests
+        config = copy.deepcopy(default_config)
+        config.update({
+            'FINE_PRINT': None,
+            'THEME_COLOR': None,
+            'FAVICON': None,
+            'LINKS_NAVBAR1': [],
+            'LINKS_NAVBAR2': [],
+            'SEARCH_DISABLED': True,
+            'OUTPUT': None,
+            'OUTPUT_STUBS': os.path.join(self.path, 'output'),
+            'STUB_HEADER': ''
+        })
+
+        if 'INPUT_MODULES' not in config_overrides:
+            sys.path.append(self.path)
+            config['INPUT_MODULES'] = [self.dirname]
+
+        # Update it with config overrides
+        config.update(config_overrides)
+
+        run(self.path, config, templates=templates)
diff --git a/documentation/test_python/content_html_escape/stubs/content_html_escape/__init__.pyi b/documentation/test_python/content_html_escape/stubs/content_html_escape/__init__.pyi
new file mode 100644 (file)
index 0000000..b2fd29b
--- /dev/null
@@ -0,0 +1,29 @@
+import enum
+
+class Enum(enum.Enum):
+    VALUE_THAT_SHOULD_BE_ESCAPED = '<&>'
+
+class Class:
+    class ClassEnum(enum.Enum):
+        VALUE_THAT_SHOULD_BE_ESCAPED = '<&>'
+
+    DATA_THAT_SHOULD_BE_ESCAPED = '<&>'
+
+    @staticmethod
+    def staticmethod(default_string_that_should_be_escaped = '<&>'):
+        ...
+
+    @classmethod
+    def classmethod(default_string_that_should_be_escaped = '<&>'):
+        ...
+
+    def method(self, default_string_that_should_be_escaped = '<&>'):
+        ...
+
+    def __dunder_method__(self, default_string_that_should_be_escaped = '<&>'):
+        ...
+
+DATA_THAT_SHOULD_BE_ESCAPED = '<&>'
+
+def function(default_string_that_should_be_escaped = '<&>'):
+    ...
diff --git a/documentation/test_python/content_html_escape/stubs/content_html_escape/pybind.pyi b/documentation/test_python/content_html_escape/stubs/content_html_escape/pybind.pyi
new file mode 100644 (file)
index 0000000..55f92fe
--- /dev/null
@@ -0,0 +1,2 @@
+def default_value_should_be_escaped(string: str = '<&>') -> int:
+    ...
diff --git a/documentation/test_python/inspect_annotations/stubs/inspect_annotations-py36.pyi b/documentation/test_python/inspect_annotations/stubs/inspect_annotations-py36.pyi
new file mode 100644 (file)
index 0000000..9868aab
--- /dev/null
@@ -0,0 +1,120 @@
+import typing
+
+class AContainer:
+    def __new__(cls, *args, **kwds):
+        ...
+
+class AContainer2:
+    def __iter__(self):
+        ...
+
+    def __new__(cls, *args, **kwds):
+        ...
+
+    def __next__(self):
+        ...
+
+    def __subclasshook__(subclass):
+        ...
+
+class Foo:
+    @property
+    def a_property(self) -> typing.List[bool]:
+        ...
+
+class FooSlots:
+    @property
+    def annotated(self) -> typing.List[str]:
+        ...
+    @annotated.setter
+    def annotated(self, value: typing.List[str]):
+        ...
+    @annotated.deleter
+    def annotated(self):
+        ...
+
+    @property
+    def unannotated(self):
+        ...
+    @unannotated.setter
+    def unannotated(self, value):
+        ...
+    @unannotated.deleter
+    def unannotated(self):
+        ...
+
+ANNOTATED_VAR: typing.Tuple[bool, str] = (False, 'No.')
+
+UNANNOTATED_VAR = 3.45
+
+def annotated_positional_keyword(bar = False, *, foo: str, **kwargs):
+    ...
+
+def annotation(param: typing.List[int], another: bool, third: str = 'hello') -> float:
+    ...
+
+def annotation_any(a: typing.Any):
+    ...
+
+def annotation_callable(a: typing.Callable[[float, int], str]):
+    ...
+
+def annotation_callable_no_args(a: typing.Callable[[], typing.Dict[int, float]]):
+    ...
+
+def annotation_ellipsis(a: typing.Callable[[...], int], b: typing.Tuple[str, ...]):
+    ...
+
+def annotation_func_instead_of_type(a):
+    ...
+
+def annotation_func_instead_of_type_nested(a, b, c):
+    ...
+
+def annotation_generic(a: typing.List['Tp']) -> 'Tp':
+    ...
+
+def annotation_invalid() -> 'Foo.Bar':
+    ...
+
+def annotation_list_noparam(a: typing.List):
+    ...
+
+def annotation_optional(a: typing.Optional[float]):
+    ...
+
+def annotation_strings(param: typing.List[int], another: bool, third: str = 'hello') -> float:
+    ...
+
+def annotation_tuple_instead_of_tuple(a):
+    ...
+
+def annotation_union(a: typing.Union[float, int]):
+    ...
+
+def annotation_union_of_forward_reference(a: typing.Union[int, 'something.Undefined']):
+    ...
+
+def annotation_union_second_bracketed(a: typing.Union[float, typing.List[int]]):
+    ...
+
+def args_kwargs(a, b, *args, **kwargs):
+    ...
+
+def no_annotation(a, b, z):
+    ...
+
+def no_annotation_default_param(param, another, third = 'hello'):
+    ...
+
+def partial_annotation(foo, param: typing.Tuple[int, int], unannotated, cls: object):
+    ...
+
+def positional_keyword(positional_kw, *, kw_only):
+    ...
+
+def returns_none(a: typing.Callable[[], None]) -> None:
+    ...
+
+def returns_none_type(a: typing.Callable[[], None]) -> None:
+    ...
diff --git a/documentation/test_python/inspect_annotations/stubs/inspect_annotations-py37+38.pyi b/documentation/test_python/inspect_annotations/stubs/inspect_annotations-py37+38.pyi
new file mode 100644 (file)
index 0000000..7a8d420
--- /dev/null
@@ -0,0 +1,121 @@
+import typing
+
+class AContainer:
+    def __new__(cls, *args, **kwds):
+        ...
+
+class AContainer2:
+    def __iter__(self):
+        ...
+
+    def __new__(cls, *args, **kwds):
+        ...
+
+    def __next__(self):
+        ...
+
+    @classmethod
+    def __subclasshook__(C):
+        ...
+
+class Foo:
+    @property
+    def a_property(self) -> typing.List[bool]:
+        ...
+
+class FooSlots:
+    @property
+    def annotated(self) -> typing.List[str]:
+        ...
+    @annotated.setter
+    def annotated(self, value: typing.List[str]):
+        ...
+    @annotated.deleter
+    def annotated(self):
+        ...
+
+    @property
+    def unannotated(self):
+        ...
+    @unannotated.setter
+    def unannotated(self, value):
+        ...
+    @unannotated.deleter
+    def unannotated(self):
+        ...
+
+ANNOTATED_VAR: typing.Tuple[bool, str] = (False, 'No.')
+
+UNANNOTATED_VAR = 3.45
+
+def annotated_positional_keyword(bar = False, *, foo: str, **kwargs):
+    ...
+
+def annotation(param: typing.List[int], another: bool, third: str = 'hello') -> float:
+    ...
+
+def annotation_any(a: typing.Any):
+    ...
+
+def annotation_callable(a: typing.Callable[[float, int], str]):
+    ...
+
+def annotation_callable_no_args(a: typing.Callable[[], typing.Dict[int, float]]):
+    ...
+
+def annotation_ellipsis(a: typing.Callable[[...], int], b: typing.Tuple[str, ...]):
+    ...
+
+def annotation_func_instead_of_type(a):
+    ...
+
+def annotation_func_instead_of_type_nested(a, b, c):
+    ...
+
+def annotation_generic(a: typing.List['Tp']) -> 'Tp':
+    ...
+
+def annotation_invalid() -> 'Foo.Bar':
+    ...
+
+def annotation_list_noparam(a: typing.List['T']):
+    ...
+
+def annotation_optional(a: typing.Optional[float]):
+    ...
+
+def annotation_strings(param: typing.List[int], another: bool, third: str = 'hello') -> float:
+    ...
+
+def annotation_tuple_instead_of_tuple(a):
+    ...
+
+def annotation_union(a: typing.Union[float, int]):
+    ...
+
+def annotation_union_of_forward_reference(a: typing.Union[int, 'something.Undefined']):
+    ...
+
+def annotation_union_second_bracketed(a: typing.Union[float, typing.List[int]]):
+    ...
+
+def args_kwargs(a, b, *args, **kwargs):
+    ...
+
+def no_annotation(a, b, z):
+    ...
+
+def no_annotation_default_param(param, another, third = 'hello'):
+    ...
+
+def partial_annotation(foo, param: typing.Tuple[int, int], unannotated, cls: object):
+    ...
+
+def positional_keyword(positional_kw, *, kw_only):
+    ...
+
+def returns_none(a: typing.Callable[[], None]) -> None:
+    ...
+
+def returns_none_type(a: typing.Callable[[], None]) -> None:
+    ...
diff --git a/documentation/test_python/inspect_annotations/stubs/inspect_annotations.pyi b/documentation/test_python/inspect_annotations/stubs/inspect_annotations.pyi
new file mode 100644 (file)
index 0000000..42c2f30
--- /dev/null
@@ -0,0 +1,121 @@
+import typing
+
+class AContainer:
+    ...
+
+class AContainer2:
+    @classmethod
+    def __class_getitem__(cls, *args):
+        ...
+
+    def __iter__(self):
+        ...
+
+    def __next__(self):
+        ...
+
+    @classmethod
+    def __subclasshook__(C):
+        ...
+
+class Foo:
+    @property
+    def a_property(self) -> typing.List[bool]:
+        ...
+
+class FooSlots:
+    @property
+    def annotated(self) -> typing.List[str]:
+        ...
+    @annotated.setter
+    def annotated(self, value: typing.List[str]):
+        ...
+    @annotated.deleter
+    def annotated(self):
+        ...
+
+    @property
+    def unannotated(self):
+        ...
+    @unannotated.setter
+    def unannotated(self, value):
+        ...
+    @unannotated.deleter
+    def unannotated(self):
+        ...
+
+ANNOTATED_VAR: typing.Tuple[bool, str] = (False, 'No.')
+
+UNANNOTATED_VAR = 3.45
+
+def annotated_positional_keyword(bar = False, *, foo: str, **kwargs):
+    ...
+
+def annotation(param: typing.List[int], another: bool, third: str = 'hello') -> float:
+    ...
+
+def annotation_any(a: typing.Any):
+    ...
+
+def annotation_callable(a: typing.Callable[[float, int], str]):
+    ...
+
+def annotation_callable_no_args(a: typing.Callable[[], typing.Dict[int, float]]):
+    ...
+
+def annotation_ellipsis(a: typing.Callable[[...], int], b: typing.Tuple[str, ...]):
+    ...
+
+def annotation_func_instead_of_type(a):
+    ...
+
+def annotation_func_instead_of_type_nested(a, b, c):
+    ...
+
+def annotation_generic(a: typing.List['Tp']) -> 'Tp':
+    ...
+
+def annotation_invalid() -> 'Foo.Bar':
+    ...
+
+def annotation_list_noparam(a: typing.List):
+    ...
+
+def annotation_optional(a: typing.Optional[float]):
+    ...
+
+def annotation_strings(param: typing.List[int], another: bool, third: str = 'hello') -> float:
+    ...
+
+def annotation_tuple_instead_of_tuple(a):
+    ...
+
+def annotation_union(a: typing.Union[float, int]):
+    ...
+
+def annotation_union_of_forward_reference(a: typing.Union[int, 'something.Undefined']):
+    ...
+
+def annotation_union_second_bracketed(a: typing.Union[float, typing.List[int]]):
+    ...
+
+def args_kwargs(a, b, *args, **kwargs):
+    ...
+
+def no_annotation(a, b, z):
+    ...
+
+def no_annotation_default_param(param, another, third = 'hello'):
+    ...
+
+def partial_annotation(foo, param: typing.Tuple[int, int], unannotated, cls: object):
+    ...
+
+def positional_keyword(positional_kw, *, kw_only):
+    ...
+
+def returns_none(a: typing.Callable[[], None]) -> None:
+    ...
+
+def returns_none_type(a: typing.Callable[[], None]) -> None:
+    ...
diff --git a/documentation/test_python/inspect_attrs/stubs/inspect_attrs.pyi b/documentation/test_python/inspect_attrs/stubs/inspect_attrs.pyi
new file mode 100644 (file)
index 0000000..9acd884
--- /dev/null
@@ -0,0 +1,107 @@
+import typing
+
+class MyClass:
+    plain_data: float = 35
+
+    def __init__(self, annotated: float, unannotated = 4, complex_annotation: typing.List[typing.Tuple[int, float]] = [], complex_annotation_in_attr: typing.List[typing.Tuple[int, float]] = [], hidden_property: float = 3) -> None:
+        ...
+
+    @property
+    def annotated(self) -> float:
+        ...
+    @annotated.setter
+    def annotated(self, value: float):
+        ...
+    @annotated.deleter
+    def annotated(self):
+        ...
+
+    @property
+    def complex_annotation(self) -> typing.List[typing.Tuple[int, float]]:
+        ...
+    @complex_annotation.setter
+    def complex_annotation(self, value: typing.List[typing.Tuple[int, float]]):
+        ...
+    @complex_annotation.deleter
+    def complex_annotation(self):
+        ...
+
+    @property
+    def unannotated(self):
+        ...
+    @unannotated.setter
+    def unannotated(self, value):
+        ...
+    @unannotated.deleter
+    def unannotated(self):
+        ...
+
+    @property
+    def complex_annotation_in_attr(self) -> typing.List[typing.Tuple[int, float]]:
+        ...
+    @complex_annotation_in_attr.setter
+    def complex_annotation_in_attr(self, value: typing.List[typing.Tuple[int, float]]):
+        ...
+    @complex_annotation_in_attr.deleter
+    def complex_annotation_in_attr(self):
+        ...
+
+class MyClassAutoAttribs:
+    unannotated = 4
+
+    def __init__(self, annotated: float, complex_annotation: typing.List[typing.Tuple[int, float]] = []) -> None:
+        ...
+
+    @property
+    def annotated(self) -> float:
+        ...
+    @annotated.setter
+    def annotated(self, value: float):
+        ...
+    @annotated.deleter
+    def annotated(self):
+        ...
+
+    @property
+    def complex_annotation(self) -> typing.List[typing.Tuple[int, float]]:
+        ...
+    @complex_annotation.setter
+    def complex_annotation(self, value: typing.List[typing.Tuple[int, float]]):
+        ...
+    @complex_annotation.deleter
+    def complex_annotation(self):
+        ...
+
+class MySlotClass:
+    def __init__(self, annotated: float, complex_annotation: typing.List[typing.Tuple[int, float]] = [], complex_annotation_in_attr: typing.List[typing.Tuple[int, float]] = []) -> None:
+        ...
+
+    @property
+    def annotated(self) -> float:
+        ...
+    @annotated.setter
+    def annotated(self, value: float):
+        ...
+    @annotated.deleter
+    def annotated(self):
+        ...
+
+    @property
+    def complex_annotation(self) -> typing.List[typing.Tuple[int, float]]:
+        ...
+    @complex_annotation.setter
+    def complex_annotation(self, value: typing.List[typing.Tuple[int, float]]):
+        ...
+    @complex_annotation.deleter
+    def complex_annotation(self):
+        ...
+
+    @property
+    def complex_annotation_in_attr(self) -> typing.List[typing.Tuple[int, float]]:
+        ...
+    @complex_annotation_in_attr.setter
+    def complex_annotation_in_attr(self, value: typing.List[typing.Tuple[int, float]]):
+        ...
+    @complex_annotation_in_attr.deleter
+    def complex_annotation_in_attr(self):
+        ...
diff --git a/documentation/test_python/inspect_builtin/stubs/inspect_builtin-310.pyi b/documentation/test_python/inspect_builtin/stubs/inspect_builtin-310.pyi
new file mode 100644 (file)
index 0000000..35fcc67
--- /dev/null
@@ -0,0 +1,27 @@
+class BaseException:
+    def with_traceback(self, *args):
+        ...
+
+    def __reduce__(self, *args):
+        ...
+
+    def __setstate__(self, *args):
+        ...
+
+    @property
+    def __cause__(self):
+        ...
+
+    @property
+    def __context__(self):
+        ...
+
+    @property
+    def args(self):
+        ...
+
+def pow(x, y, /):
+    ...
+
+def log(*args):
+    ...
diff --git a/documentation/test_python/inspect_builtin/stubs/inspect_builtin-36.pyi b/documentation/test_python/inspect_builtin/stubs/inspect_builtin-36.pyi
new file mode 100644 (file)
index 0000000..e7adbb7
--- /dev/null
@@ -0,0 +1,24 @@
+class BaseException:
+    def with_traceback(self, *args):
+        ...
+
+    def __reduce__(self, *args):
+        ...
+
+    def __setstate__(self, *args):
+        ...
+
+    @property
+    def __cause__(self):
+        ...
+
+    @property
+    def __context__(self):
+        ...
+
+    @property
+    def args(self):
+        ...
+
+def log(*args):
+    ...
diff --git a/documentation/test_python/inspect_builtin/stubs/inspect_builtin.pyi b/documentation/test_python/inspect_builtin/stubs/inspect_builtin.pyi
new file mode 100644 (file)
index 0000000..acd0dd3
--- /dev/null
@@ -0,0 +1,30 @@
+class BaseException:
+    def add_note(self, *args):
+        ...
+
+    def with_traceback(self, *args):
+        ...
+
+    def __reduce__(self, *args):
+        ...
+
+    def __setstate__(self, *args):
+        ...
+
+    @property
+    def __cause__(self):
+        ...
+
+    @property
+    def __context__(self):
+        ...
+
+    @property
+    def args(self):
+        ...
+
+def pow(x, y, /):
+    ...
+
+def log(*args):
+    ...
diff --git a/documentation/test_python/inspect_name_mapping/stubs/inspect_name_mapping/__init__.py b/documentation/test_python/inspect_name_mapping/stubs/inspect_name_mapping/__init__.py
new file mode 100644 (file)
index 0000000..d09cdbc
--- /dev/null
@@ -0,0 +1,6 @@
+class Class:
+    def a_thing(self) -> Class:
+        ...
+
+def foo() -> Class:
+    ...
diff --git a/documentation/test_python/inspect_name_mapping/stubs/inspect_name_mapping/submodule.py b/documentation/test_python/inspect_name_mapping/stubs/inspect_name_mapping/submodule.py
new file mode 100644 (file)
index 0000000..1b9d468
--- /dev/null
@@ -0,0 +1,4 @@
+from . import *
+
+def foo(a: Class, b: int) -> yay.ThisGotOverridenExternally:
+    ...
diff --git a/documentation/test_python/inspect_string/stubs/inspect_string/__init__-310.pyi b/documentation/test_python/inspect_string/stubs/inspect_string/__init__-310.pyi
new file mode 100644 (file)
index 0000000..a23171d
--- /dev/null
@@ -0,0 +1,123 @@
+import enum
+
+class MyEnum(enum.Enum):
+    VALUE = 0
+    ANOTHER = 1
+    YAY = 2
+
+class UndocumentedEnum(enum.IntFlag):
+    FLAG_ONE = 1
+    FLAG_SIXTEEN = 16
+
+class DerivedException:
+    def with_traceback(self, *args):
+        ...
+
+    def __reduce__(self, *args):
+        ...
+
+    def __setstate__(self, *args):
+        ...
+
+    @property
+    def __cause__(self):
+        ...
+
+    @property
+    def __context__(self):
+        ...
+
+    @property
+    def args(self):
+        ...
+
+class Foo:
+    class InnerEnum(enum.Enum):
+        VALUE = 0
+        ANOTHER = 1
+        YAY = 2
+
+    class UndocumentedInnerEnum(enum.IntFlag):
+        FLAG_ONE = 1
+        FLAG_SIXTEEN = 16
+
+    class Subclass:
+        ...
+
+    A_DATA = 'BOO'
+
+    DATA_DECLARATION: int = None
+
+    @staticmethod
+    def static_func(a):
+        ...
+
+    @classmethod
+    def func_on_class(a):
+        ...
+
+    def func(self, a, b):
+        ...
+
+    @property
+    def a_property(self):
+        ...
+
+    @property
+    def deletable_property(self):
+        ...
+    @deletable_property.deleter
+    def deletable_property(self):
+        ...
+
+    @property
+    def writable_property(self):
+        ...
+    @writable_property.setter
+    def writable_property(self, value):
+        ...
+
+    @property
+    def writeonly_property(self):
+        ...
+    @writeonly_property.setter
+    def writeonly_property(self, value):
+        ...
+
+class FooSlots:
+    @property
+    def first(self):
+        ...
+    @first.setter
+    def first(self, value):
+        ...
+    @first.deleter
+    def first(self):
+        ...
+
+    @property
+    def second(self):
+        ...
+    @second.setter
+    def second(self, value):
+        ...
+    @second.deleter
+    def second(self):
+        ...
+
+class Specials:
+    def __add__(self, other):
+        ...
+
+    def __and__(self, other):
+        ...
+
+    def __init__(self):
+        ...
+
+A_CONSTANT = 3.24
+
+foo: ...
+
+def function():
+    ...
diff --git a/documentation/test_python/inspect_string/stubs/inspect_string/__init__.pyi b/documentation/test_python/inspect_string/stubs/inspect_string/__init__.pyi
new file mode 100644 (file)
index 0000000..0a8cb2d
--- /dev/null
@@ -0,0 +1,126 @@
+import enum
+
+class MyEnum(enum.Enum):
+    VALUE = 0
+    ANOTHER = 1
+    YAY = 2
+
+class UndocumentedEnum(enum.IntFlag):
+    FLAG_ONE = 1
+    FLAG_SIXTEEN = 16
+
+class DerivedException:
+    def add_note(self, *args):
+        ...
+
+    def with_traceback(self, *args):
+        ...
+
+    def __reduce__(self, *args):
+        ...
+
+    def __setstate__(self, *args):
+        ...
+
+    @property
+    def __cause__(self):
+        ...
+
+    @property
+    def __context__(self):
+        ...
+
+    @property
+    def args(self):
+        ...
+
+class Foo:
+    class InnerEnum(enum.Enum):
+        VALUE = 0
+        ANOTHER = 1
+        YAY = 2
+
+    class UndocumentedInnerEnum(enum.IntFlag):
+        FLAG_ONE = 1
+        FLAG_SIXTEEN = 16
+
+    class Subclass:
+        ...
+
+    A_DATA = 'BOO'
+
+    DATA_DECLARATION: int = None
+
+    @staticmethod
+    def static_func(a):
+        ...
+
+    @classmethod
+    def func_on_class(a):
+        ...
+
+    def func(self, a, b):
+        ...
+
+    @property
+    def a_property(self):
+        ...
+
+    @property
+    def deletable_property(self):
+        ...
+    @deletable_property.deleter
+    def deletable_property(self):
+        ...
+
+    @property
+    def writable_property(self):
+        ...
+    @writable_property.setter
+    def writable_property(self, value):
+        ...
+
+    @property
+    def writeonly_property(self):
+        ...
+    @writeonly_property.setter
+    def writeonly_property(self, value):
+        ...
+
+class FooSlots:
+    @property
+    def first(self):
+        ...
+    @first.setter
+    def first(self, value):
+        ...
+    @first.deleter
+    def first(self):
+        ...
+
+    @property
+    def second(self):
+        ...
+    @second.setter
+    def second(self, value):
+        ...
+    @second.deleter
+    def second(self):
+        ...
+
+class Specials:
+    def __add__(self, other):
+        ...
+
+    def __and__(self, other):
+        ...
+
+    def __init__(self):
+        ...
+
+A_CONSTANT = 3.24
+
+foo: ...
+
+def function():
+    ...
diff --git a/documentation/test_python/inspect_string/stubs/inspect_string/another_module.pyi b/documentation/test_python/inspect_string/stubs/inspect_string/another_module.pyi
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/documentation/test_python/inspect_string/stubs/inspect_string/subpackage/inner.pyi b/documentation/test_python/inspect_string/stubs/inspect_string/subpackage/inner.pyi
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/documentation/test_python/inspect_value_formatting/stubs/inspect_value_formatting.pyi b/documentation/test_python/inspect_value_formatting/stubs/inspect_value_formatting.pyi
new file mode 100644 (file)
index 0000000..2e7a05d
--- /dev/null
@@ -0,0 +1,25 @@
+import enum
+
+class MyEnum(enum.Enum):
+    YAY = 2
+
+class Foo:
+    ...
+
+AN_UNREPRESENTABLE_VALUE: ...
+
+A_FALSE_VALUE = False
+
+A_NONE_VALUE = None
+
+A_ZERO_VALUE = 0
+
+ENUM_THING = MyEnum.YAY
+
+LARGE_VALUE_WILL_BE_AN_ELLIPSIS = ...
+
+def basics(string_param = 'string', tuple_param = (3, 5), float_param = 1.2, unrepresentable_param = ...):
+    ...
+
+def setup_callback(unknown_function_is_an_ellipsis = ..., builtin_function_is_an_ellipsis = ..., lambda_is_an_ellipsis = ...):
+    ...
diff --git a/documentation/test_python/pybind_enums/stubs/pybind_enums.pyi b/documentation/test_python/pybind_enums/stubs/pybind_enums.pyi
new file mode 100644 (file)
index 0000000..67d751a
--- /dev/null
@@ -0,0 +1,11 @@
+import enum
+
+class MyEnum(enum.Enum):
+    First = 0
+    Second = 1
+    Third = 74
+    CONSISTANTE = -5
+
+class SixtyfourBitFlag(enum.Enum):
+    Yes = 1000000000000
+    No = 18446744073709551615
diff --git a/documentation/test_python/pybind_name_mapping/stubs/pybind_name_mapping/__init__.py b/documentation/test_python/pybind_name_mapping/stubs/pybind_name_mapping/__init__.py
new file mode 100644 (file)
index 0000000..30aa554
--- /dev/null
@@ -0,0 +1,7 @@
+class Class:
+    @staticmethod
+    def a_thing() -> Class:
+        ...
+
+def foo() -> Class:
+    ...
diff --git a/documentation/test_python/pybind_name_mapping/stubs/pybind_name_mapping/submodule.py b/documentation/test_python/pybind_name_mapping/stubs/pybind_name_mapping/submodule.py
new file mode 100644 (file)
index 0000000..689bcc6
--- /dev/null
@@ -0,0 +1,4 @@
+from . import *
+
+def foo(arg0: Class, arg1: int, /) -> int:
+    ...
index 595d5685ed87112a856e14a3af371484476686c3..61e25f542d446a41e1b4338afaefeea9a7db08de 100644 (file)
@@ -106,6 +106,10 @@ could be another, but it's not added yet.)");
         .def_property("foo", &MyClass::foo, &MyClass::setFoo, "A read/write property")
         .def_property_readonly("bar", &MyClass::foo, "A read-only property");
 
+    m.def_submodule("just_overloads", "Stubs for this module should import typing as well")
+        .def("overloaded", static_cast<std::string(*)(int)>(&overloaded), "Overloaded for ints")
+        .def("overloaded", static_cast<bool(*)(float)>(&overloaded), "Overloaded for floats");
+
     py::class_<MyClass23> pybind23{m, "MyClass23", "Testing pybind 2.3 features"};
 
     /* Checker so the Python side can detect if testing pybind 2.3 features is
index a276ed198f36e2e1dbe2b650ae1ab80d5b77f9f9..41006c58a7e86509ac68a77315d4535453e5ffcb 100644 (file)
             <li>
               Reference
               <ul>
+                <li><a href="#packages">Modules</a></li>
                 <li><a href="#classes">Classes</a></li>
                 <li><a href="#functions">Functions</a></li>
               </ul>
             </li>
           </ul>
         </nav>
+        <section id="namespaces">
+          <h2><a href="#namespaces">Modules</a></h2>
+          <dl class="m-doc">
+            <dt>module <a href="pybind_signatures.just_overloads.html" class="m-doc">just_overloads</a></dt>
+            <dd>Stubs for this module should import typing as well</dd>
+          </dl>
+        </section>
         <section id="classes">
           <h2><a href="#classes">Classes</a></h2>
           <dl class="m-doc">
diff --git a/documentation/test_python/pybind_signatures/stubs/pybind_signatures/__init__-pybind22.pyi b/documentation/test_python/pybind_signatures/stubs/pybind_signatures/__init__-pybind22.pyi
new file mode 100644 (file)
index 0000000..a962104
--- /dev/null
@@ -0,0 +1,124 @@
+import typing
+
+class MyClass:
+    @staticmethod
+    def static_function(arg0: int, arg1: float, /) -> MyClass:
+        ...
+
+    def another(self, /) -> int:
+        ...
+
+    def instance_function(self, arg0: int, arg1: str, /) -> tuple[float, int]:
+        ...
+
+    def instance_function_kwargs(self, hey: int, what: str = '<eh?>') -> tuple[float, int]:
+        ...
+
+    def __init__(self, /) -> None:
+        ...
+
+    @property
+    def bar(self) -> float:
+        ...
+
+    @property
+    def foo(self) -> float:
+        ...
+    @foo.setter
+    def foo(self, value: float):
+        ...
+
+class MyClass23:
+    is_pybind23 = False
+
+class MyClass26:
+    is_pybind26 = False
+
+def crazy_signature(*args):
+    ...
+
+def duck(*args, **kwargs) -> None:
+    ...
+
+def escape_docstring(arg0: int, /) -> None:
+    ...
+
+def failed_parse_docstring(*args):
+    ...
+
+def full_docstring(arg0: int, /) -> None:
+    ...
+
+@typing.overload
+def full_docstring_overloaded(arg0: int, arg1: int, /) -> None:
+    ...
+
+@typing.overload
+def full_docstring_overloaded(arg0: float, arg1: float, /) -> None:
+    ...
+
+@typing.overload
+def overloaded(arg0: int, /) -> str:
+    ...
+
+@typing.overload
+def overloaded(arg0: float, /) -> bool:
+    ...
+
+def scale(arg0: int, arg1: float, /) -> int:
+    ...
+
+def scale_kwargs(a: int, argument: float) -> int:
+    ...
+
+def takes_a_function(arg0: typing.Callable[[float, list[float]], int], /) -> None:
+    ...
+
+def takes_a_function_returning_none(arg0: typing.Callable[[], None], /) -> None:
+    ...
+
+def taking_a_list_returning_a_tuple(arg0: list[float], /) -> tuple[int, int, int]:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: float, arg1: float, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: int, arg1: float, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: bool, arg1: float, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: float, arg1: int, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: int, arg1: int, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: bool, arg1: int, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: float, arg1: bool, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: int, arg1: bool, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: bool, arg1: bool, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: str, arg1: str, /) -> None:
+    ...
+
+def void_function(arg0: int, /) -> None:
+    ...
diff --git a/documentation/test_python/pybind_signatures/stubs/pybind_signatures/__init__-pybind25.pyi b/documentation/test_python/pybind_signatures/stubs/pybind_signatures/__init__-pybind25.pyi
new file mode 100644 (file)
index 0000000..dc12283
--- /dev/null
@@ -0,0 +1,138 @@
+import typing
+
+class MyClass:
+    @staticmethod
+    def static_function(arg0: int, arg1: float, /) -> MyClass:
+        ...
+
+    def another(self, /) -> int:
+        ...
+
+    def instance_function(self, arg0: int, arg1: str, /) -> tuple[float, int]:
+        ...
+
+    def instance_function_kwargs(self, hey: int, what: str = '<eh?>') -> tuple[float, int]:
+        ...
+
+    def __init__(self, /) -> None:
+        ...
+
+    @property
+    def bar(self) -> float:
+        ...
+
+    @property
+    def foo(self) -> float:
+        ...
+    @foo.setter
+    def foo(self, value: float):
+        ...
+
+class MyClass23:
+    is_pybind23 = True
+
+    @property
+    def writeonly(self) -> float:
+        ...
+    @writeonly.setter
+    def writeonly(self, value: float):
+        ...
+
+    @property
+    def writeonly_crazy(self):
+        ...
+    @writeonly_crazy.setter
+    def writeonly_crazy(self, value):
+        ...
+
+class MyClass26:
+    is_pybind26 = False
+
+def crazy_signature(*args):
+    ...
+
+def duck(*args, **kwargs) -> None:
+    ...
+
+def escape_docstring(arg0: int, /) -> None:
+    ...
+
+def failed_parse_docstring(*args):
+    ...
+
+def full_docstring(arg0: int, /) -> None:
+    ...
+
+@typing.overload
+def full_docstring_overloaded(arg0: int, arg1: int, /) -> None:
+    ...
+
+@typing.overload
+def full_docstring_overloaded(arg0: float, arg1: float, /) -> None:
+    ...
+
+@typing.overload
+def overloaded(arg0: int, /) -> str:
+    ...
+
+@typing.overload
+def overloaded(arg0: float, /) -> bool:
+    ...
+
+def scale(arg0: int, arg1: float, /) -> int:
+    ...
+
+def scale_kwargs(a: int, argument: float) -> int:
+    ...
+
+def takes_a_function(arg0: typing.Callable[[float, list[float]], int], /) -> None:
+    ...
+
+def takes_a_function_returning_none(arg0: typing.Callable[[], None], /) -> None:
+    ...
+
+def taking_a_list_returning_a_tuple(arg0: list[float], /) -> tuple[int, int, int]:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: float, arg1: float, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: int, arg1: float, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: bool, arg1: float, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: float, arg1: int, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: int, arg1: int, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: bool, arg1: int, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: float, arg1: bool, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: int, arg1: bool, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: bool, arg1: bool, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: str, arg1: str, /) -> None:
+    ...
+
+def void_function(arg0: int, /) -> None:
+    ...
diff --git a/documentation/test_python/pybind_signatures/stubs/pybind_signatures/__init__.pyi b/documentation/test_python/pybind_signatures/stubs/pybind_signatures/__init__.pyi
new file mode 100644 (file)
index 0000000..ce8bfcb
--- /dev/null
@@ -0,0 +1,150 @@
+import typing
+
+class MyClass:
+    @staticmethod
+    def static_function(arg0: int, arg1: float, /) -> MyClass:
+        ...
+
+    def another(self, /) -> int:
+        ...
+
+    def instance_function(self, arg0: int, arg1: str, /) -> tuple[float, int]:
+        ...
+
+    def instance_function_kwargs(self, hey: int, what: str = '<eh?>') -> tuple[float, int]:
+        ...
+
+    def __init__(self, /) -> None:
+        ...
+
+    @property
+    def bar(self) -> float:
+        ...
+
+    @property
+    def foo(self) -> float:
+        ...
+    @foo.setter
+    def foo(self, value: float):
+        ...
+
+class MyClass23:
+    is_pybind23 = True
+
+    @property
+    def writeonly(self) -> float:
+        ...
+    @writeonly.setter
+    def writeonly(self, value: float):
+        ...
+
+    @property
+    def writeonly_crazy(self):
+        ...
+    @writeonly_crazy.setter
+    def writeonly_crazy(self, value):
+        ...
+
+class MyClass26:
+    is_pybind26 = True
+
+    @staticmethod
+    def keyword_only(b: float, *, keyword: str = 'no') -> int:
+        ...
+
+    @staticmethod
+    def positional_keyword_only(a: int, /, b: float, *, keyword: str = 'no') -> int:
+        ...
+
+    @staticmethod
+    def positional_only(a: int, /, b: float) -> int:
+        ...
+
+def crazy_signature(*args):
+    ...
+
+def duck(*args, **kwargs) -> None:
+    ...
+
+def escape_docstring(arg0: int, /) -> None:
+    ...
+
+def failed_parse_docstring(*args):
+    ...
+
+def full_docstring(arg0: int, /) -> None:
+    ...
+
+@typing.overload
+def full_docstring_overloaded(arg0: int, arg1: int, /) -> None:
+    ...
+
+@typing.overload
+def full_docstring_overloaded(arg0: float, arg1: float, /) -> None:
+    ...
+
+@typing.overload
+def overloaded(arg0: int, /) -> str:
+    ...
+
+@typing.overload
+def overloaded(arg0: float, /) -> bool:
+    ...
+
+def scale(arg0: int, arg1: float, /) -> int:
+    ...
+
+def scale_kwargs(a: int, argument: float) -> int:
+    ...
+
+def takes_a_function(arg0: typing.Callable[[float, list[float]], int], /) -> None:
+    ...
+
+def takes_a_function_returning_none(arg0: typing.Callable[[], None], /) -> None:
+    ...
+
+def taking_a_list_returning_a_tuple(arg0: list[float], /) -> tuple[int, int, int]:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: float, arg1: float, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: int, arg1: float, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: bool, arg1: float, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: float, arg1: int, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: int, arg1: int, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: bool, arg1: int, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: float, arg1: bool, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: int, arg1: bool, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: bool, arg1: bool, /) -> None:
+    ...
+
+@typing.overload
+def tenOverloads(arg0: str, arg1: str, /) -> None:
+    ...
+
+def void_function(arg0: int, /) -> None:
+    ...
diff --git a/documentation/test_python/pybind_signatures/stubs/pybind_signatures/just_overloads.pyi b/documentation/test_python/pybind_signatures/stubs/pybind_signatures/just_overloads.pyi
new file mode 100644 (file)
index 0000000..646e5d4
--- /dev/null
@@ -0,0 +1,9 @@
+import typing
+
+@typing.overload
+def overloaded(arg0: int, /) -> str:
+    ...
+
+@typing.overload
+def overloaded(arg0: float, /) -> bool:
+    ...
diff --git a/documentation/test_python/stubs_custom_header_extension/stubs/stubs_custom_header_extension/__init__.custom.py b/documentation/test_python/stubs_custom_header_extension/stubs/stubs_custom_header_extension/__init__.custom.py
new file mode 100644 (file)
index 0000000..c58b904
--- /dev/null
@@ -0,0 +1,3 @@
+#!/usr/bin/env python3
+
+# This is a custom header containing no trailing newline on its own.
diff --git a/documentation/test_python/stubs_custom_header_extension/stubs/stubs_custom_header_extension/sub.custom.py b/documentation/test_python/stubs_custom_header_extension/stubs/stubs_custom_header_extension/sub.custom.py
new file mode 100644 (file)
index 0000000..c58b904
--- /dev/null
@@ -0,0 +1,3 @@
+#!/usr/bin/env python3
+
+# This is a custom header containing no trailing newline on its own.
diff --git a/documentation/test_python/stubs_custom_header_extension/stubs_custom_header_extension/__init__.py b/documentation/test_python/stubs_custom_header_extension/stubs_custom_header_extension/__init__.py
new file mode 100644 (file)
index 0000000..9ded2a2
--- /dev/null
@@ -0,0 +1 @@
+from . import sub
diff --git a/documentation/test_python/stubs_custom_header_extension/stubs_custom_header_extension/sub.py b/documentation/test_python/stubs_custom_header_extension/stubs_custom_header_extension/sub.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/documentation/test_python/stubs_module_dependencies/another_module.py b/documentation/test_python/stubs_module_dependencies/another_module.py
new file mode 100644 (file)
index 0000000..d95cb04
--- /dev/null
@@ -0,0 +1,2 @@
+class Another:
+    ...
diff --git a/documentation/test_python/stubs_module_dependencies/stubs/stubs_module_dependencies/__init__.py b/documentation/test_python/stubs_module_dependencies/stubs/stubs_module_dependencies/__init__.py
new file mode 100644 (file)
index 0000000..c98490c
--- /dev/null
@@ -0,0 +1,27 @@
+import abc
+import email.mime.audio
+import enum
+import json.decoder
+import logging
+import typing
+import unittest.loader
+import unparsed_enum_module
+from . import sub
+
+class RootEnum(enum.Enum):
+    A_VALUE = 3
+
+class Root:
+    CLASS_VALUE_REFERENCING_MODULE_ITSELF: Root = RootEnum.A_VALUE
+
+    def method(self, library_type_alias: abc.ABC, builtin: float, external_enum_value = unparsed_enum_module.UnparsedEnumSubclass.UNPARSED_VALUE) -> json.decoder.JSONDecodeError:
+        ...
+
+    @property
+    def prop(self) -> logging.Logger:
+        ...
+
+VALUE_TYPE: typing.Optional[unittest.loader.TestLoader] = None
+
+def function(should_only_bring_import_sub: sub.Type.InnerClass) -> email.mime.audio.MIMEAudio:
+    ...
diff --git a/documentation/test_python/stubs_module_dependencies/stubs/stubs_module_dependencies/pybind/__init__.py b/documentation/test_python/stubs_module_dependencies/stubs/stubs_module_dependencies/pybind/__init__.py
new file mode 100644 (file)
index 0000000..11437d0
--- /dev/null
@@ -0,0 +1,5 @@
+import typing
+from . import sub
+
+def function(arg0: sub.Foo, /) -> typing.Callable[[], int]:
+    ...
diff --git a/documentation/test_python/stubs_module_dependencies/stubs/stubs_module_dependencies/sub/inner.py b/documentation/test_python/stubs_module_dependencies/stubs/stubs_module_dependencies/sub/inner.py
new file mode 100644 (file)
index 0000000..0ae6344
--- /dev/null
@@ -0,0 +1,14 @@
+import another_module
+import unparsed_enum_module
+import unparsed_module
+from . import *
+from .. import *
+
+class EnumSubclass(unparsed_enum_module.UnparsedEnumClass):
+    A_VALUE = 36
+
+def all_should_be_the_same_relative_type(a: Type, b: Type, c: Type):
+    ...
+
+def foo(type_three_levels_up: Root, unparsed_type: unparsed_module.UnparsedName, enum = RootEnum.A_VALUE) -> another_module.Another:
+    ...
diff --git a/documentation/test_python/stubs_module_dependencies/stubs_module_dependencies/__init__.py b/documentation/test_python/stubs_module_dependencies/stubs_module_dependencies/__init__.py
new file mode 100644 (file)
index 0000000..b3a7497
--- /dev/null
@@ -0,0 +1,30 @@
+import unparsed_enum_module
+import enum
+from . import sub, pybind
+import json
+import logging
+import email.mime.audio
+import abc
+import typing
+import unittest
+
+# The enum dependency is handled explitcitly in the code
+class RootEnum(enum.Enum):
+    A_VALUE = 3
+
+class Root:
+    # is a string as it'd lead to a circular import otherwise
+    CLASS_VALUE_REFERENCING_MODULE_ITSELF: 'Root' = RootEnum.A_VALUE
+
+    def method(self, library_type_alias: abc.ABC, builtin: float, external_enum_value = unparsed_enum_module.UnparsedEnumSubclass.UNPARSED_VALUE) -> json.decoder.JSONDecodeError:
+        ...
+
+    @property
+    def prop(self) -> logging.Logger:
+        ...
+
+# The typing dependency is handled explitcitly in the code
+VALUE_TYPE: typing.Optional[unittest.TestLoader] = None
+
+def function(should_only_bring_import_sub: sub.Type.InnerClass) -> email.mime.audio.MIMEAudio:
+    ...
diff --git a/documentation/test_python/stubs_module_dependencies/stubs_module_dependencies/pybind.cpp b/documentation/test_python/stubs_module_dependencies/stubs_module_dependencies/pybind.cpp
new file mode 100644 (file)
index 0000000..efe84f5
--- /dev/null
@@ -0,0 +1,18 @@
+#include <pybind11/pybind11.h>
+#include <pybind11/functional.h>
+
+namespace py = pybind11;
+
+namespace {
+
+struct Foo {};
+
+std::function<int()> function(const Foo&) { return []{ return 3; }; }
+
+}
+
+PYBIND11_MODULE(pybind, m) {
+    py::class_<Foo>{m.def_submodule("sub"), "Foo"};
+
+    m.def("function", &function);
+}
diff --git a/documentation/test_python/stubs_module_dependencies/stubs_module_dependencies/sub/__init__.py b/documentation/test_python/stubs_module_dependencies/stubs_module_dependencies/sub/__init__.py
new file mode 100644 (file)
index 0000000..475004c
--- /dev/null
@@ -0,0 +1,3 @@
+class Type:
+    class InnerClass:
+        ...
diff --git a/documentation/test_python/stubs_module_dependencies/stubs_module_dependencies/sub/inner.py b/documentation/test_python/stubs_module_dependencies/stubs_module_dependencies/sub/inner.py
new file mode 100644 (file)
index 0000000..cf441e7
--- /dev/null
@@ -0,0 +1,19 @@
+import stubs_module_dependencies
+from stubs_module_dependencies import Root, RootEnum, sub
+
+from . import Type
+
+import another_module
+import unparsed_enum_module
+import unparsed_module
+
+class EnumSubclass(unparsed_enum_module.UnparsedEnumClass):
+    A_VALUE = 36
+
+def all_should_be_the_same_relative_type(a: stubs_module_dependencies.sub.Type,
+                                b: sub.Type,
+                                c: Type):
+    ...
+
+def foo(type_three_levels_up: Root, unparsed_type: unparsed_module.UnparsedName, enum = RootEnum.A_VALUE) -> another_module.Another:
+    ...
diff --git a/documentation/test_python/stubs_module_dependencies/unparsed_enum_module.py b/documentation/test_python/stubs_module_dependencies/unparsed_enum_module.py
new file mode 100644 (file)
index 0000000..5cc3cd6
--- /dev/null
@@ -0,0 +1,11 @@
+# This file isn't included in m.css parsing, so its types are unknown, but it's
+# still imported as a dependency and the type is correctly recognized as an
+# enum type
+
+import enum
+
+class UnparsedEnumClass(enum.Enum):
+    ...
+
+class UnparsedEnumSubclass(enum.Enum):
+    UNPARSED_VALUE = 1337
diff --git a/documentation/test_python/stubs_module_dependencies/unparsed_module.py b/documentation/test_python/stubs_module_dependencies/unparsed_module.py
new file mode 100644 (file)
index 0000000..66fbf52
--- /dev/null
@@ -0,0 +1,5 @@
+# This file isn't included in m.css parsing, so its types are unknown, but it's
+# still imported as a dependency
+
+class UnparsedName:
+    ...
diff --git a/documentation/test_python/stubs_nested_classes/stubs_nested_classes.Class.Inner.html b/documentation/test_python/stubs_nested_classes/stubs_nested_classes.Class.Inner.html
new file mode 100644 (file)
index 0000000..938f17a
--- /dev/null
@@ -0,0 +1,58 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>stubs_nested_classes.Class.Inner | 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="stubs_nested_classes.html">stubs_nested_classes</a>.<wbr/></span><span class="m-breadcrumb"><a href="stubs_nested_classes.Class.html">Class</a>.<wbr/></span>Inner <span class="m-thin">class</span>
+        </h1>
+        <nav class="m-block m-default">
+          <h3>Contents</h3>
+          <ul>
+            <li>
+              Reference
+              <ul>
+                <li><a href="#classes">Classes</a></li>
+                <li><a href="#staticmethods">Static methods</a></li>
+              </ul>
+            </li>
+          </ul>
+        </nav>
+        <section id="classes">
+          <h2><a href="#classes">Classes</a></h2>
+          <dl class="m-doc">
+            <dt>class <a href="stubs_nested_classes.Class.Inner.OneMore.html" class="m-doc">OneMore</a></dt>
+            <dd></dd>
+          </dl>
+        </section>
+        <section id="staticmethods">
+          <h2><a href="#staticmethods">Static methods</a></h2>
+          <dl class="m-doc">
+            <dt id="staticmethod">
+              <span class="m-doc-wrap-bumper">def <a href="#staticmethod" class="m-doc-self">staticmethod</a>(</span><span class="m-doc-wrap">)</span>
+            </dt>
+            <dd></dd>
+          </dl>
+        </section>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/stubs_nested_classes/stubs_nested_classes.Class.InnerAnother.AndAnother.YetAnother.html b/documentation/test_python/stubs_nested_classes/stubs_nested_classes.Class.InnerAnother.AndAnother.YetAnother.html
new file mode 100644 (file)
index 0000000..bdccf3a
--- /dev/null
@@ -0,0 +1,50 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>stubs_nested_classes.Class.InnerAnother.AndAnother.YetAnother | 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="stubs_nested_classes.html">stubs_nested_classes</a>.<wbr/></span><span class="m-breadcrumb"><a href="stubs_nested_classes.Class.html">Class</a>.<wbr/></span><span class="m-breadcrumb"><a href="stubs_nested_classes.Class.InnerAnother.html">InnerAnother</a>.<wbr/></span><span class="m-breadcrumb"><a href="stubs_nested_classes.Class.InnerAnother.AndAnother.html">AndAnother</a>.<wbr/></span>YetAnother <span class="m-thin">class</span>
+        </h1>
+        <nav class="m-block m-default">
+          <h3>Contents</h3>
+          <ul>
+            <li>
+              Reference
+              <ul>
+                <li><a href="#data">Data</a></li>
+              </ul>
+            </li>
+          </ul>
+        </nav>
+        <section id="data">
+          <h2><a href="#data">Data</a></h2>
+          <dl class="m-doc">
+            <dt id="DATA">
+              <a href="#DATA" class="m-doc-self">DATA</a>: float = 3
+            </dt>
+            <dd></dd>
+          </dl>
+        </section>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/stubs_nested_classes/stubs_nested_classes.Class.InnerAnother.AndAnother.html b/documentation/test_python/stubs_nested_classes/stubs_nested_classes.Class.InnerAnother.AndAnother.html
new file mode 100644 (file)
index 0000000..5d1627f
--- /dev/null
@@ -0,0 +1,58 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>stubs_nested_classes.Class.InnerAnother.AndAnother | 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="stubs_nested_classes.html">stubs_nested_classes</a>.<wbr/></span><span class="m-breadcrumb"><a href="stubs_nested_classes.Class.html">Class</a>.<wbr/></span><span class="m-breadcrumb"><a href="stubs_nested_classes.Class.InnerAnother.html">InnerAnother</a>.<wbr/></span>AndAnother <span class="m-thin">class</span>
+        </h1>
+        <nav class="m-block m-default">
+          <h3>Contents</h3>
+          <ul>
+            <li>
+              Reference
+              <ul>
+                <li><a href="#classes">Classes</a></li>
+                <li><a href="#data">Data</a></li>
+              </ul>
+            </li>
+          </ul>
+        </nav>
+        <section id="classes">
+          <h2><a href="#classes">Classes</a></h2>
+          <dl class="m-doc">
+            <dt>class <a href="stubs_nested_classes.Class.InnerAnother.AndAnother.YetAnother.html" class="m-doc">YetAnother</a></dt>
+            <dd></dd>
+          </dl>
+        </section>
+        <section id="data">
+          <h2><a href="#data">Data</a></h2>
+          <dl class="m-doc">
+            <dt id="AND_DATA">
+              <a href="#AND_DATA" class="m-doc-self">AND_DATA</a>: str = &#x27;b&#x27;
+            </dt>
+            <dd></dd>
+          </dl>
+        </section>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/stubs_nested_classes/stubs_nested_classes.Class.html b/documentation/test_python/stubs_nested_classes/stubs_nested_classes.Class.html
new file mode 100644 (file)
index 0000000..cdd4c42
--- /dev/null
@@ -0,0 +1,60 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>stubs_nested_classes.Class | My Python Project</title>
+  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,400i,600,600i%7CSource+Code+Pro:400,400i,600" />
+  <link rel="stylesheet" href="m-dark+documentation.compiled.css" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+</head>
+<body>
+<header><nav id="navigation">
+  <div class="m-container">
+    <div class="m-row">
+      <a href="index.html" id="m-navbar-brand" class="m-col-t-8 m-col-m-none m-left-m">My Python Project</a>
+    </div>
+  </div>
+</nav></header>
+<main><article>
+  <div class="m-container m-container-inflatable">
+    <div class="m-row">
+      <div class="m-col-l-10 m-push-l-1">
+        <h1>
+          <span class="m-breadcrumb"><a href="stubs_nested_classes.html">stubs_nested_classes</a>.<wbr/></span>Class <span class="m-thin">class</span>
+        </h1>
+        <nav class="m-block m-default">
+          <h3>Contents</h3>
+          <ul>
+            <li>
+              Reference
+              <ul>
+                <li><a href="#classes">Classes</a></li>
+                <li><a href="#properties">Properties</a></li>
+              </ul>
+            </li>
+          </ul>
+        </nav>
+        <section id="classes">
+          <h2><a href="#classes">Classes</a></h2>
+          <dl class="m-doc">
+            <dt>class <a href="stubs_nested_classes.Class.Inner.html" class="m-doc">Inner</a></dt>
+            <dd></dd>
+            <dt>class <a href="stubs_nested_classes.Class.InnerAnother.html" class="m-doc">InnerAnother</a></dt>
+            <dd></dd>
+          </dl>
+        </section>
+        <section id="properties">
+          <h2><a href="#properties">Properties</a></h2>
+          <dl class="m-doc">
+            <dt id="property">
+              <a href="#property" class="m-doc-self">property</a> <span class="m-label m-flat m-warning">get</span>
+            </dt>
+            <dd></dd>
+          </dl>
+        </section>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/stubs_nested_classes/stubs_nested_classes.html b/documentation/test_python/stubs_nested_classes/stubs_nested_classes.html
new file mode 100644 (file)
index 0000000..6db3268
--- /dev/null
@@ -0,0 +1,50 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>stubs_nested_classes | 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>
+          stubs_nested_classes <span class="m-thin">module</span>
+        </h1>
+        <nav class="m-block m-default">
+          <h3>Contents</h3>
+          <ul>
+            <li>
+              Reference
+              <ul>
+                <li><a href="#classes">Classes</a></li>
+              </ul>
+            </li>
+          </ul>
+        </nav>
+        <section id="classes">
+          <h2><a href="#classes">Classes</a></h2>
+          <dl class="m-doc">
+            <dt>class <a href="stubs_nested_classes.Class.html" class="m-doc">Class</a></dt>
+            <dd></dd>
+            <dt>class <a href="stubs_nested_classes.SomeOther.html" class="m-doc">SomeOther</a></dt>
+            <dd></dd>
+          </dl>
+        </section>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/stubs_nested_classes/stubs_nested_classes.py b/documentation/test_python/stubs_nested_classes/stubs_nested_classes.py
new file mode 100644 (file)
index 0000000..e2820d0
--- /dev/null
@@ -0,0 +1,22 @@
+class Class:
+    class Inner:
+        class OneMore:
+            ...
+
+        @staticmethod
+        def staticmethod():
+            ...
+
+    class InnerAnother:
+        class AndAnother:
+            class YetAnother:
+                DATA: float = 3
+
+            AND_DATA: str = 'b'
+
+    @property
+    def property(self):
+        ...
+
+class SomeOther:
+    ...
diff --git a/documentation/test_python/stubs_spacing/empty_module.py b/documentation/test_python/stubs_spacing/empty_module.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/documentation/test_python/stubs_spacing/stubs_spacing.py b/documentation/test_python/stubs_spacing/stubs_spacing.py
new file mode 100644 (file)
index 0000000..f49e1a3
--- /dev/null
@@ -0,0 +1,127 @@
+# This file is both an input and output, i.e. the generated stub should have
+# exactly the same spacing as the input.
+
+import enum
+
+class Enum1(enum.Enum):
+    VALUE1 = 0
+    VALUE2 = 1
+
+class Enum2(enum.Enum):
+    VALUE1 = 10
+    VALUE2 = 20
+    VALUE3 = 30
+
+class EnumEmpty(enum.Enum):
+    ...
+
+class Class1:
+    @staticmethod
+    def staticmethod1():
+        ...
+
+    @staticmethod
+    def staticmethod2():
+        ...
+
+    @staticmethod
+    def staticmethod3():
+        ...
+
+    @classmethod
+    def classmethod1(*args):
+        ...
+
+    @classmethod
+    def classmethod2(*args):
+        ...
+
+    @classmethod
+    def classmethod3(*args):
+        ...
+
+    def method1(self):
+        ...
+
+    def method2(self):
+        ...
+
+    def method3(self):
+        ...
+
+    def __dunder_method1__(self):
+        ...
+
+    def __dunder_method2__(self):
+        ...
+
+    def __dunder_method3__(self):
+        ...
+
+    @property
+    def property1(self):
+        ...
+
+    @property
+    def property2(self):
+        ...
+    @property2.setter
+    def property2(self, value):
+        ...
+
+    @property
+    def property3(self):
+        ...
+    @property3.deleter
+    def property3(self):
+        ...
+
+    @property
+    def property4(self):
+        ...
+    @property4.setter
+    def property4(self, value):
+        ...
+    @property4.deleter
+    def property4(self):
+        ...
+
+class Class2:
+    class InnerEnum1(enum.Enum):
+        VALUE1 = 0
+        VALUE2 = 1
+
+    class InnerEnum2(enum.Enum):
+        ...
+
+    class InnerClass1:
+        ...
+
+    class InnerClass2:
+        ...
+
+    INNER_DATA1: str = 'a'
+
+    INNER_DATA2: int = 3
+
+    INNER_DATA3: None = None
+
+class Class3:
+    class InnerClass:
+        class InnerInnerClass:
+            ...
+
+class ClassEmpty:
+    ...
+
+DATA1: str = 'b'
+
+DATA2: int = 7
+
+DATA3: float = 15.6
+
+def function1():
+    ...
+
+def function2():
+    ...
diff --git a/documentation/test_python/stubs_spacing/stubs_spacing.rst b/documentation/test_python/stubs_spacing/stubs_spacing.rst
new file mode 100644 (file)
index 0000000..8b13789
--- /dev/null
@@ -0,0 +1 @@
+
index cdc2bccca3fe97f2e3e5db1168627f9b7fd87a90..e527ddd2c9ab92dc7ffa05b36bfb9c8d67d190ca 100644 (file)
@@ -76,6 +76,16 @@ class HtmlEscape(BaseInspectTestCase):
         self.assertEqual(*self.actual_expected_contents('content_html_escape.Class.html'))
         self.assertEqual(*self.actual_expected_contents('content_html_escape.pybind.html'))
 
+    def test_stubs(self):
+        self.run_python_stubs({
+            'PYBIND11_COMPATIBILITY': True,
+        })
+
+        # Compared to the HTML output, *none* of these should have any HTML
+        # entities
+        self.assertEqual(*self.actual_expected_contents('content_html_escape/__init__.pyi'))
+        self.assertEqual(*self.actual_expected_contents('content_html_escape/pybind.pyi'))
+
     @unittest.skip("Page names are currently not exposed to search and there's nothing else that would require escaping, nothing to test")
     def test_search(self):
         # Re-run everything with search enabled, the search data shouldn't be
index 7bd2a0a62a600f93cd7ee1894742c2821b44123c..16fd3d31400219eb1ece84e5d3887ff1a0aba3e0 100644 (file)
@@ -61,6 +61,20 @@ class String(BaseInspectTestCase):
         self.assertEqual(*self.actual_expected_contents('classes.html'))
         self.assertEqual(*self.actual_expected_contents('modules.html'))
 
+    def test_stubs(self):
+        sys.path.append(self.path)
+        self.run_python_stubs({
+            'INPUT_MODULES': ['inspect_string', 'inspect_string.subpackage', 'inspect_string.subpackage.inner']
+        })
+
+        # Python 3.11 adds BaseException.add_note()
+        if sys.version_info >= (3, 11):
+            self.assertEqual(*self.actual_expected_contents('inspect_string/__init__.pyi'))
+        else:
+            self.assertEqual(*self.actual_expected_contents('inspect_string/__init__.pyi', 'inspect_string/__init__-310.pyi'))
+        self.assertEqual(*self.actual_expected_contents('inspect_string/subpackage/inner.pyi'))
+        self.assertEqual(*self.actual_expected_contents('inspect_string/another_module.pyi'))
+
 class Object(BaseInspectTestCase):
     def test(self):
         # Reuse the stuff from inspect_string, but this time reference it via
@@ -94,6 +108,24 @@ class Object(BaseInspectTestCase):
         self.assertEqual(*self.actual_expected_contents('classes.html', '../inspect_string/classes.html'))
         self.assertEqual(*self.actual_expected_contents('modules.html', '../inspect_string/modules.html'))
 
+    def test_stubs(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
+        import inspect_string.subpackage.inner
+        self.run_python_stubs({
+            'INPUT_MODULES': [inspect_string, inspect_string.subpackage, inspect_string.subpackage.inner]
+        })
+
+        # Python 3.11 adds BaseException.add_note()
+        if sys.version_info >= (3, 11):
+            self.assertEqual(*self.actual_expected_contents('inspect_string/__init__.pyi', '../inspect_string/stubs/inspect_string/__init__.pyi'))
+        else:
+            self.assertEqual(*self.actual_expected_contents('inspect_string/__init__.pyi', '../inspect_string/stubs/inspect_string/__init__-310.pyi'))
+        self.assertEqual(*self.actual_expected_contents('inspect_string/subpackage/inner.pyi', '../inspect_string/stubs/inspect_string/subpackage/inner.pyi'))
+        self.assertEqual(*self.actual_expected_contents('inspect_string/another_module.pyi', '../inspect_string/stubs/inspect_string/another_module.pyi'))
+
 class AllProperty(BaseInspectTestCase):
     def test(self):
         self.run_python()
@@ -116,6 +148,16 @@ class Annotations(BaseInspectTestCase):
         else:
             self.assertEqual(*self.actual_expected_contents('inspect_annotations.AContainer.html', 'inspect_annotations.AContainer-py36-38.html'))
 
+    def test_stubs(self):
+        self.run_python_stubs()
+        # TODO handle TypeVar correctly
+        if sys.version_info >= (3, 9):
+            self.assertEqual(*self.actual_expected_contents('inspect_annotations.pyi'))
+        elif sys.version_info >= (3, 7) and sys.version_info < (3, 9):
+            self.assertEqual(*self.actual_expected_contents('inspect_annotations.pyi', 'inspect_annotations-py37+38.pyi'))
+        else:
+            self.assertEqual(*self.actual_expected_contents('inspect_annotations.pyi', 'inspect_annotations-py36.pyi'))
+
 class Builtin(BaseInspectTestCase):
     def test(self):
         self.run_python({
@@ -144,6 +186,17 @@ class Builtin(BaseInspectTestCase):
         else:
             self.assertEqual(*self.actual_expected_contents('inspect_builtin.BaseException.html', 'inspect_builtin.BaseException-310.html'))
 
+    def test_stubs(self):
+        self.run_python_stubs()
+
+        # Python 3.11 adds BaseException.add_note()
+        if sys.version_info >= (3, 11):
+            self.assertEqual(*self.actual_expected_contents('inspect_builtin.pyi'))
+        elif sys.version_info >= (3, 7):
+            self.assertEqual(*self.actual_expected_contents('inspect_builtin.pyi', 'inspect_builtin-310.pyi'))
+        else:
+            self.assertEqual(*self.actual_expected_contents('inspect_builtin.pyi', 'inspect_builtin-36.pyi'))
+
 class NameMapping(BaseInspectTestCase):
     def test(self):
         self.run_python({
@@ -155,6 +208,24 @@ class NameMapping(BaseInspectTestCase):
         self.assertEqual(*self.actual_expected_contents('inspect_name_mapping.Class.html'))
         self.assertEqual(*self.actual_expected_contents('inspect_name_mapping.submodule.html'))
 
+    def test_stubs(self):
+        self.run_python_stubs({
+            'NAME_MAPPING': {
+                # There will not be any `import yay` or anything for this, the
+                # assumption is that the name mapping makes sense without
+                # having to do something extra
+                'inspect_name_mapping._sub.bar._NameThatGetsOverridenExternally': 'yay.ThisGotOverridenExternally'
+            },
+            # So it looks like a regular Python file so I can verify the
+            # imports (KDevelop doesn't look for .pyi for imports)
+            'STUB_EXTENSION': '.py'
+        })
+        # The stubs/ directory is implicitly prepended only for *.pyi files to
+        # make testing against the input itself possible, so here I have to do
+        # it manually.
+        self.assertEqual(*self.actual_expected_contents('inspect_name_mapping/__init__.py', 'stubs/inspect_name_mapping/__init__.py'))
+        self.assertEqual(*self.actual_expected_contents('inspect_name_mapping/submodule.py', 'stubs/inspect_name_mapping/submodule.py'))
+
 class Recursive(BaseInspectTestCase):
     def test(self):
         self.run_python()
@@ -246,6 +317,14 @@ class Attrs(BaseInspectTestCase):
             self.assertEqual(*self.actual_expected_contents('inspect_attrs.MyClassAutoAttribs.html', 'inspect_attrs.MyClassAutoAttribs-attrs193.html'))
             self.assertEqual(*self.actual_expected_contents('inspect_attrs.MySlotClass.html', 'inspect_attrs.MySlotClass-attrs193.html'))
 
+    def test_stubs(self):
+        self.run_python_stubs({
+            'PLUGINS': ['m.sphinx'],
+            'INPUT_DOCS': ['docs.rst'],
+            'ATTRS_COMPATIBILITY': True
+        })
+        self.assertEqual(*self.actual_expected_contents('inspect_attrs.pyi'))
+
 class Underscored(BaseInspectTestCase):
     def test(self):
         self.run_python({
@@ -262,6 +341,10 @@ class ValueFormatting(BaseInspectTestCase):
         self.run_python({})
         self.assertEqual(*self.actual_expected_contents('inspect_value_formatting.html'))
 
+    def test_stubs(self):
+        self.run_python_stubs()
+        self.assertEqual(*self.actual_expected_contents('inspect_value_formatting.pyi'))
+
 class DuplicateClass(BaseInspectTestCase):
     def test(self):
         self.run_python({})
index 3e8582cf7db46c0255199974887e20c582884643..640945cfcadff47e78a76576018d17bc12a8cca8 100644 (file)
@@ -404,6 +404,25 @@ class Signatures(BaseInspectTestCase):
         if pybind_signatures.MyClass26.is_pybind26:
             self.assertEqual(*self.actual_expected_contents('pybind_signatures.MyClass26.html'))
 
+    def test_stubs(self):
+        sys.path.append(self.path)
+        import pybind_signatures
+        self.run_python_stubs({
+            # Nothing in false_positives that would affect stub generation and
+            # wouldn't be tested by the HTML output already
+            'INPUT_MODULES': [pybind_signatures],
+            'PYBIND11_COMPATIBILITY': True
+        })
+
+        # TODO handle writeonly properties correctly
+        if pybind_signatures.MyClass26.is_pybind26:
+            self.assertEqual(*self.actual_expected_contents('pybind_signatures/__init__.pyi'))
+        elif pybind_signatures.MyClass23.is_pybind23:
+            self.assertEqual(*self.actual_expected_contents('pybind_signatures/__init__.pyi', 'pybind_signatures/__init__-pybind25.pyi'))
+        else:
+            self.assertEqual(*self.actual_expected_contents('pybind_signatures/__init__.pyi', 'pybind_signatures/__init__-pybind22.pyi'))
+        self.assertEqual(*self.actual_expected_contents('pybind_signatures/just_overloads.pyi'))
+
 class Enums(BaseInspectTestCase):
     def test(self):
         self.run_python({
@@ -413,6 +432,14 @@ class Enums(BaseInspectTestCase):
         })
         self.assertEqual(*self.actual_expected_contents('pybind_enums.html'))
 
+    def test_stubs(self):
+        self.run_python_stubs({
+            'PLUGINS': ['m.sphinx'],
+            'INPUT_DOCS': ['docs.rst'],
+            'PYBIND11_COMPATIBILITY': True,
+        })
+        self.assertEqual(*self.actual_expected_contents('pybind_enums.pyi'))
+
 class Submodules(BaseInspectTestCase):
     def test(self):
         self.run_python({
@@ -420,6 +447,9 @@ class Submodules(BaseInspectTestCase):
         })
         self.assertEqual(*self.actual_expected_contents('pybind_submodules.html'))
 
+    # Nothing pybind-specific here that would affect stub generation and
+    # wouldn't be tested by the HTML output already
+
 class SubmodulesPackage(BaseInspectTestCase):
     def test(self):
         self.run_python({
@@ -427,6 +457,9 @@ class SubmodulesPackage(BaseInspectTestCase):
         })
         self.assertEqual(*self.actual_expected_contents('pybind_submodules_package.sub.html'))
 
+    # Nothing pybind-specific here that would affect stub generation and
+    # wouldn't be tested by the HTML output already
+
 class NameMapping(BaseInspectTestCase):
     def test(self):
         self.run_python({
@@ -436,6 +469,19 @@ class NameMapping(BaseInspectTestCase):
         self.assertEqual(*self.actual_expected_contents('pybind_name_mapping.Class.html'))
         self.assertEqual(*self.actual_expected_contents('pybind_name_mapping.submodule.html'))
 
+    def test_stubs(self):
+        self.run_python_stubs({
+            'PYBIND11_COMPATIBILITY': True,
+            # So it looks like a regular Python file so I can verify the
+            # imports (KDevelop doesn't look for .pyi for imports)
+            'STUB_EXTENSION': '.py'
+        })
+        # The stubs/ directory is implicitly prepended only for *.pyi files to
+        # make testing against the input itself possible, so here I have to do
+        # it manually.
+        self.assertEqual(*self.actual_expected_contents('pybind_name_mapping/__init__.py', 'stubs/pybind_name_mapping/__init__.py'))
+        self.assertEqual(*self.actual_expected_contents('pybind_name_mapping/submodule.py', 'stubs/pybind_name_mapping/submodule.py'))
+
 class TypeLinks(BaseInspectTestCase):
     def test(self):
         sys.path.append(self.path)
diff --git a/documentation/test_python/test_stubs.py b/documentation/test_python/test_stubs.py
new file mode 100644 (file)
index 0000000..e84e04c
--- /dev/null
@@ -0,0 +1,116 @@
+#
+#   This file is part of m.css.
+#
+#   Copyright © 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024
+#             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 unittest
+
+from . import BaseInspectTestCase
+
+from _search import pretty_print, searchdata_filename
+from python import EntryType
+
+class CustomHeaderExtension(BaseInspectTestCase):
+    def test(self):
+        self.run_python_stubs({
+            'STUB_HEADER': '#!/usr/bin/env python3\n\n# This is a custom header containing no trailing newline on its own.',
+            'STUB_EXTENSION': '.custom.py'
+        })
+
+        # The stubs/ directory is implicitly prepended only for *.pyi files to
+        # make testing against the input itself possible, so here I have to do
+        # it manually.
+        self.assertEqual(*self.actual_expected_contents('stubs_custom_header_extension/__init__.custom.py', 'stubs/stubs_custom_header_extension/__init__.custom.py'))
+        self.assertEqual(*self.actual_expected_contents('stubs_custom_header_extension/sub.custom.py', 'stubs/stubs_custom_header_extension/__init__.custom.py'))
+
+class ModuleDependencies(BaseInspectTestCase):
+    def test(self):
+        sys.path.append(self.path)
+        self.run_python_stubs({
+            # unparsed_module explicitly not included
+            'INPUT_MODULES': ['stubs_module_dependencies', 'stubs_module_dependencies.sub', 'stubs_module_dependencies.sub.inner', 'another_module'],
+            'PYBIND11_COMPATIBILITY': True,
+            # So it looks like a regular Python file so I can verify the
+            # imports (KDevelop doesn't look for .pyi for imports)
+            'STUB_EXTENSION': '.py'
+        })
+
+        # The stubs/ directory is implicitly prepended only for *.pyi files to
+        # make testing against the input itself possible, so here I have to do
+        # it manually.
+        self.assertEqual(*self.actual_expected_contents('stubs_module_dependencies/__init__.py', 'stubs/stubs_module_dependencies/__init__.py'))
+        self.assertEqual(*self.actual_expected_contents('stubs_module_dependencies/sub/inner.py', 'stubs/stubs_module_dependencies/sub/inner.py'))
+
+class NestedClasses(BaseInspectTestCase):
+    def test(self):
+        self.run_python_stubs({
+            'STUB_EXTENSION': '.py'
+        })
+
+        # The output should be the same as the input, yes
+        self.assertEqual(*self.actual_expected_contents('stubs_nested_classes.py'))
+
+    def test_html(self):
+        self.run_python()
+
+        self.assertEqual(*self.actual_expected_contents('stubs_nested_classes.Class.html'))
+        self.assertEqual(*self.actual_expected_contents('stubs_nested_classes.Class.Inner.html'))
+        self.assertEqual(*self.actual_expected_contents('stubs_nested_classes.Class.InnerAnother.AndAnother.html'))
+        self.assertEqual(*self.actual_expected_contents('stubs_nested_classes.Class.InnerAnother.AndAnother.YetAnother.html'))
+
+    def test_both(self):
+        self.run_python({
+            'OUTPUT_STUBS': os.path.join(self.path, 'output'),
+            'STUB_HEADER': '',
+            'STUB_EXTENSION': '.py'
+        })
+
+        # There shouldn't be any difference compared to running these separately
+        self.assertEqual(*self.actual_expected_contents('stubs_nested_classes.py'))
+        self.assertEqual(*self.actual_expected_contents('stubs_nested_classes.Class.html'))
+        self.assertEqual(*self.actual_expected_contents('stubs_nested_classes.Class.Inner.html'))
+        self.assertEqual(*self.actual_expected_contents('stubs_nested_classes.Class.InnerAnother.AndAnother.html'))
+        self.assertEqual(*self.actual_expected_contents('stubs_nested_classes.Class.InnerAnother.AndAnother.YetAnother.html'))
+
+class Spacing(BaseInspectTestCase):
+    def test(self):
+        self.run_python_stubs({
+            'STUB_HEADER': '# This file is both an input and output, i.e. the generated stub should have\n# exactly the same spacing as the input.',
+            'STUB_EXTENSION': '.py'
+        })
+
+        # The output should be the same as the input, yes
+        # TODO make classmethod accept cls instead of *args once it's fixed
+        self.assertEqual(*self.actual_expected_contents('stubs_spacing.py'))
+
+    def test_empty_module(self):
+        sys.path.append(self.path)
+        self.run_python_stubs({
+            'INPUT_MODULES': ['empty_module'],
+            'STUB_EXTENSION': '.py'
+        })
+
+        # The output should be the same as the input, yes
+        self.assertEqual(*self.actual_expected_contents('empty_module.py'))