Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 9 additions & 0 deletions .cursor/skills/google-docstring-format/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ paths = ["google_docstring_parser", "tools"]
require_param_types = true
check_references = true
check_type_consistency = true
min_short_description_length = 50
max_short_description_length = 160 # SEO meta description; 0 to disable
exclude_files = ["test_malformed_docstrings.py"]
```

Expand Down Expand Up @@ -42,6 +44,11 @@ exclude_files = ["test_malformed_docstrings.py"]
```
- Each reference needs non-empty `description` and `source` (colon-separated).

### Short description
- Short description = first paragraph (up to first blank line). Multi-line first para joined with spaces.
- Use several sentences for meta descriptions. SEO: 120-160 chars recommended.
- `min_short_description_length` / `max_short_description_length` enforce bounds (0 to disable).

### Type validation
- `dict`, `list`, `set`, `tuple`, etc. require brackets: `list[str]`, `dict[str, int]`
- No unclosed parentheses in param types
Expand All @@ -56,6 +63,8 @@ exclude_files = ["test_malformed_docstrings.py"]
| `missing_dash` / `dash_in_single` | Single ref: no dash. Multiple refs: all start with `-` |
| `Invalid section name 'return:'` | Use `Returns:` |
| `Returns section is missing type annotation` | Add type before colon in Returns |
| `Short description too short` | Add more text to first paragraph (up to blank line) |
| `Short description too long` | Trim first paragraph; aim for 120-160 chars for SEO |

## Verify

Expand Down
2 changes: 1 addition & 1 deletion .cursor/skills/python-pre-commit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ pre-commit run --all-files

- **Ruff**: `[tool.ruff]` in pyproject.toml (line-length 120, py310, pydocstyle google)
- **Mypy**: `[tool.mypy]` in pyproject.toml (strict: disallow_untyped_defs, etc.)
- **Docstrings**: `[tool.docstring_checker]` in pyproject.toml
- **Docstrings**: `[tool.docstring_checker]` in pyproject.toml (paths, min/max_short_description_length, etc.)

## Fixing failures

Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ References:

Each reference is parsed into a dictionary with `description` and `source` keys. URLs in the source are properly handled, ensuring colons in URLs are not confused with the separator colon.

## Short Description (Meta Description)

The checker extracts the **short description** as the first paragraph (up to the first blank line). Multi-line first paragraphs are joined with spaces. This is useful for meta descriptions on documentation sites. SEO best practice: 120-160 characters. Use `min_short_description_length` and `max_short_description_length` to enforce bounds (0 to disable).

## Pre-commit Hook

