Skip to content

Commit 75f15c7

Browse files
committed
fix typehint
1 parent b9d8037 commit 75f15c7

8 files changed

Lines changed: 79 additions & 94 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
strategy:
1414
fail-fast: false
1515
matrix:
16-
python-version: ["3.10", "3.11", "3.12", "3.13"]
16+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
1717

1818
steps:
1919
- uses: actions/checkout@v4

pyproject.toml

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "runtime-docstrings"
3-
version = "0.1.2"
3+
version = "0.1.3"
44
description = "Runtime access to Python class attribute docstrings (PEP 224)"
55
readme = "README.md"
66
license = "MIT"
@@ -40,3 +40,18 @@ branch = true
4040

4141
[tool.coverage.report]
4242
precision = 2
43+
44+
[tool.coverage.html]
45+
show_contexts = true
46+
47+
[tool.ruff]
48+
fix = true
49+
50+
line-length = 100
51+
52+
[tool.ruff.lint]
53+
select = ["ALL"]
54+
ignore = ["COM812", "S101"]
55+
56+
[tool.ruff.lint.pydocstyle]
57+
convention = "google"

src/runtime_docstrings/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
1-
from ._parser import docstrings as docstrings, get_docstrings as get_docstrings
1+
"""Runtime attribute docstrings."""
2+
3+
from ._parser import docstrings as docstrings
4+
from ._parser import get_docstrings as get_docstrings

src/runtime_docstrings/_parser.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
from __future__ import annotations
22

3-
3+
import ast
44
import dataclasses
55
import inspect
6-
7-
import warnings
8-
from textwrap import dedent
9-
import ast
106
import types
7+
import warnings
118
from enum import Enum
9+
from textwrap import dedent
10+
from typing import TypeVar
11+
12+
T = TypeVar("T", bound=type)
1213

1314

1415
def _parse_docstrings(node: ast.ClassDef) -> dict[str, str]:
@@ -28,9 +29,7 @@ def _parse_docstrings(node: ast.ClassDef) -> dict[str, str]:
2829
continue
2930

3031
match body[index]:
31-
case ast.Expr(value=ast.Constant(value=doc_str)) if isinstance(
32-
doc_str, str
33-
):
32+
case ast.Expr(value=ast.Constant(value=doc_str)) if isinstance(doc_str, str):
3433
docs[name] = inspect.cleandoc(doc_str)
3534
index += 1
3635
return docs
@@ -45,6 +44,7 @@ def get_docstrings(cls: type) -> dict[str, str]:
4544
Returns:
4645
A dictionary where keys are attribute names and values are their
4746
corresponding docstrings.
47+
4848
"""
4949
if "__attribute_docs__" in cls.__dict__:
5050
return cls.__attribute_docs__
@@ -81,23 +81,27 @@ def _attach_enum(cls: type[Enum], comments: dict[str, str]) -> None:
8181
canonical_name = member.name
8282
if name != canonical_name and name in comments:
8383
warnings.warn(
84-
f"Enum alias member {cls.__name__}.{name} has docstring that should be documented on {cls.__name__}.{canonical_name}"
84+
f"Enum alias member {cls.__name__}.{name} has docstring "
85+
f"that should be documented on {cls.__name__}.{canonical_name}",
86+
stacklevel=1,
8587
)
8688
if canonical_name not in comments:
8789
member.__doc__ = comments[name]
8890

8991

90-
def docstrings(cls: type) -> type:
91-
"""Decorator that attaches attribute/member docstrings to a class.
92+
def docstrings(cls: T) -> T:
93+
"""Attach attribute/member docstrings to a class.
9294
93-
If the class is an enum, docstrings are attached via enum members.
95+
If the class is an enum, attach docstrings via enum members.
9496
95-
If the class is a dataclass, docstrings are attached via field metadata.
97+
If the class is a dataclass, attach docstrings via field metadata.
9698
97-
Parameters:
99+
Args:
98100
cls: The class to process.
101+
99102
Returns:
100103
The same class with attached docstrings.
104+
101105
"""
102106
assert inspect.isclass(cls), "cls must be a class"
103107

tests/test_class.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22

