Skip to content

Commit 64685e9

Browse files
committed
update
1 parent a0e06bf commit 64685e9

File tree

5 files changed

+155
-121
lines changed

5 files changed

+155
-121
lines changed

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,8 +239,8 @@ unit-tests = "uv run pytest --numprocesses=${TESTS_CONCURRENCY:-auto} tests/unit
239239
unit-tests-cov = "uv run pytest --numprocesses=${TESTS_CONCURRENCY:-auto} --cov=src/apify_client --cov-report=xml:coverage-unit.xml tests/unit"
240240
integration-tests = "uv run pytest --numprocesses=${TESTS_CONCURRENCY:-auto} tests/integration"
241241
integration-tests-cov = "uv run pytest --numprocesses=${TESTS_CONCURRENCY:-auto} --cov=src/apify_client --cov-report=xml:coverage-integration.xml tests/integration"
242-
check-async-docstrings = "uv run python scripts/check_async_docstrings.py"
243-
fix-async-docstrings = "uv run python scripts/fix_async_docstrings.py"
242+
check-async-docstrings = "uv run python -m scripts.check_async_docstrings"
243+
fix-async-docstrings = "uv run python -m scripts.fix_async_docstrings"
244244
check-code = ["lint", "type-check", "unit-tests", "check-async-docstrings"]
245245

246246
[tool.poe.tasks.install-dev]

scripts/__init__.py

Whitespace-only changes.

scripts/_utils.py

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,37 @@
1-
import pathlib
1+
"""Shared utilities for async docstring scripts."""
2+
3+
from __future__ import annotations
4+
25
import re
6+
from pathlib import Path
7+
from typing import TYPE_CHECKING
38

4-
PACKAGE_NAME = 'apify_client'
5-
REPO_ROOT = pathlib.Path(__file__).parent.resolve() / '..'
6-
PYPROJECT_TOML_FILE_PATH = REPO_ROOT / 'pyproject.toml'
9+
from griffe import Module, load
710

11+
if TYPE_CHECKING:
12+
from collections.abc import Generator
813

914
SKIPPED_METHODS = {
1015
'with_custom_http_client',
1116
}
12-
"""Components where the async and sync docstrings are intentionally different."""
17+
"""Methods where the async and sync docstrings are intentionally different."""
18+
19+
SRC_PATH = Path(__file__).resolve().parent.parent / 'src'
20+
21+
22+
def load_package() -> Module:
23+
"""Load the apify_client package using griffe."""
24+
package = load('apify_client', search_paths=[str(SRC_PATH)])
25+
if not isinstance(package, Module):
26+
raise TypeError('Expected griffe to load a Module')
27+
return package
28+
29+
30+
def walk_modules(module: Module) -> Generator[Module]:
31+
"""Recursively yield all modules in the package."""
32+
yield module
33+
for submodule in module.modules.values():
34+
yield from walk_modules(submodule)
1335

1436

1537
def sync_to_async_docstring(docstring: str) -> str:
@@ -23,7 +45,7 @@ def sync_to_async_docstring(docstring: str) -> str:
2345
(r'Retry a function', r'Retry an async function'),
2446
(r'Function to retry', r'Async function to retry'),
2547
]
26-
res = docstring
48+
result = docstring
2749
for pattern, replacement in substitutions:
28-
res = re.sub(pattern, replacement, res, flags=re.MULTILINE)
29-
return res
50+
result = re.sub(pattern, replacement, result, flags=re.MULTILINE)
51+
return result

scripts/check_async_docstrings.py

Lines changed: 41 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,58 @@
11
#!/usr/bin/env python
22

3-
"""Check if async docstrings are the same as sync."""
3+
"""Check that async client docstrings are in sync with their sync counterparts."""
4+
5+
from __future__ import annotations
46

57
import sys
6-
from collections.abc import Generator
7-
from pathlib import Path
88

9-
from griffe import Module, load
9+
from ._utils import SKIPPED_METHODS, load_package, sync_to_async_docstring, walk_modules
1010

11-
from ._utils import SKIPPED_METHODS, sync_to_async_docstring
1211

13-
found_issues = False
12+
def main() -> None:
13+
"""Check all async client methods for docstring mismatches."""
14+
package = load_package()
15+
found_issues = False
1416

15-
# Load the apify_client package
16-
src_path = Path(__file__).parent.resolve() / '../src'
17-
package = load('apify_client', search_paths=[str(src_path)])
17+
for module in walk_modules(package):
18+
for async_class in module.classes.values():
19+
if not async_class.name.endswith('ClientAsync'):
20+
continue
1821

22+
sync_class = module.classes.get(async_class.name.replace('ClientAsync', 'Client'))
23+
if not sync_class:
24+
continue
1925

20-
def walk_modules(module: Module) -> Generator[Module]:
21-
"""Recursively yield all modules in the package."""
22-
yield module
23-
for submodule in module.modules.values():
24-
yield from walk_modules(submodule)
26+
for async_method in async_class.functions.values():
27+
if any(str(d.value) == 'ignore_docs' for d in async_method.decorators):
28+
continue
2529

