Skip to content

Commit 8582e69

Browse files
committed
v0.1.0
1 parent 1ab0922 commit 8582e69

11 files changed

Lines changed: 2110 additions & 11 deletions

File tree

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,9 @@ wheels/
99
# Virtual environments
1010
.venv
1111

12-
# Lock files
12+
# uv
1313
uv.lock
14+
15+
# coverage
16+
.coverage
17+
.coverage.*

README.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,106 @@
22
# runtime-docstrings
33

44
Runtime access to Python class attribute docstrings (PEP 224)
5+
6+
## Installation
7+
8+
```bash
9+
pip install runtime-docstrings
10+
```
11+
12+
## Usage
13+
14+
### Class
15+
16+
```python
17+
from runtime_docstrings import docstrings, get_docstrings
18+
19+
@docstrings
20+
class Person:
21+
"""A person with various attributes."""
22+
23+
name: str
24+
"""The person's full name."""
25+
26+
email: str
27+
"""Contact email address."""
28+
29+
age: int = 0
30+
"""The person's age in years."""
31+
32+
# Access docstrings map directly on class (IDE style)
33+
docs = get_docstrings(Person)
34+
print(docs["name"]) # "The person's full name."
35+
print(docs["age"]) # "The person's age in years."
36+
print(docs["email"]) # "Contact email address."
37+
38+
# Access via PEP 224 style attributes (uses Python MRO lookup)
39+
print(Person.__doc_name__) # "The person's full name."
40+
print(Person.__doc_age__) # "The person's age in years."
41+
print(Person.__doc_email__) # "Contact email address."
42+
```
43+
44+
### Enum
45+
46+
```python
47+
from enum import Enum
48+
from runtime_docstrings import docstrings
49+
50+
@docstrings
51+
class Status(Enum):
52+
"""Status enumeration for task tracking."""
53+
54+
PENDING = "pending"
55+
"""Task is waiting to be processed."""
56+
57+
RUNNING = "running"
58+
"""Task is currently being executed."""
59+
60+
COMPLETED = "completed"
61+
"""Task has finished successfully."""
62+
63+
FAILED = "failed"
64+
"""Task encountered an error."""
65+
66+
# Supports all the standard class access patterns
67+
68+
# Access via enum member __doc__ attribute
69+
print(Status.PENDING.__doc__) # "Task is waiting to be processed."
70+
print(Status.COMPLETED.__doc__) # "Task has finished successfully."
71+
72+
# Iterate through all members with their documentation
73+
for member in Status:
74+
if member.__doc__:
75+
print(f"{member.name}: {member.__doc__}")
76+
```
77+
78+
### Dataclass
79+
80+
```python
81+
from dataclasses import dataclass, fields
82+
from runtime_docstrings import docstrings, get_docstrings
83+
84+
@docstrings
85+
@dataclass
86+
class Product:
87+
"""A product in an e-commerce system."""
88+
89+
name: str
90+
"""Product name."""
91+
92+
price: float
93+
"""Price in USD."""
94+
95+
category: str = ""
96+
"""Product category."""
97+
98+
description: str = ""
99+
"""Detailed product description."""
100+
101+
# Supports all the standard class access patterns
102+
103+
# Access via dataclass field metadata
104+
for field in fields(Product):
105+
if field.metadata.get("__doc__"):
106+
print(f"{field.name}: {field.metadata['__doc__']}")
107+
```

pyproject.toml

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,29 @@
11
[project]
22
name = "runtime-docstrings"
3-
version = "0.0.0"
3+
version = "0.1.0"
44
description = "Runtime access to Python class attribute docstrings (PEP 224)"
55
readme = "README.md"
66
authors = [{ name = "gesslerpd", email = "[email protected]" }]
77
requires-python = ">=3.10"
88
dependencies = []
99

10+
[project.urls]
11+
Homepage = "https://github.com/gesslerpd/runtime-docstrings"
12+
Repository = "https://github.com/gesslerpd/runtime-docstrings"
13+
Issues = "https://github.com/gesslerpd/runtime-docstrings/issues"
14+
1015
[build-system]
1116
requires = ["uv-build>=0.8.2"]
1217
build-backend = "uv_build"
1318

1419
[dependency-groups]
15-
dev = [
16-
"pytest>=8.4.1",
17-
"ruff>=0.12.4",
18-
]
20+
dev = ["pytest>=8.4.1", "pytest-cov>=6.2.1", "ruff>=0.12.4"]
21+
22+
[tool.pytest.ini_options]
23+
addopts = "--import-mode=importlib"
24+
25+
[tool.coverage.run]
26+
branch = true
27+
28+
[tool.coverage.report]
29+
precision = 2

src/runtime_docstrings/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from ._parser import docstrings as docstrings, get_docstrings as get_docstrings