33

44
def _prop(self):
5-
"""A property."""
5+
"""Property.
6+
7+
Returns: The property value.
8+
9+
"""
610
return self.__doc__ # pragma: no cover
711

812

@@ -77,7 +81,7 @@ def test_all():
7781

7882
assert Child.__doc_BASE_VAR__ == "Represents a base variable."
7983
assert Child.__doc_CHILD_VAR__ == "Represents a child variable."
80-
assert Child.ins_prop.__doc__ == "A property."
84+
assert Child.ins_prop.__doc__.startswith("Property.")
8185
assert Child.another_prop.__doc__ == "Another property."
8286

8387
assert Child.__doc__ is None

tests/test_dataclass.py

Lines changed: 21 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
"""Test dataclass support for runtime_docs."""
22

33
import dataclasses
4-
from dataclasses import dataclass, field, InitVar
5-
from typing import List, Dict, Optional, ClassVar
4+
from dataclasses import InitVar, dataclass, field
65
from enum import Enum
7-
6+
from typing import ClassVar
87

98
from runtime_docstrings import docstrings, get_docstrings
109

@@ -20,12 +19,12 @@ class BasicDataClass:
2019
age: int = 30
2120
"""The age field with default value."""
2221

23-
tags: List[str] = dataclasses.field(default_factory=list)
22+
tags: list[str] = dataclasses.field(default_factory=list)
2423
"""List of tags associated with this entity."""
2524

2625
active: bool = True
2726

28-
description: Optional[str] = None
27+
description: str | None = None
2928
"""Optional description text."""
3029

3130

@@ -87,31 +86,27 @@ class SlottedDataClass:
8786
age: int = 30
8887
"""The age field with default value."""
8988

90-
tags: List[str] = field(default_factory=list)
89+
tags: list[str] = field(default_factory=list)
9190
"""List of tags associated with this entity."""
9291

9392
active: bool = True
9493

95-
description: Optional[str] = None
94+
description: str | None = None
9695
"""Optional description text."""
9796

9897

9998
def test_slotted_dataclass_docstrings():
10099
"""Test that docstrings are correctly extracted from slotted dataclass fields."""
101100
docs = get_docstrings(SlottedDataClass)
102101

103-
assert "name" in docs
104102
assert docs["name"] == "The name field."
105103

106-
assert "age" in docs
107104
assert docs["age"] == "The age field with default value."
108105

109-
assert "tags" in docs
110106
assert docs["tags"] == "List of tags associated with this entity."
111107

112108
assert "active" not in docs # No docstring for this field
113109

114-
assert "description" in docs
115110
assert docs["description"] == "Optional description text."
116111

117112

@@ -148,36 +143,31 @@ class DataClassWithMetadata:
148143
"""The name field with metadata."""
149144

150145
age: int = field(
151-
default=30, metadata={"validator": "int_validator", "min": 0, "max": 120}
146+
default=30,
147+
metadata={"validator": "int_validator", "min": 0, "max": 120},
152148
)
153149
"""The age field with default value and metadata."""
154150

