diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73de838..8fc32f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - name: Checkout code diff --git a/CHANGELOG.md b/CHANGELOG.md index e0b7ff0..1788b9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ 0.2.2 is the first 0.2.x release published to PyPI. The 0.2.0 and 0.2.1 tags exist in git, but their publish runs were cancelled, so upgrading from PyPI goes straight from 0.1.16 to 0.2.2. The notes below cover every change in that jump. +## 0.2.5 + +Fixed: +- Inline field documentation (a string literal under a dataclass field) is extracted again on Python 3.13 and later. Class source lookup used `inspect._ClassFinder`, a private helper that Python 3.13 removed, so every dataclass came back with no field help. Classes are now located by qualified name from the module AST directly. CI now tests Python 3.13. + ## 0.2.4 Fixed: diff --git a/pyproject.toml b/pyproject.toml index 6cd0c1b..114bc21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-introspect" -version = "0.2.4" +version = "0.2.5" description = "Pure Python introspection toolkit for function signatures, dataclasses, and type hints" readme = "README.md" requires-python = ">=3.10" @@ -21,6 +21,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Utilities", ] diff --git a/src/python_introspect/__init__.py b/src/python_introspect/__init__.py index d131c8b..2dcf2a2 100644 --- a/src/python_introspect/__init__.py +++ b/src/python_introspect/__init__.py @@ -9,7 +9,7 @@ type resolution for framework-specific types (lazy configs, proxies, etc.) """ -__version__ = "0.2.4" +__version__ = "0.2.5" from .signature_analyzer import ( SignatureAnalyzer, diff --git a/src/python_introspect/signature_analyzer.py b/src/python_introspect/signature_analyzer.py index 40b3ca5..aa920e4 100644 --- a/src/python_introspect/signature_analyzer.py +++ b/src/python_introspect/signature_analyzer.py @@ -771,6 +771,26 @@ def type_hints(self) -> Dict[str, Any]: ) +def _class_node_for_qualname(tree: ast.AST, qualname: str) -> Optional[ast.ClassDef]: + """Return the class statement whose qualified name is ``qualname``. + + Function bodies contribute ```` exactly as ``__qualname__`` does. + """ + pending = [(node, ()) for node in reversed(getattr(tree, 'body', ()))] + while pending: + node, scope = pending.pop() + if isinstance(node, ast.ClassDef): + node_scope = (*scope, node.name) + if '.'.join(node_scope) == qualname: + return node + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + node_scope = (*scope, node.name, '') + else: + node_scope = scope + pending.extend((child, node_scope) for child in reversed(list(ast.iter_child_nodes(node)))) + return None + + class ClassSourceDeclaration(NamedTuple): """Immutable located class source and its source-only documentation views.""" @@ -1196,20 +1216,13 @@ def _module_source_tree(source: str) -> ast.AST: def _qualified_class_source(source: str, qualname: str) -> Optional[ClassSourceDeclaration]: """Use Python's qualified class/decorator locator on current source content.""" tree = SignatureAnalyzer._module_source_tree(source) - finder = inspect._ClassFinder(qualname) - try: - finder.visit(tree) - except inspect.ClassFoundException as found: - lines = source.splitlines(keepends=True) - first_line = found.args[0] + 1 - block = ''.join(inspect.getblock(lines[found.args[0]:])) - class_node = next( - node for node in ast.walk(tree) - if isinstance(node, ast.ClassDef) - and min([node.lineno, *(decorator.lineno for decorator in node.decorator_list)]) == first_line - ) - return ClassSourceDeclaration.from_block(block, class_node, first_line) - return None + class_node = _class_node_for_qualname(tree, qualname) + if class_node is None: + return None + first_line = min([class_node.lineno, *(decorator.lineno for decorator in class_node.decorator_list)]) + lines = source.splitlines(keepends=True) + block = ''.join(inspect.getblock(lines[first_line - 1:])) + return ClassSourceDeclaration.from_block(block, class_node, first_line) @staticmethod def _extract_inline_field_docs(dataclass_type: type) -> Dict[str, str]: