Skip to content

Commit 3bebdf3

Browse files
committed
Support and default to inplace stub generation
1 parent dc775eb commit 3bebdf3

File tree

3 files changed

+67
-31
lines changed

3 files changed

+67
-31
lines changed

pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ ignore = [
9090
"RET504", # Assignment before `return` statement facilitates debugging
9191
"PTH123", # Using builtin open() instead of Path.open() is fine
9292
"SIM108", # Terniary operator is always more readable
93+
"SIM103", # Don't recommend returning the condition directly
9394
]
9495

9596

src/docstub/_cli.py

+18-13
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@
1616
from ._cache import FileCache
1717
from ._config import Config
1818
from ._stubs import (
19+
STUB_HEADER_COMMENT,
1920
Py2StubTransformer,
2021
try_format_stub,
21-
walk_source,
22+
walk_python_package,
2223
walk_source_and_targets,
2324
)
2425
from ._utils import ErrorReporter, GroupedErrorReporter
@@ -27,9 +28,6 @@
2728
logger = logging.getLogger(__name__)
2829

2930

30-
STUB_HEADER_COMMENT = "# File generated with docstub"
31-
32-
3331
def _load_configuration(config_path=None):
3432
"""Load and merge configuration from CWD and optional files.
3533
@@ -78,13 +76,13 @@ def _setup_logging(*, verbose):
7876
)
7977

8078

81-
def _build_import_map(config, source_dir):
79+
def _build_import_map(config, root_path):
8280
"""Build a map of known imports.
8381
8482
Parameters
8583
----------
8684
config : ~.Config
87-
source_dir : Path
85+
root_path : Path
8886
8987
Returns
9088
-------
@@ -98,10 +96,11 @@ def _build_import_map(config, source_dir):
9896
cache_dir=Path.cwd() / ".docstub_cache",
9997
name=f"{__version__}/collected_types",
10098
)
101-
for source_path in walk_source(source_dir):
102-
logger.info("collecting types in %s", source_path)
103-
known_imports_in_source = collect_cached_types(source_path)
104-
known_imports.update(known_imports_in_source)
99+
if root_path.is_dir():
100+
for source_path in walk_python_package(root_path):
101+
logger.info("collecting types in %s", source_path)
102+
known_imports_in_source = collect_cached_types(source_path)
103+
known_imports.update(known_imports_in_source)
105104

106105
known_imports.update(KnownImport.many_from_config(config.known_imports))
107106

@@ -138,7 +137,7 @@ def report_execution_time():
138137
"--out-dir",
139138
type=click.Path(file_okay=False),
140139
metavar="PATH",
141-
help="Set output directory explicitly.",
140+
help="Set output directory explicitly. Otherwise, stubs are generated inplace.",
142141
)
143142
@click.option(
144143
"--config",
@@ -176,7 +175,7 @@ def main(root_path, out_dir, config_path, group_errors, allow_errors, verbose):
176175
177176
Parameters
178177
----------
179-
source_dir : Path
178+
root_path : Path
180179
out_dir : Path
181180
config_path : Path
182181
group_errors : bool
@@ -189,6 +188,12 @@ def main(root_path, out_dir, config_path, group_errors, allow_errors, verbose):
189188
_setup_logging(verbose=verbose)
190189

191190
root_path = Path(root_path)
191+
if root_path.is_file():
192+
logger.warning(
193+
"Running docstub on a single file is experimental. Relative imports "
194+
"or type references won't work."
195+
)
196+
192197
config = _load_configuration(config_path)
193198
known_imports = _build_import_map(config, root_path)
194199

@@ -204,7 +209,7 @@ def main(root_path, out_dir, config_path, group_errors, allow_errors, verbose):
204209
if root_path.is_file():
205210
out_dir = root_path.parent
206211
else:
207-
out_dir = root_path.parent / (root_path.name + "-stubs")
212+
out_dir = root_path
208213
out_dir = Path(out_dir)
209214
out_dir.mkdir(parents=True, exist_ok=True)
210215

src/docstub/_stubs.py

+48-18
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1-
"""Transform Python source files to typed stub files."""
1+
"""Transform Python source files to typed stub files.
2+
3+
Attributes
4+
----------
5+
STUB_HEADER_COMMENT : Final[str]
6+
"""
27

38
import enum
49
import logging
10+
import re
511
from dataclasses import dataclass
612
from functools import wraps
713
from typing import ClassVar
@@ -16,7 +22,10 @@
1622
logger = logging.getLogger(__name__)
1723

1824

19-
def _is_python_package(path):
25+
STUB_HEADER_COMMENT = "# File generated with docstub"
26+
27+
28+
def is_python_package(path):
2029
"""
2130
Parameters
2231
----------
@@ -30,8 +39,31 @@ def _is_python_package(path):
3039
return is_package
3140

3241

33-
def walk_source(root_dir):
34-
"""Iterate modules in a Python package and its target stub files.
42+
def is_docstub_generated(path):
43+
"""Check if the stub file was generated by docstub.
44+
45+
Parameters
46+
----------
47+
path : Path
48+
49+
Returns
50+
-------
51+
is_generated : bool
52+
"""
53+
assert path.suffix == ".pyi"
54+
with path.open("r") as fo:
55+
content = fo.read()
56+
if re.match(f"^{re.escape(STUB_HEADER_COMMENT)}", content):
57+
return True
58+
return False
59+
60+
61+
def walk_python_package(root_dir):
62+
"""Iterate source files in a Python package.
63+
64+
Given a Python package, yield the path of contained Python modules. If an
65+
alternate stub file already exists and isn't generated by docstub, it is
66+
returned instead.
3567
3668
Parameters
3769
----------
@@ -43,26 +75,24 @@ def walk_source(root_dir):
4375
source_path : Path
4476
Either a Python file or a stub file that takes precedence.
4577
"""
46-
queue = [root_dir]
47-
while queue:
48-
path = queue.pop(0)
49-
78+
for path in root_dir.iterdir():
5079
if path.is_dir():
51-
if _is_python_package(path):
52-
queue.extend(path.iterdir())
80+
if is_python_package(path):
81+
yield from walk_python_package(path)
5382
else:
54-
logger.debug("skipping directory %s", path)
83+
logger.debug("skipping directory %s which isn't a Python package", path)
5584
continue
5685

5786
assert path.is_file()
58-
5987
suffix = path.suffix.lower()
60-
if suffix not in {".py", ".pyi"}:
61-
continue
62-
if suffix == ".py" and path.with_suffix(".pyi").exists():
63-
continue # Stub file already exists and takes precedence
6488

65-
yield path
89+
if suffix == ".py":
90+
stub = path.with_suffix(".pyi")
91+
if stub.exists() and not is_docstub_generated(stub):
92+
# Non-generated stub file already exists and takes precedence
93+
yield stub
94+
else:
95+
yield path
6696

6797

6898
def walk_source_and_targets(root_path, target_dir):
@@ -87,7 +117,7 @@ def walk_source_and_targets(root_path, target_dir):
87117
yield root_path, stub_path
88118
return
89119

90-
for source_path in walk_source(root_path):
120+
for source_path in walk_python_package(root_path):
91121
stub_path = target_dir / source_path.with_suffix(".pyi").relative_to(root_path)
92122
yield source_path, stub_path
93123

0 commit comments

Comments
 (0)