diff --git a/sphinx/ext/viewcode.py b/sphinx/ext/viewcode.py index ebfe4322f84..b64d67bdad3 100644 --- a/sphinx/ext/viewcode.py +++ b/sphinx/ext/viewcode.py @@ -29,6 +29,7 @@ from sphinx.application import Sphinx from sphinx.builders import Builder from sphinx.environment import BuildEnvironment + from sphinx.util._pathlib import _StrPath from sphinx.util.typing import ExtensionMetadata logger = logging.getLogger(__name__) @@ -207,7 +208,7 @@ def remove_viewcode_anchors(self) -> None: node.parent.remove(node) -def get_module_filename(app: Sphinx, modname: str) -> str | None: +def get_module_filename(app: Sphinx, modname: str) -> _StrPath | None: """Get module filename for *modname*.""" source_info = app.emit_firstresult('viewcode-find-source', modname) if source_info: diff --git a/sphinx/pycode/__init__.py b/sphinx/pycode/__init__.py index 81f0519a5a1..0242cf9c8be 100644 --- a/sphinx/pycode/__init__.py +++ b/sphinx/pycode/__init__.py @@ -2,8 +2,6 @@ from __future__ import annotations -import os -import os.path import tokenize from importlib import import_module from typing import TYPE_CHECKING, Any, Literal @@ -13,6 +11,7 @@ from sphinx.util._pathlib import _StrPath if TYPE_CHECKING: + import os from inspect import Signature @@ -28,7 +27,7 @@ class ModuleAnalyzer: cache: dict[tuple[Literal['file', 'module'], str | _StrPath], Any] = {} @staticmethod - def get_module_source(modname: str) -> tuple[str | None, str | None]: + def get_module_source(modname: str) -> tuple[_StrPath | None, str | None]: """Try to find the source code for a module. Returns ('filename', 'source'). One of it can be None if @@ -39,14 +38,15 @@ def get_module_source(modname: str) -> tuple[str | None, str | None]: except Exception as err: raise PycodeError('error importing %r' % modname, err) from err loader = getattr(mod, '__loader__', None) - filename = getattr(mod, '__file__', None) + filename: str | None = getattr(mod, '__file__', None) if loader and getattr(loader, 'get_source', None): # prefer Native loader, as it respects #coding directive try: source = loader.get_source(modname) if source: + mod_path = None if filename is None else _StrPath(filename) # no exception and not None - it must be module source - return filename, source + return mod_path, source except ImportError: pass # Try other "source-mining" methods if filename is None and loader and getattr(loader, 'get_filename', None): @@ -60,24 +60,28 @@ def get_module_source(modname: str) -> tuple[str | None, str | None]: if filename is None: # all methods for getting filename failed, so raise... raise PycodeError('no source found for module %r' % modname) - filename = os.path.normpath(os.path.abspath(filename)) - if filename.lower().endswith(('.pyo', '.pyc')): - filename = filename[:-1] - if not os.path.isfile(filename) and os.path.isfile(filename + 'w'): - filename += 'w' - elif not filename.lower().endswith(('.py', '.pyw')): - raise PycodeError('source is not a .py file: %r' % filename) - - if not os.path.isfile(filename): - raise PycodeError('source file is not present: %r' % filename) - return filename, None + mod_path = _StrPath(filename).resolve() + if mod_path.suffix in {'.pyo', '.pyc'}: + mod_path_pyw = mod_path.with_suffix('.pyw') + if not mod_path.is_file() and mod_path_pyw.is_file(): + mod_path = mod_path_pyw + else: + mod_path = mod_path.with_suffix('.py') + elif mod_path.suffix not in {'.py', '.pyw'}: + msg = f'source is not a .py file: {mod_path!r}' + raise PycodeError(msg) + + if not mod_path.is_file(): + msg = f'source file is not present: {mod_path!r}' + raise PycodeError(msg) + return mod_path, None @classmethod def for_string( cls: type[ModuleAnalyzer], string: str, modname: str, - srcname: str = '', + srcname: str | os.PathLike[str] = '', ) -> ModuleAnalyzer: return cls(string, modname, srcname)