Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
name: Test Admonitions Generation
name: Test Admonitions & Attributes Generation
on:
pull_request:
types: [synchronize, reopened, ready_for_review]
paths:
- src/telegram/**
- docs/**
- .github/workflows/docs-admonitions.yml
- .github/workflows/doc-tests.yml
push:
branches:
- master

permissions: {}

jobs:
test-admonitions:
name: Test Admonitions Generation
test-admonitions-attributes:
name: Test Admonitions & Attributes Generation
runs-on: ${{matrix.os}}
permissions:
# for uploading artifacts
Expand All @@ -38,5 +38,5 @@ jobs:
run: |
python -W ignore -m pip install --upgrade pip
python -W ignore -m pip install .[all] --group all
- name: Test autogeneration of admonitions
run: pytest -v --tb=short tests/docs/admonition_inserter.py
- name: Test autogeneration of admonitions and attributes
run: pytest -v --tb=short tests/docs/admonition_inserter.py tests/docs/attribute_inserter.py
6 changes: 5 additions & 1 deletion changes/unreleased/5240.iJXyH8RNWNNQpC47SfNHzw.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
documentation = "Documentation Improvements"
documentation = """Documentation Improvements

* Autogenerate "Attributes" section for easier maintainence and reliability.
"""

pull_requests = [
{ uid = "5240", author_uids = ["harshil21", "Poolitzer"] },
{ uid = "5241", author_uids = ["harshil21"] },
{ uid = "5245", author_uids = ["harshil21"] },
]
258 changes: 258 additions & 0 deletions docs/auxil/attribute_inserter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2026
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser Public License for more details.
#
# You should have received a copy of the GNU Lesser Public License
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""Automatic generation of ``Attributes:`` section entries from ``Args:`` in class docstrings."""

import inspect
import re
import warnings
from dataclasses import dataclass

from telegram import TelegramObject

ENTRY_PATTERN: re.Pattern[str] = re.compile(r"^ (\w+) \((.+)\):\s*(.*)")

KNOWN_SECTION_TITLES: frozenset[str] = frozenset(
{
"Args",
"Attributes",
"Returns",
"Raises",
"Note",
"Notes",
"Example",
"Examples",
"Keyword Args",
"Keyword Arguments",
}
)


def _is_section_header(line: str) -> bool:
return line.endswith(":") and line[:-1] in KNOWN_SECTION_TITLES


def _is_col0_noncontent(line: str) -> bool:
"""Non-blank col-0 line that is not a section header (e.g. RST substitution definitions)."""
return bool(line) and not line[0].isspace() and not _is_section_header(line)


def _save_entry(
entries: dict[str, "DocstringEntry"],
name: str,
raw_type: str,
raw_lines: list[str],
) -> None:
lines = list(raw_lines)
while lines and lines[-1] == "":
lines.pop()

is_optional = raw_type.endswith(", optional")
type_str = raw_type.removesuffix(", optional") if is_optional else raw_type

entries[name] = DocstringEntry(
name=name,
type_str=type_str,
is_optional=is_optional,
all_lines=tuple(lines),
)


@dataclass(frozen=True, slots=True)
class DocstringEntry:
name: str
type_str: str
is_optional: bool
all_lines: tuple[str, ...]

def to_attribute_lines(self) -> list[str]:
if not self.all_lines:
warnings.warn(
f"DocstringEntry {self.name!r} has no lines; skipping attribute generation.",
stacklevel=2,
)
return []

m = ENTRY_PATTERN.match(self.all_lines[0])
if m is None:
warnings.warn(
f"DocstringEntry {self.name!r}: first line does not match the entry pattern "
f"({self.all_lines[0]!r}); returning raw lines unchanged.",
stacklevel=2,
)
return list(self.all_lines)

desc: str = m.group(3)
new_type = self.type_str.replace("Sequence[", "tuple[")
new_desc = f"Optional. {desc}" if self.is_optional else desc
return [f" {self.name} ({new_type}): {new_desc}", *self.all_lines[1:]]


@dataclass(slots=True)
class DocstringSection:
title: str
entries: dict[str, DocstringEntry]
start_idx: int
end_idx: int


class DocstringParser:
"""Parse a Google-style docstring (list of lines) into sections and entries."""

__slots__ = ("_lines", "_sections")

def __init__(self, lines: list[str]) -> None:
self._lines = lines
self._sections: dict[str, DocstringSection] | None = None

@property
def sections(self) -> dict[str, DocstringSection]:
if self._sections is None:
self._sections = self._parse()
return self._sections

def get_section(self, title: str) -> DocstringSection | None:
return self.sections.get(title)

def _parse(self) -> dict[str, DocstringSection]:
sections: dict[str, DocstringSection] = {}
lines = self._lines
n = len(lines)
i = 0

while i < n:
line = lines[i]
if _is_section_header(line):
title = line[:-1]
start_idx = i
i += 1
entries, end_idx = self._parse_section_entries(i, n)
i = end_idx
sections[title] = DocstringSection(
title=title,
entries=entries,
start_idx=start_idx,
end_idx=end_idx,
)
else:
i += 1

return sections

def _parse_section_entries(
self,
start: int,
end: int,
) -> tuple[dict[str, DocstringEntry], int]:
entries: dict[str, DocstringEntry] = {}
current_name: str | None = None
current_raw_type: str = ""
current_lines: list[str] = []

lines = self._lines
i = start

while i < end:
line = lines[i]

# RST substitution definitions and other on-section content end the section.
if _is_section_header(line) or _is_col0_noncontent(line):
break

m = ENTRY_PATTERN.match(line)
if m:
if current_name is not None:
_save_entry(entries, current_name, current_raw_type, current_lines)
current_name = m.group(1)
current_raw_type = m.group(2)
current_lines = [line]
elif current_name is not None:
current_lines.append(line)

i += 1

if current_name is not None:
_save_entry(entries, current_name, current_raw_type, current_lines)

return entries, i


class AttributeInserter:
"""Inserts auto-generated ``Attributes:`` entries into class docstrings."""

def insert_attributes(self, obj: type, lines: list[str]) -> None:
"""Insert missing attribute entries derived from the ``Args:`` section in-place."""
parser = DocstringParser(lines)

args_section = parser.get_section("Args")
attrs_section = parser.get_section("Attributes")

already_documented: set[str] = (
set(attrs_section.entries.keys()) if attrs_section is not None else set()
)
args_entries: dict[str, DocstringEntry] = (
args_section.entries if args_section is not None else {}
)
args_names: set[str] = set(args_entries.keys())

properties_on_class: set[str] = {
name for name, _ in inspect.getmembers(obj, lambda o: isinstance(o, property))
}

# Warn about own public slots that have no documentation source.
# Get slots from TGObject if it's a TGObj subclass:
if issubclass(obj, TelegramObject):
all_slots = {
s
for c in obj.__mro__[:-1]
if issubclass(c, TelegramObject)
for s in c.__slots__
if not s.startswith("_")
}
all_slots.remove("api_kwargs")
else:
all_slots = (s for s in getattr(obj, "__slots__", ()) if not s.startswith("_"))
for slot in all_slots:
if (
slot not in already_documented
and slot not in args_names
and slot not in properties_on_class
):
raise RuntimeError(
f"Class {obj.__qualname__!r}: public slot {slot!r} has no documentation "
f"source. Please add it to the 'Attributes:' section manually.",
)

new_attr_lines: list[str] = []
for name, entry in args_entries.items():
if name in already_documented or name in properties_on_class:
continue
new_attr_lines.extend(entry.to_attribute_lines())
new_attr_lines.append("")

if not new_attr_lines:
return

if attrs_section is not None:
insert_idx = attrs_section.end_idx
while insert_idx > attrs_section.start_idx + 1 and lines[insert_idx - 1] == "":
insert_idx -= 1
lines[insert_idx:insert_idx] = new_attr_lines
else:
if lines and lines[-1].strip():
lines.append("")
lines.append("Attributes:")
lines.extend(new_attr_lines)
16 changes: 0 additions & 16 deletions docs/auxil/bot_insertion.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,23 +91,7 @@
]


def find_insert_pos_for_kwargs(lines: list[str]) -> int:
"""Finds the correct position to insert the keyword arguments and returns the index."""
for idx, value in reversed(list(enumerate(lines))): # reversed since :returns: is at the end
if value.startswith("Returns"):
return idx
return False


def find_insert_pos_for_raises(lines: list[str]) -> int:
"""Finds the correct position to insert the Raises block and returns the index."""
if "Raises:" in lines:
return -1 # Don't insert if there's already a Raises block
return len(lines) # Insert at the end if there's no Raises block


def check_timeout_and_api_kwargs_presence(obj: object) -> int:
"""Checks if the method has timeout and api_kwargs keyword only parameters."""
sig = inspect.signature(obj)
params_to_check = (
"read_timeout",
Expand Down
22 changes: 13 additions & 9 deletions docs/auxil/sphinx_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,10 @@
import telegram
import telegram.ext
from docs.auxil.admonition_inserter import AdmonitionInserter
from docs.auxil.attribute_inserter import AttributeInserter, DocstringParser
from docs.auxil.bot_insertion import (
RAISES_BLOCK,
check_timeout_and_api_kwargs_presence,
find_insert_pos_for_kwargs,
find_insert_pos_for_raises,
get_updates_read_timeout_addition,
keyword_args,
media_write_timeout_change,
Expand All @@ -44,6 +43,7 @@


ADMONITION_INSERTER = AdmonitionInserter()
ATTRIBUTE_INSERTER = AttributeInserter()

# Some base classes are implementation detail
# We want to instead show *their* base class
Expand Down Expand Up @@ -112,11 +112,12 @@ def autodoc_process_docstring(
):
# Logic for inserting keyword args into docstrings:
# -------------------------------------------------
insert_index = find_insert_pos_for_kwargs(lines)
if not insert_index:
returns_section = DocstringParser(lines).get_section("Returns")
if not returns_section:
raise ValueError(
f"Couldn't find the correct position to insert the keyword args for {obj}."
)
insert_index = returns_section.start_idx

get_updates: bool = method_name == "get_updates"
# The below can be done in 1 line with itertools.chain, but this must be modified in-place
Expand All @@ -140,10 +141,8 @@ def autodoc_process_docstring(
# Logic for inserting Raises:
# -------------------------------------------------
# We will only insert the Raises block if there isn't already one.

insert_index = find_insert_pos_for_raises(lines)
if insert_index != -1:
lines[insert_index:insert_index] = RAISES_BLOCK
if DocstringParser(lines).get_section("Raises") is None:
lines.extend(RAISES_BLOCK)

# Logic for inserting "Shortcuts" admonition:
# -------------------------------------------
Expand All @@ -154,7 +153,12 @@ def autodoc_process_docstring(

# 2-4) Insert "Returned in", "Available in", "Use in" admonitions into classes
# (where applicable)
if what == "class":
if what in ("class", "exception"):
# Auto-generate Attributes section entries from Args before admonitions are inserted
ATTRIBUTE_INSERTER.insert_attributes(
obj=typing.cast("type", obj),
lines=lines,
)
ADMONITION_INSERTER.insert_admonitions(
obj=typing.cast("type", obj), # since "what" == class, we know it's not just object
docstring_lines=lines,
Expand Down
Loading
Loading