From 699abdd57242d33291a0ea4ee333c7d4de11a30c Mon Sep 17 00:00:00 2001 From: =?utf8?q?Vladim=C3=ADr=20Vondru=C5=A1?= Date: Thu, 26 Sep 2024 15:57:09 +0200 Subject: [PATCH] documentation/python: initial support for generating Python stubs. 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. --- doc/documentation/python.rst | 123 ++- documentation/python.py | 719 ++++++++++++------ documentation/templates/python/stub.pyi | 97 +++ documentation/test_python/CMakeLists.txt | 6 + documentation/test_python/__init__.py | 26 +- .../stubs/content_html_escape/__init__.pyi | 29 + .../stubs/content_html_escape/pybind.pyi | 2 + .../stubs/inspect_annotations-py36.pyi | 120 +++ .../stubs/inspect_annotations-py37+38.pyi | 121 +++ .../stubs/inspect_annotations.pyi | 121 +++ .../inspect_attrs/stubs/inspect_attrs.pyi | 107 +++ .../stubs/inspect_builtin-310.pyi | 27 + .../stubs/inspect_builtin-36.pyi | 24 + .../inspect_builtin/stubs/inspect_builtin.pyi | 30 + .../stubs/inspect_name_mapping/__init__.py | 6 + .../stubs/inspect_name_mapping/submodule.py | 4 + .../stubs/inspect_string/__init__-310.pyi | 123 +++ .../stubs/inspect_string/__init__.pyi | 126 +++ .../stubs/inspect_string/another_module.pyi | 0 .../stubs/inspect_string/subpackage/inner.pyi | 0 .../stubs/inspect_value_formatting.pyi | 25 + .../pybind_enums/stubs/pybind_enums.pyi | 11 + .../stubs/pybind_name_mapping/__init__.py | 7 + .../stubs/pybind_name_mapping/submodule.py | 4 + .../pybind_signatures/pybind_signatures.cpp | 4 + .../pybind_signatures/pybind_signatures.html | 8 + .../pybind_signatures/__init__-pybind22.pyi | 124 +++ .../pybind_signatures/__init__-pybind25.pyi | 138 ++++ .../stubs/pybind_signatures/__init__.pyi | 150 ++++ .../pybind_signatures/just_overloads.pyi | 9 + .../__init__.custom.py | 3 + .../sub.custom.py | 3 + .../stubs_custom_header_extension/__init__.py | 1 + .../stubs_custom_header_extension/sub.py | 0 .../another_module.py | 2 + .../stubs_module_dependencies/__init__.py | 27 + .../pybind/__init__.py | 5 + .../stubs_module_dependencies/sub/inner.py | 14 + .../stubs_module_dependencies/__init__.py | 30 + .../stubs_module_dependencies/pybind.cpp | 18 + .../stubs_module_dependencies/sub/__init__.py | 3 + .../stubs_module_dependencies/sub/inner.py | 19 + .../unparsed_enum_module.py | 11 + .../unparsed_module.py | 5 + .../stubs_nested_classes.Class.Inner.html | 58 ++ ...ss.InnerAnother.AndAnother.YetAnother.html | 50 ++ ...classes.Class.InnerAnother.AndAnother.html | 58 ++ .../stubs_nested_classes.Class.html | 60 ++ .../stubs_nested_classes.html | 50 ++ .../stubs_nested_classes.py | 22 + .../test_python/stubs_spacing/empty_module.py | 0 .../stubs_spacing/stubs_spacing.py | 127 ++++ .../stubs_spacing/stubs_spacing.rst | 1 + documentation/test_python/test_content.py | 10 + documentation/test_python/test_inspect.py | 83 ++ documentation/test_python/test_pybind.py | 46 ++ documentation/test_python/test_stubs.py | 116 +++ 57 files changed, 2862 insertions(+), 251 deletions(-) create mode 100644 documentation/templates/python/stub.pyi create mode 100644 documentation/test_python/content_html_escape/stubs/content_html_escape/__init__.pyi create mode 100644 documentation/test_python/content_html_escape/stubs/content_html_escape/pybind.pyi create mode 100644 documentation/test_python/inspect_annotations/stubs/inspect_annotations-py36.pyi create mode 100644 documentation/test_python/inspect_annotations/stubs/inspect_annotations-py37+38.pyi create mode 100644 documentation/test_python/inspect_annotations/stubs/inspect_annotations.pyi create mode 100644 documentation/test_python/inspect_attrs/stubs/inspect_attrs.pyi create mode 100644 documentation/test_python/inspect_builtin/stubs/inspect_builtin-310.pyi create mode 100644 documentation/test_python/inspect_builtin/stubs/inspect_builtin-36.pyi create mode 100644 documentation/test_python/inspect_builtin/stubs/inspect_builtin.pyi create mode 100644 documentation/test_python/inspect_name_mapping/stubs/inspect_name_mapping/__init__.py create mode 100644 documentation/test_python/inspect_name_mapping/stubs/inspect_name_mapping/submodule.py create mode 100644 documentation/test_python/inspect_string/stubs/inspect_string/__init__-310.pyi create mode 100644 documentation/test_python/inspect_string/stubs/inspect_string/__init__.pyi create mode 100644 documentation/test_python/inspect_string/stubs/inspect_string/another_module.pyi create mode 100644 documentation/test_python/inspect_string/stubs/inspect_string/subpackage/inner.pyi create mode 100644 documentation/test_python/inspect_value_formatting/stubs/inspect_value_formatting.pyi create mode 100644 documentation/test_python/pybind_enums/stubs/pybind_enums.pyi create mode 100644 documentation/test_python/pybind_name_mapping/stubs/pybind_name_mapping/__init__.py create mode 100644 documentation/test_python/pybind_name_mapping/stubs/pybind_name_mapping/submodule.py create mode 100644 documentation/test_python/pybind_signatures/stubs/pybind_signatures/__init__-pybind22.pyi create mode 100644 documentation/test_python/pybind_signatures/stubs/pybind_signatures/__init__-pybind25.pyi create mode 100644 documentation/test_python/pybind_signatures/stubs/pybind_signatures/__init__.pyi create mode 100644 documentation/test_python/pybind_signatures/stubs/pybind_signatures/just_overloads.pyi create mode 100644 documentation/test_python/stubs_custom_header_extension/stubs/stubs_custom_header_extension/__init__.custom.py create mode 100644 documentation/test_python/stubs_custom_header_extension/stubs/stubs_custom_header_extension/sub.custom.py create mode 100644 documentation/test_python/stubs_custom_header_extension/stubs_custom_header_extension/__init__.py create mode 100644 documentation/test_python/stubs_custom_header_extension/stubs_custom_header_extension/sub.py create mode 100644 documentation/test_python/stubs_module_dependencies/another_module.py create mode 100644 documentation/test_python/stubs_module_dependencies/stubs/stubs_module_dependencies/__init__.py create mode 100644 documentation/test_python/stubs_module_dependencies/stubs/stubs_module_dependencies/pybind/__init__.py create mode 100644 documentation/test_python/stubs_module_dependencies/stubs/stubs_module_dependencies/sub/inner.py create mode 100644 documentation/test_python/stubs_module_dependencies/stubs_module_dependencies/__init__.py create mode 100644 documentation/test_python/stubs_module_dependencies/stubs_module_dependencies/pybind.cpp create mode 100644 documentation/test_python/stubs_module_dependencies/stubs_module_dependencies/sub/__init__.py create mode 100644 documentation/test_python/stubs_module_dependencies/stubs_module_dependencies/sub/inner.py create mode 100644 documentation/test_python/stubs_module_dependencies/unparsed_enum_module.py create mode 100644 documentation/test_python/stubs_module_dependencies/unparsed_module.py create mode 100644 documentation/test_python/stubs_nested_classes/stubs_nested_classes.Class.Inner.html create mode 100644 documentation/test_python/stubs_nested_classes/stubs_nested_classes.Class.InnerAnother.AndAnother.YetAnother.html create mode 100644 documentation/test_python/stubs_nested_classes/stubs_nested_classes.Class.InnerAnother.AndAnother.html create mode 100644 documentation/test_python/stubs_nested_classes/stubs_nested_classes.Class.html create mode 100644 documentation/test_python/stubs_nested_classes/stubs_nested_classes.html create mode 100644 documentation/test_python/stubs_nested_classes/stubs_nested_classes.py create mode 100644 documentation/test_python/stubs_spacing/empty_module.py create mode 100644 documentation/test_python/stubs_spacing/stubs_spacing.py create mode 100644 documentation/test_python/stubs_spacing/stubs_spacing.rst create mode 100644 documentation/test_python/test_stubs.py diff --git a/doc/documentation/python.rst b/doc/documentation/python.rst index 5e7f6b80..8820c1ad 100644 --- a/doc/documentation/python.rst +++ b/doc/documentation/python.rst @@ -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 ` +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 ` 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 ` 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:`` 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 diff --git a/documentation/python.py b/documentation/python.py index 2465dc3a..6b6c4608 100755 --- a/documentation/python.py +++ b/documentation/python.py @@ -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, '{}'.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 /__init__.pyi so + # submodules can be next to it. It could be done always, but writing + # just .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 index 00000000..9431c0e7 --- /dev/null +++ b/documentation/templates/python/stub.pyi @@ -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 %} diff --git a/documentation/test_python/CMakeLists.txt b/documentation/test_python/CMakeLists.txt index bb7457ab..cb3cb9af 100644 --- a/documentation/test_python/CMakeLists.txt +++ b/documentation/test_python/CMakeLists.txt @@ -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) diff --git a/documentation/test_python/__init__.py b/documentation/test_python/__init__.py index 16b62a1f..50ed76d1 100644 --- a/documentation/test_python/__init__.py +++ b/documentation/test_python/__init__.py @@ -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 index 00000000..b2fd29b4 --- /dev/null +++ b/documentation/test_python/content_html_escape/stubs/content_html_escape/__init__.pyi @@ -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 index 00000000..55f92fe7 --- /dev/null +++ b/documentation/test_python/content_html_escape/stubs/content_html_escape/pybind.pyi @@ -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 index 00000000..9868aabb --- /dev/null +++ b/documentation/test_python/inspect_annotations/stubs/inspect_annotations-py36.pyi @@ -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 index 00000000..7a8d4203 --- /dev/null +++ b/documentation/test_python/inspect_annotations/stubs/inspect_annotations-py37+38.pyi @@ -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 index 00000000..42c2f30b --- /dev/null +++ b/documentation/test_python/inspect_annotations/stubs/inspect_annotations.pyi @@ -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 index 00000000..9acd8843 --- /dev/null +++ b/documentation/test_python/inspect_attrs/stubs/inspect_attrs.pyi @@ -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 index 00000000..35fcc671 --- /dev/null +++ b/documentation/test_python/inspect_builtin/stubs/inspect_builtin-310.pyi @@ -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 index 00000000..e7adbb7f --- /dev/null +++ b/documentation/test_python/inspect_builtin/stubs/inspect_builtin-36.pyi @@ -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 index 00000000..acd0dd30 --- /dev/null +++ b/documentation/test_python/inspect_builtin/stubs/inspect_builtin.pyi @@ -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 index 00000000..d09cdbc1 --- /dev/null +++ b/documentation/test_python/inspect_name_mapping/stubs/inspect_name_mapping/__init__.py @@ -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 index 00000000..1b9d4686 --- /dev/null +++ b/documentation/test_python/inspect_name_mapping/stubs/inspect_name_mapping/submodule.py @@ -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 index 00000000..a23171dd --- /dev/null +++ b/documentation/test_python/inspect_string/stubs/inspect_string/__init__-310.pyi @@ -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 index 00000000..0a8cb2d1 --- /dev/null +++ b/documentation/test_python/inspect_string/stubs/inspect_string/__init__.pyi @@ -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 index 00000000..e69de29b 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 index 00000000..e69de29b 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 index 00000000..2e7a05dc --- /dev/null +++ b/documentation/test_python/inspect_value_formatting/stubs/inspect_value_formatting.pyi @@ -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 index 00000000..67d751aa --- /dev/null +++ b/documentation/test_python/pybind_enums/stubs/pybind_enums.pyi @@ -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 index 00000000..30aa5541 --- /dev/null +++ b/documentation/test_python/pybind_name_mapping/stubs/pybind_name_mapping/__init__.py @@ -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 index 00000000..689bcc6f --- /dev/null +++ b/documentation/test_python/pybind_name_mapping/stubs/pybind_name_mapping/submodule.py @@ -0,0 +1,4 @@ +from . import * + +def foo(arg0: Class, arg1: int, /) -> int: + ... diff --git a/documentation/test_python/pybind_signatures/pybind_signatures.cpp b/documentation/test_python/pybind_signatures/pybind_signatures.cpp index 595d5685..61e25f54 100644 --- a/documentation/test_python/pybind_signatures/pybind_signatures.cpp +++ b/documentation/test_python/pybind_signatures/pybind_signatures.cpp @@ -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(&overloaded), "Overloaded for ints") + .def("overloaded", static_cast(&overloaded), "Overloaded for floats"); + py::class_ pybind23{m, "MyClass23", "Testing pybind 2.3 features"}; /* Checker so the Python side can detect if testing pybind 2.3 features is diff --git a/documentation/test_python/pybind_signatures/pybind_signatures.html b/documentation/test_python/pybind_signatures/pybind_signatures.html index a276ed19..41006c58 100644 --- a/documentation/test_python/pybind_signatures/pybind_signatures.html +++ b/documentation/test_python/pybind_signatures/pybind_signatures.html @@ -29,12 +29,20 @@
  • Reference
  • +
    +

    Modules

    +
    +
    module just_overloads
    +
    Stubs for this module should import typing as well
    +
    +

    Classes

    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 index 00000000..a9621047 --- /dev/null +++ b/documentation/test_python/pybind_signatures/stubs/pybind_signatures/__init__-pybind22.pyi @@ -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 = '') -> 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 index 00000000..dc12283c --- /dev/null +++ b/documentation/test_python/pybind_signatures/stubs/pybind_signatures/__init__-pybind25.pyi @@ -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 = '') -> 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 index 00000000..ce8bfcb6 --- /dev/null +++ b/documentation/test_python/pybind_signatures/stubs/pybind_signatures/__init__.pyi @@ -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 = '') -> 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 index 00000000..646e5d41 --- /dev/null +++ b/documentation/test_python/pybind_signatures/stubs/pybind_signatures/just_overloads.pyi @@ -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 index 00000000..c58b9044 --- /dev/null +++ b/documentation/test_python/stubs_custom_header_extension/stubs/stubs_custom_header_extension/__init__.custom.py @@ -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 index 00000000..c58b9044 --- /dev/null +++ b/documentation/test_python/stubs_custom_header_extension/stubs/stubs_custom_header_extension/sub.custom.py @@ -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 index 00000000..9ded2a2e --- /dev/null +++ b/documentation/test_python/stubs_custom_header_extension/stubs_custom_header_extension/__init__.py @@ -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 index 00000000..e69de29b 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 index 00000000..d95cb049 --- /dev/null +++ b/documentation/test_python/stubs_module_dependencies/another_module.py @@ -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 index 00000000..c98490cc --- /dev/null +++ b/documentation/test_python/stubs_module_dependencies/stubs/stubs_module_dependencies/__init__.py @@ -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 index 00000000..11437d0d --- /dev/null +++ b/documentation/test_python/stubs_module_dependencies/stubs/stubs_module_dependencies/pybind/__init__.py @@ -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 index 00000000..0ae63444 --- /dev/null +++ b/documentation/test_python/stubs_module_dependencies/stubs/stubs_module_dependencies/sub/inner.py @@ -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 index 00000000..b3a7497d --- /dev/null +++ b/documentation/test_python/stubs_module_dependencies/stubs_module_dependencies/__init__.py @@ -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 index 00000000..efe84f57 --- /dev/null +++ b/documentation/test_python/stubs_module_dependencies/stubs_module_dependencies/pybind.cpp @@ -0,0 +1,18 @@ +#include +#include + +namespace py = pybind11; + +namespace { + +struct Foo {}; + +std::function function(const Foo&) { return []{ return 3; }; } + +} + +PYBIND11_MODULE(pybind, m) { + py::class_{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 index 00000000..475004c0 --- /dev/null +++ b/documentation/test_python/stubs_module_dependencies/stubs_module_dependencies/sub/__init__.py @@ -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 index 00000000..cf441e76 --- /dev/null +++ b/documentation/test_python/stubs_module_dependencies/stubs_module_dependencies/sub/inner.py @@ -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 index 00000000..5cc3cd60 --- /dev/null +++ b/documentation/test_python/stubs_module_dependencies/unparsed_enum_module.py @@ -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 index 00000000..66fbf525 --- /dev/null +++ b/documentation/test_python/stubs_module_dependencies/unparsed_module.py @@ -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 index 00000000..938f17a9 --- /dev/null +++ b/documentation/test_python/stubs_nested_classes/stubs_nested_classes.Class.Inner.html @@ -0,0 +1,58 @@ + + + + + stubs_nested_classes.Class.Inner | My Python Project + + + + + +
    +
    + + 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 index 00000000..bdccf3a2 --- /dev/null +++ b/documentation/test_python/stubs_nested_classes/stubs_nested_classes.Class.InnerAnother.AndAnother.YetAnother.html @@ -0,0 +1,50 @@ + + + + + stubs_nested_classes.Class.InnerAnother.AndAnother.YetAnother | My Python Project + + + + + +
    +
    + + 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 index 00000000..5d1627f7 --- /dev/null +++ b/documentation/test_python/stubs_nested_classes/stubs_nested_classes.Class.InnerAnother.AndAnother.html @@ -0,0 +1,58 @@ + + + + + stubs_nested_classes.Class.InnerAnother.AndAnother | My Python Project + + + + + +
    +
    + + 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 index 00000000..cdd4c422 --- /dev/null +++ b/documentation/test_python/stubs_nested_classes/stubs_nested_classes.Class.html @@ -0,0 +1,60 @@ + + + + + stubs_nested_classes.Class | My Python Project + + + + + +
    +
    + + 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 index 00000000..6db3268b --- /dev/null +++ b/documentation/test_python/stubs_nested_classes/stubs_nested_classes.html @@ -0,0 +1,50 @@ + + + + + stubs_nested_classes | My Python Project + + + + + +
    +
    +
    +
    +
    +

    + stubs_nested_classes module +

    + +
    +

    Classes

    +
    +
    class Class
    +
    +
    class SomeOther
    +
    +
    +
    +
    +
    +
    +
    + + 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 index 00000000..e2820d05 --- /dev/null +++ b/documentation/test_python/stubs_nested_classes/stubs_nested_classes.py @@ -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 index 00000000..e69de29b diff --git a/documentation/test_python/stubs_spacing/stubs_spacing.py b/documentation/test_python/stubs_spacing/stubs_spacing.py new file mode 100644 index 00000000..f49e1a31 --- /dev/null +++ b/documentation/test_python/stubs_spacing/stubs_spacing.py @@ -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 index 00000000..8b137891 --- /dev/null +++ b/documentation/test_python/stubs_spacing/stubs_spacing.rst @@ -0,0 +1 @@ + diff --git a/documentation/test_python/test_content.py b/documentation/test_python/test_content.py index cdc2bccc..e527ddd2 100644 --- a/documentation/test_python/test_content.py +++ b/documentation/test_python/test_content.py @@ -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 diff --git a/documentation/test_python/test_inspect.py b/documentation/test_python/test_inspect.py index 7bd2a0a6..16fd3d31 100644 --- a/documentation/test_python/test_inspect.py +++ b/documentation/test_python/test_inspect.py @@ -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({}) diff --git a/documentation/test_python/test_pybind.py b/documentation/test_python/test_pybind.py index 3e8582cf..640945cf 100644 --- a/documentation/test_python/test_pybind.py +++ b/documentation/test_python/test_pybind.py @@ -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 index 00000000..e84e04cb --- /dev/null +++ b/documentation/test_python/test_stubs.py @@ -0,0 +1,116 @@ +# +# This file is part of m.css. +# +# Copyright © 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024 +# Vladimír Vondruš +# +# 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')) -- 2.30.2