diff --git a/.cursor/skills/google-docstring-format/SKILL.md b/.cursor/skills/google-docstring-format/SKILL.md index f64d067..912fba9 100644 --- a/.cursor/skills/google-docstring-format/SKILL.md +++ b/.cursor/skills/google-docstring-format/SKILL.md @@ -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"] ``` @@ -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 @@ -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 diff --git a/.cursor/skills/python-pre-commit/SKILL.md b/.cursor/skills/python-pre-commit/SKILL.md index 24185d5..95513ba 100644 --- a/.cursor/skills/python-pre-commit/SKILL.md +++ b/.cursor/skills/python-pre-commit/SKILL.md @@ -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 diff --git a/README.md b/README.md index 8915b19..0c1b4b3 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 ``` diff --git a/pyproject.toml b/pyproject.toml index 30ff06b..f9e8fcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_docstring_checker/test_check_docstrings.py b/tests/test_docstring_checker/test_check_docstrings.py index 665e2c0..1573998 100644 --- a/tests/test_docstring_checker/test_check_docstrings.py +++ b/tests/test_docstring_checker/test_check_docstrings.py @@ -10,6 +10,7 @@ from tools.check_docstrings import ( _config_keys_match, + _extract_short_description, check_short_description_length, ) @@ -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: @@ -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", [ diff --git a/tools/README.md b/tools/README.md index 9720e28..7378b4f 100644 --- a/tools/README.md +++ b/tools/README.md @@ -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 @@ -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 ``` @@ -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 diff --git a/tools/check_docstrings.py b/tools/check_docstrings.py index f577fd5..ce598a1 100755 --- a/tools/check_docstrings.py +++ b/tools/check_docstrings.py @@ -30,6 +30,7 @@ "check_references": True, "check_type_consistency": False, "min_short_description_length": 0, + "max_short_description_length": 0, # 160 recommended for SEO meta descriptions "exclude_files": [], "verbose": False, } @@ -47,6 +48,7 @@ class DocstringContext(NamedTuple): check_references (bool): Whether to check references for errors check_type_consistency (bool): Whether to compare docstring types with annotations min_short_description_length (int): Minimum length for short description + max_short_description_length (int): Maximum length for short description (0 to disable) node (ast.AST | None): AST node for the function or class Returns: @@ -61,6 +63,7 @@ class DocstringContext(NamedTuple): check_references: bool = True check_type_consistency: bool = False min_short_description_length: int = 0 + max_short_description_length: int = 0 node: ast.AST | None = None @@ -70,6 +73,7 @@ class DocstringContext(NamedTuple): "check_references": ("check_references", bool), "check_type_consistency": ("check_type_consistency", bool), "min_short_description_length": ("min_short_description_length", int), + "max_short_description_length": ("max_short_description_length", int), "exclude_files": ("exclude_files", list), "verbose": ("verbose", bool), } @@ -391,33 +395,69 @@ def check_returns_section_name(docstring: str) -> list[str]: return errors -def check_short_description_length(parsed: dict[str, Any], min_length: int) -> list[str]: - """Check that the short description meets the minimum length requirement. +def _extract_short_description(description: str) -> str: + r"""Extract short description: first paragraph (up to first blank line), normalized. + + Leading and trailing whitespace (including blank lines) are stripped before + paragraph detection. Splits on one or more blank lines (\\n\\s*\\n), takes + the first part, then normalizes internal whitespace (tabs, newlines, multiple + spaces) to single spaces. Joins multi-line first paragraph for meta description use. Args: - parsed (dict[str, Any]): Parsed docstring dictionary - min_length (int): Minimum length for short description (0 to disable) + description (str): Full description text Returns: - list[str]: List of error messages for short descriptions that are too short + str: First paragraph as single line, or empty string """ - if min_length <= 0: - return [] + if not description: + return "" + parts = re.split(r"\n\s*\n", description.strip()) + first_para = parts[0].strip() if parts else "" + return " ".join(first_para.split()) if first_para else "" + + +def check_short_description_length( + parsed: dict[str, Any], + min_length: int, + max_length: int = 0, +) -> list[str]: + """Check that the short description meets length requirements. - short_desc = (parsed.get("Description") or "").split("\n")[0].strip() + Short description is the first paragraph (up to first blank line). + SEO: 120-160 chars recommended for meta descriptions. + + Args: + parsed (dict[str, Any]): Parsed docstring dictionary + min_length (int): Minimum length (0 to disable) + max_length (int): Maximum length (0 to disable) + + Returns: + list[str]: List of error messages for length violations + """ + short_desc = _extract_short_description(parsed.get("Description") or "") if not short_desc: return [] - if len(short_desc) < min_length: + errors: list[str] = [] + if min_length > 0 and len(short_desc) < min_length: preview = ( short_desc[:SHORT_DESC_PREVIEW_LENGTH] + "..." if len(short_desc) > SHORT_DESC_PREVIEW_LENGTH else short_desc ) - return [ + errors.append( f"Short description too short ({len(short_desc)} chars, min {min_length}): '{preview}'", - ] - return [] + ) + if max_length > 0 and len(short_desc) > max_length: + preview = ( + short_desc[:SHORT_DESC_PREVIEW_LENGTH] + "..." + if len(short_desc) > SHORT_DESC_PREVIEW_LENGTH + else short_desc + ) + errors.append( + f"Short description too long ({len(short_desc)} chars, max {max_length}): '{preview}'", + ) + return errors def check_returns_type(docstring_dict: dict[str, Any]) -> list[str]: @@ -591,12 +631,13 @@ def _check_additional_validations(context: DocstringContext, parsed: dict[str, A ) errors.extend(ref_errors) - if context.min_short_description_length > 0: + if context.min_short_description_length > 0 or context.max_short_description_length > 0: length_errors, _ = safe_execute( context, check_short_description_length, parsed, context.min_short_description_length, + context.max_short_description_length, error_prefix="Error checking short description length", ) errors.extend(length_errors) @@ -661,6 +702,7 @@ def check_file( check_references: bool = True, check_type_consistency: bool = False, min_short_description_length: int = 0, + max_short_description_length: int = 0, ) -> list[str]: """Check docstrings in a Python file for parsing and validation errors. @@ -671,6 +713,7 @@ def check_file( check_references (bool): Whether to check references for errors check_type_consistency (bool): Whether to compare docstring types with annotations min_short_description_length (int): Minimum length for short description (0 to disable) + max_short_description_length (int): Maximum length for short description (0 to disable) Returns: list[str]: List of error messages @@ -699,6 +742,7 @@ def check_file( check_references=check_references, check_type_consistency=check_type_consistency, min_short_description_length=min_short_description_length, + max_short_description_length=max_short_description_length, node=node, ) errors.extend(_process_docstring(context, docstring)) @@ -714,6 +758,7 @@ def scan_directory( check_references: bool = True, check_type_consistency: bool = False, min_short_description_length: int = 0, + max_short_description_length: int = 0, ) -> list[str]: """Scan a directory for Python files and check their docstrings. @@ -725,6 +770,7 @@ def scan_directory( check_references (bool): Whether to check references for errors check_type_consistency (bool): Whether to compare docstring types with annotations min_short_description_length (int): Minimum length for short description (0 to disable) + max_short_description_length (int): Maximum length for short description (0 to disable) Returns: list[str]: List of error messages @@ -755,6 +801,7 @@ def scan_directory( check_references, check_type_consistency, min_short_description_length, + max_short_description_length, ), ) return errors @@ -813,13 +860,19 @@ def _parse_args() -> argparse.Namespace: metavar="N", help="Minimum length for short description (0 to disable)", ) + parser.add_argument( + "--max-short-description-length", + type=int, + metavar="N", + help="Maximum length for short description (0 to disable, 160 recommended for SEO)", + ) return parser.parse_args() def _get_config_values( args: argparse.Namespace, config: dict[str, Any], -) -> tuple[list[str], bool, bool, bool, bool, int, list[str]]: +) -> tuple[list[str], bool, bool, bool, bool, int, int, list[str]]: """Get configuration values from command line arguments and config file. Args: @@ -827,13 +880,14 @@ def _get_config_values( config (dict[str, Any]): Configuration dictionary Returns: - tuple[list[str], bool, bool, bool, bool, int, list[str]]: Tuple containing: + tuple[list[str], bool, bool, bool, bool, int, int, list[str]]: Tuple containing: - List of paths to check - Whether to require parameter types - Whether to enable verbose output - Whether to check references - Whether to check type consistency - Minimum short description length + - Maximum short description length - List of files to exclude """ # Get paths @@ -873,6 +927,11 @@ def _get_config_values( if args.min_short_description_length is not None: min_short_description_length = args.min_short_description_length + # Get max_short_description_length - CLI overrides config + max_short_description_length = config.get("max_short_description_length", 0) + if args.max_short_description_length is not None: + max_short_description_length = args.max_short_description_length + return ( paths, require_param_types, @@ -880,6 +939,7 @@ def _get_config_values( check_references, check_type_consistency, min_short_description_length, + max_short_description_length, exclude_files, ) @@ -892,6 +952,7 @@ def _process_paths( check_references: bool, check_type_consistency: bool, min_short_description_length: int, + max_short_description_length: int, ) -> list[str]: """Process paths and check docstrings in each file or directory. @@ -903,6 +964,7 @@ def _process_paths( check_references (bool): Whether to check references for errors check_type_consistency (bool): Whether to compare docstring types with annotations min_short_description_length (int): Minimum length for short description (0 to disable) + max_short_description_length (int): Maximum length for short description (0 to disable) Returns: list[str]: List of error messages @@ -919,6 +981,7 @@ def _process_paths( check_references, check_type_consistency, min_short_description_length, + max_short_description_length, ) all_errors.extend(errors) elif path.is_file() and path.suffix == ".py": @@ -929,6 +992,7 @@ def _process_paths( check_references, check_type_consistency, min_short_description_length, + max_short_description_length, ) all_errors.extend(errors) else: @@ -956,6 +1020,7 @@ def main() -> None: check_references, check_type_consistency, min_short_description_length, + max_short_description_length, exclude_files, ) = _get_config_values(args, config) @@ -967,6 +1032,7 @@ def main() -> None: print(f" Check references: {check_references}") print(f" Check type consistency: {check_type_consistency}") print(f" Min short description length: {min_short_description_length}") + print(f" Max short description length: {max_short_description_length}") print(f" Exclude files: {exclude_files}") # Check if paths is empty @@ -985,6 +1051,7 @@ def main() -> None: check_references, check_type_consistency, min_short_description_length, + max_short_description_length, ): for error in all_errors: print(error)