This package includes a pre-commit hook that checks if Google-style docstrings in your codebase can be parsed correctly.
Expand Down Expand Up @@ -148,6 +152,7 @@ require_param_types = true # Require parameter types in docstr
check_references = true # Check references for proper format
check_type_consistency = true # Compare docstring types with annotations
exclude_files = ["conftest.py", "__init__.py"] # Files to exclude from checks
min_short_description_length = 10 # Minimum summary length; set to 0 to disable
min_short_description_length = 50 # Minimum short description length; 0 to disable
max_short_description_length = 160 # Maximum short description length; 0 to disable (SEO: 120-160)
verbose = false # Enable verbose output
```
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ requires = [ "setuptools>=45", "wheel" ]

[project]
name = "google-docstring-parser"
version = "0.0.10"
version = "0.0.11"

description = "A lightweight, efficient parser for Google-style Python docstrings that converts them into structured dictionaries."
readme = "README.md"
Expand Down
220 changes: 209 additions & 11 deletions tests/test_docstring_checker/test_check_docstrings.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from tools.check_docstrings import (
_config_keys_match,
_extract_short_description,
check_short_description_length,
)

Expand Down Expand Up @@ -161,7 +162,8 @@ def test_config_from_pyproject_toml() -> None:
assert "Exclude files: ['test_malformed_docstrings.py']" in result.stdout, "Should read exclude_files from pyproject.toml"

# Check that it reads min_short_description_length from pyproject.toml
assert "Min short description length: 50" in result.stdout, "Should read min_short_description_length from pyproject.toml"
assert "Min short description length: 50" in result.stdout
assert "Max short description length:" in result.stdout


def test_missing_param_types_in_real_code() -> None:
Expand Down Expand Up @@ -589,51 +591,247 @@ def missing_docstring_function(param1):


@pytest.mark.parametrize(
"parsed,min_length,expected_errors",
"description,expected",
[
("", ""),
("One line.", "One line."),
("First line.\n\nSecond para.", "First line."),
("Line one.\nLine two.\n\nSecond para.", "Line one. Line two."),
# Multiple blank lines between paragraphs
("Para one.\n\n\n\nPara two.", "Para one."),
# Whitespace-only line counts as blank
("Para one.\n \n\t\nPara two.", "Para one."),
# Single newline (no blank) - same paragraph
("Line one.\nLine two.", "Line one. Line two."),
# Leading/trailing whitespace stripped
(" \n First para. \n\n Second.", "First para."),
(" Only line. ", "Only line."),
# Multiple sentences in first paragraph
(
"First sentence. Second sentence. Third sentence.\n\nMore below.",
"First sentence. Second sentence. Third sentence.",
),
# Tab and mixed whitespace normalized to single space
("Word1\t\tWord2\nWord3", "Word1 Word2 Word3"),
# Empty after strip
(" \n\n ", ""),
# Only first paragraph when multiple
(
"A. B. C.\n\nD. E.\n\nF.",
"A. B. C.",
),
],
)
def test_extract_short_description(description: str, expected: str) -> None:
"""Test that short description is first paragraph (up to first blank line)."""
assert _extract_short_description(description) == expected


@pytest.mark.parametrize(
"parsed,min_length,max_length,expected_errors",
[
(
{"Description": "Short."},
50,
0,
["Short description too short (6 chars, min 50): 'Short.'"],
),
(
{"Description": "A" * 49},
50,
0,
["Short description too short (49 chars, min 50): 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'"],
),
({"Description": "A" * 50}, 50, []),
({"Description": "A" * 60}, 50, []),
({"Description": ""}, 50, []),
({}, 50, []),
({"Description": "Short."}, 0, []),
({"Description": "Short."}, 5, []),
({"Description": "Short."}, 6, []),
({"Description": "A" * 50}, 50, 0, []),
({"Description": "A" * 60}, 50, 0, []),
({"Description": ""}, 50, 0, []),
({}, 50, 0, []),
({"Description": "Short."}, 0, 0, []),
({"Description": "Short."}, 5, 0, []),
({"Description": "Short."}, 6, 0, []),
(
{"Description": "Short."},
7,
0,
["Short description too short (6 chars, min 7): 'Short.'"],
),
(
{"Description": "First line.\n\nSecond paragraph."},
50,
0,
["Short description too short (11 chars, min 50): 'First line.'"],
),
# First paragraph = up to blank line (multi-line)
(
{"Description": "First line. More.\nSecond line of para.\n\nSecond para."},
0,
0,
[],
),
# Max length with multi-line first paragraph (joined with spaces)
(
{
"Description": (
"Line one with some text.\n"
"And another line that pushes length over max.\n\n"
"Next paragraph."
)
},
0,
60,
[
"Short description too long (70 chars, max 60): "
"'Line one with some text. And another line that pus...'"
],
),
# Max length (single-line)
(
{"Description": "A" * 161},
0,
160,
["Short description too long (161 chars, max 160): 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA...'"],
),
({"Description": "A" * 160}, 0, 160, []),
({"Description": "A" * 50}, 50, 160, []),
(
{"Description": "Short."},
50,
5,
[
"Short description too short (6 chars, min 50): 'Short.'",
"Short description too long (6 chars, max 5): 'Short.'",
],
),
],
)
def test_check_short_description_length(
parsed: dict[str, Any], min_length: int, expected_errors: list[str]
parsed: dict[str, Any],
min_length: int,
max_length: int,
expected_errors: list[str],
) -> None:
"""Test that check_short_description_length validates correctly.

Args:
parsed (dict): Parsed docstring dict with Description key
min_length (int): Minimum length threshold
max_length (int): Maximum length threshold (0 to disable)
expected_errors (list[str]): Expected error messages
"""
result = check_short_description_length(parsed, min_length)
result = check_short_description_length(parsed, min_length, max_length)
assert result == expected_errors


@pytest.mark.parametrize(
"code,min_len,max_len,expected_returncode,expected_in_output",
[
# Short description too short - fails ("X." = 2 chars < min 5)
(
'''
"""Test module."""

def foo():
"""X.

Returns:
None
"""
pass
''',
5,
0,
1,
"Short description too short",
),
# Short description OK (multi-line first para)
(
'''
"""Test module."""

def foo():
"""First sentence. Second sentence for more context. Third sentence if needed.

Returns:
None
"""
pass
''',
50,
160,
0,
"",
),
# Short description too long - fails (6 chars > max 5)
(
'''
"""Test module."""

def foo():
"""Short.

Returns:
None
"""
pass
''',
0,
5,
1,
"Short description too long",
),
# First paragraph = up to blank line (52 chars, min 50)
(
'''
"""Test module."""

def foo():
"""First line. Second line. Third line. More text here.

Returns:
None
"""
pass
''',
50,
160,
0,
"",
),
],
)
def test_short_description_integration(
code: str,
min_len: int,
max_len: int,
expected_returncode: int,
expected_in_output: str,
tmp_path: Path,
) -> None:
"""Test short description length in full check_file flow."""

def run_check() -> subprocess.CompletedProcess[str]:
return subprocess.run(
[
sys.executable,
"-m",
"tools.check_docstrings",
str(tmp_path / "test.py"),
"--min-short-description-length",
str(min_len),
"--max-short-description-length",
str(max_len),
],
capture_output=True,
text=True,
)

(tmp_path / "test.py").write_text(code)
result = run_check()
assert result.returncode == expected_returncode
if expected_in_output:
assert expected_in_output in result.stdout


@pytest.mark.parametrize(
"code,expected_returncode,expected_in_output",
[
Expand Down
5 changes: 5 additions & 0 deletions tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,11 @@ check_references = true
# Whether to compare docstring types with function annotations
check_type_consistency = true

# Short description = first paragraph (up to first blank line). SEO: 120-160 chars recommended.
# Minimum short description length (0 to disable)
min_short_description_length = 0
# Maximum short description length (0 to disable, 160 recommended for meta descriptions)
max_short_description_length = 0

# List of filenames to exclude from checks
# These can be just filenames (e.g., "conftest.py") or paths ending with the filename
Expand Down Expand Up @@ -103,6 +106,7 @@ require_param_types = true
check_references = true
check_type_consistency = true
min_short_description_length = 0
max_short_description_length = 0
exclude_files = ["conftest.py", "__init__.py"]
verbose = false
```
Expand Down Expand Up @@ -131,5 +135,6 @@ Command line options:
- `--check-type-consistency`: Compare docstring types with function annotations
- `--no-check-type-consistency`: Skip type consistency checking
- `--min-short-description-length N`: Minimum short description length (0 to disable)
- `--max-short-description-length N`: Maximum short description length (0 to disable, 160 for SEO)
- `--exclude-files`: Comma-separated list of filenames to exclude
- `-v, --verbose`: Enable verbose output
Loading
Loading