30+
if async_method.name in SKIPPED_METHODS:
31+
continue
2632

27-
# Go through every module in the package
28-
if not isinstance(package, Module):
29-
raise TypeError('Expected griffe to load a Module')
30-
for module in walk_modules(package):
31-
for async_class in module.classes.values():
32-
if not async_class.name.endswith('ClientAsync'):
33-
continue
33+
sync_method = sync_class.functions.get(async_method.name)
34+
if not sync_method or not sync_method.docstring:
35+
continue
3436

35-
# Find the corresponding sync class (same name, but without Async)
36-
sync_class = module.classes.get(async_class.name.replace('ClientAsync', 'Client'))
37-
if not sync_class:
38-
continue
37+
expected_docstring = sync_to_async_docstring(sync_method.docstring.value)
3938

40-
# Go through all methods in the async class
41-
for async_method in async_class.functions.values():
42-
# Skip methods with @ignore_docs decorator
43-
if any(str(d.value) == 'ignore_docs' for d in async_method.decorators):
44-
continue
39+
if not async_method.docstring:
40+
print(f'Missing docstring for "{async_class.name}.{async_method.name}"!')
41+
found_issues = True
42+
elif async_method.docstring.value != expected_docstring:
43+
print(
44+
f'Docstring mismatch: "{async_class.name}.{async_method.name}"'
45+
f' vs "{sync_class.name}.{sync_method.name}"'
46+
)
47+
found_issues = True
4548

46-
# Skip methods whose docstrings are intentionally different
47-
if async_method.name in SKIPPED_METHODS:
48-
continue
49+
if found_issues:
50+
print()
51+
print('Issues with async docstrings found. Fix them by running `uv run poe fix-async-docstrings`.')
52+
sys.exit(1)
53+
else:
54+
print('Success: async method docstrings are in sync with sync method docstrings.')
4955

50-
# Find corresponding sync method in the sync class
51-
sync_method = sync_class.functions.get(async_method.name)
52-
if not sync_method or not sync_method.docstring:
53-
continue
5456

55-
expected_docstring = sync_to_async_docstring(sync_method.docstring.value)
56-
57-
if not async_method.docstring:
58-
print(f'Missing docstring for "{async_class.name}.{async_method.name}"!')
59-
found_issues = True
60-
elif async_method.docstring.value != expected_docstring:
61-
print(
62-
f'Docstring for "{async_class.name}.{async_method.name}" is out of sync with "{sync_class.name}.{sync_method.name}"!' # noqa: E501
63-
)
64-
found_issues = True
65-
66-
if found_issues:
67-
print()
68-
print(
69-
'Issues with async docstrings found. Please fix them manually or by running `uv run poe fix-async-docstrings`.'
70-
)
71-
sys.exit(1)
72-
else:
73-
print('Success: async method docstrings are in sync with sync method docstrings.')
57+
if __name__ == '__main__':
58+
main()

scripts/fix_async_docstrings.py

Lines changed: 82 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,20 @@
11
#!/usr/bin/env python
22

3-
import ast
4-
from collections.abc import Generator
5-
from pathlib import Path
3+
"""Fix async client docstrings to match their sync counterparts."""
64

7-
from griffe import Module, load
5+
from __future__ import annotations
86

9-
from ._utils import SKIPPED_METHODS, sync_to_async_docstring
7+
import ast
8+
from pathlib import Path
9+
from typing import TYPE_CHECKING
1010

11-
# Load the apify_client package
12-
src_path = Path(__file__).parent.resolve() / '../src'
13-
package = load('apify_client', search_paths=[str(src_path)])
11+
from ._utils import SKIPPED_METHODS, load_package, sync_to_async_docstring, walk_modules
1412

13+
if TYPE_CHECKING:
14+
from griffe import Class, Module
1515

16-
def walk_modules(module: Module) -> Generator[Module]:
17-
"""Recursively yield all modules in the package."""
18-
yield module
19-
for submodule in module.modules.values():
20-
yield from walk_modules(submodule)
16+
Replacement = tuple[str, str, str, bool]
17+
EditOp = tuple[str, int, int | None, str]
2118

2219

2320
def format_docstring(content: str, indent: str) -> str:
@@ -71,57 +68,37 @@ def find_method_body_start(tree: ast.AST, class_name: str, method_name: str) ->
7168
return None
7269

7370

74-
# Go through every module in the package
75-
if not isinstance(package, Module):
76-
raise TypeError('Expected griffe to load a Module')
77-
for module in walk_modules(package):
78-
replacements = []
71+
def collect_replacements(sync_class: Class, async_class: Class) -> list[Replacement]:
72+
"""Collect docstring replacements needed for an async class."""
73+
replacements: list[Replacement] = []
7974

80-
for async_class in module.classes.values():
81-
if not async_class.name.endswith('ClientAsync'):
75+
for async_method in async_class.functions.values():
76+
if any(str(d.value) == 'ignore_docs' for d in async_method.decorators):
8277
continue
8378

84-
# Find the corresponding sync class (same name, but without Async)
85-
sync_class = module.classes.get(async_class.name.replace('ClientAsync', 'Client'))
86-
if not sync_class:
79+
if async_method.name in SKIPPED_METHODS:
8780
continue
8881

