diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ee788e0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,101 @@ +# Project Context for Agents + +## Project Overview +This is a Python project that implements an "End Of File Fixer" - a tool that ensures files end with exactly one newline character. The tool scans files in a directory and automatically adds or removes newlines at the end of files to maintain consistent formatting. + +Despite the project name, the description in `pyproject.toml` mentions "Implementations of the Circuit Breaker" which appears to be incorrect or outdated. + +### Key Technologies +- **Language**: Python 3.10+ +- **Dependencies**: + - `pathspec` for gitignore pattern matching +- **Development Tools**: + - `uv` for package management and building + - `ruff` for linting and formatting + - `mypy` for type checking + - `pytest` for testing + - `just` as a command runner + +### Architecture +The project follows a simple CLI tool structure: +- Main entry point: `end_of_file_fixer/main.py` +- CLI interface using `argparse` +- File processing logic in `_fix_file()` function +- Integration with `.gitignore` patterns via `pathspec` + +## Building and Running + +### Setup +```bash +# Install dependencies +just install +``` + +### Development Commands +```bash +# Run linting and type checking +just lint + +# Run tests +just test + +# Run tests with arguments +just test -v + +# Format code +just lint # Includes auto-formatting +``` + +### Using the Tool +```bash +# Fix files in a directory (modifies files) +python -m end_of_file_fixer.main /path/to/directory + +# Check files in a directory (dry run) +python -m end_of_file_fixer.main /path/to/directory --check +``` + +Or using the installed script: +```bash +# Fix files +end-of-file-fixer /path/to/directory + +# Check files +end-of-file-fixer /path/to/directory --check +``` + +## Development Conventions + +### Code Style +- Line length: 120 characters +- Strict type checking with mypy +- Ruff linting with specific rule exceptions (see pyproject.toml) +- No mandatory docstrings (D1 ignored) + +### Testing +- Uses pytest framework +- Tests should be placed in the `tests/` directory +- Follow standard pytest naming conventions (`test_*.py` files, `test_*` functions) + +### Project Structure +``` +end-of-file-fixer/ +├── end_of_file_fixer/ # Main package +│ ├── __init__.py # Package initializer +│ └── main.py # Main CLI implementation +├── tests/ # Test files +│ └── test_dummy.py # Sample test file +├── pyproject.toml # Project configuration +├── Justfile # Command definitions +├── README.md # Project description +└── .gitignore # Git ignore patterns +``` + +### Dependency Management +- Uses `uv` for fast dependency resolution and installation +- Dependencies defined in `pyproject.toml` +- Development dependencies in `[dependency-groups].dev` + +### CI/CD +- Linting and type checking enforced in CI +- Publishing handled via `just publish` command diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a176c1b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 modern-python + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 6d0a59c..97c0fe4 100644 --- a/README.md +++ b/README.md @@ -1 +1,130 @@ # End Of File Fixer + +A command-line tool that ensures all your text files end with exactly one newline character. +This tool helps maintain consistent file formatting across your codebase by automatically adding or removing trailing newlines as needed. + +## Why This Matters + +Many POSIX systems expect text files to end with a newline character. Having consistent line endings: +- Prevents spurious diffs in version control +- Ensures proper concatenation of files +- Satisfies POSIX compliance +- Improves readability in terminal environments + +## Features + +- Automatically adds a newline to files that don't end with one +- Removes excess trailing newlines from files that have too many +- Respects `.gitignore` patterns to avoid processing unwanted files +- Works with all text file types +- Cross-platform compatibility (Windows, macOS, Linux) +- Dry-run mode to preview changes before applying them + +## Installation + +### Using uv + +```bash +uv add end-of-file-fixer +``` + +### Using pip + +```bash +pip install end-of-file-fixer +``` + +## Usage + +### Basic Usage + +To fix all files in the current directory and subdirectories: + +```bash +end-of-file-fixer . +``` + +To check which files would be modified without making changes: + +```bash +end-of-file-fixer . --check +``` + +## How It Works + +The end-of-file-fixer processes files in the following way: + +1. **Files with no trailing newline**: Adds exactly one newline at the end +2. **Files with exactly one trailing newline**: Leaves unchanged +3. **Files with multiple trailing newlines**: Truncates to exactly one newline +4. **Empty files**: Left unchanged + +### Examples + +| Original File Content | After Processing | +|----------------------|------------------| +| `hello world` | `hello world\n` | +| `hello world\n` | `hello world\n` | +| `hello world\n\n\n` | `hello world\n` | +| `` (empty file) | `` (unchanged) | + +## Configuration + +The tool automatically respects patterns in your `.gitignore` file, so it won't process files that are ignored by Git. Additionally, it always ignores: +- `.git` directories +- `.cache` directories (used by uv) + +## Exit Codes + +- `0`: No files needed fixing or all files were successfully fixed +- `1`: Some files needed fixing (when using `--check` mode) + +## Development + +### Prerequisites + +- [uv](https://docs.astral.sh/uv/) for dependency management + +### Setup + +```bash +# Clone the repository +git clone https://github.com/community-of-python/end-of-file-fixer.git +cd end-of-file-fixer + +# Install dependencies +just install +``` + +### Running Tests + +```bash +# Run tests +just test +``` + +### Linting + +```bash +# Run linting and formatting +just lint +``` + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/AmazingFeature`) +3. Commit your changes (`git commit -m 'Add some amazing feature'`) +4. Push to the branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Related Projects + +- [pre-commit](https://pre-commit.com/) - A framework for managing and maintaining multi-language pre-commit hooks +- [editorconfig](https://editorconfig.org/) - Helps maintain consistent coding styles across different editors and IDEs diff --git a/end_of_file_fixer/main.py b/end_of_file_fixer/main.py index 9c20848..d75a61c 100644 --- a/end_of_file_fixer/main.py +++ b/end_of_file_fixer/main.py @@ -7,7 +7,7 @@ import pathspec -def _fix_file(file_obj: IO[bytes]) -> int: +def _fix_file(file_obj: IO[bytes], check: bool) -> int: # noqa: C901 # Test for newline at end of file # Empty files will throw IOError here try: @@ -18,18 +18,20 @@ def _fix_file(file_obj: IO[bytes]) -> int: last_character = file_obj.read(1) # last_character will be '' for an empty file if last_character not in {b"\n", b"\r"} and last_character != b"": - # Needs this seek for windows, otherwise IOError - file_obj.seek(0, os.SEEK_END) - file_obj.write(b"\n") + if not check: + # Needs this seek for windows, otherwise IOError + file_obj.seek(0, os.SEEK_END) + file_obj.write(b"\n") return 1 while last_character in {b"\n", b"\r"}: # Deal with the beginning of the file if file_obj.tell() == 1: - # If we've reached the beginning of the file and it is all - # linebreaks then we can make this file empty - file_obj.seek(0) - file_obj.truncate() + if not check: + # If we've reached the beginning of the file and it is all + # linebreaks then we can make this file empty + file_obj.seek(0) + file_obj.truncate() return 1 # Go back two bytes and read a character @@ -43,20 +45,24 @@ def _fix_file(file_obj: IO[bytes]) -> int: for sequence in (b"\n", b"\r\n", b"\r"): if remaining == sequence: return 0 + if remaining.startswith(sequence): - file_obj.seek(position + len(sequence)) - file_obj.truncate() + if not check: + file_obj.seek(position + len(sequence)) + file_obj.truncate() return 1 - return 0 + return 0 # pragma: no cover def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("path", help="path to directory", type=pathlib.Path) + parser.add_argument("--check", action="store_true") args = parser.parse_args() path: pathlib.Path = args.path + check: bool = args.check gitignore_path = path / ".gitignore" ignore_patterns = [ @@ -72,7 +78,7 @@ def main() -> int: retv = 0 for filename in gitignore_spec.match_tree(path, negate=True): with pathlib.Path(filename).open("rb+") as f: - ret_for_file = _fix_file(f) + ret_for_file = _fix_file(f, check=check) if ret_for_file: sys.stdout.write(f"Fixing {filename}") retv |= ret_for_file diff --git a/pyproject.toml b/pyproject.toml index 3a2d655..a4dfd64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "end-of-file-fixer" -description = "Implementations of the Circuit Breaker" +description = "A command-line tool that ensures all your text files end with exactly one newline character" readme = "README.md" requires-python = ">=3.10,<4" dependencies = [ diff --git a/tests/fixtures/empty.txt b/tests/fixtures/empty.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/multiple_newlines.txt b/tests/fixtures/multiple_newlines.txt new file mode 100644 index 0000000..1b82957 --- /dev/null +++ b/tests/fixtures/multiple_newlines.txt @@ -0,0 +1,2 @@ +This file has multiple newlines at the end + diff --git a/tests/fixtures/newlines_only.txt b/tests/fixtures/newlines_only.txt new file mode 100644 index 0000000..b28b04f --- /dev/null +++ b/tests/fixtures/newlines_only.txt @@ -0,0 +1,3 @@ + + + diff --git a/tests/fixtures/no_newline.txt b/tests/fixtures/no_newline.txt new file mode 100644 index 0000000..e91be77 --- /dev/null +++ b/tests/fixtures/no_newline.txt @@ -0,0 +1 @@ +This file has no newline at the end \ No newline at end of file diff --git a/tests/fixtures/one_newline.txt b/tests/fixtures/one_newline.txt new file mode 100644 index 0000000..e771aed --- /dev/null +++ b/tests/fixtures/one_newline.txt @@ -0,0 +1 @@ +This file has exactly one newline at the end diff --git a/tests/fixtures/perfect.txt b/tests/fixtures/perfect.txt new file mode 100644 index 0000000..7cb062f --- /dev/null +++ b/tests/fixtures/perfect.txt @@ -0,0 +1 @@ +This file already has exactly one newline at the end diff --git a/tests/fixtures/temp_file.tmp b/tests/fixtures/temp_file.tmp new file mode 100644 index 0000000..ae0e930 --- /dev/null +++ b/tests/fixtures/temp_file.tmp @@ -0,0 +1 @@ +This is a temporary file that should be ignored \ No newline at end of file diff --git a/tests/test_dummy.py b/tests/test_dummy.py deleted file mode 100644 index 3fb8a97..0000000 --- a/tests/test_dummy.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_dummy() -> None: - assert True diff --git a/tests/test_end_of_file_fixer.py b/tests/test_end_of_file_fixer.py new file mode 100644 index 0000000..44ad6cb --- /dev/null +++ b/tests/test_end_of_file_fixer.py @@ -0,0 +1,190 @@ +import os +import shutil +import sys +import tempfile +from io import StringIO +from pathlib import Path + +from end_of_file_fixer.main import main + + +def test_end_of_file_fixer_command_with_check_false() -> None: + # Create a temporary directory with test files + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Copy fixture files to temp directory + fixtures_dir = Path(__file__).parent / "fixtures" + for fixture_file in fixtures_dir.glob("*.txt"): + shutil.copy(fixture_file, temp_path / fixture_file.name) + + # Change to the temp directory and capture stdout + original_cwd = Path.cwd() + original_argv = sys.argv + original_stdout = sys.stdout + captured_output = StringIO() + + try: + os.chdir(temp_dir) + sys.argv = ["end-of-file-fixer", "."] + sys.stdout = captured_output + + # Run end-of-file-fixer . command + result = main() + + # Should exit with code 1 (since some files needed fixing) + assert result == 1 + + # Should output which files are being fixed + output = captured_output.getvalue() + assert "no_newline.txt" in output + assert "multiple_newlines.txt" in output + assert "newlines_only.txt" in output + + finally: + os.chdir(original_cwd) + sys.argv = original_argv + sys.stdout = original_stdout + + # Check that files were fixed correctly + # File with no newline should now have one + no_newline_content = (temp_path / "no_newline.txt").read_text() + assert no_newline_content == "This file has no newline at the end\n" + + # File with one newline should be unchanged + one_newline_content = (temp_path / "one_newline.txt").read_text() + assert one_newline_content == "This file has exactly one newline at the end\n" + + # File with multiple newlines should be truncated to one + multiple_newlines_content = (temp_path / "multiple_newlines.txt").read_text() + assert multiple_newlines_content == "This file has multiple newlines at the end\n" + + # File with only newlines should be made empty + newlines_only_content = (temp_path / "newlines_only.txt").read_text() + assert newlines_only_content == "" + + # Perfect file should be unchanged + perfect_content = (temp_path / "perfect.txt").read_text() + assert perfect_content == "This file already has exactly one newline at the end\n" + + # Empty file should remain empty + empty_content = (temp_path / "empty.txt").read_text() + assert empty_content == "" + + +def test_end_of_file_fixer_command_with_check_true() -> None: + # Create a temporary directory with test files + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Copy fixture files to temp directory + fixtures_dir = Path(__file__).parent / "fixtures" + for fixture_file in fixtures_dir.glob("*.txt"): + shutil.copy(fixture_file, temp_path / fixture_file.name) + + # Change to the temp directory and capture stdout + original_cwd = Path.cwd() + original_argv = sys.argv + original_stdout = sys.stdout + captured_output = StringIO() + + try: + os.chdir(temp_dir) + sys.argv = ["end-of-file-fixer", ".", "--check"] + sys.stdout = captured_output + + # Run end-of-file-fixer . --check command + result = main() + + # Should exit with code 1 (since some files need fixing) + assert result == 1 + + # Should output which files need fixing + output = captured_output.getvalue() + assert "no_newline.txt" in output + assert "multiple_newlines.txt" in output + assert "newlines_only.txt" in output + + finally: + os.chdir(original_cwd) + sys.argv = original_argv + sys.stdout = original_stdout + + # Files should be unchanged in check mode + no_newline_content = (temp_path / "no_newline.txt").read_text() + assert no_newline_content == "This file has no newline at the end" # Unchanged + + one_newline_content = (temp_path / "one_newline.txt").read_text() + assert one_newline_content == "This file has exactly one newline at the end\n" # Unchanged + + multiple_newlines_content = (temp_path / "multiple_newlines.txt").read_text() + assert multiple_newlines_content == "This file has multiple newlines at the end\n\n" # Unchanged + + newlines_only_content = (temp_path / "newlines_only.txt").read_text() + assert newlines_only_content == "\n\n\n" # Unchanged + + perfect_content = (temp_path / "perfect.txt").read_text() + assert perfect_content == "This file already has exactly one newline at the end\n" # Unchanged + + empty_content = (temp_path / "empty.txt").read_text() + assert empty_content == "" # Unchanged + + +def test_end_of_file_fixer_with_gitignore() -> None: + # Create a temporary directory with test files including .gitignore + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Copy all fixture files to temp directory + fixtures_dir = Path(__file__).parent / "fixtures" + for fixture_file in fixtures_dir.glob("*"): + assert shutil.copy(fixture_file, temp_path / fixture_file.name) + + Path(temp_path / ".gitignore").write_text("*.tmp") + + # Change to the temp directory and capture stdout + original_cwd = Path.cwd() + original_argv = sys.argv + original_stdout = sys.stdout + captured_output = StringIO() + + try: + os.chdir(temp_dir) + sys.argv = ["end-of-file-fixer", "."] + sys.stdout = captured_output + + # Run end-of-file-fixer . command + result = main() + + # Should exit with code 1 (since some files needed fixing) + assert result == 1 + + # Should output which files are being fixed + output = captured_output.getvalue() + assert "no_newline.txt" in output + assert "multiple_newlines.txt" in output + assert "newlines_only.txt" in output + # Should NOT mention the .tmp file since it's ignored by .gitignore + assert "temp_file.tmp" not in output + + finally: + os.chdir(original_cwd) + sys.argv = original_argv + sys.stdout = original_stdout + + # Check that files were fixed correctly + # File with no newline should now have one + no_newline_content = (temp_path / "no_newline.txt").read_text() + assert no_newline_content == "This file has no newline at the end\n" + + # File with multiple newlines should be truncated to one + multiple_newlines_content = (temp_path / "multiple_newlines.txt").read_text() + assert multiple_newlines_content == "This file has multiple newlines at the end\n" + + # File with only newlines should be made empty + newlines_only_content = (temp_path / "newlines_only.txt").read_text() + assert newlines_only_content == "" + + # The .tmp file should be unchanged since it's ignored + temp_file_content = (temp_path / "temp_file.tmp").read_text() + assert temp_file_content == "This is a temporary file that should be ignored"