diff --git a/.vscode/settings.json b/.vscode/settings.json index 371c316..aa4016e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,6 +18,9 @@ "editor.defaultFormatter": "ms-python.black-formatter" }, "cSpell.words": [ - "pysqlsync" + "autodoc", + "Levente", + "pysqlsync", + "setuptools" ] } diff --git a/README.md b/README.md index cb3388f..f4344e9 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ This extension to Sphinx [autodoc](https://www.sphinx-doc.org/en/master/usage/ex 1. Ensure that you have type hints in all your classes, functions and methods. 2. Add description to your classes, functions and methods as a doc-string. 3. Use `:param name: text` to assign a description to member variables and function parameters. -4. Register `DocstringProcessor` to the event `autodoc-process-docstring` in Sphinx's `conf.py`. +4. Register `Processor` to the events `autodoc-process-docstring` and `autodoc-before-process-signature` in Sphinx's `conf.py`. 5. Enjoy how type information is automatically injected in the doc-string on `make html`. ## Motivation @@ -49,3 +49,12 @@ def send_message( :returns: The message identifier. """ ``` + +## Features + +* Data-class member variables are published if they have a corresponding `:param ...:` in the class-level doc-string. +* All enumeration members are published, even if they lack a description. +* Magic methods (e.g. `__eq__`) are published if they have a doc-string. +* Multi-line code blocks in doc-strings are converted to syntax-highlighted monospace text. +* Primary keys in entity classes have extra visuals (e.g. with a symbol 🔑). See [pysqlsync](https://github.com/hunyadi/pysqlsync) for how to define an entity class (using the `@dataclass` syntax) with a primary key (with the type hint `PrimaryKey[T]`). +* Type aliases are substituted even if [Postponed Evaluation of Annotations (PEP 563)](https://peps.python.org/pep-0563/) is turned off. diff --git a/doc/conf.py b/doc/conf.py index 0660751..f7bb1a8 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -6,7 +6,7 @@ from sphinx.application import Sphinx from strong_typing.core import JsonType -from sphinx_doc.autodoc import DocstringProcessor, skip_member +from sphinx_doc.autodoc import Processor, skip_member sys.path.insert(0, os.path.abspath(".")) @@ -53,6 +53,7 @@ add_module_names = False autoclass_content = "class" autodoc_class_signature = "separated" +autodoc_member_order = "bysource" autodoc_default_options = { "exclude-members": "__init__", "member-order": "bysource", @@ -76,8 +77,7 @@ class json: def setup(app: Sphinx) -> None: - app.connect( - "autodoc-process-docstring", - DocstringProcessor(type_transform={JsonType: json}), - ) + processor = Processor(type_transform={JsonType: json}) + app.connect("autodoc-process-docstring", processor.process_docstring) + app.connect("autodoc-before-process-signature", processor.process_signature) app.connect("autodoc-skip-member", skip_member) diff --git a/doc/example.py b/doc/example.py index 39f3eae..a764459 100644 --- a/doc/example.py +++ b/doc/example.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import enum import sys from dataclasses import dataclass @@ -7,6 +5,7 @@ from typing import Union from pysqlsync.model.key_types import PrimaryKey, Unique +from strong_typing.auxiliary import int8, int16, uint32, uint64 from strong_typing.core import JsonType, Schema if sys.version_info > (3, 10): @@ -42,6 +41,25 @@ class SampleClass: """ A data-class with several member variables. + Class doc-strings can include code blocks. + + A code block formatted as HTML: + + ```html + + +

A paragraph.

+ + + ``` + + A code block formatted as Python: + + ```python + if sys.version_info > (3, 10): + SimpleType = bool | int | float | str + ``` + :param boolean: A member variable of type `bool`. :param integer: A member variable of type `int`. :param double: A member variable of type `float`. @@ -75,7 +93,7 @@ def __gt__(self, other: "SampleClass") -> bool: return self.integer > other.integer - def to_json(self) -> JsonType: + def to_json(self) -> "JsonType": """ Serializes the data to JSON. @@ -84,7 +102,7 @@ def to_json(self) -> JsonType: ... @staticmethod - def from_json(obj: JsonType) -> "SampleClass": + def from_json(obj: "JsonType") -> "SampleClass": """ De-serializes the data from JSON. @@ -109,6 +127,23 @@ class DerivedClass(SampleClass): schema: Schema +@dataclass +class FixedWidthIntegers: + """ + Fixed-width integers have a compact representation. + + :param integer8: A signed integer of 8 bits. + :param integer16: A signed integer of 16 bits. + :param unsigned32: An unsigned integer of 32 bits. + :param unsigned64: An unsigned integer of 64 bits. + """ + + integer8: int8 + integer16: int16 + unsigned32: uint32 + unsigned64: uint64 + + @dataclass class LookupTable: """ diff --git a/doc/index.rst b/doc/index.rst index 025c165..feb11fd 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1,6 +1,21 @@ Enrich Sphinx documentation with Python type information ======================================================== +This extension to Sphinx `autodoc `_ enriches class +member variable and function parameter lists with type information extracted from Python type annotations. + +This page is a demonstration of the tool's capabilities; visit the `project page `_ +for more information. + +Usage +----- + +1. Ensure that you have type hints in all your classes, functions and methods. +2. Add description to your classes, functions and methods as a doc-string. +3. Use ``:param name: text`` to assign a description to member variables and function parameters. +4. Register ``Processor`` to the events ``autodoc-process-docstring`` and ``autodoc-before-process-signature`` in Sphinx's ``conf.py``. +5. Enjoy how type information is automatically injected in the doc-string on ``make html``. + .. toctree:: :maxdepth: 2 :caption: Contents: diff --git a/doc/objects.rst b/doc/objects.rst index 7afdc9a..d690ca6 100644 --- a/doc/objects.rst +++ b/doc/objects.rst @@ -6,5 +6,8 @@ Types in the module ------------------- +This page showcases classes and functions extracted from the module +`example `_. + .. automodule:: example - :members: EnumType, MyException, PlainClass, SampleClass, DerivedClass, LookupTable, EntityTable, send_message + :members: EnumType, MyException, PlainClass, SampleClass, DerivedClass, FixedWidthIntegers, LookupTable, EntityTable, send_message diff --git a/sphinx_doc/autodoc.py b/sphinx_doc/autodoc.py index 6178df6..a672276 100644 --- a/sphinx_doc/autodoc.py +++ b/sphinx_doc/autodoc.py @@ -3,6 +3,7 @@ import inspect import re import sys +import types import typing from dataclasses import dataclass from typing import Annotated, Any, Callable, Optional, TypeVar, Union @@ -46,7 +47,9 @@ def __init__( def __call__(self, arg_type: TypeLike) -> TypeLike: if isinstance(arg_type, str): - raise TypeError("expected: evaluated type; got: `str`") + raise TypeError( + f"expected: evaluated type; got: `str` with value: {arg_type}" + ) mapped_type = self.type_transform.get( python_type_to_str(arg_type, use_union_operator=True) @@ -84,18 +87,20 @@ class MissingFieldError(SphinxError): T = TypeVar("T") -class DocstringProcessor: +class Processor: """ Ensures a compact yet comprehensive documentation is generated for classes and enumerations. - This class should be registered with a call to + This class should be registered as follows: ``` - app.connect("autodoc-process-docstring", DocstringProcessor(...)) + p = Processor(...) + app.connect("autodoc-process-docstring", p.process_docstring) + app.connect("autodoc-before-process-signature", p.process_signature) ``` """ - symbols: Symbols - type_transform: TypeTransform + _symbols: Symbols + _type_transform: TypeTransform def __init__( self, @@ -110,19 +115,19 @@ def __init__( """ if symbols is not None: - self.symbols = symbols + self._symbols = symbols else: - self.symbols = Symbols() + self._symbols = Symbols() if type_transform is not None: - self.type_transform = TypeTransform(type_transform) + self._type_transform = TypeTransform(type_transform) else: - self.type_transform = TypeTransform() + self._type_transform = TypeTransform() def _python_type_to_str(self, arg_type: TypeLike) -> str: """Emits a string representation of a Python type, with substitutions.""" - transformed_type = self.type_transform(arg_type) + transformed_type = self._type_transform(arg_type) return python_type_to_str(transformed_type, use_union_operator=True) def _process_object( @@ -168,7 +173,7 @@ def _transform_field(self, field_type: TypeLike, text: str) -> tuple[str, str]: return self._python_type_to_str(field_type), text - def process_class(self, cls: type, lines: list[str]) -> None: + def _process_class(self, cls: type, lines: list[str]) -> None: """ Lists all fields of a plain class including field name, type and description string. @@ -183,7 +188,9 @@ def process_class(self, cls: type, lines: list[str]) -> None: } self._process_object(cls.__name__, fields, lines, self._transform_field) - def process_dataclass(self, cls: type[DataclassInstance], lines: list[str]) -> None: + def _process_dataclass( + self, cls: type[DataclassInstance], lines: list[str] + ) -> None: """ Lists all fields of a data-class including field name, type and description string. @@ -218,16 +225,16 @@ def _transform_column(self, prop: FieldProperties, text: str) -> tuple[str, str] # emit an emoji for SQL primary key and unique constraint pieces: list[str] = [] if prop.is_primary: - pieces.append(self.symbols.primary_key) + pieces.append(self._symbols.primary_key) if prop.is_unique: - pieces.append(self.symbols.unique_constraint) + pieces.append(self._symbols.unique_constraint) pieces.append(text) description = " ".join(pieces) return field_type, description - def process_table(self, cls: type[DataclassInstance], lines: list[str]) -> None: + def _process_table(self, cls: type[DataclassInstance], lines: list[str]) -> None: """ Lists all fields of an entity that translates to a SQL table. @@ -242,11 +249,11 @@ def process_table(self, cls: type[DataclassInstance], lines: list[str]) -> None: } self._process_object(cls.__name__, fields, lines, self._transform_column) - def process_function(self, func: Callable[..., Any], lines: list[str]) -> None: + def _process_function(self, func: Callable[..., Any], lines: list[str]) -> None: params = typing.get_type_hints(func, include_extras=True) self._process_object(func.__name__, params, lines, self._transform_field) - def process_enum( + def _process_enum( self, cls: type[enum.Enum], options: Options, lines: list[str] ) -> None: """ @@ -259,7 +266,7 @@ def process_enum( options["undoc-members"] = True return - def process_codeblock(self, lines: list[str]) -> None: + def _process_codeblock(self, lines: list[str]) -> None: """ Replaces Markdown-style code blocks with Sphinx-style code blocks. @@ -287,7 +294,7 @@ def process_codeblock(self, lines: list[str]) -> None: lines[:] = target_lines - def __call__( + def process_docstring( self, app: Sphinx, what: str, @@ -312,25 +319,60 @@ def __call__( :param lines: The lines of the docstring. """ - self.process_codeblock(lines) + self._process_codeblock(lines) if what == "class": cls: type = typing.cast(type, obj) if issubclass(cls, enum.Enum): - self.process_enum(cls, options, lines) + self._process_enum(cls, options, lines) elif is_dataclass_type(cls): if dataclass_has_primary_key(cls): - self.process_table(cls, lines) + self._process_table(cls, lines) else: - self.process_dataclass(cls, lines) + self._process_dataclass(cls, lines) elif inspect.isclass(cls): - self.process_class(cls, lines) + self._process_class(cls, lines) elif what == "exception": exc = typing.cast(type[Exception], obj) - self.process_class(exc, lines) + self._process_class(exc, lines) elif what in ["function", "method"]: func = typing.cast(Callable[..., Any], obj) - self.process_function(func, lines) + self._process_function(func, lines) + + def _transform_param( + self, param_type: TypeLike, module: types.ModuleType + ) -> TypeLike: + """ + Maps type hints for function parameters. + + :param param_type: The parameter type (including forward references) to transform. + :param module: The context in which to evaluate types. + """ + + param_type = evaluate_type(param_type, module) + if isinstance(param_type, str): + # may hit this path with `from __future__ import annotations` + param_type = evaluate_type(param_type, module) + return self._type_transform(param_type) + + def process_signature( + self, app: Sphinx, obj: types.FunctionType, bound_method: bool + ) -> None: + """ + Ensures a compact yet comprehensive documentation is generated for functions and bound functions. + + The parameters are passed by `autodoc` in Sphinx. + + :param app: The Sphinx application object. + :param obj: The function object itself. + :param bound_method: True if the object is a bound method, False otherwise. + """ + + module = sys.modules[obj.__module__] + obj.__annotations__.update( + (name, self._transform_param(param_type, module)) + for name, param_type in obj.__annotations__.items() + ) def process_docstring( @@ -345,8 +387,8 @@ def process_docstring( ``` """ - processor = DocstringProcessor() - processor(app, what, name, obj, options, lines) + processor = Processor() + processor.process_docstring(app, what, name, obj, options, lines) def skip_member(