src/runtime_docstrings/_parser.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
from __future__ import annotations
2+
3+
4+
import dataclasses
5+
import inspect
6+
7+
import warnings
8+
from textwrap import dedent
9+
import ast
10+
import types
11+
from enum import Enum
12+
from typing import TypeVar
13+
14+
T = TypeVar("T", bound=type)
15+
16+
17+
def _parse_docstrings(node: ast.ClassDef) -> dict[str, str]:
18+
docs: dict[str, str] = {}
19+
body = node.body
20+
for index in range(len(body) - 1):
21+
match body[index]:
22+
case ast.AnnAssign(target=ast.Name(id=name)):
23+
pass
24+
case ast.Assign(targets=[ast.Name(id=name)]):
25+
pass
26+
case _:
27+
continue
28+
29+
match body[index + 1]:
30+
case ast.Expr(value=ast.Constant(value=doc_str)) if isinstance(
31+
doc_str, str
32+
):
33+
docs[name] = inspect.cleandoc(doc_str)
34+
return docs
35+
36+
37+
def get_docstrings(cls: type) -> dict[str, str]:
38+
if "__attribute_docs__" in cls.__dict__:
39+
return cls.__attribute_docs__
40+
source = dedent(inspect.getsource(cls))
41+
tree = ast.parse(source)
42+
node = tree.body[0]
43+
assert isinstance(node, ast.ClassDef)
44+
# only process top-level class definition (no NodeVisitor recursion)
45+
docs = _parse_docstrings(node)
46+
# IDE style (no MRO resolution)
47+
cls.__attribute_docs__ = docs
48+
return docs
49+
50+
51+
def _attach_class(cls: type, comments: dict[str, str]) -> None:
52+
for name, docstring in comments.items():
53+
setattr(cls, f"__doc_{name}__", docstring)
54+
55+
56+
def _attach_dataclass(cls: type, comments: dict[str, str]) -> None:
57+
for field in dataclasses.fields(cls):
58+
field.metadata = types.MappingProxyType(
59+
{"__doc__": comments.get(field.name), **field.metadata}
60+
)
61+
62+
63+
def _attach_enum(cls: type[Enum], comments: dict[str, str]) -> None:
64+
# enum members (canonical)
65+
for member in cls:
66+
member.__doc__ = comments.get(member.name)
67+
68+
# enum alias members
69+
for name, member in cls.__members__.items():
70+
canonical_name = member.name
71+
if name != canonical_name and name in comments:
72+
warnings.warn(
73+
f"Enum alias member {cls.__name__}.{name} has docstring that should be documented on {cls.__name__}.{canonical_name}"
74+
)
75+
if canonical_name not in comments:
76+
member.__doc__ = comments[name]
77+
78+
79+
def docstrings(cls: T) -> T:
80+
assert inspect.isclass(cls), "cls must be a class"
81+
82+
# Extract docstrings from the class definition
83+
comments = get_docstrings(cls)
84+
if not comments:
85+
return cls
86+
87+
_attach_class(cls, comments)
88+
89+
if issubclass(cls, Enum):
90+
_attach_enum(cls, comments)
91+
elif dataclasses.is_dataclass(cls):
92+
_attach_dataclass(cls, comments)
93+
94+
return cls

tests/test_class.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from runtime_docstrings import docstrings, get_docstrings
2+
3+
4+
def _prop(self):
5+
"""A property."""
6+
return self.__doc__ # pragma: no cover
7+
8+
9+
@docstrings
10+
class Base:
11+
"""Base class."""
12+
13+
BASE_VAR = "base"
14+
"""Represents a base variable."""
15+
16+
ins_prop = property(_prop)
17+
"""Instance property."""
18+
19+
another_prop = property(_prop, doc="Another property.")
20+
"""Another instance property."""
21+
22+
"""Ok"""
23+
OK: str = "ok"
24+
25+
AL: str
26+
"""Alright"""
27+
28+
OKR = ""
29+
"""okr"""
30+
31+
@docstrings
32+
class Inner:
33+
"""Inner class for demonstration."""
34+
35+
INNER_VAR: int = 42
36+
"""An inner variable."""
37+
INNER_VAR2: str = "inner"
38+
"""Another inner variable."""
39+
40+
41+
@docstrings
42+
class Child(Base):
43+
BASE_VAR = "overridden_base"
44+
45+
CHILD_VAR = "child"
46+
"""Represents a child variable."""
47+
48+
49+
@docstrings
50+
class NoDocClass:
51+
"""This class has no attribute docstrings."""
52+
53+
"""Test"""
54+
NO_DOC_VAR = "no_doc"
55+
56+
HEY = 3
57+
58+
59+
def test_no_doc_class():
60+
assert get_docstrings(NoDocClass) == {}
61+
62+
63+
def test_all():
64+
assert get_docstrings(Base) == {
65+
"AL": "Alright",
66+
"OKR": "okr",
67+
"BASE_VAR": "Represents a base variable.",
68+
"another_prop": "Another instance property.",
69+
"ins_prop": "Instance property.",
70+
}
71+
assert get_docstrings(Base.Inner) == {
72+
"INNER_VAR": "An inner variable.",
73+
"INNER_VAR2": "Another inner variable.",
74+
}
75+
76+
assert get_docstrings(Child) == {"CHILD_VAR": "Represents a child variable."}
77+
78+
assert Child.__doc_BASE_VAR__ == "Represents a base variable."
79+
assert Child.__doc_CHILD_VAR__ == "Represents a child variable."
80+
assert Child.ins_prop.__doc__ == "A property."
81+
assert Child.another_prop.__doc__ == "Another property."
82+
83+
assert Child.__doc__ is None
84+
assert Base.__doc__ == "Base class."

0 commit comments

Comments
 (0)