Skip to content

Commit d2ba71d

Browse files
geographikalagru
andauthored
Avoid infinite loop in module_name_from_path (#89)
`Path(".").parent` can't move past the "." in a relative path and will just return `Path(".")` again. This lead to `while True` never breaking. To fix this, we make sure that absolute paths are used. We need to resolve the `path` before `lru_cache` sees it. Otherwise, `lru_cache` might return a wrong cached result in case the current working directory changes. That should never happen in docstub, but I think it's still good to be defensive here. This change also adds a few other defensive guards and asserts. Long term, it might be the least error-prone to resolve all paths to absolute ones as soon as possible. However, we'd have to do some additional work to shorten paths that are within the current working directory. Otherwise, users might see unnecessarily long paths in their output. Also: - Warn in case docstub is run on a subpackage only - Appease mypy pointing out call to untyped function in tests - Appease mypy pointing out a missing return type annotation --------- Co-authored-by: Lars Grüter <lagru@mailbox.org>
1 parent 4e8560d commit d2ba71d

4 files changed

Lines changed: 92 additions & 17 deletions

File tree

src/docstub/_cli.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from ._config import Config
1919
from ._path_utils import (
2020
STUB_HEADER_COMMENT,
21+
find_package_root,
2122
walk_source_and_targets,
2223
walk_source_package,
2324
)
@@ -326,8 +327,13 @@ def run(
326327
root_path = Path(root_path)
327328
if root_path.is_file():
328329
logger.warning(
329-
"Running docstub on a single file. Relative imports "
330-
"or type references outside this file won't work."
330+
"Running docstub on a single module. Relative imports "
331+
"or type references pointing outside this module won't work."
332+
)
333+
elif find_package_root(root_path) != root_path.resolve():
334+
logger.warning(
335+
"Running docstub only on a subpackage. Relative imports "
336+
"or type references pointing outside this subpackage won't work."
331337
)
332338

333339
config = _load_configuration(config_paths)

src/docstub/_path_utils.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ def is_python_package_dir(path):
107107

108108

109109
def find_package_root(path):
110-
"""Determine the root a Python package from any path pointing inside it.
110+
"""Determine the root of a Python package from any path pointing inside it.
111111
112112
Parameters
113113
----------
@@ -121,18 +121,21 @@ def find_package_root(path):
121121
--------
122122
>>> from pathlib import Path
123123
>>> package_root = find_package_root(Path(__file__))
124-
>>> (package_root / "docstub").is_dir()
124+
>>> package_root.name
125+
'docstub'
126+
127+
>>> find_package_root(package_root) == package_root
125128
True
126129
"""
127-
root = path
128-
if root.is_file():
129-
root = root.parent
130+
root = path.resolve() # `Path.parent` can't move past relative "." part
130131

131132
for _ in range(2**16):
132-
if not is_python_package_dir(root):
133+
parent = root.parent
134+
assert parent
135+
if not is_python_package_dir(parent):
133136
logger.debug("Detected %s as the package root of %s", root, path)
134137
return root
135-
root = root.parent
138+
root = parent
136139

137140
msg = f"exceeded iteration length while trying to find package root for {path}"
138141
raise RuntimeError(msg)

src/docstub/_utils.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import itertools
22
import re
3-
from functools import lru_cache
3+
from functools import lru_cache, wraps
44
from zlib import crc32
55

66

@@ -60,6 +60,33 @@ def escape_qualname(name):
6060
return qualname
6161

6262

63+
def _resolve_path_before_caching(func):
64+
"""Resolve relative paths passed to :func:`module_name_from_path`.
65+
66+
:func:`module_name_from_path` makes use of Python's :func:`lru_cache`
67+
decorator. Caching results based on relative paths may return wrong results
68+
if the current working directory changes.
69+
70+
Access the :func:`lru_cache` specific attributes with ``func.__wrapped__``.
71+
72+
Parameters
73+
----------
74+
func : Callable
75+
76+
Returns
77+
-------
78+
wrapped : Callable
79+
"""
80+
81+
@wraps(func)
82+
def wrapped(file_path):
83+
file_path = file_path.resolve()
84+
return func(file_path)
85+
86+
return wrapped
87+
88+
89+
@_resolve_path_before_caching
6390
@lru_cache(maxsize=100)
6491
def module_name_from_path(path):
6592
"""Find the full name of a module within its package from its file path.
@@ -86,16 +113,25 @@ def module_name_from_path(path):
86113

87114
name_parts = []
88115
if path.name != "__init__.py":
116+
assert path.stem
89117
name_parts.insert(0, path.stem)
90118

119+
iter_limit = 10_000
91120
directory = path.parent
92-
while True:
121+
for _ in range(iter_limit):
93122
is_in_package = (directory / "__init__.py").is_file()
94123
if is_in_package:
124+
assert directory.name
95125
name_parts.insert(0, directory.name)
96126
directory = directory.parent
97127
else:
98128
break
129+
else:
130+
msg = (
131+
f"Reached iteration limit ({iter_limit}) "
132+
f"while trying to find module name for {path!r}"
133+
)
134+
raise RuntimeError(msg)
99135

100136
name = ".".join(name_parts)
101137
return name

tests/test_utils.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
1+
import os
2+
from pathlib import Path
3+
14
from docstub import _utils
25

36

7+
def _create_dummy_package(root: Path, structure: list[str]) -> None:
8+
"""Create a dummy Python package in `root` based on subpaths in `structure`."""
9+
for item in structure:
10+
path = root / item
11+
if item.endswith(".py"):
12+
path.touch()
13+
else:
14+
path.mkdir()
15+
16+
417
class Test_module_name_from_path:
518
def test_basic(self, tmp_path):
619
# Package structure
@@ -12,12 +25,7 @@ def test_basic(self, tmp_path):
1225
"foo/baz/__init__.py",
1326
"foo/baz/qux.py",
1427
]
15-
for item in structure:
16-
path = tmp_path / item
17-
if item.endswith(".py"):
18-
path.touch()
19-
else:
20-
path.mkdir()
28+
_create_dummy_package(tmp_path, structure)
2129

2230
assert _utils.module_name_from_path(tmp_path / "foo/__init__.py") == "foo"
2331
assert _utils.module_name_from_path(tmp_path / "foo/bar.py") == "foo.bar"
@@ -28,6 +36,28 @@ def test_basic(self, tmp_path):
2836
_utils.module_name_from_path(tmp_path / "foo/baz/qux.py") == "foo.baz.qux"
2937
)
3038

39+
def test_relative_path(self, tmp_path_cwd):
40+
structure = [
41+
"foo/",
42+
"foo/__init__.py",
43+
"foo/bar.py",
44+
"foo/baz/",
45+
"foo/baz/__init__.py",
46+
"foo/baz/bar.py",
47+
]
48+
_create_dummy_package(tmp_path_cwd, structure)
49+
os.chdir(tmp_path_cwd / "foo")
50+
cwd = Path()
51+
52+
assert _utils.module_name_from_path(cwd / "__init__.py") == "foo"
53+
assert _utils.module_name_from_path(cwd / "bar.py") == "foo.bar"
54+
55+
# `./__init__.py` and `./bar.py` should return different results in
56+
# different working directories
57+
os.chdir(tmp_path_cwd / "foo/baz")
58+
assert _utils.module_name_from_path(cwd / "__init__.py") == "foo.baz"
59+
assert _utils.module_name_from_path(cwd / "bar.py") == "foo.baz.bar"
60+
3161

3262
def test_pyfile_checksum(tmp_path):
3363
# Create package

0 commit comments

Comments
 (0)