Skip to content

Commit

Permalink
Miscellaneous improvements
Browse files Browse the repository at this point in the history
  • Loading branch information
hunyadi committed Dec 1, 2024
1 parent f807751 commit b31420e
Show file tree
Hide file tree
Showing 7 changed files with 148 additions and 41 deletions.
5 changes: 4 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
"editor.defaultFormatter": "ms-python.black-formatter"
},
"cSpell.words": [
"pysqlsync"
"autodoc",
"Levente",
"pysqlsync",
"setuptools"
]
}
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
10 changes: 5 additions & 5 deletions doc/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("."))

Expand Down Expand Up @@ -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",
Expand All @@ -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)
43 changes: 39 additions & 4 deletions doc/example.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
from __future__ import annotations

import enum
import sys
from dataclasses import dataclass
from datetime import datetime
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):
Expand Down Expand Up @@ -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
<html>
<body>
<p>A paragraph.</p>
</body>
</html>
```
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`.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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:
"""
Expand Down
15 changes: 15 additions & 0 deletions doc/index.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
Enrich Sphinx documentation with Python type information
========================================================

This extension to Sphinx `autodoc <https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html>`_ 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 <https://github.com/hunyadi/sphinx_doc>`_
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:
Expand Down
5 changes: 4 additions & 1 deletion doc/objects.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@
Types in the module
-------------------

This page showcases classes and functions extracted from the module
`example <https://github.com/hunyadi/sphinx_doc/blob/master/doc/example.py>`_.

.. automodule:: example
:members: EnumType, MyException, PlainClass, SampleClass, DerivedClass, LookupTable, EntityTable, send_message
:members: EnumType, MyException, PlainClass, SampleClass, DerivedClass, FixedWidthIntegers, LookupTable, EntityTable, send_message
100 changes: 71 additions & 29 deletions sphinx_doc/autodoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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:
"""
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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(
Expand Down

0 comments on commit b31420e

Please sign in to comment.