Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
70979f8
Copy test cases from "numpy"
jsh9 Dec 18, 2025
b507aa4
Manually adjust test cases
jsh9 Dec 18, 2025
aea1732
Fix more files in tests/test_data/end_to_end/google
jsh9 Dec 18, 2025
629cd06
Fix more test cases in tests/test_data/line_wrap/google
jsh9 Dec 18, 2025
f6989bf
Implement intermediate wrapper
jsh9 Dec 18, 2025
004f9fb
tweak playground.py
jsh9 Dec 18, 2025
c630070
Implement wrapping logic of the 2nd pass
jsh9 Dec 21, 2025
a472072
Don't add blank line after rST table and code block
jsh9 Dec 21, 2025
3c12cda
Add tests, etc.
jsh9 Dec 22, 2025
82410f1
remove spaces
jsh9 Dec 22, 2025
2c3fa03
Fix a bug with paragraphs within arg desc
jsh9 Dec 22, 2025
96a3c66
Fix multiple empty lines in the beginning
jsh9 Dec 22, 2025
d9511ac
Fix test case (empty_lines_are_respected.txt)
jsh9 Dec 22, 2025
2742f6b
Fix doctest handling
jsh9 Dec 22, 2025
710d4b5
Fix
jsh9 Feb 7, 2026
98a5295
More fixes
jsh9 Feb 8, 2026
cc021da
Fix logic for long type hints
jsh9 Feb 8, 2026
a4d1ace
Fix section title
jsh9 Feb 8, 2026
bd942af
Fix section header mapping
jsh9 Feb 8, 2026
7f92669
Fix wrapping within code fence
jsh9 Feb 8, 2026
6df9593
Double backtick pairs; fix URL issue
jsh9 Feb 8, 2026
54baf80
Fix expected docstring in contents_that_are_not_wrapped.txt
jsh9 Feb 8, 2026
c144e07
standardize default values
jsh9 Feb 8, 2026
6cb1bc2
Fix test case
jsh9 Feb 8, 2026
f7ea701
Fix typo in test case
jsh9 Feb 8, 2026
72ac19d
Fix wrapping typo in test case
jsh9 Feb 8, 2026
c93ef48
Remove/revert debugging changes
jsh9 Feb 8, 2026
4c780fd
Revert changes to playground
jsh9 Feb 8, 2026
14edc18
Use separate routes for Google style and numpy style
jsh9 Feb 8, 2026
79866c8
Revert "Fix test case"
jsh9 Feb 8, 2026
8b053cd
Revert "Fix expected docstring in contents_that_are_not_wrapped.txt"
jsh9 Feb 8, 2026
80de85a
Fix leng length of 1st line of docstrings
jsh9 Feb 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion format_docstring/docstring_rewriter.py
Original file line number Diff line number Diff line change
Expand Up @@ -682,10 +682,17 @@ def wrap_docstring(
"""
style = (docstring_style or '').strip().lower()
if style == 'google':
# For Google style, infer leading_indent from first non-empty line if not set
effective_leading_indent = leading_indent
if effective_leading_indent == 0:
for line in docstring.splitlines():
if line.strip():
effective_leading_indent = len(line) - len(line.lstrip())
break
return wrap_docstring_google(
docstring,
line_length=line_length,
leading_indent=leading_indent,
leading_indent=effective_leading_indent,
fix_rst_backticks=fix_rst_backticks,
parameter_metadata=function_param_metadata,
return_annotation=function_return_annotation,
Expand Down
1,347 changes: 1,336 additions & 11 deletions format_docstring/line_wrap_google.py

Large diffs are not rendered by default.

149 changes: 147 additions & 2 deletions format_docstring/line_wrap_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,18 +410,27 @@ def fix_typos_in_section_headings(lines: list[str]) -> list[str]:

def segment_lines_by_wrappability(
lines: list[str],
*,
style: str = 'numpy',
) -> list[tuple[list[str], bool]]:
"""
Segment lines into chunks that can or cannot be wrapped.

Scans through the lines to detect rST tables, bulleted lists, and literal
blocks (paragraphs following ::), which should not be wrapped. Other
content can be wrapped.

For Google-style docstrings, also detects doctest blocks (>>>) and fenced
code blocks (```), which are common in that style.

Parameters
----------
lines : list[str]
The list of lines to segment.
style : str, default='numpy'
The docstring style being processed. Supported values:
- 'numpy': Detect tables, lists, and literal blocks only
- 'google': Additionally detect doctest blocks and fenced code blocks

Returns
-------
Expand Down Expand Up @@ -488,19 +497,45 @@ def segment_lines_by_wrappability(
current_idx = literal_end_idx
continue

# Check for doctest block (Google style only)
if style == 'google':
is_doctest, doctest_end_idx = is_doctest_block(lines, current_idx)
if is_doctest:
# Add doctest segment (not wrappable)
doctest_lines = lines[current_idx:doctest_end_idx]
segments.append((doctest_lines, False))
current_idx = doctest_end_idx
continue

# Check for fenced code block (``` - Google style only)
if style == 'google':
is_fence, fence_end_idx = is_code_fence(lines, current_idx)
if is_fence:
# Add code fence segment (not wrappable)
fence_lines = lines[current_idx:fence_end_idx]
segments.append((fence_lines, False))
current_idx = fence_end_idx
continue

# Neither table, list, nor literal block - collect wrappable content
start_idx = current_idx
current_idx += 1

# Continue collecting wrappable lines until we hit a table/list/literal
# or end
# (or doctest/fence for Google style)
while current_idx < len(lines):
is_table, _ = is_rST_table(lines, current_idx)
is_list, _ = is_bulleted_list(lines, current_idx)
is_literal, _ = _is_literal_block_paragraph(lines, current_idx)

if is_table or is_list or is_literal:
break

if style == 'google':
is_doctest, _ = is_doctest_block(lines, current_idx)
is_fence, _ = is_code_fence(lines, current_idx)
if is_doctest or is_fence:
break

current_idx += 1

Expand All @@ -511,6 +546,59 @@ def segment_lines_by_wrappability(
return segments


def is_code_fence(lines: list[str], start_idx: int = 0) -> tuple[bool, int]:
"""
Check if lines starting at start_idx form a fenced code block.

Fenced code blocks are delimited by triple backticks (```) or tildes (~~~).
The opening fence can optionally include a language identifier.

Parameters
----------
lines : list[str]
The list of lines to check.
start_idx : int, default=0
The starting index to check from.

Returns
-------
tuple[bool, int]
A tuple of (is_code_fence, end_idx) where is_code_fence indicates if a
code fence was found starting at start_idx, and end_idx is the index
after the closing fence line (or start_idx if no code fence found).

Examples
--------
>>> lines = ['```python', 'def foo():', ' pass', '```', 'more text']
>>> is_code_fence(lines, 0)
(True, 4)
>>> is_code_fence(lines, 4)
(False, 4)
"""
if start_idx >= len(lines):
return False, start_idx

line = lines[start_idx]
stripped = line.lstrip()

# Check for opening fence (``` or ~~~)
if not (stripped.startswith("```") or stripped.startswith("~~~")):
return False, start_idx

# Determine the fence character used
fence_char = stripped[0] # Either ` or ~

# Find the closing fence
for i in range(start_idx + 1, len(lines)):
line_i = lines[i].lstrip()
if line_i.startswith(fence_char * 3) and line_i.rstrip() == fence_char * 3:
# Found closing fence
return True, i + 1

# No closing fence found - treat the rest as code block
return True, len(lines)


def is_rST_table(lines: list[str], start_idx: int = 0) -> tuple[bool, int]: # noqa: N802
"""
Check if lines starting at start_idx form a reStructuredText table.
Expand Down Expand Up @@ -851,6 +939,63 @@ def _is_continuation_line(line: str, list_item_indent: int) -> bool:
return line_indent > list_item_indent


def is_doctest_block(
lines: list[str], start_idx: int
) -> tuple[bool, int]:
"""
Check if lines starting at start_idx form a Python doctest block.

A doctest block starts with '>>>' and includes subsequent lines
that also start with '>>>' or '...'.

Parameters
----------
lines : list[str]
The list of lines to check.
start_idx : int
The starting index to check from.

Returns
-------
tuple[bool, int]
(is_doctest, end_idx)
"""
if start_idx >= len(lines):
return False, start_idx

line = lines[start_idx].strip()
if not line.startswith(">>>"):
return False, start_idx

# Found start of doctest block
current_idx = start_idx + 1
while current_idx < len(lines):
next_line = lines[current_idx].strip()
# Continue if it's a prompt '>>>', continuation '...', or empty?
# Standard doctest: '>>>' or '...'.
# Sometimes results don't have perfix, but that's hard to distinguish from normal text.
# Ideally we only capture the interactive session parts.
# But commonly examples include output without prefix.
# For wrapping purposes, catching the '>>>' and '...' sequence is the most critical/safe part.
# If we include output lines, we risk capturing normal text.
# However, usually output lines shouldn't be wrapped either?
# Let's stick to explicit '>>>' and '...' for now to be safe,
# or maybe indentation-based continuation?
# If the next line is indentedSAME as the start line?

# Simple heuristic: consecutive lines starting with `>>>` or `...`
if next_line.startswith(">>>") or next_line.startswith("..."):
current_idx += 1
continue

# If line is empty, it breaks the block?
# Or if it's output?
# Let's stop at non-matching line.
break

return True, current_idx


def _is_literal_block_paragraph(
lines: list[str], start_idx: int
) -> tuple[bool, int]:
Expand Down
5 changes: 3 additions & 2 deletions format_docstring/main_py.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,9 @@ def main(
"""Format .py files."""
ret = 0

if docstring_style.lower() != 'numpy':
raise ValueError('Only "numpy" style is supported for now.')
# Validating style is handled by the rewriter or Click choice, but strictly speaking
# we don't need to block non-numpy here anymore as rewriter supports google.
pass

for path in paths:
fixer = PythonFileFixer(
Expand Down
1 change: 1 addition & 0 deletions tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def load_cases_from_dir(data_dir: Path) -> list[tuple[str, int, str, str]]:


def load_case_from_file(filename: Path) -> tuple[str, int, str, str]:
"""Load a single test case from a file."""
raw = filename.read_text(encoding='utf-8')
first_nl = raw.find('\n')
if first_nl == -1:
Expand Down
2 changes: 2 additions & 0 deletions tests/test_data/end_to_end/google/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
All the test cases in `tests/test_data/line_wrap/google` should be here, but not
all the test cases here are over there.
37 changes: 37 additions & 0 deletions tests/test_data/end_to_end/google/arg_name_is_default.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
LINE_LENGTH: 79

**********
def func1(arg1: str, default: int = 2, _default: str = "value", default_: list[int] | None = None) -> None:
"""
Do something

Args:
arg1 (str): Very very very very very very very very very very very very very very very very
very very very very very very very very very very very very very very very very
very very very very very very very
default (int): Quite quite quite quite quite quite quite quite quite quite quite quite quite quite quite quite
quite quite quite quite quite quite quite quite quite quite quite quite quite quite quite quite long
_default (str default is "value"): Placeholder description for _default parameter.
default_ (list[int] | None default is None): Placeholder description for default_ parameter.
"""
pass

**********
def func1(arg1: str, default: int = 2, _default: str = "value", default_: list[int] | None = None) -> None:
"""
Do something

Args:
arg1 (str): Very very very very very very very very very very very very
very very very very very very very very very very very very very
very very very very very very very very very very very very
default (int, default=2): Quite quite quite quite quite quite quite
quite quite quite quite quite quite quite quite quite quite quite
quite quite quite quite quite quite quite quite quite quite quite
long
_default (str, default="value"): Placeholder description for _default
parameter.
default_ (list[int] | None, default=None): Placeholder description for
default_ parameter.
"""
pass
51 changes: 51 additions & 0 deletions tests/test_data/end_to_end/google/colon_spacing_fix.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
LINE_LENGTH: 79

**********
def func1():
def func2():
def func3():
def func4():
def func5():
def func6():
def func7():
def func8():
def func9():
def func10():
"""
Args:
arg1 (dict[str, list[str]]): First argument with missing space before colon.
arg2 (dict[str, list[str]]): Second argument with extra space after colon.
arg3 (Optional[int]): Third argument with no spaces around colon.
*args (Any): Variable positional arguments.
**kwargs (dict[str, Any]): Variable keyword arguments.
"""
return None

**********
def func1():
def func2():
def func3():
def func4():
def func5():
def func6():
def func7():
def func8():
def func9():
def func10():
"""
Args:
arg1 (dict[str, list[str]]): First
argument with missing space
before colon.
arg2 (dict[str, list[str]]): Second
argument with extra space after
colon.
arg3 (Optional[int]): Third
argument with no spaces around
colon.
*args (Any): Variable positional
arguments.
**kwargs (dict[str, Any]): Variable
keyword arguments.
"""
return None
Loading
Loading