From 5d686f2cd58aaca20ee1bfdfe2b95daac010e192 Mon Sep 17 00:00:00 2001 From: Vladimir Iglovikov Date: Mon, 16 Mar 2026 11:23:25 +0800 Subject: [PATCH 1/9] chore: add Cursor skills for docstrings, pytest, pre-commit, PyPI - google-docstring-format: project docstring rules and check-docstrings fixes - pytest-parametrize: test layout and parametrize patterns - python-pre-commit: hook config, run workflow, debugging - pypi-release: version bump and release workflow Made-with: Cursor --- .../skills/google-docstring-format/SKILL.md | 64 +++++++++++++++++++ .cursor/skills/pypi-release/SKILL.md | 36 +++++++++++ .cursor/skills/pytest-parametrize/SKILL.md | 48 ++++++++++++++ .cursor/skills/python-pre-commit/SKILL.md | 48 ++++++++++++++ 4 files changed, 196 insertions(+) create mode 100644 .cursor/skills/google-docstring-format/SKILL.md create mode 100644 .cursor/skills/pypi-release/SKILL.md create mode 100644 .cursor/skills/pytest-parametrize/SKILL.md create mode 100644 .cursor/skills/python-pre-commit/SKILL.md diff --git a/.cursor/skills/google-docstring-format/SKILL.md b/.cursor/skills/google-docstring-format/SKILL.md new file mode 100644 index 0000000..f64d067 --- /dev/null +++ b/.cursor/skills/google-docstring-format/SKILL.md @@ -0,0 +1,64 @@ +--- +name: google-docstring-format +description: Write and fix Google-style docstrings that pass the project's check-docstrings pre-commit hook. Use when writing docstrings, fixing check-docstrings failures, ReferenceFormatError, InvalidTypeAnnotationError, or when docstring validation fails. +--- + +# Google Docstring Format (Project) + +## Config (pyproject.toml) + +```toml +[tool.docstring_checker] +paths = ["google_docstring_parser", "tools"] +require_param_types = true +check_references = true +check_type_consistency = true +exclude_files = ["test_malformed_docstrings.py"] +``` + +## Rules + +### Args +- Every parameter **must** have a type: `param_name (type): description` +- Use `list[str]` not `list`; `dict[str, Any]` not `dict`. Bare collections fail validation. +- Types must match function annotations when `check_type_consistency` is true. + +### Returns +- Use `Returns:` (plural), not `Return:` or `return:` or `returns:` +- Must have type: `Returns:\n dict[str, Any]: Description` +- Or just `None` if no return value. + +### References +- **Single reference**: no leading dash + ``` + Reference: + Paper title: https://example.com/paper + ``` +- **Multiple references**: all must start with `-` + ``` + References: + - First paper: https://example.com/paper1 + - Second paper: https://example.com/paper2 + ``` +- Each reference needs non-empty `description` and `source` (colon-separated). + +### Type validation +- `dict`, `list`, `set`, `tuple`, etc. require brackets: `list[str]`, `dict[str, int]` +- No unclosed parentheses in param types +- Brackets must be balanced and matched + +## Fixing errors + +| Error | Fix | +|-------|-----| +| `Parameter 'x' is missing a type` | Add `(type)` after param name | +| `Collection 'list' must include element types` | Use `list[str]` not `list` | +| `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 | + +## Verify + +```bash +pre-commit run check-docstrings --all-files +``` diff --git a/.cursor/skills/pypi-release/SKILL.md b/.cursor/skills/pypi-release/SKILL.md new file mode 100644 index 0000000..1bf0363 --- /dev/null +++ b/.cursor/skills/pypi-release/SKILL.md @@ -0,0 +1,36 @@ +--- +name: pypi-release +description: Cut a PyPI release - bump version, build, upload. Use when releasing, publishing to PyPI, bumping version, or creating a new release. +--- + +# PyPI Release (Project) + +## Workflow + +1. **Bump version** in `pyproject.toml`: + ```toml + version = "0.0.10" # was 0.0.9 + ``` + +2. **Commit and push**, create GitHub release (tag + publish) + +3. **CI runs** `upload_to_pypi.yml` on `release: published`: + - Builds with `python -m build` + - Uploads with `twine upload dist/*` + - Uses `PYPI_API_TOKEN` secret + +## Manual build/upload (if needed) + +```bash +pip install build twine +python -m build +twine upload dist/* +``` + +Requires `TWINE_USERNAME=__token__` and `TWINE_PASSWORD` (PyPI token). + +## CI job (upload_to_pypi.yml) + +- Trigger: `release: types: [published]` +- Removes `tests` and `benchmark` before build +- Uses `secrets.PYPI_API_TOKEN` diff --git a/.cursor/skills/pytest-parametrize/SKILL.md b/.cursor/skills/pytest-parametrize/SKILL.md new file mode 100644 index 0000000..1a10bfa --- /dev/null +++ b/.cursor/skills/pytest-parametrize/SKILL.md @@ -0,0 +1,48 @@ +--- +name: pytest-parametrize +description: Write pytest tests using parametrize for similar cases, fixtures, and project test layout. Use when adding tests, writing test cases, parametrizing, or when asked to test new code. +--- + +# Pytest and Parametrize (Project) + +## Test layout + +- `tests/test_*.py` for top-level tests +- `tests/test_docstring_checker/` for checker-specific tests +- Use `pytest` and `@pytest.mark.parametrize` + +## Parametrize pattern + +```python +import pytest + +@pytest.mark.parametrize( + "docstring,expected", + [ + ( + """Description. + + Args: + x (int): Param x + """, + {"Description": "Description.", "Args": [{"name": "x", "type": "int", "description": "Param x"}]}, + ), + # More cases... + ], +) +def test_parse(docstring: str, expected: dict) -> None: + assert parse_google_docstring(docstring) == expected +``` + +## Guidelines + +- Use parametrize when testing multiple similar inputs/outputs (same structure, different values) +- Keep each case as a `(input, expected)` tuple for clarity +- Use fixtures for shared setup (e.g. sample docstrings, config) +- Follow project style: type hints on test functions, no `# type: ignore` unless necessary + +## Run tests + +```bash +pytest +``` diff --git a/.cursor/skills/python-pre-commit/SKILL.md b/.cursor/skills/python-pre-commit/SKILL.md new file mode 100644 index 0000000..e788554 --- /dev/null +++ b/.cursor/skills/python-pre-commit/SKILL.md @@ -0,0 +1,48 @@ +--- +name: python-pre-commit +description: Work with pre-commit hooks, fix pre-commit failures, add or update hooks. Use when pre-commit fails, adding pre-commit hooks, or debugging ruff/mypy/check-docstrings issues. +--- + +# Python Pre-commit (Project) + +## Workflow + +**Do not run ruff or flake8 directly.** Use: + +```bash +pre-commit run --all-files +``` + +## Hooks (from .pre-commit-config.yaml) + +| Hook | Config | Notes | +|------|--------|-------| +| ruff | pyproject.toml | Lint + format. `args: [--fix]` | +| ruff-format | pyproject.toml | Formatter | +| mypy | pyproject.toml | `files: ^(google_docstring_parser\|tests)/` | +| check-docstrings | local | `python -m tools.check_docstrings` | +| pyproject-fmt | - | Formats pyproject.toml | +| codespell | - | Spell check | +| pre-commit-hooks | - | AST, TOML, JSON, etc. | + +## Config locations + +- **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 + +## Fixing failures + +1. Run `pre-commit run --all-files` to see all errors +2. Ruff: fix lint/format, often auto-fixable with `--fix` +3. Mypy: add types, fix annotations +4. check-docstrings: see google-docstring-format skill + +## Add new hook + +Edit `.pre-commit-config.yaml`, then: + +```bash +pre-commit autoupdate # optional: update revs +pre-commit run --all-files +``` From 18505f2c3adcb8b04e4e211f9c2dccd53214524a Mon Sep 17 00:00:00 2001 From: Vladimir Iglovikov Date: Mon, 16 Mar 2026 11:26:27 +0800 Subject: [PATCH 2/9] chore(ci): switch to astral-sh/setup-uv from setup-python - Use astral-sh/setup-uv@v7 with enable-cache and activate-environment - Remove manual pip cache and Install uv steps - Simplify install: uv pip install wheel . -r requirements-dev.txt Made-with: Cursor --- .github/workflows/ci.yml | 52 +++++++++++----------------------------- 1 file changed, 14 insertions(+), 38 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a952b2f..820e615 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,43 +14,23 @@ jobs: matrix: operating-system: [ubuntu-latest, windows-latest, macos-13] python-version: ["3.10", "3.11", "3.12"] - include: - - operating-system: ubuntu-latest - path: ~/.cache/pip - - operating-system: windows-latest - path: ~\AppData\Local\pip\Cache - - operating-system: macos-13 - path: ~/Library/Caches/pip fail-fast: true steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - name: Install uv and set Python version ${{ matrix.python-version }} + uses: astral-sh/setup-uv@v7 with: + enable-cache: true python-version: ${{ matrix.python-version }} - cache: 'pip' - cache-dependency-path: | - requirements-dev.txt - - - name: Cache Python packages - uses: actions/cache@v4 - with: - path: ${{ matrix.path }} - key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements-dev.txt') }} - restore-keys: | - ${{ runner.os }}-pip-${{ matrix.python-version }}- - ${{ runner.os }}-pip- - - - name: Install uv - run: pip install uv + activate-environment: true - name: Install dependencies run: | - uv pip install --system --upgrade pip wheel - uv pip install --system -r requirements-dev.txt - uv pip install --system . + uv pip install wheel + uv pip install . + uv pip install -r requirements-dev.txt - name: Run PyTest with coverage run: pytest @@ -65,22 +45,18 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - name: Install uv and set Python version ${{ matrix.python-version }} + uses: astral-sh/setup-uv@v7 with: + enable-cache: true python-version: ${{ matrix.python-version }} - cache: 'pip' - cache-dependency-path: | - requirements-dev.txt - - - name: Install uv - run: pip install uv + activate-environment: true - name: Install requirements run: | - uv pip install --system --upgrade pip - uv pip install --system -r requirements-dev.txt - uv pip install --system . + uv pip install wheel + uv pip install . + uv pip install -r requirements-dev.txt - name: Run checks run: pre-commit run --all-files From 31a8fe422c0d82779142ecb8497319661a888f78 Mon Sep 17 00:00:00 2001 From: Vladimir Iglovikov Date: Mon, 16 Mar 2026 11:34:14 +0800 Subject: [PATCH 3/9] feat(check-docstrings): add min short description length check Pre-commit now fails when docstring short descriptions are below a configurable minimum (default 50 chars). Addresses Bing SEO feedback about meta descriptions derived from docstrings. Configurable via pyproject.toml or --min-short-description-length. Set to 0 to disable. Made-with: Cursor --- .../google_docstring_parser.py | 24 ++-- google_docstring_parser/type_validation.py | 22 +-- pyproject.toml | 2 + .../test_check_docstrings.py | 60 +++++++- .../test_example_files.py | 37 ++++- tools/check_docstrings.py | 136 ++++++++++++++---- 6 files changed, 228 insertions(+), 53 deletions(-) diff --git a/google_docstring_parser/google_docstring_parser.py b/google_docstring_parser/google_docstring_parser.py index ba81b5e..57efedd 100644 --- a/google_docstring_parser/google_docstring_parser.py +++ b/google_docstring_parser/google_docstring_parser.py @@ -40,7 +40,7 @@ class ReferenceFormatError(ValueError): - """Error raised when a reference format is invalid. + """Error raised when a reference format is invalid or malformed. Args: code (str): Error code identifying the specific format issue @@ -88,7 +88,7 @@ def __init__(self, line: str) -> None: def _extract_sections(docstring: str) -> dict[str, str]: - """Extract sections from a docstring. + """Extract named sections from a Google-style docstring. Args: docstring (str): The docstring to extract sections from @@ -170,7 +170,7 @@ def _find_separator_colon(content: str) -> int: def _parse_reference_line(line: str, *, is_single: bool = False) -> dict[str, str]: - """Parse a single reference line. + """Parse a single reference line into description and source. Args: line (str): The line to parse @@ -252,7 +252,7 @@ def _identify_main_reference_lines(lines: list[str]) -> list[str]: def _process_single_reference(main_line: str, all_lines: list[str]) -> dict[str, str]: - """Process a single reference entry. + """Process a single reference entry from the References section. Args: main_line (str): The main reference line @@ -285,7 +285,7 @@ def _process_single_reference(main_line: str, all_lines: list[str]) -> dict[str, def _process_multiple_references(lines: list[str]) -> list[dict[str, str]]: - """Process multiple reference entries. + """Process multiple reference entries from the References section. Args: lines (list[str]): Lines containing multiple references @@ -338,7 +338,7 @@ def _process_multiple_references(lines: list[str]) -> list[dict[str, str]]: def _parse_references(reference_content: str) -> list[dict[str, str]]: - """Parse references section content. + """Parse references section content into structured reference entries. Args: reference_content (str): Content of the references section @@ -373,7 +373,7 @@ def _parse_references(reference_content: str) -> list[dict[str, str]]: def _validate_type_with_error_handling(type_str: str, result: dict[str, Any], collect_errors: bool) -> None: - """Validate a type annotation and handle any errors. + """Validate a type annotation and handle any validation errors. This function validates type annotations and handles errors differently based on the collect_errors flag: - When collect_errors is True: Errors are added to result["errors"] list instead of being raised @@ -408,7 +408,7 @@ def _process_args_with_validation( validate_types: bool, collect_errors: bool, ) -> None: - """Process the Args section with type validation. + """Process the Args section with type validation and error collection. Args: sections (dict[str, str]): The sections dictionary @@ -439,7 +439,7 @@ def _process_args_with_validation( def _parse_returns_section(sections: dict[str, str], *, validate_types: bool) -> dict[str, str] | str: - """Process the Returns section of a docstring. + """Process the Returns section of a docstring into type and description. Args: sections (dict[str, str]): The sections dictionary @@ -482,7 +482,7 @@ def _process_returns_with_validation( validate_types: bool, collect_errors: bool, ) -> None: - """Process the Returns section with type validation. + """Process the Returns section with type validation and error handling. Args: sections (dict[str, str]): The sections dictionary @@ -506,7 +506,7 @@ def _process_returns_with_validation( def _process_references_section(sections: dict[str, str], result: dict[str, Any]) -> None: - """Process the References section. + """Process the References section into structured reference entries. Args: sections (dict[str, str]): The sections dictionary @@ -527,7 +527,7 @@ def parse_google_docstring( validate_types: bool = True, collect_errors: bool = True, ) -> dict[str, Any]: - """Parse a Google-style docstring. + """Parse a Google-style docstring into a structured dictionary. Args: docstring (str): The docstring to parse diff --git a/google_docstring_parser/type_validation.py b/google_docstring_parser/type_validation.py index 62ce373..83db1d3 100644 --- a/google_docstring_parser/type_validation.py +++ b/google_docstring_parser/type_validation.py @@ -89,7 +89,7 @@ class InvalidTypeAnnotationError(ValueError): - """Error raised when a type annotation is invalid. + """Error raised when a type annotation is invalid or malformed. Args: message (str): The error message. @@ -100,7 +100,7 @@ class InvalidTypeAnnotationError(ValueError): INVALID_NESTED_TYPE = "Invalid nested type: {}" def __init__(self, message: str) -> None: - """Initialize the error with a message. + """Initialize the error instance with a descriptive message. Args: message (str): The error message. @@ -125,7 +125,7 @@ class BracketValidationError(ValueError): WRONG_BRACKET_TYPE = "Collection '{}' must use square brackets for type arguments, not '{}'" def __init__(self, error_type: str) -> None: - """Initialize with a specific error type. + """Initialize the error with a specific bracket validation type. Args: error_type (str): One of the predefined error types. @@ -137,7 +137,7 @@ def __init__(self, error_type: str) -> None: def is_collection_type(type_name: str) -> bool: - """Check if a type name is a known collection type. + """Check if a type name is a known collection type (list, dict, etc). Args: type_name (str): The type name to check. @@ -261,7 +261,7 @@ def _is_within_string_literal(text: str, position: int) -> bool: def _looks_like_type_annotation(text: str) -> bool: - """Check if text looks like a type annotation. + """Check if text looks like a type annotation using heuristics. Args: text (str): The text to check @@ -277,7 +277,7 @@ def _looks_like_type_annotation(text: str) -> bool: def _process_string_literals(text: str) -> tuple[str, list[str]]: - """Process string literals in text. + """Process string literals in text by replacing them with placeholders. Args: text (str): The text to process @@ -386,7 +386,7 @@ def _check_for_opening_bracket( bracket_stack: list[str], collection_stack: list[tuple[str, str]], ) -> None: - """Check for opening bracket in type declaration. + """Check for opening bracket in type declaration and update stacks. Args: tokens (list[str]): List of tokens @@ -409,7 +409,7 @@ def _check_for_opening_bracket( def _check_for_closing_bracket(token: str, bracket_stack: list[str], collection_stack: list[tuple[str, str]]) -> None: - """Check for closing bracket in type declaration. + """Check for closing bracket in type declaration and validate pairing. Args: token (str): Current token @@ -440,7 +440,7 @@ def _check_for_closing_bracket(token: str, bracket_stack: list[str], collection_ def _check_for_bare_collection(tokens: list[str], i: int, token: str) -> None: - """Check for bare collection type usage. + """Check for bare collection type usage without type arguments. Args: tokens (list[str]): List of tokens @@ -487,7 +487,7 @@ def _is_bare_collection_in_nested_type(token: str, tokens: list[str], i: int, br def _check_tokens_for_collection_type_usage(tokens: list[str]) -> None: - """Check tokens for proper collection type usage. + """Check tokens for proper collection type usage and brackets. Args: tokens (list[str]): List of tokens to check @@ -539,7 +539,7 @@ def _check_tokens_for_collection_type_usage(tokens: list[str]) -> None: def _validate_type_declaration(declaration: str) -> None: - """Validate a type declaration. + """Validate a type declaration for syntax and collection usage. Args: declaration (str): The type declaration to validate diff --git a/pyproject.toml b/pyproject.toml index 9138f38..14fe6de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,6 +119,7 @@ lint.per-file-ignores = { "__init__.py" = [ "BLE001", "FBT002", "ANN201", + "PLR0913", ] } lint.fixable = [ "ALL" ] @@ -146,5 +147,6 @@ paths = [ "google_docstring_parser", "tools" ] require_param_types = true check_references = true check_type_consistency = true +min_short_description_length = 50 exclude_files = [ "test_malformed_docstrings.py" ] verbose = false diff --git a/tests/test_docstring_checker/test_check_docstrings.py b/tests/test_docstring_checker/test_check_docstrings.py index 6f55e65..9e1272f 100644 --- a/tests/test_docstring_checker/test_check_docstrings.py +++ b/tests/test_docstring_checker/test_check_docstrings.py @@ -4,9 +4,12 @@ import subprocess import sys from pathlib import Path +from typing import Any import pytest +from tools.check_docstrings import check_short_description_length + def test_valid_docstrings() -> None: """Test that valid docstrings pass the checker.""" @@ -21,7 +24,9 @@ def test_valid_docstrings() -> None: "tools.check_docstrings", str(valid_dir), "--exclude-files", - "test_malformed_docstrings.py,test_check_docstrings.py" + "test_malformed_docstrings.py,test_check_docstrings.py", + "--min-short-description-length", + "0", ], capture_output=True, text=True, @@ -126,6 +131,9 @@ def test_config_from_pyproject_toml() -> None: # Check that it reads exclude_files from pyproject.toml 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" + def test_missing_param_types_in_real_code() -> None: """Test that the checker detects missing parameter types in a real file with missing types.""" @@ -249,6 +257,8 @@ def test_error_count_reporting(code: str, expected_count: int, expected_message: "tools.check_docstrings", str(temp_file), "--require-param-types", + "--min-short-description-length", + "0", ], capture_output=True, text=True, @@ -347,6 +357,8 @@ def test_returns_validation(code: str, expected_returncode: int, expected_output "tools.check_docstrings", str(temp_file), "--verbose", + "--min-short-description-length", + "0", ], capture_output=True, text=True, @@ -545,3 +557,49 @@ def missing_docstring_function(param1): # Check that it shows the empty paths in the configuration output assert "Paths: []" in result.stdout, "Should show empty paths list in configuration" + + +@pytest.mark.parametrize( + "parsed,min_length,expected_errors", + [ + ( + {"Description": "Short."}, + 50, + ["Short description too short (6 chars, min 50): 'Short.'"], + ), + ( + {"Description": "A" * 49}, + 50, + ["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": "Short."}, + 7, + ["Short description too short (6 chars, min 7): 'Short.'"], + ), + ( + {"Description": "First line.\n\nSecond paragraph."}, + 50, + ["Short description too short (11 chars, min 50): 'First line.'"], + ), + ], +) +def test_check_short_description_length( + parsed: dict[str, Any], min_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 + expected_errors (list[str]): Expected error messages + """ + result = check_short_description_length(parsed, min_length) + assert result == expected_errors diff --git a/tests/test_docstring_checker/test_example_files.py b/tests/test_docstring_checker/test_example_files.py index 58146f3..7a3b341 100644 --- a/tests/test_docstring_checker/test_example_files.py +++ b/tests/test_docstring_checker/test_example_files.py @@ -11,14 +11,24 @@ def test_valid_docstrings_file() -> None: """Test that the valid docstrings file passes the checker.""" valid_file = Path(__file__).parent / "test_valid_docstrings.py" - errors = check_file(valid_file, require_param_types=False, verbose=True) + errors = check_file( + valid_file, + require_param_types=False, + verbose=True, + min_short_description_length=0, + ) assert not errors, f"Found errors in valid docstrings file: {errors}" def test_malformed_docstrings_file() -> None: """Test that the malformed docstrings file fails the checker.""" malformed_file = Path(__file__).parent / "test_malformed_docstrings.py" - errors = check_file(malformed_file, require_param_types=False, verbose=True) + errors = check_file( + malformed_file, + require_param_types=False, + verbose=True, + min_short_description_length=0, + ) # Check that we found the expected errors assert errors, "No errors found in malformed docstrings file" @@ -33,10 +43,20 @@ def test_require_param_types_on_malformed_file() -> None: malformed_file = Path(__file__).parent / "test_malformed_docstrings.py" # First check without requiring types - errors_without_types = check_file(malformed_file, require_param_types=False, verbose=True) + errors_without_types = check_file( + malformed_file, + require_param_types=False, + verbose=True, + min_short_description_length=0, + ) # Then check with requiring types - errors_with_types = check_file(malformed_file, require_param_types=True, verbose=True) + errors_with_types = check_file( + malformed_file, + require_param_types=True, + verbose=True, + min_short_description_length=0, + ) # Should find more errors when requiring types assert len(errors_with_types) > len(errors_without_types) @@ -56,6 +76,7 @@ def test_scan_directory() -> None: exclude_files=["test_malformed_docstrings.py"], require_param_types=False, verbose=True, + min_short_description_length=0, ) # Scan without excluding the malformed file @@ -64,6 +85,7 @@ def test_scan_directory() -> None: exclude_files=[], require_param_types=False, verbose=True, + min_short_description_length=0, ) # Should find more errors when including the malformed file @@ -82,7 +104,12 @@ def test_scan_directory() -> None: def test_parametrized_file_checks(filename: str, require_types: bool, expected_error_count: int) -> None: """Test checking different files with different settings.""" file_path = Path(__file__).parent / filename - errors = check_file(file_path, require_param_types=require_types, verbose=True) + errors = check_file( + file_path, + require_param_types=require_types, + verbose=True, + min_short_description_length=0, + ) # Check that we found at least the expected number of errors assert len(errors) >= expected_error_count, ( diff --git a/tools/check_docstrings.py b/tools/check_docstrings.py index 677e939..15fb2b0 100755 --- a/tools/check_docstrings.py +++ b/tools/check_docstrings.py @@ -25,13 +25,14 @@ "paths": [], # Empty by default, so no directories are scanned unless explicitly specified "require_param_types": False, "check_references": True, + "min_short_description_length": 50, "exclude_files": [], "verbose": False, } class DocstringContext(NamedTuple): - """Context for docstring processing. + """Context for docstring processing, validation, and error reporting. Args: file_path (Path): Path to the file @@ -40,6 +41,7 @@ class DocstringContext(NamedTuple): verbose (bool): Whether to print verbose output require_param_types (bool): Whether parameter types are required check_references (bool): Whether to check references for errors + min_short_description_length (int): Minimum length for short description Returns: DocstringContext: A named tuple containing docstring processing context @@ -51,6 +53,7 @@ class DocstringContext(NamedTuple): verbose: bool require_param_types: bool = False check_references: bool = True + min_short_description_length: int = 50 def load_pyproject_config() -> dict[str, Any]: @@ -82,6 +85,8 @@ def load_pyproject_config() -> dict[str, Any]: config["require_param_types"] = bool(tool_config["require_param_types"]) if "check_references" in tool_config: config["check_references"] = bool(tool_config["check_references"]) + if "min_short_description_length" in tool_config: + config["min_short_description_length"] = int(tool_config["min_short_description_length"]) if "exclude_files" in tool_config: config["exclude_files"] = tool_config["exclude_files"] if "verbose" in tool_config: @@ -94,7 +99,7 @@ def load_pyproject_config() -> dict[str, Any]: def get_docstrings(file_path: Path) -> list[tuple[str, int, str | None, ast.AST | None]]: - """Extract docstrings from a Python file. + """Extract docstrings from a Python file using AST parsing. Args: file_path (Path): Path to the Python file @@ -128,7 +133,7 @@ def get_docstrings(file_path: Path) -> list[tuple[str, int, str | None, ast.AST def check_param_types(docstring_dict: dict[str, Any], require_types: bool) -> list[str]: - """Check if all parameters have types if required. + """Check if all parameters have types when types are required. Args: docstring_dict (dict[str, Any]): Parsed docstring dictionary @@ -177,7 +182,7 @@ def _check_reference_fields(reference: dict[str, Any], index: int) -> list[str]: def check_references(docstring_dict: dict[str, Any]) -> list[str]: - """Check references section for common errors. + """Check references section for common formatting errors. Args: docstring_dict (dict[str, Any]): Parsed docstring dictionary @@ -216,7 +221,7 @@ def check_references(docstring_dict: dict[str, Any]) -> list[str]: def validate_docstring(docstring: str) -> list[str]: - """Perform additional validation on a docstring. + """Perform additional validation on docstring format and structure. Args: docstring (str): The docstring to validate @@ -250,7 +255,7 @@ def validate_docstring(docstring: str) -> list[str]: def check_returns_section_name(docstring: str) -> list[str]: - """Check for incorrect Returns section names. + """Check for incorrect Returns section names (e.g. return vs Returns). Args: docstring (str): The docstring to check @@ -267,8 +272,34 @@ 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. + + Args: + parsed (dict[str, Any]): Parsed docstring dictionary + min_length (int): Minimum length for short description (0 to disable) + + Returns: + list[str]: List of error messages for short descriptions that are too short + """ + if min_length <= 0: + return [] + + short_desc = (parsed.get("Description") or "").split("\n")[0].strip() + if not short_desc: + return [] + + if len(short_desc) < min_length: + preview_len = 50 + preview = short_desc[:preview_len] + "..." if len(short_desc) > preview_len else short_desc + return [ + f"Short description too short ({len(short_desc)} chars, min {min_length}): '{preview}'", + ] + return [] + + def check_returns_type(docstring_dict: dict[str, Any]) -> list[str]: - """Check Returns type in a docstring.""" + """Check that the Returns section has proper type annotation.""" errors = [] if returns := docstring_dict.get("Returns"): # Special case: Returns section just contains "None" @@ -286,7 +317,7 @@ def check_returns_type(docstring_dict: dict[str, Any]) -> list[str]: def _format_error(context: DocstringContext, error: str) -> str: - """Format an error message consistently. + """Format an error message consistently with file, line, and name. Args: context (DocstringContext): Docstring context @@ -333,7 +364,7 @@ def safe_execute( def _check_returns_section(context: DocstringContext, docstring: str) -> list[str]: - """Check the Returns section name. + """Check the Returns section name for correct spelling. Args: context (DocstringContext): Docstring context @@ -352,7 +383,7 @@ def _check_returns_section(context: DocstringContext, docstring: str) -> list[st def _validate_docstring_format(context: DocstringContext, docstring: str) -> list[str]: - """Validate docstring format. + """Validate docstring format for common structural issues. Args: context (DocstringContext): Docstring context @@ -371,7 +402,7 @@ def _validate_docstring_format(context: DocstringContext, docstring: str) -> lis def _parse_and_check_returns(context: DocstringContext, docstring: str) -> tuple[list[str], dict[str, Any] | None]: - """Parse docstring and check returns type. + """Parse docstring and check that the Returns section has proper type. Args: context (DocstringContext): Docstring context @@ -408,7 +439,7 @@ def _parse_and_check_returns(context: DocstringContext, docstring: str) -> tuple def _check_additional_validations(context: DocstringContext, parsed: dict[str, Any]) -> list[str]: - """Run additional validations on parsed docstring. + """Run additional validations on the parsed docstring dictionary. Args: context (DocstringContext): Docstring context @@ -438,11 +469,21 @@ def _check_additional_validations(context: DocstringContext, parsed: dict[str, A ) errors.extend(ref_errors) + if context.min_short_description_length > 0: + length_errors, _ = safe_execute( + context, + check_short_description_length, + parsed, + context.min_short_description_length, + error_prefix="Error checking short description length", + ) + errors.extend(length_errors) + return errors def _process_docstring(context: DocstringContext, docstring: str) -> list[str]: - """Process a single docstring. + """Process a single docstring and collect all validation errors. Args: context (DocstringContext): Docstring context @@ -482,14 +523,16 @@ def check_file( require_param_types: bool = False, verbose: bool = False, check_references: bool = True, + min_short_description_length: int = 50, ) -> list[str]: - """Check docstrings in a file. + """Check docstrings in a Python file for parsing and validation errors. Args: file_path (Path): Path to the Python file require_param_types (bool): Whether parameter types are required verbose (bool): Whether to print verbose output check_references (bool): Whether to check references for errors + min_short_description_length (int): Minimum length for short description (0 to disable) Returns: list[str]: List of error messages @@ -516,6 +559,7 @@ def check_file( verbose=verbose, require_param_types=require_param_types, check_references=check_references, + min_short_description_length=min_short_description_length, ) errors.extend(_process_docstring(context, docstring)) @@ -528,6 +572,7 @@ def scan_directory( require_param_types: bool = False, verbose: bool = False, check_references: bool = True, + min_short_description_length: int = 50, ) -> list[str]: """Scan a directory for Python files and check their docstrings. @@ -537,6 +582,7 @@ def scan_directory( require_param_types (bool): Whether parameter types are required verbose (bool): Whether to print verbose output check_references (bool): Whether to check references for errors + min_short_description_length (int): Minimum length for short description (0 to disable) Returns: list[str]: List of error messages @@ -559,12 +605,20 @@ def scan_directory( break if not should_exclude: - errors.extend(check_file(py_file, require_param_types, verbose, check_references)) + errors.extend( + check_file( + py_file, + require_param_types, + verbose, + check_references, + min_short_description_length, + ), + ) return errors def _parse_args() -> argparse.Namespace: - """Parse command line arguments. + """Parse command line arguments for the docstring checker. Returns: argparse.Namespace: Parsed command line arguments @@ -598,13 +652,19 @@ def _parse_args() -> argparse.Namespace: default="", ) parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") + parser.add_argument( + "--min-short-description-length", + type=int, + metavar="N", + help="Minimum length for short description (0 to disable)", + ) return parser.parse_args() def _get_config_values( args: argparse.Namespace, config: dict[str, Any], -) -> tuple[list[str], bool, bool, bool, list[str]]: +) -> tuple[list[str], bool, bool, bool, int, list[str]]: """Get configuration values from command line arguments and config file. Args: @@ -612,11 +672,12 @@ def _get_config_values( config (dict[str, Any]): Configuration dictionary Returns: - tuple[list[str], bool, bool, bool, list[str]]: Tuple containing: + tuple[list[str], bool, bool, bool, int, list[str]]: Tuple containing: - List of paths to check - Whether to require parameter types - Whether to check references - Whether to enable verbose output + - Minimum short description length - List of files to exclude """ # Get paths @@ -644,7 +705,12 @@ def _get_config_values( if not exclude_files: exclude_files = config["exclude_files"] - return paths, require_param_types, verbose, check_references, exclude_files + # Get min_short_description_length - CLI overrides config + min_short_description_length = config.get("min_short_description_length", 50) + if args.min_short_description_length is not None: + min_short_description_length = args.min_short_description_length + + return paths, require_param_types, verbose, check_references, min_short_description_length, exclude_files def _process_paths( @@ -653,8 +719,9 @@ def _process_paths( require_param_types: bool, verbose: bool, check_references: bool, + min_short_description_length: int, ) -> list[str]: - """Process paths and check docstrings. + """Process paths and check docstrings in each file or directory. Args: paths (list[str]): List of paths to check @@ -662,6 +729,7 @@ def _process_paths( require_param_types (bool): Whether parameter types are required verbose (bool): Whether to print verbose output check_references (bool): Whether to check references for errors + min_short_description_length (int): Minimum length for short description (0 to disable) Returns: list[str]: List of error messages @@ -670,10 +738,23 @@ def _process_paths( for path_str in paths: path = Path(path_str) if path.is_dir(): - errors = scan_directory(path, exclude_files, require_param_types, verbose, check_references) + errors = scan_directory( + path, + exclude_files, + require_param_types, + verbose, + check_references, + min_short_description_length, + ) all_errors.extend(errors) elif path.is_file() and path.suffix == ".py": - errors = check_file(path, require_param_types, verbose, check_references) + errors = check_file( + path, + require_param_types, + verbose, + check_references, + min_short_description_length, + ) all_errors.extend(errors) else: print(f"Error: {path} is not a directory or Python file") @@ -681,7 +762,7 @@ def _process_paths( def main() -> None: - """Run the docstring checker. + """Run the docstring checker and exit with appropriate status code. Returns: None @@ -693,7 +774,12 @@ def main() -> None: args = _parse_args() # Get configuration values - paths, require_param_types, verbose, check_references, exclude_files = _get_config_values(args, config) + paths, require_param_types, verbose, check_references, min_short_description_length, exclude_files = ( + _get_config_values( + args, + config, + ) + ) # Print configuration if verbose if verbose: @@ -701,6 +787,7 @@ def main() -> None: print(f" Paths: {paths}") print(f" Require parameter types: {require_param_types}") print(f" Check references: {check_references}") + print(f" Min short description length: {min_short_description_length}") print(f" Exclude files: {exclude_files}") # Check if paths is empty @@ -717,6 +804,7 @@ def main() -> None: require_param_types, verbose, check_references, + min_short_description_length, ): for error in all_errors: print(error) From 8bd1a57cb1eb3feb26a9f4af419835fda4ff4174 Mon Sep 17 00:00:00 2001 From: Vladimir Iglovikov Date: Mon, 16 Mar 2026 11:44:50 +0800 Subject: [PATCH 4/9] feat(tools): implement check_type_consistency for docstring checker Compare docstring types (Args, Returns) with Python function annotations. When they mismatch, report an error. Uses Python 3.10+ typing (list, dict, tuple, X|Y) - no List, Dict, Tuple, Union normalization. - Add check_type_consistency() with _annotation_to_str, _normalize_type - Wire config (pyproject.toml), CLI (--check-type-consistency, --no-check-type-consistency) - Pass AST node through pipeline for annotation extraction - Skip ClassDef, self/cls; skip when annotation missing in source - Add tests (param match/mismatch, return mismatch, missing annotation, self) - Update tools/README.md and README.md - Fix test_valid_docstrings to use Python 3.10+ typing Made-with: Cursor --- .github/workflows/ci.yml | 2 +- .gitignore | 2 + README.md | 1 + pyproject.toml | 1 + .../test_check_docstrings.py | 133 ++++++++++++++ .../test_valid_docstrings.py | 6 +- tools/README.md | 10 ++ tools/check_docstrings.py | 166 ++++++++++++++++-- 8 files changed, 307 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 820e615..1e171ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: operating-system: [ubuntu-latest, windows-latest, macos-13] - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] fail-fast: true steps: diff --git a/.gitignore b/.gitignore index 0a19790..c3fa66d 100644 --- a/.gitignore +++ b/.gitignore @@ -172,3 +172,5 @@ cython_debug/ # PyPI configuration file .pypirc + +uv.lock diff --git a/README.md b/README.md index 34ce2c1..20c5b39 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,7 @@ Add a `[tool.docstring_checker]` section to your pyproject.toml: paths = ["src", "tests"] # Directories or files to scan require_param_types = true # Require parameter types in docstrings 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 verbose = false # Enable verbose output ``` diff --git a/pyproject.toml b/pyproject.toml index 14fe6de..1f75563 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,6 +120,7 @@ lint.per-file-ignores = { "__init__.py" = [ "FBT002", "ANN201", "PLR0913", + "C901", ] } lint.fixable = [ "ALL" ] diff --git a/tests/test_docstring_checker/test_check_docstrings.py b/tests/test_docstring_checker/test_check_docstrings.py index 9e1272f..5862ad1 100644 --- a/tests/test_docstring_checker/test_check_docstrings.py +++ b/tests/test_docstring_checker/test_check_docstrings.py @@ -603,3 +603,136 @@ def test_check_short_description_length( """ result = check_short_description_length(parsed, min_length) assert result == expected_errors + + +@pytest.mark.parametrize( + "code,expected_returncode,expected_in_output", + [ + # Param type match - no error + ( + ''' +"""Test module with matching types.""" + +def foo(x: int) -> str: + """Function with matching docstring types. + + Args: + x (int): Param x + + Returns: + str: Result + """ + return str(x) +''', + 0, + "", + ), + # Param type mismatch - error + ( + ''' +"""Test module with param type mismatch.""" + +def foo(x: int) -> str: + """Function with wrong docstring type. + + Args: + x (str): Docstring says str, annotation says int + + Returns: + str: Result + """ + return str(x) +''', + 1, + "docstring says 'str' but annotation says 'int'", + ), + # Return type mismatch - error + ( + ''' +"""Test module with return type mismatch.""" + +def foo(x: int) -> str: + """Function with wrong return type in docstring. + + Args: + x (int): Param x + + Returns: + int: Docstring says int, annotation says str + """ + return str(x) +''', + 1, + "Returns: docstring says 'int' but annotation says 'str'", + ), + # Missing annotation in source - skip (no error) + ( + ''' +"""Test module with no annotations.""" + +def foo(x): + """Function with no type annotations in source. + + Args: + x (int): Param x + + Returns: + str: Result + """ + return str(x) +''', + 0, + "", + ), + # self skipped - method with self, only x is compared + ( + ''' +"""Test module with self param.""" + +def method(self, x: int) -> None: + """Method with self. + + Args: + x (int): Param x + + Returns: + None + """ + pass +''', + 0, + "", + ), + ], +) +def test_check_type_consistency( + code: str, expected_returncode: int, expected_in_output: str, tmp_path: Path +) -> None: + """Test that check_type_consistency compares docstring types with annotations. + + Args: + code (str): Python code to test + expected_returncode (int): Expected return code + expected_in_output (str): Expected substring in output (empty for success) + tmp_path (Path): Temporary directory fixture + """ + temp_file = tmp_path / "test_file.py" + temp_file.write_text(code) + + result = subprocess.run( + [ + sys.executable, + "-m", + "tools.check_docstrings", + str(temp_file), + "--check-type-consistency", + "--min-short-description-length", + "0", + ], + capture_output=True, + text=True, + ) + + assert result.returncode == expected_returncode + if expected_in_output: + assert expected_in_output in result.stdout diff --git a/tests/test_docstring_checker/test_valid_docstrings.py b/tests/test_docstring_checker/test_valid_docstrings.py index 4625247..7a02bc6 100644 --- a/tests/test_docstring_checker/test_valid_docstrings.py +++ b/tests/test_docstring_checker/test_valid_docstrings.py @@ -2,7 +2,7 @@ This file contains properly formatted docstrings to test the docstring checker. """ -from typing import Dict, List, Any, Union +from typing import Any def simple_function() -> bool: @@ -23,14 +23,14 @@ def function_with_args(param1: int, param2: str) -> bool: return True -def function_with_sections(param: Dict[str, Any]) -> List[Any]: +def function_with_sections(param: dict[str, Any]) -> list[Any]: """Function with multiple sections. Args: param (dict[str, Any]): A dictionary parameter Returns: - list[str]: A list of items + list[Any]: A list of items Raises: ValueError: If param is empty diff --git a/tools/README.md b/tools/README.md index 89eeee1..828e6c8 100644 --- a/tools/README.md +++ b/tools/README.md @@ -43,6 +43,9 @@ require_param_types = true # Whether to check references for proper format check_references = true +# Whether to compare docstring types with function annotations +check_type_consistency = true + # List of filenames to exclude from checks # These can be just filenames (e.g., "conftest.py") or paths ending with the filename exclude_files = ["conftest.py", "__init__.py", "tests/fixtures/bad_docstrings.py"] @@ -57,6 +60,10 @@ verbose = false When `require_param_types = true`, the hook will check if all parameters in docstrings have their types specified. This helps ensure consistent documentation across your codebase. +#### Type Consistency Checking + +When `check_type_consistency = true`, the hook compares docstring types (Args, Returns) with Python function annotations. Mismatches are reported (e.g., docstring says `int` but annotation says `str`). Use Python 3.10+ style: `list`, `dict`, `tuple`, `X | Y` — not `List`, `Dict`, `Tuple`, `Union`. + #### Reference Checking When `check_references = true`, the hook will validate the References section in docstrings for proper formatting. It checks for: @@ -91,6 +98,7 @@ References: paths = ["src", "tests"] require_param_types = true check_references = true +check_type_consistency = true exclude_files = ["conftest.py", "__init__.py"] verbose = false ``` @@ -116,5 +124,7 @@ Command line options: - `--require-param-types`: Require parameter types in docstrings - `--check-references`: Check references for proper format - `--no-check-references`: Skip reference checking +- `--check-type-consistency`: Compare docstring types with function annotations +- `--no-check-type-consistency`: Skip type consistency checking - `--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 15fb2b0..1dcb74d 100755 --- a/tools/check_docstrings.py +++ b/tools/check_docstrings.py @@ -25,6 +25,7 @@ "paths": [], # Empty by default, so no directories are scanned unless explicitly specified "require_param_types": False, "check_references": True, + "check_type_consistency": False, "min_short_description_length": 50, "exclude_files": [], "verbose": False, @@ -41,7 +42,9 @@ class DocstringContext(NamedTuple): verbose (bool): Whether to print verbose output require_param_types (bool): Whether parameter types are required 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 + node (ast.AST | None): AST node for the function or class Returns: DocstringContext: A named tuple containing docstring processing context @@ -53,7 +56,9 @@ class DocstringContext(NamedTuple): verbose: bool require_param_types: bool = False check_references: bool = True + check_type_consistency: bool = False min_short_description_length: int = 50 + node: ast.AST | None = None def load_pyproject_config() -> dict[str, Any]: @@ -85,6 +90,8 @@ def load_pyproject_config() -> dict[str, Any]: config["require_param_types"] = bool(tool_config["require_param_types"]) if "check_references" in tool_config: config["check_references"] = bool(tool_config["check_references"]) + if "check_type_consistency" in tool_config: + config["check_type_consistency"] = bool(tool_config["check_type_consistency"]) if "min_short_description_length" in tool_config: config["min_short_description_length"] = int(tool_config["min_short_description_length"]) if "exclude_files" in tool_config: @@ -155,6 +162,89 @@ def check_param_types(docstring_dict: dict[str, Any], require_types: bool) -> li return errors +def _normalize_type(type_str: str) -> str: + """Normalize type string for comparison (whitespace and quotes only). + + Python 3.10+ typing uses list, dict, tuple, X|Y - no List, Dict, Tuple, Union. + We do not normalize those; mismatches will be reported. + + Args: + type_str (str): Type string to normalize + + Returns: + str: Normalized type string + """ + return type_str.strip().strip("'\"") + + +def _annotation_to_str(annotation: ast.expr | None) -> str | None: + """Extract the type string from an AST annotation node. + + Args: + annotation (ast.expr | None): AST annotation node + + Returns: + str | None: Type string or None if no annotation + """ + if annotation is None: + return None + if isinstance(annotation, ast.Constant) and isinstance(annotation.value, str): + return annotation.value + return ast.unparse(annotation) + + +def check_type_consistency( + parsed: dict[str, Any], + node: ast.FunctionDef | ast.AsyncFunctionDef, +) -> list[str]: + """Compare docstring types with function annotations. + + Args: + parsed (dict[str, Any]): Parsed docstring dictionary + node (ast.FunctionDef | ast.AsyncFunctionDef): Function AST node + + Returns: + list[str]: List of error messages for type mismatches + """ + errors = [] + + # Build param dict from AST (skip self/cls) + ast_params: dict[str, str] = {} + for arg in node.args.args: + if arg.arg in ("self", "cls"): + continue + if ann_str := _annotation_to_str(arg.annotation): + ast_params[arg.arg] = ann_str + + # Compare Args + for arg in parsed.get("Args", []): + doc_type = arg.get("type") + if not doc_type: + continue + param_name = arg.get("name") + if not param_name or param_name not in ast_params: + continue + ast_type = ast_params[param_name] + if _normalize_type(doc_type) != _normalize_type(ast_type): + errors.append( + f"Parameter '{param_name}': docstring says '{doc_type}' but annotation says '{ast_type}'", + ) + + # Compare Returns + returns = parsed.get("Returns") + if ( + isinstance(returns, dict) + and (doc_ret := returns.get("type")) + and (ast_ret := _annotation_to_str(node.returns)) + and _normalize_type(doc_ret) != _normalize_type(ast_ret) + ): + errors.append( + f"Returns: docstring says '{doc_ret}' but annotation says '{ast_ret}'", + ) + + return errors + + def _check_reference_fields(reference: dict[str, Any], index: int) -> list[str]: """Check a single reference for missing or empty fields. @@ -479,6 +569,20 @@ def _check_additional_validations(context: DocstringContext, parsed: dict[str, A ) errors.extend(length_errors) + if ( + context.check_type_consistency + and context.node is not None + and isinstance(context.node, (ast.FunctionDef, ast.AsyncFunctionDef)) + ): + consistency_errors, _ = safe_execute( + context, + check_type_consistency, + parsed, + context.node, + error_prefix="Error checking type consistency", + ) + errors.extend(consistency_errors) + return errors @@ -523,6 +627,7 @@ def check_file( require_param_types: bool = False, verbose: bool = False, check_references: bool = True, + check_type_consistency: bool = False, min_short_description_length: int = 50, ) -> list[str]: """Check docstrings in a Python file for parsing and validation errors. @@ -532,6 +637,7 @@ def check_file( require_param_types (bool): Whether parameter types are required verbose (bool): Whether to print verbose output 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) Returns: @@ -551,7 +657,7 @@ def check_file( print(error_msg) return errors - for name, line_no, docstring, _ in docstrings: + for name, line_no, docstring, node in docstrings: context = DocstringContext( file_path=file_path, line_no=line_no, @@ -559,7 +665,9 @@ def check_file( verbose=verbose, require_param_types=require_param_types, check_references=check_references, + check_type_consistency=check_type_consistency, min_short_description_length=min_short_description_length, + node=node, ) errors.extend(_process_docstring(context, docstring)) @@ -572,6 +680,7 @@ def scan_directory( require_param_types: bool = False, verbose: bool = False, check_references: bool = True, + check_type_consistency: bool = False, min_short_description_length: int = 50, ) -> list[str]: """Scan a directory for Python files and check their docstrings. @@ -582,6 +691,7 @@ def scan_directory( require_param_types (bool): Whether parameter types are required verbose (bool): Whether to print verbose output 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) Returns: @@ -611,6 +721,7 @@ def scan_directory( require_param_types, verbose, check_references, + check_type_consistency, min_short_description_length, ), ) @@ -646,6 +757,16 @@ def _parse_args() -> argparse.Namespace: action="store_true", help="Skip reference checking", ) + parser.add_argument( + "--check-type-consistency", + action="store_true", + help="Compare docstring types with function annotations", + ) + parser.add_argument( + "--no-check-type-consistency", + action="store_true", + help="Skip type consistency checking", + ) parser.add_argument( "--exclude-files", help="Comma-separated list of filenames to exclude", @@ -664,7 +785,7 @@ def _parse_args() -> argparse.Namespace: def _get_config_values( args: argparse.Namespace, config: dict[str, Any], -) -> tuple[list[str], bool, bool, bool, int, list[str]]: +) -> tuple[list[str], bool, bool, bool, bool, int, list[str]]: """Get configuration values from command line arguments and config file. Args: @@ -672,10 +793,11 @@ def _get_config_values( config (dict[str, Any]): Configuration dictionary Returns: - tuple[list[str], bool, bool, bool, int, list[str]]: Tuple containing: + tuple[list[str], bool, bool, bool, bool, int, list[str]]: Tuple containing: - List of paths to check - Whether to require parameter types - Whether to check references + - Whether to check type consistency - Whether to enable verbose output - Minimum short description length - List of files to exclude @@ -696,6 +818,13 @@ def _get_config_values( if args.no_check_references: check_references = False + # Get check_type_consistency - handle both positive and negative flags + check_type_consistency = config.get("check_type_consistency", False) + if args.check_type_consistency: + check_type_consistency = True + if args.no_check_type_consistency: + check_type_consistency = False + # Get exclude_files exclude_files = [] if args.exclude_files: @@ -710,7 +839,15 @@ def _get_config_values( if args.min_short_description_length is not None: min_short_description_length = args.min_short_description_length - return paths, require_param_types, verbose, check_references, min_short_description_length, exclude_files + return ( + paths, + require_param_types, + verbose, + check_references, + check_type_consistency, + min_short_description_length, + exclude_files, + ) def _process_paths( @@ -719,6 +856,7 @@ def _process_paths( require_param_types: bool, verbose: bool, check_references: bool, + check_type_consistency: bool, min_short_description_length: int, ) -> list[str]: """Process paths and check docstrings in each file or directory. @@ -729,6 +867,7 @@ def _process_paths( require_param_types (bool): Whether parameter types are required verbose (bool): Whether to print verbose output 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) Returns: @@ -744,6 +883,7 @@ def _process_paths( require_param_types, verbose, check_references, + check_type_consistency, min_short_description_length, ) all_errors.extend(errors) @@ -753,6 +893,7 @@ def _process_paths( require_param_types, verbose, check_references, + check_type_consistency, min_short_description_length, ) all_errors.extend(errors) @@ -774,12 +915,15 @@ def main() -> None: args = _parse_args() # Get configuration values - paths, require_param_types, verbose, check_references, min_short_description_length, exclude_files = ( - _get_config_values( - args, - config, - ) - ) + ( + paths, + require_param_types, + verbose, + check_references, + check_type_consistency, + min_short_description_length, + exclude_files, + ) = _get_config_values(args, config) # Print configuration if verbose if verbose: @@ -787,6 +931,7 @@ def main() -> None: print(f" Paths: {paths}") print(f" Require parameter types: {require_param_types}") 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" Exclude files: {exclude_files}") @@ -804,6 +949,7 @@ def main() -> None: require_param_types, verbose, check_references, + check_type_consistency, min_short_description_length, ): for error in all_errors: From 299e270f812fb7cc9b1fb4862a9290595f2e86e1 Mon Sep 17 00:00:00 2001 From: Vladimir Iglovikov Date: Mon, 16 Mar 2026 11:47:06 +0800 Subject: [PATCH 5/9] refactor: enable C901, fix complexity in load_pyproject_config - Add rule to python-pre-commit skill: do not disable refactor-forcing checks - Remove C901 from tools/*.py per-file-ignores - Extract _CONFIG_KEYS and _apply_tool_config to reduce load_pyproject_config complexity Made-with: Cursor --- .cursor/skills/python-pre-commit/SKILL.md | 4 +++ pyproject.toml | 1 - tools/check_docstrings.py | 41 +++++++++++------------ 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/.cursor/skills/python-pre-commit/SKILL.md b/.cursor/skills/python-pre-commit/SKILL.md index e788554..24185d5 100644 --- a/.cursor/skills/python-pre-commit/SKILL.md +++ b/.cursor/skills/python-pre-commit/SKILL.md @@ -25,6 +25,10 @@ pre-commit run --all-files | codespell | - | Spell check | | pre-commit-hooks | - | AST, TOML, JSON, etc. | +## Lint rules + +**Do not disable checks that force refactoring for better code** (e.g. C901 complexity). Fix the code instead. + ## Config locations - **Ruff**: `[tool.ruff]` in pyproject.toml (line-length 120, py310, pydocstyle google) diff --git a/pyproject.toml b/pyproject.toml index 1f75563..14fe6de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,7 +120,6 @@ lint.per-file-ignores = { "__init__.py" = [ "FBT002", "ANN201", "PLR0913", - "C901", ] } lint.fixable = [ "ALL" ] diff --git a/tools/check_docstrings.py b/tools/check_docstrings.py index 1dcb74d..ded7e37 100755 --- a/tools/check_docstrings.py +++ b/tools/check_docstrings.py @@ -61,6 +61,25 @@ class DocstringContext(NamedTuple): node: ast.AST | None = None +_CONFIG_KEYS: dict[str, tuple[str, type]] = { + "paths": ("paths", list), + "require_param_types": ("require_param_types", bool), + "check_references": ("check_references", bool), + "check_type_consistency": ("check_type_consistency", bool), + "min_short_description_length": ("min_short_description_length", int), + "exclude_files": ("exclude_files", list), + "verbose": ("verbose", bool), +} + + +def _apply_tool_config(config: dict[str, Any], tool_config: dict[str, Any]) -> None: + """Apply tool_config values to config. Modifies config in place.""" + for key, (config_key, converter) in _CONFIG_KEYS.items(): + if key in tool_config: + raw = tool_config[key] + config[config_key] = raw if converter is list else converter(raw) + + def load_pyproject_config() -> dict[str, Any]: """Load configuration from pyproject.toml if it exists. @@ -68,8 +87,6 @@ def load_pyproject_config() -> dict[str, Any]: dict[str, Any]: Dictionary with configuration values """ config = DEFAULT_CONFIG.copy() - - # Look for pyproject.toml in the current directory pyproject_path = Path("pyproject.toml") if not pyproject_path.is_file(): return config @@ -77,28 +94,10 @@ def load_pyproject_config() -> dict[str, Any]: try: with pyproject_path.open("rb") as f: pyproject_data = tomli.load(f) - - # Check if our tool is configured tool_config = pyproject_data.get("tool", {}).get("docstring_checker", {}) if not tool_config: return config - - # Update config with values from pyproject.toml - if "paths" in tool_config: - config["paths"] = tool_config["paths"] - if "require_param_types" in tool_config: - config["require_param_types"] = bool(tool_config["require_param_types"]) - if "check_references" in tool_config: - config["check_references"] = bool(tool_config["check_references"]) - if "check_type_consistency" in tool_config: - config["check_type_consistency"] = bool(tool_config["check_type_consistency"]) - if "min_short_description_length" in tool_config: - config["min_short_description_length"] = int(tool_config["min_short_description_length"]) - if "exclude_files" in tool_config: - config["exclude_files"] = tool_config["exclude_files"] - if "verbose" in tool_config: - config["verbose"] = bool(tool_config["verbose"]) - + _apply_tool_config(config, tool_config) except Exception as e: print(f"Warning: Failed to load configuration from pyproject.toml: {e}") From bbcc15964b92381ea7bc20cfb4768279bc69c82f Mon Sep 17 00:00:00 2001 From: Vladimir Iglovikov Date: Mon, 16 Mar 2026 11:48:36 +0800 Subject: [PATCH 6/9] Version to 0.0.10 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 14fe6de..30ff06b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ requires = [ "setuptools>=45", "wheel" ] [project] name = "google-docstring-parser" -version = "0.0.9" +version = "0.0.10" description = "A lightweight, efficient parser for Google-style Python docstrings that converts them into structured dictionaries." readme = "README.md" From e103c5b4f34fd1992cefcdb2c020f2e7a25c6575 Mon Sep 17 00:00:00 2001 From: Vladimir Iglovikov Date: Mon, 16 Mar 2026 11:54:06 +0800 Subject: [PATCH 7/9] feat(check-docstrings): address Sourcery feedback - Add mutually exclusive argparse groups for --check/--no-check flags - Add _config_keys_match guard and test for DEFAULT_CONFIG/_CONFIG_KEYS sync - Use SHORT_DESC_PREVIEW_LENGTH constant for preview length - Extend type consistency to pos-only, kw-only, varargs, kwargs params - Use macos-latest in CI matrix Made-with: Cursor --- .github/workflows/ci.yml | 2 +- .../test_check_docstrings.py | 90 ++++++++++++++++++- tools/check_docstrings.py | 64 +++++++++---- 3 files changed, 138 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e171ac..db8787e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: runs-on: ${{ matrix.operating-system }} strategy: matrix: - operating-system: [ubuntu-latest, windows-latest, macos-13] + operating-system: [ubuntu-latest, windows-latest, macos-latest] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] fail-fast: true diff --git a/tests/test_docstring_checker/test_check_docstrings.py b/tests/test_docstring_checker/test_check_docstrings.py index 5862ad1..50a2879 100644 --- a/tests/test_docstring_checker/test_check_docstrings.py +++ b/tests/test_docstring_checker/test_check_docstrings.py @@ -8,7 +8,36 @@ import pytest -from tools.check_docstrings import check_short_description_length +from tools.check_docstrings import ( + _config_keys_match, + check_short_description_length, +) + + +def test_config_keys_match() -> None: + """Test that DEFAULT_CONFIG and _CONFIG_KEYS stay in sync.""" + assert _config_keys_match(), "DEFAULT_CONFIG and _CONFIG_KEYS must have the same keys" + + +def test_mutually_exclusive_type_consistency_flags(tmp_path: Path) -> None: + """Test that --check-type-consistency and --no-check-type-consistency cannot be used together.""" + test_file = tmp_path / "test.py" + test_file.write_text('"""Module."""\ndef foo(): pass\n') + + result = subprocess.run( + [ + sys.executable, + "-m", + "tools.check_docstrings", + str(test_file), + "--check-type-consistency", + "--no-check-type-consistency", + ], + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "not allowed with" in result.stderr def test_valid_docstrings() -> None: @@ -703,6 +732,65 @@ def method(self, x: int) -> None: 0, "", ), + # pos-only param type mismatch + ( + ''' +"""Test module with pos-only param mismatch.""" + +def foo(x: int, /, y: str) -> None: + """Function with pos-only param. + + Args: + x (str): Docstring says str, annotation says int + y (str): Correct + + Returns: + None + """ + pass +''', + 1, + "docstring says 'str' but annotation says 'int'", + ), + # kw-only param type mismatch + ( + ''' +"""Test module with kw-only param mismatch.""" + +def foo(x: int, *, y: str) -> None: + """Function with kw-only param. + + Args: + x (int): Correct + y (int): Docstring says int, annotation says str + + Returns: + None + """ + pass +''', + 1, + "docstring says 'int' but annotation says 'str'", + ), + # *args type mismatch + ( + ''' +"""Test module with *args type mismatch.""" + +def foo(*args: int) -> None: + """Function with varargs. + + Args: + args (str): Docstring says str, annotation says int + + Returns: + None + """ + pass +''', + 1, + "docstring says 'str' but annotation says 'int'", + ), ], ) def test_check_type_consistency( diff --git a/tools/check_docstrings.py b/tools/check_docstrings.py index ded7e37..561c793 100755 --- a/tools/check_docstrings.py +++ b/tools/check_docstrings.py @@ -20,6 +20,9 @@ parse_google_docstring, ) +# Preview length for short description error messages +SHORT_DESC_PREVIEW_LENGTH = 50 + # Default configuration DEFAULT_CONFIG = { "paths": [], # Empty by default, so no directories are scanned unless explicitly specified @@ -72,6 +75,11 @@ class DocstringContext(NamedTuple): } +def _config_keys_match() -> bool: + """Return True if DEFAULT_CONFIG and _CONFIG_KEYS have the same keys.""" + return set(DEFAULT_CONFIG.keys()) == set(_CONFIG_KEYS.keys()) + + def _apply_tool_config(config: dict[str, Any], tool_config: dict[str, Any]) -> None: """Apply tool_config values to config. Modifies config in place.""" for key, (config_key, converter) in _CONFIG_KEYS.items(): @@ -192,6 +200,32 @@ def _annotation_to_str(annotation: ast.expr | None) -> str | None: return ast.unparse(annotation) +def _get_ast_param_types(node: ast.FunctionDef | ast.AsyncFunctionDef) -> dict[str, str]: + """Extract parameter names and type strings from function AST. + + Args: + node (ast.FunctionDef | ast.AsyncFunctionDef): Function AST node + + Returns: + dict[str, str]: Map of param name to annotation string (skips self/cls) + """ + ast_params: dict[str, str] = {} + all_args: list[ast.arg] = [] + all_args.extend(node.args.posonlyargs) + all_args.extend(node.args.args) + all_args.extend(node.args.kwonlyargs) + if node.args.vararg is not None: + all_args.append(node.args.vararg) + if node.args.kwarg is not None: + all_args.append(node.args.kwarg) + for arg in all_args: + if arg.arg in ("self", "cls"): + continue + if ann_str := _annotation_to_str(arg.annotation): + ast_params[arg.arg] = ann_str + return ast_params + + def check_type_consistency( parsed: dict[str, Any], node: ast.FunctionDef | ast.AsyncFunctionDef, @@ -206,14 +240,7 @@ def check_type_consistency( list[str]: List of error messages for type mismatches """ errors = [] - - # Build param dict from AST (skip self/cls) - ast_params: dict[str, str] = {} - for arg in node.args.args: - if arg.arg in ("self", "cls"): - continue - if ann_str := _annotation_to_str(arg.annotation): - ast_params[arg.arg] = ann_str + ast_params = _get_ast_param_types(node) # Compare Args for arg in parsed.get("Args", []): @@ -379,8 +406,11 @@ def check_short_description_length(parsed: dict[str, Any], min_length: int) -> l return [] if len(short_desc) < min_length: - preview_len = 50 - preview = short_desc[:preview_len] + "..." if len(short_desc) > preview_len else short_desc + preview = ( + short_desc[:SHORT_DESC_PREVIEW_LENGTH] + "..." + if len(short_desc) > SHORT_DESC_PREVIEW_LENGTH + else short_desc + ) return [ f"Short description too short ({len(short_desc)} chars, min {min_length}): '{preview}'", ] @@ -746,22 +776,24 @@ def _parse_args() -> argparse.Namespace: action="store_true", help="Require parameter types in docstrings", ) - parser.add_argument( + ref_group = parser.add_mutually_exclusive_group() + ref_group.add_argument( "--check-references", action="store_true", help="Check references for errors", ) - parser.add_argument( + ref_group.add_argument( "--no-check-references", action="store_true", help="Skip reference checking", ) - parser.add_argument( + type_consistency_group = parser.add_mutually_exclusive_group() + type_consistency_group.add_argument( "--check-type-consistency", action="store_true", help="Compare docstring types with function annotations", ) - parser.add_argument( + type_consistency_group.add_argument( "--no-check-type-consistency", action="store_true", help="Skip type consistency checking", @@ -810,14 +842,14 @@ def _get_config_values( # Get verbose verbose = args.verbose or config["verbose"] - # Get check_references - handle both positive and negative flags + # Get check_references - handle both positive and negative flags (mutually exclusive) check_references = config["check_references"] if args.check_references: check_references = True if args.no_check_references: check_references = False - # Get check_type_consistency - handle both positive and negative flags + # Get check_type_consistency - handle both positive and negative flags (mutually exclusive) check_type_consistency = config.get("check_type_consistency", False) if args.check_type_consistency: check_type_consistency = True From 9a6cef52d78d072f304a1deb3d05aadf81862457 Mon Sep 17 00:00:00 2001 From: Vladimir Iglovikov Date: Mon, 16 Mar 2026 11:58:11 +0800 Subject: [PATCH 8/9] fix(check-docstrings): address Copilot feedback - Default min_short_description_length to 0 for backward compatibility - Handle Returns string 'None' in type consistency check - Update type consistency docs to use list[str], dict[str, Any], etc - Document min_short_description_length in README and tools/README Made-with: Cursor --- README.md | 1 + .../test_check_docstrings.py | 16 ++++++++++++ tools/README.md | 7 +++++- tools/check_docstrings.py | 25 ++++++++++--------- 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 20c5b39..8277f55 100644 --- a/README.md +++ b/README.md @@ -148,5 +148,6 @@ 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 = 0 # Minimum summary length; set to 0 to disable verbose = false # Enable verbose output ``` diff --git a/tests/test_docstring_checker/test_check_docstrings.py b/tests/test_docstring_checker/test_check_docstrings.py index 50a2879..6dc8cf7 100644 --- a/tests/test_docstring_checker/test_check_docstrings.py +++ b/tests/test_docstring_checker/test_check_docstrings.py @@ -791,6 +791,22 @@ def foo(*args: int) -> None: 1, "docstring says 'str' but annotation says 'int'", ), + # Returns string "None" vs annotation str - mismatch detected + ( + ''' +"""Test module with Returns as string None.""" + +def foo() -> str: + """Function returning str but docstring says None. + + Returns: + None + """ + return "x" +''', + 1, + "Returns: docstring says 'None' but annotation says 'str'", + ), ], ) def test_check_type_consistency( diff --git a/tools/README.md b/tools/README.md index 828e6c8..9720e28 100644 --- a/tools/README.md +++ b/tools/README.md @@ -46,6 +46,9 @@ check_references = true # Whether to compare docstring types with function annotations check_type_consistency = true +# Minimum short description length (0 to disable) +min_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 exclude_files = ["conftest.py", "__init__.py", "tests/fixtures/bad_docstrings.py"] @@ -62,7 +65,7 @@ When `require_param_types = true`, the hook will check if all parameters in docs #### Type Consistency Checking -When `check_type_consistency = true`, the hook compares docstring types (Args, Returns) with Python function annotations. Mismatches are reported (e.g., docstring says `int` but annotation says `str`). Use Python 3.10+ style: `list`, `dict`, `tuple`, `X | Y` — not `List`, `Dict`, `Tuple`, `Union`. +When `check_type_consistency = true`, the hook compares docstring types (Args, Returns) with Python function annotations. Mismatches are reported (e.g., docstring says `int` but annotation says `str`). Use Python 3.10+ style: `list[str]`, `dict[str, Any]`, `tuple[int, str]`, `X | Y` — not `List`, `Dict`, `Tuple`, `Union`. #### Reference Checking @@ -99,6 +102,7 @@ paths = ["src", "tests"] require_param_types = true check_references = true check_type_consistency = true +min_short_description_length = 0 exclude_files = ["conftest.py", "__init__.py"] verbose = false ``` @@ -126,5 +130,6 @@ Command line options: - `--no-check-references`: Skip reference checking - `--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) - `--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 561c793..a54f604 100755 --- a/tools/check_docstrings.py +++ b/tools/check_docstrings.py @@ -29,7 +29,7 @@ "require_param_types": False, "check_references": True, "check_type_consistency": False, - "min_short_description_length": 50, + "min_short_description_length": 0, "exclude_files": [], "verbose": False, } @@ -60,7 +60,7 @@ class DocstringContext(NamedTuple): require_param_types: bool = False check_references: bool = True check_type_consistency: bool = False - min_short_description_length: int = 50 + min_short_description_length: int = 0 node: ast.AST | None = None @@ -256,14 +256,15 @@ def check_type_consistency( f"Parameter '{param_name}': docstring says '{doc_type}' but annotation says '{ast_type}'", ) - # Compare Returns + # Compare Returns (handle both dict and string "None" from parse_google_docstring) returns = parsed.get("Returns") - if ( - isinstance(returns, dict) - and (doc_ret := returns.get("type")) - and (ast_ret := _annotation_to_str(node.returns)) - and _normalize_type(doc_ret) != _normalize_type(ast_ret) - ): + doc_ret: str | None = None + if isinstance(returns, dict): + doc_ret = returns.get("type") + elif isinstance(returns, str): + doc_ret = returns + ast_ret = _annotation_to_str(node.returns) + if doc_ret and ast_ret and _normalize_type(doc_ret) != _normalize_type(ast_ret): errors.append( f"Returns: docstring says '{doc_ret}' but annotation says '{ast_ret}'", ) @@ -657,7 +658,7 @@ def check_file( verbose: bool = False, check_references: bool = True, check_type_consistency: bool = False, - min_short_description_length: int = 50, + min_short_description_length: int = 0, ) -> list[str]: """Check docstrings in a Python file for parsing and validation errors. @@ -710,7 +711,7 @@ def scan_directory( verbose: bool = False, check_references: bool = True, check_type_consistency: bool = False, - min_short_description_length: int = 50, + min_short_description_length: int = 0, ) -> list[str]: """Scan a directory for Python files and check their docstrings. @@ -866,7 +867,7 @@ def _get_config_values( exclude_files = config["exclude_files"] # Get min_short_description_length - CLI overrides config - min_short_description_length = config.get("min_short_description_length", 50) + min_short_description_length = config.get("min_short_description_length", 0) if args.min_short_description_length is not None: min_short_description_length = args.min_short_description_length From f3e08656f8d0b2b7b58383f57f50e44183f48351 Mon Sep 17 00:00:00 2001 From: Vladimir Iglovikov Date: Mon, 16 Mar 2026 12:07:32 +0800 Subject: [PATCH 9/9] fix(check-docstrings): address Copilot review feedback - Normalize internal whitespace in type comparison (tuple[int, str] vs tuple[int,str]) - Fix _get_config_values docstring return tuple order - Use min_short_description_length=10 in README example - Add test for whitespace normalization Made-with: Cursor --- README.md | 2 +- .../test_check_docstrings.py | 19 +++++++++++++++++++ tools/check_docstrings.py | 8 +++++--- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8277f55..8915b19 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,6 @@ 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 = 0 # Minimum summary length; set to 0 to disable +min_short_description_length = 10 # Minimum summary length; set to 0 to disable verbose = false # Enable verbose output ``` diff --git a/tests/test_docstring_checker/test_check_docstrings.py b/tests/test_docstring_checker/test_check_docstrings.py index 6dc8cf7..665e2c0 100644 --- a/tests/test_docstring_checker/test_check_docstrings.py +++ b/tests/test_docstring_checker/test_check_docstrings.py @@ -807,6 +807,25 @@ def foo() -> str: 1, "Returns: docstring says 'None' but annotation says 'str'", ), + # Whitespace normalization: tuple[int, str] vs tuple[int,str] - no error + ( + ''' +"""Test module with whitespace in type.""" + +def foo(x: tuple[int, str]) -> int | None: + """Function with types that may have different whitespace. + + Args: + x (tuple[int, str]): Param with spaces in docstring + + Returns: + int | None: Union with spaces + """ + return None +''', + 0, + "", + ), ], ) def test_check_type_consistency( diff --git a/tools/check_docstrings.py b/tools/check_docstrings.py index a54f604..f577fd5 100755 --- a/tools/check_docstrings.py +++ b/tools/check_docstrings.py @@ -170,10 +170,11 @@ def check_param_types(docstring_dict: dict[str, Any], require_types: bool) -> li def _normalize_type(type_str: str) -> str: - """Normalize type string for comparison (whitespace and quotes only). + """Normalize type string for comparison (quotes and whitespace only). Python 3.10+ typing uses list, dict, tuple, X|Y - no List, Dict, Tuple, Union. We do not normalize those; mismatches will be reported. + Internal whitespace differences (e.g., around commas, |, or brackets) are ignored. Args: type_str (str): Type string to normalize @@ -181,7 +182,8 @@ def _normalize_type(type_str: str) -> str: Returns: str: Normalized type string """ - return type_str.strip().strip("'\"") + normalized = type_str.strip().strip("'\"") + return re.sub(r"\s+", "", normalized) def _annotation_to_str(annotation: ast.expr | None) -> str | None: @@ -828,9 +830,9 @@ def _get_config_values( tuple[list[str], bool, bool, bool, bool, 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 - - Whether to enable verbose output - Minimum short description length - List of files to exclude """