89-
# Go through all methods in the async class
90-
for async_method in async_class.functions.values():
91-
# Skip methods with @ignore_docs decorator
92-
if any(str(d.value) == 'ignore_docs' for d in async_method.decorators):
93-
continue
82+
sync_method = sync_class.functions.get(async_method.name)
83+
if not sync_method or not sync_method.docstring:
84+
continue
9485

95-
# Skip methods whose docstrings are intentionally different
96-
if async_method.name in SKIPPED_METHODS:
97-
continue
86+
correct_docstring = sync_to_async_docstring(sync_method.docstring.value)
9887

99-
# Find corresponding sync method in the sync class
100-
sync_method = sync_class.functions.get(async_method.name)
101-
if not sync_method or not sync_method.docstring:
102-
continue
88+
if not async_method.docstring:
89+
print(f' Adding missing docstring for "{async_class.name}.{async_method.name}"')
90+
replacements.append((async_class.name, async_method.name, correct_docstring, False))
91+
elif async_method.docstring.value != correct_docstring:
92+
print(f' Updating docstring for "{async_class.name}.{async_method.name}"')
93+
replacements.append((async_class.name, async_method.name, correct_docstring, True))
10394

104-
correct_docstring = sync_to_async_docstring(sync_method.docstring.value)
95+
return replacements
10596

106-
if not async_method.docstring:
107-
print(f'Fixing missing docstring for "{async_class.name}.{async_method.name}"...')
108-
replacements.append((async_class.name, async_method.name, correct_docstring, False))
109-
elif async_method.docstring.value != correct_docstring:
110-
replacements.append((async_class.name, async_method.name, correct_docstring, True))
11197

112-
if not replacements:
113-
continue
98+
def build_edit_ops(tree: ast.AST, replacements: list[Replacement]) -> list[EditOp]:
99+
"""Build a list of edit operations from the collected replacements."""
100+
ops: list[EditOp] = []
114101

115-
# Read the source file and parse with ast for precise locations
116-
filepath = module.filepath
117-
if not isinstance(filepath, Path):
118-
continue
119-
source = filepath.read_text(encoding='utf-8')
120-
source_lines = source.splitlines(keepends=True)
121-
tree = ast.parse(source)
122-
123-
# Collect replacement operations with line numbers
124-
ops = []
125102
for class_name, method_name, correct_docstring, has_existing in replacements:
126103
if has_existing:
127104
result = find_docstring_range(tree, class_name, method_name)
@@ -140,7 +117,11 @@ def find_method_body_start(tree: ast.AST, class_name: str, method_name: str) ->
140117
formatted = format_docstring(correct_docstring, indent)
141118
ops.append(('insert', insert_line, None, formatted))
142119

143-
# Sort by start line descending (process bottom-up to preserve line numbers)
120+
return ops
121+
122+
123+
def apply_edit_ops(source_lines: list[str], ops: list[EditOp]) -> list[str]:
124+
"""Apply edit operations to source lines (bottom-up to preserve line numbers)."""
144125
ops.sort(key=lambda x: x[1], reverse=True)
145126

146127
for op_type, start_line, end_line, formatted in ops:
@@ -150,5 +131,51 @@ def find_method_body_start(tree: ast.AST, class_name: str, method_name: str) ->
150131
elif op_type == 'insert':
151132
source_lines[start_line - 1 : start_line - 1] = formatted_lines
152133

153-
# Save the updated source code back to the file
134+
return source_lines
135+
136+
137+
def fix_module(module: Module) -> int:
138+
"""Fix docstrings in a single module. Returns the number of fixes applied."""
139+
replacements: list[Replacement] = []
140+
141+
for async_class in module.classes.values():
142+
if not async_class.name.endswith('ClientAsync'):
143+
continue
144+
145+
sync_class = module.classes.get(async_class.name.replace('ClientAsync', 'Client'))
146+
if not sync_class:
147+
continue
148+
149+
replacements.extend(collect_replacements(sync_class, async_class))
150+
151+
if not replacements:
152+
return 0
153+
154+
filepath = module.filepath
155+
if not isinstance(filepath, Path):
156+
return 0
157+
158+
source = filepath.read_text(encoding='utf-8')
159+
source_lines = source.splitlines(keepends=True)
160+
tree = ast.parse(source)
161+
162+
ops = build_edit_ops(tree, replacements)
163+
source_lines = apply_edit_ops(source_lines, ops)
164+
154165
filepath.write_text(''.join(source_lines), encoding='utf-8')
166+
return len(ops)
167+
168+
169+
def main() -> None:
170+
"""Fix all async client methods with missing or mismatched docstrings."""
171+
package = load_package()
172+
fixed_count = sum(fix_module(module) for module in walk_modules(package))
173+
174+
if fixed_count:
175+
print(f'\nFixed {fixed_count} docstring(s).')
176+
else:
177+
print('All async docstrings are already in sync.')
178+
179+
180+
if __name__ == '__main__':
181+
main()

0 commit comments

Comments
 (0)