155-
tags: List[str] = field(
156-
default_factory=list, metadata={"doc": "Existing doc in metadata"}
151+
tags: list[str] = field(
152+
default_factory=list,
153+
metadata={"doc": "Existing doc in metadata"},
157154
)
158155
"""List of tags with existing doc in metadata."""
159156

160157

161158
def test_field_metadata_docstrings():
162159
"""Test that docstrings are correctly added to field metadata."""
163-
164160
fields = {f.name: f for f in dataclasses.fields(DataClassWithMetadata)}
165161

166162
# Check that the field docstrings are correctly stored
167163
assert "name" in fields
168164
assert fields["name"].metadata["__doc__"] == "The name field with metadata."
169165

170166
assert "age" in fields
171-
assert (
172-
fields["age"].metadata["__doc__"]
173-
== "The age field with default value and metadata."
174-
)
167+
assert fields["age"].metadata["__doc__"] == "The age field with default value and metadata."
175168

176169
assert "tags" in fields
177-
assert (
178-
fields["tags"].metadata["__doc__"]
179-
== "List of tags with existing doc in metadata."
180-
)
170+
assert fields["tags"].metadata["__doc__"] == "List of tags with existing doc in metadata."
181171

182172

183173
def test_field_metadata_preservation():
@@ -248,9 +238,7 @@ def test_dataclass_inheritance():
248238
assert "shared_field" in grandparent_docs
249239
assert grandparent_docs["shared_field"] == "Shared field from grandparent."
250240
assert "another_shared" in grandparent_docs
251-
assert (
252-
grandparent_docs["another_shared"] == "Another shared field from grandparent."
253-
)
241+
assert grandparent_docs["another_shared"] == "Another shared field from grandparent."
254242

255243
parent_docs = get_docstrings(ParentDataClass)
256244
assert "parent_field" in parent_docs
@@ -282,10 +270,7 @@ def test_dataclass_inheritance():
282270
assert ParentDataClass.__doc_shared_field__ == "Shared field from parent."
283271

284272
assert hasattr(GrandParentDataClass, "__doc_another_shared__")
285-
assert (
286-
GrandParentDataClass.__doc_another_shared__
287-
== "Another shared field from grandparent."
288-
)
273+
assert GrandParentDataClass.__doc_another_shared__ == "Another shared field from grandparent."
289274

290275
assert hasattr(ChildDataClass, "__doc_another_shared__")
291276
assert ChildDataClass.__doc_another_shared__ == "Overridden shared field."
@@ -301,10 +286,7 @@ def test_dataclass_inheritance():
301286
assert ParentDataClass.__doc_parent_field__ == "The parent field."
302287
assert ParentDataClass.__doc_grandparent_field__ == "The grandparent field."
303288
assert ParentDataClass.__doc_shared_field__ == "Shared field from parent."
304-
assert (
305-
ParentDataClass.__doc_another_shared__
306-
== "Another shared field from grandparent."
307-
)
289+
assert ParentDataClass.__doc_another_shared__ == "Another shared field from grandparent."
308290

309291

310292
@docstrings
@@ -430,7 +412,6 @@ def test_api_consistency():
430412

431413
def test_complex_inheritance_mro():
432414
"""Test MRO resolution with complex inheritance involving different class types."""
433-
434415
# Test MRO resolution for complex inheritance
435416
assert ComplexInheritance.__doc_complex_field__ == "Complex field."
436417

@@ -526,10 +507,7 @@ def test_multi_level_mixed_inheritance():
526507
# Test MRO resolution for intermediate class
527508
assert IntermediateDataClass.__doc_base_attr__ == "Base attribute."
528509
assert IntermediateDataClass.__doc_intermediate_field__ == "Intermediate field."
529-
assert (
530-
IntermediateDataClass.__doc_shared_attr__
531-
== "Shared attribute from intermediate."
532-
)
510+
assert IntermediateDataClass.__doc_shared_attr__ == "Shared attribute from intermediate."
533511

534512
# Test instance creation and attribute access
535513
child_instance = ChildRegularClass()
@@ -625,11 +603,11 @@ def test_special_char_docstrings():
625603
class DefaultFactoryDataClass:
626604
"""A dataclass with default factory functions."""
627605

628-
simple_list: List[str] = field(default_factory=list)
606+
simple_list: list[str] = field(default_factory=list)
629607
"""A simple list with default factory."""
630608

631-
complex_dict: Dict[str, List[int]] = field(
632-
default_factory=lambda: {"default": [1, 2, 3]}
609+
complex_dict: dict[str, list[int]] = field(
610+
default_factory=lambda: {"default": [1, 2, 3]},
633611
)
634612
"""A complex dictionary with default factory lambda."""
635613

@@ -740,7 +718,7 @@ class ClassVarDataClass:
740718
DEFAULT_AGE: ClassVar[int] = 30
741719
"""Default age constant."""
742720

743-
CONSTANTS: ClassVar[Dict[str, str]] = {"DEFAULT_NAME": "Unknown"}
721+
CONSTANTS: ClassVar[dict[str, str]] = {"DEFAULT_NAME": "Unknown"}
744722
"""Dictionary of constants."""
745723

746724

0 commit comments

Comments
 (0)