diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 21728be..eac8d54 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -18,9 +18,9 @@ body: options: - label: I have read the [README.md](../README.md) required: true - - label: I have searched for existing issues that might be related to this bug + - label: I am using the [latest version of Agent Docstrings](https://github.com/Artemonim/AgentDocstrings/releases/latest) required: true - - label: I am using the latest version of Agent Docstrings + - label: I have searched for existing issues that might be related to this bug required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 4b30e38..d90a1fe 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -3,6 +3,9 @@ contact_links: - name: 📖 Documentation url: https://github.com/Artemonim/AgentDocstrings/blob/master/README.md about: Read the project documentation and setup instructions + - name: 📝 Contribution Guide + url: https://github.com/Artemonim/AgentDocstrings/blob/master/CONTRIBUTING.md + about: Learn how to contribute to the project - name: 💬 Discussions url: https://github.com/Artemonim/AgentDocstrings/discussions about: Ask questions and discuss ideas with the community diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 434906e..ee0f1a4 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -18,9 +18,9 @@ body: options: - label: I have read the [README.md](../README.md) required: true - - label: I am using the latest version of Agent Docstrings + - label: I am using the [latest version of Agent Docstrings](https://github.com/Artemonim/AgentDocstrings/releases/latest) required: true - - label: I have searched for existing issues to see if this feature has been requested before + - label: I have searched for existing open and closed issues to see if this feature has been requested before required: true - label: This feature request is not a bug report (use Bug Report template for bugs) required: true diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml index ccebcbf..7662ba8 100644 --- a/.github/ISSUE_TEMPLATE/question.yml +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -16,9 +16,11 @@ body: label: Pre-submission Checklist description: Please confirm you have completed these steps options: + - label: I have read the [README.md](../README.md) and relevant documentation + required: true - label: I have searched existing issues and discussions for similar questions required: true - - label: I have read the [README.md](../README.md) and relevant documentation + - label: I have searched for existing open and closed issues to see if this feature has been requested before required: true - label: This is not a bug report (use Bug Report template for bugs) required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 1699769..c244cc7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,7 +1,6 @@ @@ -20,34 +19,13 @@ Please check the box that best describes the nature of your change. - [ ] 🎨 **Style**: Changes that do not affect the meaning of the code (white-space, formatting, etc.). - [ ] ⏪ **Revert**: Reverts a previous commit. ---- - -## Related Issue - - - -- *** - -## Description - - - -- - ## Checklist -- [ ] My code follows the style guidelines of this project. -- [ ] I have performed a self-review of my own code. +- [ ] My changes follows the [Contribution Guide](CONTRIBUTING.md). +- [ ] I have updated `CHANGELOG.md` under the `[NextRelease]` section. - [ ] New unit tests have been added to cover the changes. -- [ ] Manual testing has been performed +- [ ] Manual testing has been performed to verify the changes. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b976939..09e4260 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,18 +4,18 @@ on: # Run on pull requests into dev or master. pull_request: branches: [master, dev] - # After a PR is merged, the merge commit is pushed to master; we still want tests + coverage once on the resulting commit. + # Push to dev also triggers tests to ensure dev branch is always stable push: - branches: [master] + branches: [dev] jobs: test: # * Runs unit-test matrix: - # - Always on pull_request (dev or master) - # - On push to master (after merge) + # - On pull_request (to dev or master) + # - On push to dev if: | github.event_name == 'pull_request' || - (github.event_name == 'push' && github.ref == 'refs/heads/master') + (github.event_name == 'push' && github.ref == 'refs/heads/dev') name: Test on Python ${{ matrix.python-version }} (beta=${{ matrix.beta }}) runs-on: ubuntu-latest strategy: @@ -25,7 +25,7 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 - # For pull_request we check out the PR commit; for push we stay on the pushed ref (master). + # For pull_request we check out the PR commit; for push we stay on the pushed ref (dev). with: ref: ${{ github.event.pull_request.head.sha }} @@ -34,6 +34,14 @@ jobs: with: go-version: "1.22" + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Build Go parsers run: pwsh -File ./build_goparser.ps1 shell: bash @@ -43,6 +51,14 @@ jobs: with: python-version: ${{ matrix.python-version }} + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-pip- + - name: Install dependencies run: | python -m pip install --upgrade pip @@ -67,9 +83,7 @@ jobs: report: # * Only for master: either in PR to master (so reviewers see comment) or after merge push to master. - if: | - (github.event_name == 'pull_request' && github.base_ref == 'master') || - (github.event_name == 'push' && github.ref == 'refs/heads/master') + if: github.event_name == 'pull_request' && github.base_ref == 'master' name: Report Coverage runs-on: ubuntu-latest needs: test @@ -104,9 +118,24 @@ jobs: - name: Verify that version was not bumped run: | - if ! git diff --quiet origin/dev HEAD -- pyproject.toml; then - echo "::error::Version in pyproject.toml was changed in a PR to dev." + echo "Checking for version changes in pyproject.toml..." + DEV_PYPROJECT_VERSION=$(git show origin/dev:pyproject.toml | grep '^version = ' | awk -F'"' '{print $2}') + HEAD_PYPROJECT_VERSION=$(grep '^version = ' pyproject.toml | awk -F'"' '{print $2}') + + if [ "$DEV_PYPROJECT_VERSION" != "$HEAD_PYPROJECT_VERSION" ]; then + echo "::error::Version in pyproject.toml was changed in a PR to dev. (dev: $DEV_PYPROJECT_VERSION, HEAD: $HEAD_PYPROJECT_VERSION)" echo "Version bumping should only happen in a release PR to master." exit 1 fi echo "Version check passed for pyproject.toml" + + echo "Checking for version changes in agent_docstrings/__init__.py..." + DEV_INIT_VERSION=$(git show origin/dev:agent_docstrings/__init__.py | grep '^__version__ = ' | awk -F'"' '{print $2}') + HEAD_INIT_VERSION=$(grep '^__version__ = ' agent_docstrings/__init__.py | awk -F'"' '{print $2}') + + if [ "$DEV_INIT_VERSION" != "$HEAD_INIT_VERSION" ]; then + echo "::error::Version in agent_docstrings/__init__.py was changed in a PR to dev. (dev: $DEV_INIT_VERSION, HEAD: $HEAD_INIT_VERSION)" + echo "Version bumping should only happen in a release PR to master." + exit 1 + fi + echo "Version check passed for agent_docstrings/__init__.py" diff --git a/.github/workflows/release-automation.yml b/.github/workflows/release-automation.yml new file mode 100644 index 0000000..42b2d3e --- /dev/null +++ b/.github/workflows/release-automation.yml @@ -0,0 +1,56 @@ +name: Release Automation + +on: + push: + branches: + - master + +jobs: + create_release: + # We only run it for merge commits from branches release/* + if: startsWith(github.event.head_commit.message, 'Merge pull request') && contains(github.event.head_commit.message, 'from release/') + runs-on: ubuntu-latest + permissions: + contents: write # to create tags and releases + pull-requests: write # to create PR + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + # I need a complete history to read the tags and create a PR + fetch-depth: 0 + + - name: Get Version + id: get_version + run: | + # Извлекаем версию из pyproject.toml + version=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/') + echo "version=${version}" >> $GITHUB_OUTPUT + + - name: Extract Changelog Notes + id: changelog + uses: mindsers/changelog-reader-action@v2 + with: + version: ${{ steps.get_version.outputs.version }} + path: ./CHANGELOG.md + + - name: Create GitHub Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: v${{ steps.get_version.outputs.version }} + release_name: v${{ steps.get_version.outputs.version }} + body: ${{ steps.changelog.outputs.changes }} + draft: false + prerelease: false + + - name: Create back-merge PR to dev + uses: repo-sync/pull-request@v2 + with: + source_branch: "master" + destination_branch: "dev" + pr_title: "Post-Release: Merge master back into dev" + pr_body: "Automated PR to sync master back into dev after release v${{ steps.get_version.outputs.version }}." + github_token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 68e0bd4..5ab8d46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [NextRelease] +### Header + +- **subtitle**: describtion + +## [1.3.4] + +### Fixed + +- **Deterministic Processing**: Fixed a critical bug that caused line numbers in the table of contents to change on every run. This was due to inconsistent newline handling after removing an existing agent docstring. The process is now fully idempotent. +- **Robust Docstring Removal**: Improved the detection logic to correctly find and remove all agent-generated docstrings, even when located in the middle of a file or when multiple (erroneous) docstrings were present. This prevents docstring duplication on repeated runs. +- **Manual Docstring Preservation**: Ensured that manual docstrings are no longer reformatted or modified unless they are being merged with an agent-generated table of contents. +- **Version-Only Change Skipping**: Fixed a bug where files were being unnecessarily modified when only the version number in the auto-generated header differed, while the actual content structure remained unchanged. The tool now performs normalized content comparison that ignores version differences, preventing unnecessary file modifications after Agent Docstrings version updates. + +### Documentation + +- **Contribution Guide**: Added a new `CONTRIBUTING.md` file with detailed guidelines for development workflow and the release process. +- **README Update**: Updated `README.md` to link to the new contribution guide and reflect the automated release process. + +### CI/CD + +- **Release Automation**: Added a new `release-automation.yml` workflow that automatically creates Git tags, GitHub Releases, and back-merge PRs when release branches are merged to master. +- **CI Optimization**: Optimized the main CI pipeline by removing redundant test runs on master branch pushes and adding caching for pip dependencies and Go modules to speed up workflow execution. +- **Workflow Efficiency**: Changed CI triggers to run on pushes to `dev` instead of `master`, eliminating duplicate test runs while maintaining comprehensive coverage. +- **Version Check Precision**: Improved the version bump detection in CI to specifically check version changes in `pyproject.toml` and `agent_docstrings/__init__.py`, preventing false positives from other file modifications. + ## [1.3.3] ### Fixed @@ -154,36 +179,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Programmatic API**: Import and use in other Python projects - **Safe operation**: Only modifies auto-generated docstring blocks, preserves existing documentation - **Incremental updates**: Only processes files when changes are detected - -### Technical Features - -- Uses `from __future__ import annotations` for forward compatibility -- Compatible with `typing.Union` and `typing.Tuple` for Python 3.8/3.9 -- No external dependencies - built on Python standard library only -- Comprehensive test suite with pytest -- Full type checking support with mypy -- Code formatting with black -- Proper packaging for PyPI distribution - -### Configuration - -- `.agent-docstrings-ignore`: Specify files and patterns to exclude -- `.agent-docstrings-include`: Specify files and patterns to include (whitelist mode) -- Automatic integration with existing `.gitignore` files -- Support for glob patterns in configuration files - -### Documentation - -- Comprehensive README with usage examples -- Integration guides for pre-commit hooks and CI/CD -- Development setup instructions -- API documentation for programmatic usage - -## Version History - -- **1.0.1** - Parser and docstring handling improvements -- **1.0.0** - Initial stable release with multi-language support and filtering system -- **0.4.0** - (internal) -- **0.3.0** - (internal) -- **0.2.0** - (internal) -- **0.1.0** - Initial development version (internal) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c9f2e2c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,116 @@ +# Contributing to AgentDocstringsGenerator + +First off, thank you for considering contributing to AgentDocstringsGenerator! It's people like you that make this such a great tool. + +This document provides guidelines for contributing to the project. Please feel free to propose changes to this document in a pull request. + +## Table of Contents + +- [How Can I Contribute?](#how-can-i-contribute) + - [Reporting Bugs](#reporting-bugs) + - [Suggesting Enhancements](#suggesting-enhancements) + - [Pull Requests](#pull-requests) +- [Development Workflow](#development-workflow) + - [Branching Model](#branching-model) + - [Development Steps](#development-steps) + - [Updating the Changelog](#updating-the-changelog) +- [Release Process (for maintainers)](#release-process-for-maintainers) +- [Styleguides](#styleguides) + +## How Can I Contribute? + +We value community contributions and appreciate your help. Please be respectful in all your interactions. + +### Reporting Bugs + +If you find a bug, please ensure it hasn't already been reported by searching on GitHub under [Issues](https://github.com/Artemonim/AgentDocstrings/issues). If you can't find an open issue addressing the problem, please open a new one. Include a clear title, a detailed description, and a code sample or test case that demonstrates the issue. + +### Suggesting Enhancements + +If you have an idea for an enhancement, please open an issue to discuss it first. This allows us to coordinate our efforts and ensure the proposed changes align with the project's goals. + +### Pull Requests + +We welcome pull requests. Please follow these steps to have your contribution considered: + +1. Follow the [Development Workflow](#development-workflow). +2. Ensure that your code adheres to the [Styleguides](#styleguides). +3. Make sure all tests pass. +4. Fill out the pull request template provided. + +## Development Workflow + +This project uses a GitFlow-like branching model. + +### Branching Model + +- **`master`**: This branch contains the latest stable release. Direct pushes are not allowed. +- **`dev`**: This is the main development branch. All feature branches should be created from `dev`, and all pull requests should be submitted to `dev`. +- **`feature/*`**: For new features. Branched from `dev`. Example: `feature/new-parser`. +- **`fix/*`**: For bug fixes. Branched from `dev`. Example: `fix/off-by-one-error`. +- **`release/*`**: For preparing new releases. Branched from `dev`. Merged into `master`. + +### Development Steps + +1. **Fork** the repository on GitHub. +2. **Clone** your fork locally: `git clone https://github.com/YOUR_USERNAME/AgentDocstrings.git` +3. **Set up the environment**: + ```bash + cd AgentDocstrings + pip install -e .[dev] + ``` +4. **Create a new branch** from `dev`: + ```bash + git checkout dev + git pull origin dev + git checkout -b feature/your-amazing-feature + ``` +5. **Make your changes**. Write clean, readable code. +6. **Add or update tests** for your changes in the `tests/` directory. +7. **Run tests** to ensure everything is working correctly: `python -m pytest` +8. **Update the Changelog** `NextRelease` section. +9. **Commit** your changes. Use a clear and descriptive commit message. +10. **Push** your branch to your fork on GitHub: `git push origin feature/your-amazing-feature` +11. **Open a Pull Request** to the `dev` branch of the main repository. + +### Updating the Changelog + +For every change that affects the user, you must add an entry to the `CHANGELOG.md` file under the `[NextRelease]` section. Follow the format of existing entries. If your pull request resolves an existing issue, please link it in the changelog entry `(fixes #123)` or `(closes #456)`). + +Example: + +```markdown +## [NextRelease] + +### Fixed + +- **My Awesome Fix**: A brief description of what you've fixed (fixes #78). +``` + +## Release Process (for maintainers) + +The release process is partially automated. + +1. Create a release branch from `dev`: `git checkout -b release/x.y.z` (e.g., `release/1.4.0`). +2. Update the version using `bump-my-version`: + ```bash + # For a patch, minor, or major release + bump-my-version patch/minor/major + ``` + This command will update the version in `pyproject.toml` and update the `CHANGELOG.md`, replacing `[NextRelease]` with the new version tag. +3. Commit the version bump: `git commit -am "chore: release v.x.y.z"` +4. Push the release branch: `git push origin release/x.y.z` +5. Open a Pull Request from the `release/x.y.z` branch to `master`. +6. Once the PR is merged into `master`, the `release-automation` workflow will automatically: + - Create a Git tag (e.g., `v1.4.0`). + - Create a GitHub Release with the notes from `CHANGELOG.md`. + - Create a Pull Request to merge `master` back into `dev`. +7. The `release.yml` workflow will then automatically publish the package to PyPI upon the creation of the GitHub Release. + +## Styleguides + +Please follow the coding style of the project to maintain consistency. + +- **Python**: [Google Python Style Guide](https://google.github.io/styleguide/pyguide.html), formatted with `black`. +- **Comments**: Use [Better Comments](https://marketplace.visualstudio.com/items?itemName=aaron-bond.better-comments) style. +- **Docstrings**: Use Google Style Docstrings. diff --git a/Doc/AgentDocstringsExample130.webp b/Doc/AgentDocstringsExample130.webp new file mode 100644 index 0000000..f284fb3 Binary files /dev/null and b/Doc/AgentDocstringsExample130.webp differ diff --git a/README.md b/README.md index 61f3a94..ecd5b52 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ **Agent Docstrings** is a command-line tool that automatically generates and maintains a "Table of Contents" at the top of your source files. It scans for classes, functions, and methods, creating a summary that provides a high-level overview of the file's structure. - +Agent Docstrings Demo This is especially useful for AI-Agents, helping them solve the "cold start" problem of quickly understanding and navigating large, unfamiliar codebases. @@ -69,10 +69,11 @@ Imagine an AI agent tasked with modifying a large, unfamiliar codebase. Its firs #### Without Agent Docstrings: The "Blind" Approach An AI agent opens a file and has no initial context. To understand the file's structure, it must: + 1. Read a large chunk of the file. 2. Use tools like `grep_tool` or other search methods to find function and class definitions. 3. Analyze and piece together the results to build a mental map of the file. -This process is slow, api-intensive, and prone to error. + This process is slow, api-intensive, and prone to error. #### With Agent Docstrings: The "Map-First" Approach @@ -318,7 +319,7 @@ This project uses [bump-my-version](https://github.com/callowayproject/bump-my-v The tool is configured in `pyproject.toml` to automatically update the version string in `agent_docstrings/__init__.py`, `pyproject.toml`, and `CHANGELOG.md`. -**Note**: Per project configuration, this tool only modifies the files. You will need to commit and tag the changes manually after bumping the version. +**Note**: Running `bump-my-version`, you need to create a release branch and a pull request to `master`. The process of tagging, creating a GitHub Release, and publishing to PyPI is automated. For full details, see the [Contribution Guide](CONTRIBUTING.md). ## Support the Project @@ -336,11 +337,15 @@ Thank you for your support! ## Contributing -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request +We welcome contributions! Please see our [**Contribution Guide**](CONTRIBUTING.md) for detailed instructions on how to get started, our development workflow, and coding standards. + +In short: + +1. Fork the repo and create your branch from `dev`. +2. Add your feature or fix. +3. Add/update tests. +4. Update `CHANGELOG.md`. +5. Submit a pull request to `dev`. ## License diff --git a/agent_docstrings/__init__.py b/agent_docstrings/__init__.py index f48ede6..5f5575c 100644 --- a/agent_docstrings/__init__.py +++ b/agent_docstrings/__init__.py @@ -7,4 +7,4 @@ Attributes: __version__ (str): Current version of the *agent-docstrings* package. """ -__version__ = "1.3.3" \ No newline at end of file +__version__ = "1.3.4" \ No newline at end of file diff --git a/agent_docstrings/core.py b/agent_docstrings/core.py index 591f497..64f84d4 100644 --- a/agent_docstrings/core.py +++ b/agent_docstrings/core.py @@ -399,12 +399,16 @@ def process_file(path: Path, verbose: bool = False, beta: bool = False) -> None: return # ! Calculate the correct line offset for the final positions - # * First create a temporary header to count its lines + # * To ensure deterministic line numbers, we need to calculate the offset + # * based on the final file structure, not the intermediate state + + # * First, determine how many lines will be in the final header temp_header = _format_header(classes, functions, language, 0) - temp_header_lines = temp_header.splitlines() + temp_header_line_count = len(temp_header.splitlines()) - # * Calculate offset: preserved header lines + generated header lines - line_offset = header_end_line + len(temp_header_lines) + # * Calculate total offset: preserved header lines + generated header lines + # * This represents where the cleaned body will start in the final file + line_offset = header_end_line + temp_header_line_count # ! Language-specific adjustments for line numbering if language == "go": @@ -462,9 +466,9 @@ def process_file(path: Path, verbose: bool = False, beta: bool = False) -> None: if end_idx is not None: # Compute auto header content lines with correct offset for merge - # temp_header_lines holds the auto header lines including delimiters - # content_lines length is temp_header_lines minus start/end markers - offset_override = len(temp_header_lines) - 2 + # temp_header_line_count holds the auto header line count including delimiters + # content_lines length is temp_header_line_count minus start/end markers + offset_override = temp_header_line_count - 2 # Generate only the header content lines (without triple-quote delimiters) header_inner = _get_header_content_lines( classes, functions, language, offset_override @@ -498,8 +502,20 @@ def process_file(path: Path, verbose: bool = False, beta: bool = False) -> None: # Use single newlines to test composition theory new_content = "\n".join(filter(None, new_content_parts)) - # Only write changes if content changed - if new_content != original_content: + def normalize_version(text: str) -> str: + """Replaces the version string in a docstring with a placeholder.""" + return re.sub( + r"(Table of content is automatically generated by Agent Docstrings v)[\d\.]+\w*", + r"\1[VERSION]", + text, + ) + + # To avoid rewriting files just for a version bump, we compare the content + # with the version number normalized. + normalized_original = normalize_version(original_content) + normalized_new = normalize_version(new_content) + + if normalized_original != normalized_new: path.write_text(new_content, encoding="utf-8") if verbose: print(f"Processed {language.capitalize()}: {path}") diff --git a/agent_docstrings/languages/common.py b/agent_docstrings/languages/common.py index 80ae3b2..47e9fc6 100644 --- a/agent_docstrings/languages/common.py +++ b/agent_docstrings/languages/common.py @@ -1,12 +1,12 @@ """ --- AUTO-GENERATED DOCSTRING --- - Table of content is automatically generated by Agent Docstrings v1.3.2 + Table of content is automatically generated by Agent Docstrings v1.3.3 Classes/Functions: - - SignatureInfo (line 17): - - ClassInfo (line 21): - - CommentStyle (line 27): - - remove_agent_docstring(text: str, language: str) -> str (line 46) + - SignatureInfo (line 19): + - ClassInfo (line 24): + - CommentStyle (line 31): + - remove_agent_docstring(text: str, language: str) -> str (line 52) --- END AUTO-GENERATED DOCSTRING --- """ from __future__ import annotations @@ -57,22 +57,47 @@ def remove_agent_docstring(text: str, language: str) -> str: if language == "python": def replacer(match): docstring_content = match.group(0) + + # If the docstring doesn't contain the agent marker, it's a manual docstring. + # Leave it untouched. + if DOCSTRING_START_MARKER not in docstring_content: + return docstring_content + + # * Match the auto-generated block inside the docstring, including any leading/ + # * trailing whitespace and the trailing newline (if present). Use single + # * backslashes so that ``\s`` is interpreted by the *regex* engine as a + # * whitespace token instead of a literal backslash followed by ``s``. auto_content_pattern = re.compile( - rf'\s*{start_marker_escaped}[\s\S]*?{end_marker_escaped}\s*?\n?', - re.DOTALL + rf"\s*{start_marker_escaped}[\s\S]*?{end_marker_escaped}\s*\n?", + re.DOTALL, ) cleaned_docstring = auto_content_pattern.sub('', docstring_content) + + # Check what's left after removing the agent part temp_cleaned = cleaned_docstring.replace('"""', '').replace("'''", '').strip() + if not temp_cleaned: - return '' # Remove empty docstring - # Ensure single newline padding for non-empty manual comments - return f'"""\n{temp_cleaned}\n"""' - docstring_pattern = re.compile(r'^\s*("""[\s\S]*?"""|'r"'''[\s\S]*?''')") + return '' # Docstring was purely agent-generated, so remove it. + + # There was a manual part. Reformat it cleanly. + return f'"""\\n{temp_cleaned}\\n"""' + + # * Match ANY triple-quoted block (single or double quotes) anywhere in the text. + # * The former pattern anchored at ``^`` missed auto-generated blocks that were + # * not located at the very start of the file, leading to duplication issues. + docstring_pattern = re.compile( + r'("""[\s\S]*?"""|\'\'\'[\s\S]*?\'\'\')', + re.DOTALL, + ) # Iteratively clean the text cleaned_text = docstring_pattern.sub(replacer, text) - cleaned_text = docstring_pattern.sub(replacer, cleaned_text) # Run again to handle adjacent blocks - # Collapse whitespace and return - return cleaned_text.strip() + # * Run a second pass to handle cases where two docstrings appear back-to-back, + # * which can happen after removing an intermediary block. + cleaned_text = docstring_pattern.sub(replacer, cleaned_text) + # * Remove leading whitespace that may be left after docstring removal + # * to ensure consistent line numbering between runs + cleaned_text = cleaned_text.lstrip('\n') + return cleaned_text else: # For C-style comments, be more flexible with the format # Handle both compact (/**---...---*/) and expanded formats diff --git a/pyproject.toml b/pyproject.toml index 7b8d562..b2808cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agent-docstrings" -version = "1.3.3" +version = "1.3.4" description = "A command-line tool to auto-generate and update file-level docstrings summarizing classes and functions. Useful for maintaining a high-level overview of your files, especially in projects with code generated or modified by AI assistants." readme = { file = "README.md", content-type = "text/markdown" } license = { file = "LICENSE" } @@ -148,7 +148,7 @@ exclude_lines = [ ] [tool.bumpversion] -current_version = "1.3.3" +current_version = "1.3.4" commit = false tag = false @@ -164,5 +164,5 @@ replace = '__version__ = "{new_version}"' [[tool.bumpversion.files]] filename = "CHANGELOG.md" -search = "## [{current_version}]" -replace = "## [{new_version}]\n\n### Header\n\n- **subtitle**: describtion\n\n## [{current_version}]" \ No newline at end of file +search = "## [NextRelease]" +replace = "## [NextRelease]\n\n### Header\n\n- **subtitle**: describtion\n\n## [{new_version}]" \ No newline at end of file diff --git a/tests/test_common.py b/tests/test_common.py index e0a176e..8ab0452 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -1,256 +1,256 @@ -from __future__ import annotations - -""" - --- AUTO-GENERATED DOCSTRING --- - Table of content is automatically generated by Agent Docstrings v1.3.2 - - Classes/Functions: - - TestDataClasses (line 37): - - test_signature_info_creation() -> None (line 39) - - test_class_info_creation() -> None (line 44) - - test_comment_style_creation() -> None (line 60) - - TestCommentStyles (line 67): - - test_all_supported_languages_have_styles() -> None (line 69) - - test_comment_style_values(language: str, expected_start: str, expected_end: str, expected_prefix: str, expected_indent: str) -> None (line 85) - - TestHeaderStripping (line 99): - - test_strip_python_header() -> None (line 101) - - test_strip_block_comment_header() -> None (line 121) - - test_strip_c_style_comment_header() -> None (line 136) - - test_no_header_to_strip() -> None (line 151) - - test_preserve_shebang_when_stripping() -> None (line 158) - - test_strip_header_with_various_whitespace() -> None (line 169) - - test_strip_only_first_matching_header() -> None (line 175) - - test_strip_header_edge_cases() -> None (line 189) - - test_header_not_at_start() -> None (line 198) - - test_invalid_language_patterns(language: str) -> None (line 209) - --- END AUTO-GENERATED DOCSTRING --- -Tests for agent_docstrings.languages.common module. -""" -import pytest -from agent_docstrings.languages.common import ( - COMMENT_STYLES, - ClassInfo, - SignatureInfo, - CommentStyle, - remove_agent_docstring, - DOCSTRING_START_MARKER, - DOCSTRING_END_MARKER, -) - -class TestDataClasses: - """Tests for data classes used in parsing.""" - - def test_signature_info_creation(self) -> None: - """Test SignatureInfo namedtuple creation and access.""" - sig = SignatureInfo(signature="test_function(param: str) -> int", line=42) - assert sig.signature == "test_function(param: str) -> int" - assert sig.line == 42 - - def test_class_info_creation(self) -> None: - """Test ClassInfo namedtuple creation and access.""" - method = SignatureInfo(signature="method()", line=2) - inner_class = ClassInfo(name="Inner", line=3, methods=[], inner_classes=[]) - - cls = ClassInfo( - name="TestClass", - line=1, - methods=[method], - inner_classes=[inner_class] - ) - - assert cls.name == "TestClass" - assert cls.line == 1 - assert len(cls.methods) == 1 - assert cls.methods[0] == method - assert len(cls.inner_classes) == 1 - assert cls.inner_classes[0] == inner_class - - def test_comment_style_creation(self) -> None: - """Test CommentStyle namedtuple creation.""" - style = CommentStyle(start="/*", end="*/", prefix=" * ", indent=" ") - assert style.start == "/*" - assert style.end == "*/" - assert style.prefix == " * " - assert style.indent == " " - -class TestCommentStyles: - """Tests for comment style definitions.""" - - def test_all_supported_languages_have_styles(self) -> None: - """Ensure all supported languages have comment style definitions.""" - expected_languages = { - "python", "kotlin", "javascript", "typescript", "csharp", "cpp", - "c", "java", "go", "powershell", "delphi" - } - assert set(COMMENT_STYLES.keys()) == expected_languages - - @pytest.mark.parametrize("language,expected_start,expected_end,expected_prefix,expected_indent", [ - ("python", '"""', '"""', " ", " "), - ("kotlin", '/**', ' */', ' * ', " "), - ("javascript", '/**', ' */', ' * ', " "), - ("typescript", '/**', ' */', ' * ', " "), - ("csharp", '/*', ' */', ' * ', " "), - ("cpp", '/*', ' */', ' * ', " "), - ("go", '/*', ' */', ' * ', "\t"), - ]) - def test_comment_style_values( - self, - language: str, - expected_start: str, - expected_end: str, - expected_prefix: str, - expected_indent: str - ) -> None: - """Test specific comment style values for each language.""" - style = COMMENT_STYLES[language] - assert style.start == expected_start - assert style.end == expected_end - assert style.prefix == expected_prefix - assert style.indent == expected_indent - -class TestHeaderStripping: - """Tests for remove_agent_docstring function.""" - - def test_strip_python_header(self) -> None: - """Test stripping Python docstring headers.""" - content = f'''"""{DOCSTRING_START_MARKER} - - TestClass (line 5): - - method(self) (line 6) - - Functions: - - function() (line 10) -{DOCSTRING_END_MARKER}""" -class TestClass: - def method(self): - pass - -def function(): - pass''' - - expected = '''class TestClass: - def method(self): - pass - -def function(): - pass''' - - result = remove_agent_docstring(content, "python") - assert result.strip() == expected.strip() - - def test_strip_block_comment_header(self) -> None: - """Test stripping block comment headers for C-style languages.""" - content = f'''/**{DOCSTRING_START_MARKER} - * - TestClass (line 8): - * - method() (line 9) - {DOCSTRING_END_MARKER}*/ -class TestClass {{ - void method() {{}} -}}''' - - expected = '''class TestClass { - void method() {} -}''' - - for language in ["kotlin", "javascript", "typescript"]: - result = remove_agent_docstring(content, language) - assert result.strip() == expected.strip() - - def test_strip_c_style_comment_header(self) -> None: - """Test stripping C-style comment headers.""" - content = f'''/*{DOCSTRING_START_MARKER} - * - Calculator (line 6): - * - add(int, int) (line 7) - {DOCSTRING_END_MARKER}*/ -class Calculator {{ - int add(int a, int b) {{ return a + b; }} -}}''' - - expected = '''class Calculator { - int add(int a, int b) { return a + b; } -}''' - - for language in ["csharp", "cpp"]: - result = remove_agent_docstring(content, language) - assert result.strip() == expected.strip() - - def test_no_header_to_strip(self) -> None: - """Test that content without headers remains unchanged.""" - content = '''class TestClass: - def method(self): - pass''' - - result = remove_agent_docstring(content, "python") - assert result == content - - def test_preserve_shebang_when_stripping(self) -> None: - """Test that shebangs are preserved during header stripping.""" - content = f'''#!/usr/bin/env python3 -"""{DOCSTRING_START_MARKER} - - TestClass (line 6): -{DOCSTRING_END_MARKER}""" -class TestClass: - pass''' - - result = remove_agent_docstring(content, "python") - assert result.strip().startswith("#!/usr/bin/env python3") - assert "class TestClass:" in result - - def test_strip_header_with_various_whitespace(self) -> None: - """Test header stripping with different whitespace patterns.""" - base_content = f'"""{DOCSTRING_START_MARKER}\n - Test (line 4):\n{DOCSTRING_END_MARKER}"""\nclass Test: pass' - - result = remove_agent_docstring(base_content, "python") - assert DOCSTRING_START_MARKER not in result - assert "class Test: pass" in result - - def test_strip_only_first_matching_header(self) -> None: - """Test that only the first matching header is stripped.""" - content = f'''"""{DOCSTRING_START_MARKER} - - FirstClass (line 6): -{DOCSTRING_END_MARKER}""" -class FirstClass: - def method(self): - """ - This should not be stripped - """ - pass''' - - result = remove_agent_docstring(content, "python") - assert result.count(DOCSTRING_START_MARKER) == 0 - assert "This should not be stripped" in result - - def test_strip_header_edge_cases(self) -> None: - """Test edge cases in header stripping.""" - assert remove_agent_docstring("", "python") == "" - - header_only = f'"""{DOCSTRING_START_MARKER}\n - Test (line 4):\n{DOCSTRING_END_MARKER}"""' - result = remove_agent_docstring(header_only, "python") - assert result == "" - - no_newline = f'"""{DOCSTRING_START_MARKER}\n{DOCSTRING_END_MARKER}"""class Test: pass' - result = remove_agent_docstring(no_newline, "python") - assert result == "class Test: pass" - - def test_header_not_at_start(self) -> None: - """Test that headers not at the start of file are not stripped.""" - content = f'''class SomeClass: - pass - -"""{DOCSTRING_START_MARKER} - - This should not be stripped -{DOCSTRING_END_MARKER}"""''' - - result = remove_agent_docstring(content, "python") - assert "class SomeClass:" in result - assert DOCSTRING_START_MARKER in result - - @pytest.mark.parametrize("language", ["python", "kotlin", "javascript", "typescript", "csharp", "cpp"]) - def test_invalid_language_patterns(self, language: str) -> None: - invalid_contents = [ - "Classes/Functions: but not in a comment", - "/* Classes/Functions: but not closed properly", - '""" Classes/Functions: but missing closing quotes', - ] - - for content in invalid_contents: - result = remove_agent_docstring(content, language) +from __future__ import annotations + +""" + --- AUTO-GENERATED DOCSTRING --- + Table of content is automatically generated by Agent Docstrings v1.3.2 + + Classes/Functions: + - TestDataClasses (line 37): + - test_signature_info_creation() -> None (line 39) + - test_class_info_creation() -> None (line 44) + - test_comment_style_creation() -> None (line 60) + - TestCommentStyles (line 67): + - test_all_supported_languages_have_styles() -> None (line 69) + - test_comment_style_values(language: str, expected_start: str, expected_end: str, expected_prefix: str, expected_indent: str) -> None (line 85) + - TestHeaderStripping (line 99): + - test_strip_python_header() -> None (line 101) + - test_strip_block_comment_header() -> None (line 121) + - test_strip_c_style_comment_header() -> None (line 136) + - test_no_header_to_strip() -> None (line 151) + - test_preserve_shebang_when_stripping() -> None (line 158) + - test_strip_header_with_various_whitespace() -> None (line 169) + - test_strip_only_first_matching_header() -> None (line 175) + - test_strip_header_edge_cases() -> None (line 189) + - test_header_not_at_start() -> None (line 198) + - test_invalid_language_patterns(language: str) -> None (line 209) + --- END AUTO-GENERATED DOCSTRING --- +Tests for agent_docstrings.languages.common module. +""" +import pytest +from agent_docstrings.languages.common import ( + COMMENT_STYLES, + ClassInfo, + SignatureInfo, + CommentStyle, + remove_agent_docstring, + DOCSTRING_START_MARKER, + DOCSTRING_END_MARKER, +) + +class TestDataClasses: + """Tests for data classes used in parsing.""" + + def test_signature_info_creation(self) -> None: + """Test SignatureInfo namedtuple creation and access.""" + sig = SignatureInfo(signature="test_function(param: str) -> int", line=42) + assert sig.signature == "test_function(param: str) -> int" + assert sig.line == 42 + + def test_class_info_creation(self) -> None: + """Test ClassInfo namedtuple creation and access.""" + method = SignatureInfo(signature="method()", line=2) + inner_class = ClassInfo(name="Inner", line=3, methods=[], inner_classes=[]) + + cls = ClassInfo( + name="TestClass", + line=1, + methods=[method], + inner_classes=[inner_class] + ) + + assert cls.name == "TestClass" + assert cls.line == 1 + assert len(cls.methods) == 1 + assert cls.methods[0] == method + assert len(cls.inner_classes) == 1 + assert cls.inner_classes[0] == inner_class + + def test_comment_style_creation(self) -> None: + """Test CommentStyle namedtuple creation.""" + style = CommentStyle(start="/*", end="*/", prefix=" * ", indent=" ") + assert style.start == "/*" + assert style.end == "*/" + assert style.prefix == " * " + assert style.indent == " " + +class TestCommentStyles: + """Tests for comment style definitions.""" + + def test_all_supported_languages_have_styles(self) -> None: + """Ensure all supported languages have comment style definitions.""" + expected_languages = { + "python", "kotlin", "javascript", "typescript", "csharp", "cpp", + "c", "java", "go", "powershell", "delphi" + } + assert set(COMMENT_STYLES.keys()) == expected_languages + + @pytest.mark.parametrize("language,expected_start,expected_end,expected_prefix,expected_indent", [ + ("python", '"""', '"""', " ", " "), + ("kotlin", '/**', ' */', ' * ', " "), + ("javascript", '/**', ' */', ' * ', " "), + ("typescript", '/**', ' */', ' * ', " "), + ("csharp", '/*', ' */', ' * ', " "), + ("cpp", '/*', ' */', ' * ', " "), + ("go", '/*', ' */', ' * ', "\t"), + ]) + def test_comment_style_values( + self, + language: str, + expected_start: str, + expected_end: str, + expected_prefix: str, + expected_indent: str + ) -> None: + """Test specific comment style values for each language.""" + style = COMMENT_STYLES[language] + assert style.start == expected_start + assert style.end == expected_end + assert style.prefix == expected_prefix + assert style.indent == expected_indent + +class TestHeaderStripping: + """Tests for remove_agent_docstring function.""" + + def test_strip_python_header(self) -> None: + """Test stripping Python docstring headers.""" + content = f'''"""{DOCSTRING_START_MARKER} + - TestClass (line 5): + - method(self) (line 6) + - Functions: + - function() (line 10) +{DOCSTRING_END_MARKER}""" +class TestClass: + def method(self): + pass + +def function(): + pass''' + + expected = '''class TestClass: + def method(self): + pass + +def function(): + pass''' + + result = remove_agent_docstring(content, "python") + assert result.strip() == expected.strip() + + def test_strip_block_comment_header(self) -> None: + """Test stripping block comment headers for C-style languages.""" + content = f'''/**{DOCSTRING_START_MARKER} + * - TestClass (line 8): + * - method() (line 9) + {DOCSTRING_END_MARKER}*/ +class TestClass {{ + void method() {{}} +}}''' + + expected = '''class TestClass { + void method() {} +}''' + + for language in ["kotlin", "javascript", "typescript"]: + result = remove_agent_docstring(content, language) + assert result.strip() == expected.strip() + + def test_strip_c_style_comment_header(self) -> None: + """Test stripping C-style comment headers.""" + content = f'''/*{DOCSTRING_START_MARKER} + * - Calculator (line 6): + * - add(int, int) (line 7) + {DOCSTRING_END_MARKER}*/ +class Calculator {{ + int add(int a, int b) {{ return a + b; }} +}}''' + + expected = '''class Calculator { + int add(int a, int b) { return a + b; } +}''' + + for language in ["csharp", "cpp"]: + result = remove_agent_docstring(content, language) + assert result.strip() == expected.strip() + + def test_no_header_to_strip(self) -> None: + """Test that content without headers remains unchanged.""" + content = '''class TestClass: + def method(self): + pass''' + + result = remove_agent_docstring(content, "python") + assert result == content + + def test_preserve_shebang_when_stripping(self) -> None: + """Test that shebangs are preserved during header stripping.""" + content = f'''#!/usr/bin/env python3 +"""{DOCSTRING_START_MARKER} + - TestClass (line 6): +{DOCSTRING_END_MARKER}""" +class TestClass: + pass''' + + result = remove_agent_docstring(content, "python") + assert result.strip().startswith("#!/usr/bin/env python3") + assert "class TestClass:" in result + + def test_strip_header_with_various_whitespace(self) -> None: + """Test header stripping with different whitespace patterns.""" + base_content = f'"""{DOCSTRING_START_MARKER}\n - Test (line 4):\n{DOCSTRING_END_MARKER}"""\nclass Test: pass' + + result = remove_agent_docstring(base_content, "python") + assert DOCSTRING_START_MARKER not in result + assert "class Test: pass" in result + + def test_strip_only_first_matching_header(self) -> None: + """Test that only the first matching header is stripped.""" + content = f'''"""{DOCSTRING_START_MARKER} + - FirstClass (line 6): +{DOCSTRING_END_MARKER}""" +class FirstClass: + def method(self): + """ + This should not be stripped + """ + pass''' + + result = remove_agent_docstring(content, "python") + assert result.count(DOCSTRING_START_MARKER) == 0 + assert "This should not be stripped" in result + + def test_strip_header_edge_cases(self) -> None: + """Test edge cases in header stripping.""" + assert remove_agent_docstring("", "python") == "" + + header_only = f'"""{DOCSTRING_START_MARKER}\n - Test (line 4):\n{DOCSTRING_END_MARKER}"""' + result = remove_agent_docstring(header_only, "python") + assert result == "" + + no_newline = f'"""{DOCSTRING_START_MARKER}\n{DOCSTRING_END_MARKER}"""class Test: pass' + result = remove_agent_docstring(no_newline, "python") + assert result == "class Test: pass" + + def test_header_not_at_start(self) -> None: + """Test that agent docstrings anywhere in the file are stripped.""" + content = f'''class SomeClass: + pass + +"""{DOCSTRING_START_MARKER} + - This should be stripped +{DOCSTRING_END_MARKER}"""''' + + result = remove_agent_docstring(content, "python") + assert "class SomeClass:" in result + assert DOCSTRING_START_MARKER not in result + + @pytest.mark.parametrize("language", ["python", "kotlin", "javascript", "typescript", "csharp", "cpp"]) + def test_invalid_language_patterns(self, language: str) -> None: + invalid_contents = [ + "Classes/Functions: but not in a comment", + "/* Classes/Functions: but not closed properly", + '""" Classes/Functions: but missing closing quotes', + ] + + for content in invalid_contents: + result = remove_agent_docstring(content, language) assert result == content \ No newline at end of file diff --git a/tests/test_determinism.py b/tests/test_determinism.py index 0b72f15..acbb05f 100644 --- a/tests/test_determinism.py +++ b/tests/test_determinism.py @@ -38,6 +38,24 @@ def test_process_file_determinism_with_manual_python_docstring(tmp_path): assert content_after_first_run == content_after_second_run +def test_file_is_unchanged_if_no_docstring_added(tmp_path): + """ + Tests that a Python file with only a manual docstring and no classes/functions + is not modified by the agent. + """ + original_content = '"""This is a manual docstring."""\\n' + test_file_path = tmp_path / "test.py" + test_file_path.write_text(original_content, encoding="utf-8") + + # Run processing + process_file(test_file_path) + content_after_run = test_file_path.read_text(encoding="utf-8") + + # Assert that the file content is identical + assert original_content == content_after_run, \ + "File should not be modified if no agent docstring is added." + + def test_process_file_determinism(sample_files_by_language): """ Processes each sample file three times and asserts that after the first processing, diff --git a/tests/test_docstring_duplication.py b/tests/test_docstring_duplication.py index e8440e2..023516a 100644 --- a/tests/test_docstring_duplication.py +++ b/tests/test_docstring_duplication.py @@ -1,171 +1,171 @@ -""" - --- AUTO-GENERATED DOCSTRING --- - Table of content is automatically generated by Agent Docstrings v1.3.2 - - Classes/Functions: - - test_no_docstring_duplication_on_repeated_runs(source_processor) -> None (line 15) - - test_manual_docstring_preservation_with_auto_generation(source_processor) -> None (line 56) - - test_existing_auto_docstring_replacement(source_processor) -> None (line 100) - - test_multiple_auto_docstring_removal(source_processor) -> None (line 135) - --- END AUTO-GENERATED DOCSTRING --- -""" -import pytest -import re -from textwrap import dedent -from agent_docstrings import __version__ - -def test_no_docstring_duplication_on_repeated_runs(source_processor) -> None: - """ - Test that running the docstring generator multiple times on the same file - does not create duplicate auto-generated docstrings. - This test simulates the scenario where a file with manual docstring - gets processed multiple times, ensuring no double docstrings are created. - """ - # * Initial file with manual docstring - initial_content = dedent(''' - """ - Human comments - This is a manual docstring that should be preserved. - """ - def test_function(): - """This is a function docstring.""" - return "test" - class TestClass: - def method(self): - return "method" - ''').strip() - # * First run - should generate auto docstring and merge with manual - result_content_1, lines_1, _ = source_processor("test_duplication.py", initial_content) - # * Verify that auto-generated docstring was added - assert "--- AUTO-GENERATED DOCSTRING ---" in result_content_1 - assert "Human comments" in result_content_1 # Manual content preserved - assert "test_function()" in result_content_1 # Auto-generated content added - # * Count auto-generated docstring markers - auto_markers_1 = result_content_1.count("--- AUTO-GENERATED DOCSTRING ---") - assert auto_markers_1 == 1, f"Expected 1 auto docstring marker, found {auto_markers_1}" - # * Second run - should not create duplicate auto docstrings - result_content_2, lines_2, _ = source_processor("test_duplication.py", result_content_1) - # * Verify no duplication occurred - auto_markers_2 = result_content_2.count("--- AUTO-GENERATED DOCSTRING ---") - assert auto_markers_2 == 1, f"Expected 1 auto docstring marker after second run, found {auto_markers_2}" - # * Verify manual content is still preserved - assert "Human comments" in result_content_2 - assert "This is a manual docstring that should be preserved." in result_content_2 - # * Verify auto-generated content is still present - assert "test_function()" in result_content_2 - assert "TestClass" in result_content_2 - assert "method()" in result_content_2 - -def test_manual_docstring_preservation_with_auto_generation(source_processor) -> None: - """ - Test that manual docstrings are properly preserved when auto-generating - docstrings, and that the structure is correct. - """ - # * File with manual docstring only - initial_content = dedent(''' - """ - This is a manual module docstring. - It should be preserved and merged with auto-generated content. - """ - def function_one(): - pass - def function_two(): - pass - ''').strip() - result_content, lines, _ = source_processor("test_manual_preservation.py", initial_content) - # * Verify structure: manual content should come after auto-generated content - lines_list = result_content.split('\n') - # * Find the docstring boundaries - docstring_start = None - docstring_end = None - manual_content_found = False - for i, line in enumerate(lines_list): - if line.strip() == '"""' and docstring_start is None: - docstring_start = i - elif line.strip() == '"""' and docstring_start is not None: - docstring_end = i - break - assert docstring_start is not None, "Docstring start not found" - assert docstring_end is not None, "Docstring end not found" - # * Extract docstring content - docstring_content = lines_list[docstring_start:docstring_end + 1] - docstring_text = '\n'.join(docstring_content) - # * Verify auto-generated content is first - assert "--- AUTO-GENERATED DOCSTRING ---" in docstring_text - assert "function_one()" in docstring_text - assert "function_two()" in docstring_text - # * Verify manual content is preserved - assert "This is a manual module docstring." in docstring_text - assert "It should be preserved and merged with auto-generated content." in docstring_text - # * Verify only one docstring block exists - docstring_blocks = result_content.count('"""') - assert docstring_blocks == 2, f"Expected 2 triple quotes (start and end), found {docstring_blocks}" - -def test_existing_auto_docstring_replacement(source_processor) -> None: - """ - Test that existing auto-generated docstrings are properly replaced - when the file is processed again. - """ - # * File with existing auto-generated docstring - initial_content = dedent(''' - """ - --- AUTO-GENERATED DOCSTRING --- - Table of content is automatically generated by Agent Docstrings v1.3.1 - Classes/Functions: - - old_function() (line 8) - --- END AUTO-GENERATED DOCSTRING --- - """ - def old_function(): - pass - def new_function(): - pass - ''').strip() - result_content, lines, _ = source_processor("test_replacement.py", initial_content) - # * Find the docstring in the result - docstring_match = re.search(r'"""[\s\S]*?"""', result_content) - assert docstring_match, "Could not find docstring in processed file" - docstring_text = docstring_match.group(0) - # * Verify new content is in the docstring - assert "old_function()" in docstring_text - assert "new_function()" in docstring_text - # * Verify only one auto-generated docstring exists in the whole file - auto_markers = result_content.count("--- AUTO-GENERATED DOCSTRING ---") - assert auto_markers == 1, f"Expected 1 auto docstring marker, found {auto_markers}" - # * Verify the version is updated in the docstring - assert f"Agent Docstrings v{__version__}" in docstring_text - # * Verify that old_function is mentioned only once *within the docstring* - assert docstring_text.count("old_function()") == 1, "Function should appear only once in docstring" - assert docstring_text.count("new_function()") == 1, "Function should appear only once in docstring" - -def test_multiple_auto_docstring_removal(source_processor) -> None: - """ - Test that multiple auto-generated docstrings are properly removed - and replaced with a single one. - """ - # * File with multiple auto-generated docstrings (simulating a bug) - initial_content = dedent(''' - """ - --- AUTO-GENERATED DOCSTRING --- - Table of content is automatically generated by Agent Docstrings v1.3.1 - --- END AUTO-GENERATED DOCSTRING --- - """ - """ - --- AUTO-GENERATED DOCSTRING --- - Table of content is automatically generated by Agent Docstrings v1.3.2 - --- END AUTO-GENERATED DOCSTRING --- - Human comments - """ - def test_function(): - return "test" - ''').strip() - result_content, lines, _ = source_processor("test_multiple_removal.py", initial_content) - # * Verify only one auto-generated docstring exists - auto_markers = result_content.count("--- AUTO-GENERATED DOCSTRING ---") - assert auto_markers == 1, f"Expected 1 auto docstring marker, found {auto_markers}" - # * Verify manual content is preserved - assert "Human comments" in result_content - # * Verify function is documented - assert "test_function()" in result_content - # * Verify that there is only one docstring block in the final output - docstring_blocks = re.findall(r'"""[\s\S]*?"""', result_content) +""" + --- AUTO-GENERATED DOCSTRING --- + Table of content is automatically generated by Agent Docstrings v1.3.2 + + Classes/Functions: + - test_no_docstring_duplication_on_repeated_runs(source_processor) -> None (line 15) + - test_manual_docstring_preservation_with_auto_generation(source_processor) -> None (line 56) + - test_existing_auto_docstring_replacement(source_processor) -> None (line 100) + - test_multiple_auto_docstring_removal(source_processor) -> None (line 135) + --- END AUTO-GENERATED DOCSTRING --- +""" +import pytest +import re +from textwrap import dedent +from agent_docstrings import __version__ + +def test_no_docstring_duplication_on_repeated_runs(source_processor) -> None: + """ + Test that running the docstring generator multiple times on the same file + does not create duplicate auto-generated docstrings. + This test simulates the scenario where a file with manual docstring + gets processed multiple times, ensuring no double docstrings are created. + """ + # * Initial file with manual docstring + initial_content = dedent(''' + """ + Human comments + This is a manual docstring that should be preserved. + """ + def test_function(): + """This is a function docstring.""" + return "test" + class TestClass: + def method(self): + return "method" + ''').strip() + # * First run - should generate auto docstring and merge with manual + result_content_1, lines_1, _ = source_processor("test_duplication.py", initial_content) + # * Verify that auto-generated docstring was added + assert "--- AUTO-GENERATED DOCSTRING ---" in result_content_1 + assert "Human comments" in result_content_1 # Manual content preserved + assert "test_function()" in result_content_1 # Auto-generated content added + # * Count auto-generated docstring markers + auto_markers_1 = result_content_1.count("--- AUTO-GENERATED DOCSTRING ---") + assert auto_markers_1 == 1, f"Expected 1 auto docstring marker, found {auto_markers_1}" + # * Second run - should not create duplicate auto docstrings + result_content_2, lines_2, _ = source_processor("test_duplication.py", result_content_1) + # * Verify no duplication occurred + auto_markers_2 = result_content_2.count("--- AUTO-GENERATED DOCSTRING ---") + assert auto_markers_2 == 1, f"Expected 1 auto docstring marker after second run, found {auto_markers_2}" + # * Verify manual content is still preserved + assert "Human comments" in result_content_2 + assert "This is a manual docstring that should be preserved." in result_content_2 + # * Verify auto-generated content is still present + assert "test_function()" in result_content_2 + assert "TestClass" in result_content_2 + assert "method()" in result_content_2 + +def test_manual_docstring_preservation_with_auto_generation(source_processor) -> None: + """ + Test that manual docstrings are properly preserved when auto-generating + docstrings, and that the structure is correct. + """ + # * File with manual docstring only + initial_content = dedent(''' + """ + This is a manual module docstring. + It should be preserved and merged with auto-generated content. + """ + def function_one(): + pass + def function_two(): + pass + ''').strip() + result_content, lines, _ = source_processor("test_manual_preservation.py", initial_content) + # * Verify structure: manual content should come after auto-generated content + lines_list = result_content.split('\n') + # * Find the docstring boundaries + docstring_start = None + docstring_end = None + manual_content_found = False + for i, line in enumerate(lines_list): + if line.strip() == '"""' and docstring_start is None: + docstring_start = i + elif line.strip() == '"""' and docstring_start is not None: + docstring_end = i + break + assert docstring_start is not None, "Docstring start not found" + assert docstring_end is not None, "Docstring end not found" + # * Extract docstring content + docstring_content = lines_list[docstring_start:docstring_end + 1] + docstring_text = '\n'.join(docstring_content) + # * Verify auto-generated content is first + assert "--- AUTO-GENERATED DOCSTRING ---" in docstring_text + assert "function_one()" in docstring_text + assert "function_two()" in docstring_text + # * Verify manual content is preserved + assert "This is a manual module docstring." in docstring_text + assert "It should be preserved and merged with auto-generated content." in docstring_text + # * Verify only one docstring block exists + docstring_blocks = result_content.count('"""') + assert docstring_blocks == 2, f"Expected 2 triple quotes (start and end), found {docstring_blocks}" + +def test_existing_auto_docstring_replacement(source_processor) -> None: + """ + Test that existing auto-generated docstrings are properly replaced + when the file is processed again. + """ + # * File with existing auto-generated docstring + initial_content = dedent(''' + """ + --- AUTO-GENERATED DOCSTRING --- + Table of content is automatically generated by Agent Docstrings v1.3.1 + Classes/Functions: + - old_function() (line 8) + --- END AUTO-GENERATED DOCSTRING --- + """ + def old_function(): + pass + def new_function(): + pass + ''').strip() + result_content, lines, _ = source_processor("test_replacement.py", initial_content) + # * Find the docstring in the result + docstring_match = re.search(r'"""[\s\S]*?"""', result_content) + assert docstring_match, "Could not find docstring in processed file" + docstring_text = docstring_match.group(0) + # * Verify new content is in the docstring + assert "old_function()" in docstring_text + assert "new_function()" in docstring_text + # * Verify only one auto-generated docstring exists in the whole file + auto_markers = result_content.count("--- AUTO-GENERATED DOCSTRING ---") + assert auto_markers == 1, f"Expected 1 auto docstring marker, found {auto_markers}" + # * Verify the version is updated in the docstring + assert f"Agent Docstrings v{__version__}" in docstring_text + # * Verify that old_function is mentioned only once *within the docstring* + assert docstring_text.count("old_function()") == 1, "Function should appear only once in docstring" + assert docstring_text.count("new_function()") == 1, "Function should appear only once in docstring" + +def test_multiple_auto_docstring_removal(source_processor) -> None: + """ + Test that multiple auto-generated docstrings are properly removed + and replaced with a single one. + """ + # * File with multiple auto-generated docstrings (simulating a bug) + initial_content = dedent(''' + """ + --- AUTO-GENERATED DOCSTRING --- + Table of content is automatically generated by Agent Docstrings v1.3.1 + --- END AUTO-GENERATED DOCSTRING --- + """ + """ + --- AUTO-GENERATED DOCSTRING --- + Table of content is automatically generated by Agent Docstrings v1.3.2 + --- END AUTO-GENERATED DOCSTRING --- + Human comments + """ + def test_function(): + return "test" + ''').strip() + result_content, lines, _ = source_processor("test_multiple_removal.py", initial_content) + # * Verify only one auto-generated docstring exists + auto_markers = result_content.count("--- AUTO-GENERATED DOCSTRING ---") + assert auto_markers == 1, f"Expected 1 auto docstring marker, found {auto_markers}" + # * Verify manual content is preserved + assert "Human comments" in result_content + # * Verify function is documented + assert "test_function()" in result_content + # * Verify that there is only one docstring block in the final output + docstring_blocks = re.findall(r'"""[\s\S]*?"""', result_content) assert len(docstring_blocks) == 1, f"Expected 1 docstring block, found {len(docstring_blocks)}" \ No newline at end of file diff --git a/tests/test_version_change.py b/tests/test_version_change.py new file mode 100644 index 0000000..a1276bb --- /dev/null +++ b/tests/test_version_change.py @@ -0,0 +1,73 @@ +""" +Tests to ensure that files are not reprocessed when only the version has changed. +""" +import shutil +from pathlib import Path + +from agent_docstrings.core import process_file +from agent_docstrings import __version__ + + +def test_no_change_on_version_mismatch(tmp_path: Path): + """ + Verify that reprocessing a file with only a version difference + in the docstring does not result in a file modification. + """ + # 1. Create a temporary python file + source_content = ( + "def func_one():\n" + " pass\n" + "\n" + "class MyClass:\n" + " def method_one(self):\n" + " pass\n" + ) + py_file = tmp_path / "test_version.py" + py_file.write_text(source_content, encoding="utf-8") + + # 2. Process it once to generate the initial docstring + process_file(py_file) + content_after_first_run = py_file.read_text(encoding="utf-8") + assert __version__ in content_after_first_run + + # 3. Manually change the version in the header to an old one + old_version_content = content_after_first_run.replace( + f"v{__version__}", "v0.0.1" + ) + py_file.write_text(old_version_content, encoding="utf-8") + + # 4. Process the file again + process_file(py_file) + content_after_second_run = py_file.read_text(encoding="utf-8") + + # 5. Assert the file content has NOT changed + assert content_after_second_run == old_version_content + assert f"v{__version__}" not in content_after_second_run + assert "v0.0.1" in content_after_second_run + + # 6. Now, modify the structure of the file + new_source_content = ( + source_content + "\n\ndef func_two():\n pass\n" + ) + + # Add the old docstring back to simulate a real-world scenario + # where an old file is being updated. + # Get the docstring from the first run, but with the old version + docstring_end_index = content_after_first_run.rfind('"""') + 3 + docstring_from_first_run = content_after_first_run[:docstring_end_index] + + old_version_docstring = docstring_from_first_run.replace(f"v{__version__}", "v0.0.1") + + # Combine the old docstring with the NEW code + content_with_new_code_old_doc = old_version_docstring + "\n" + new_source_content + py_file.write_text(content_with_new_code_old_doc, encoding="utf-8") + + # 7. Process it again + process_file(py_file) + content_after_third_run = py_file.read_text(encoding="utf-8") + + # 8. Assert the file HAS been updated with the new structure and version + assert content_after_third_run != content_with_new_code_old_doc + assert f"v{__version__}" in content_after_third_run + assert "v0.0.1" not in content_after_third_run + assert "func_two" in content_after_third_run \ No newline at end of file diff --git a/tests/test_whitespace_preservation.py b/tests/test_whitespace_preservation.py index f4b1c64..b313ead 100644 --- a/tests/test_whitespace_preservation.py +++ b/tests/test_whitespace_preservation.py @@ -32,4 +32,21 @@ def top_level_function(): # Note: The exact number of newlines might differ slightly based on how the # docstring is inserted, so we check for at least two newlines. assert "pass\n\n\ndef" in cleaned_result, \ - f"Expected blank lines to be preserved. Cleaned result:\n{cleaned_result}" \ No newline at end of file + f"Expected blank lines to be preserved. Cleaned result:\n{cleaned_result}" + +def test_trailing_whitespace_is_preserved(source_processor) -> None: + """ + Verifies that trailing newlines at the end of a file are not removed. + """ + initial_content = dedent(''' + class FirstClass: + pass + ''') + "\\n\\n" # Add trailing newlines that dedent would strip + + result_content, _, _ = source_processor("whitespace_test.py", initial_content) + + from agent_docstrings.languages.common import remove_agent_docstring + cleaned_result = remove_agent_docstring(result_content, 'python') + + assert cleaned_result.endswith("\\n\\n"), \ + f"Expected trailing newlines to be preserved. Cleaned result ends with: {repr(cleaned_result[-5:])}" \ No newline at end of file