From 3907567e02be2f20235dc5d3b474a7f3c34e07de Mon Sep 17 00:00:00 2001 From: Elifarley Date: Fri, 20 Dec 2024 13:21:51 -0300 Subject: [PATCH 1/2] PR #61 from @Javagedes --- .github/workflows/ci.yml | 6 ++- gitignore_parser.py | 26 ++++++++++-- tests.py | 88 +++++++++++++++++----------------------- 3 files changed, 64 insertions(+), 56 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6dadf63..422f6b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,10 +8,11 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ['3.12', '3.11', '3.10', '3.9', '3.8', '3.7'] + python-version: ['3.12', '3.11'] + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} @@ -21,3 +22,4 @@ jobs: - name: Run tests run: | python -m unittest + diff --git a/gitignore_parser.py b/gitignore_parser.py index 98c4afe..af3265b 100644 --- a/gitignore_parser.py +++ b/gitignore_parser.py @@ -4,6 +4,7 @@ from os.path import abspath, dirname from pathlib import Path +import sys from typing import Reversible, Union def handle_negation(file_path, rules: Reversible["IgnoreRule"]): @@ -21,7 +22,7 @@ def parse_gitignore(full_path, base_dir=None): for line in ignore_file: counter += 1 line = line.rstrip('\n') - rule = rule_from_pattern(line, base_path=_normalize_path(base_dir), + rule = rule_from_pattern(line, base_path=Path(base_dir).resolve(), source=(full_path, counter)) if rule: rules.append(rule) @@ -41,6 +42,8 @@ def rule_from_pattern(pattern, base_path=None, source=None): Because git allows for nested .gitignore files, a base_path value is required for correct behavior. The base path should be absolute. """ + if base_path and base_path != Path(base_path).resolve(): + raise ValueError('base_path must be absolute') # Store the exact pattern for our repr and string functions orig_pattern = pattern # Early returns follow @@ -123,9 +126,14 @@ def __repr__(self): def match(self, abs_path: Union[str, Path]): matched = False if self.base_path: - rel_path = str(_normalize_path(abs_path).relative_to(self.base_path)) + rel_path = _normalize_path(abs_path).relative_to(self.base_path).as_posix() else: - rel_path = str(_normalize_path(abs_path)) + rel_path = _normalize_path(abs_path).as_posix() + # Path() strips the trailing following symbols on windows, so we need to + # preserve it: ' ', '.' + if sys.platform.startswith('win'): + rel_path += ' ' * _count_trailing_symbol(' ', abs_path) + rel_path += '.' * _count_trailing_symbol('.', abs_path) # Path() strips the trailing slash, so we need to preserve it # in case of directory-only negation if self.negation and type(abs_path) == str and abs_path[-1] == '/': @@ -216,3 +224,15 @@ def _normalize_path(path: Union[str, Path]) -> Path: `Path.resolve()` does. """ return Path(abspath(path)) + + +def _count_trailing_symbol(symbol: str, text: str) -> int: + """Count the number of trailing characters in a string.""" + count = 0 + for char in reversed(str(text)): + if char == symbol: + count += 1 + else: + break + return count + diff --git a/tests.py b/tests.py index 5ccd3af..747d6e5 100644 --- a/tests.py +++ b/tests.py @@ -4,7 +4,7 @@ from gitignore_parser import parse_gitignore -from unittest import TestCase, main +from unittest import TestCase, main, SkipTest class Test(TestCase): @@ -86,8 +86,7 @@ def test_comment(self): self.assertTrue(matches('/home/michael/#imnocomment')) def test_ignore_directory(self): - matches = \ - _parse_gitignore_string('.venv/', fake_base_dir='/home/michael') + matches = _parse_gitignore_string('.venv/', fake_base_dir='/home/michael') self.assertTrue(matches('/home/michael/.venv')) self.assertTrue(matches('/home/michael/.venv/folder')) self.assertTrue(matches('/home/michael/.venv/file.txt')) @@ -95,8 +94,7 @@ def test_ignore_directory(self): self.assertFalse(matches('/home/michael/.venv_no_folder.py')) def test_ignore_directory_asterisk(self): - matches = \ - _parse_gitignore_string('.venv/*', fake_base_dir='/home/michael') + matches = _parse_gitignore_string('.venv/*', fake_base_dir='/home/michael') self.assertFalse(matches('/home/michael/.venv')) self.assertTrue(matches('/home/michael/.venv/folder')) self.assertTrue(matches('/home/michael/.venv/file.txt')) @@ -114,25 +112,20 @@ def test_negation(self): self.assertTrue(matches('/home/michael/waste.ignore')) def test_literal_exclamation_mark(self): - matches = _parse_gitignore_string( - '\\!ignore_me!', fake_base_dir='/home/michael' - ) + matches = _parse_gitignore_string('\\!ignore_me!', fake_base_dir='/home/michael') self.assertTrue(matches('/home/michael/!ignore_me!')) self.assertFalse(matches('/home/michael/ignore_me!')) self.assertFalse(matches('/home/michael/ignore_me')) def test_double_asterisks(self): - matches = _parse_gitignore_string( - 'foo/**/Bar', fake_base_dir='/home/michael' - ) + matches = _parse_gitignore_string('foo/**/Bar', fake_base_dir='/home/michael') self.assertTrue(matches('/home/michael/foo/hello/Bar')) self.assertTrue(matches('/home/michael/foo/world/Bar')) self.assertTrue(matches('/home/michael/foo/Bar')) self.assertFalse(matches('/home/michael/foo/BarBar')) def test_double_asterisk_without_slashes_handled_like_single_asterisk(self): - matches = \ - _parse_gitignore_string('a/b**c/d', fake_base_dir='/home/michael') + matches = _parse_gitignore_string('a/b**c/d', fake_base_dir='/home/michael') self.assertTrue(matches('/home/michael/a/bc/d')) self.assertTrue(matches('/home/michael/a/bXc/d')) self.assertTrue(matches('/home/michael/a/bbc/d')) @@ -143,12 +136,10 @@ def test_double_asterisk_without_slashes_handled_like_single_asterisk(self): self.assertFalse(matches('/home/michael/a/bb/XX/cc/d')) def test_more_asterisks_handled_like_single_asterisk(self): - matches = \ - _parse_gitignore_string('***a/b', fake_base_dir='/home/michael') + matches = _parse_gitignore_string('***a/b', fake_base_dir='/home/michael') self.assertTrue(matches('/home/michael/XYZa/b')) self.assertFalse(matches('/home/michael/foo/a/b')) - matches = \ - _parse_gitignore_string('a/b***', fake_base_dir='/home/michael') + matches = _parse_gitignore_string('a/b***', fake_base_dir='/home/michael') self.assertTrue(matches('/home/michael/a/bXYZ')) self.assertFalse(matches('/home/michael/a/b/foo')) @@ -166,9 +157,7 @@ def test_directory_only_negation(self): self.assertFalse(matches('/home/michael/data/01_raw/raw_file.csv')) self.assertFalse(matches('/home/michael/data/02_processed/')) self.assertFalse(matches('/home/michael/data/02_processed/.gitkeep')) - self.assertTrue( - matches('/home/michael/data/02_processed/processed_file.csv') - ) + self.assertTrue(matches('/home/michael/data/02_processed/processed_file.csv')) def test_single_asterisk(self): matches = _parse_gitignore_string('*', fake_base_dir='/home/michael') @@ -177,16 +166,12 @@ def test_single_asterisk(self): self.assertTrue(matches('/home/michael/directory-trailing/')) def test_supports_path_type_argument(self): - matches = _parse_gitignore_string( - 'file1\n!file2', fake_base_dir='/home/michael' - ) + matches = _parse_gitignore_string('file1\n!file2', fake_base_dir='/home/michael') self.assertTrue(matches(Path('/home/michael/file1'))) self.assertFalse(matches(Path('/home/michael/file2'))) def test_slash_in_range_does_not_match_dirs(self): - matches = _parse_gitignore_string( - 'abc[X-Z/]def', fake_base_dir='/home/michael' - ) + matches = _parse_gitignore_string('abc[X-Z/]def', fake_base_dir='/home/michael') self.assertFalse(matches('/home/michael/abcdef')) self.assertTrue(matches('/home/michael/abcXdef')) self.assertTrue(matches('/home/michael/abcYdef')) @@ -195,32 +180,32 @@ def test_slash_in_range_does_not_match_dirs(self): self.assertFalse(matches('/home/michael/abcXYZdef')) def test_symlink_to_another_directory(self): - with TemporaryDirectory() as project_dir: - with TemporaryDirectory() as another_dir: - matches = \ - _parse_gitignore_string('link', fake_base_dir=project_dir) - - # Create a symlink to another directory. - link = Path(project_dir, 'link') - target = Path(another_dir, 'target') + """Test the behavior of a symlink to another directory. + + The issue https://github.com/mherrmann/gitignore_parser/issues/29 describes how + a symlink to another directory caused an exception to be raised during matching. + + This test ensures that the issue is now fixed. + """ + with TemporaryDirectory() as project_dir, TemporaryDirectory() as another_dir: + project_dir = Path(project_dir).resolve() + another_dir = Path(another_dir).resolve() + matches = _parse_gitignore_string('link', fake_base_dir=project_dir) + + # Create a symlink to another directory. + link = project_dir / 'link' + target = another_dir / 'target' + + try: link.symlink_to(target) - - # Check the intended behavior according to - # https://git-scm.com/docs/gitignore#_notes: - # Symbolic links are not followed and are matched as if they - # were regular files. - self.assertTrue(matches(link)) - - def test_symlink_to_symlink_directory(self): - with TemporaryDirectory() as project_dir: - with TemporaryDirectory() as link_dir: - link = Path(link_dir, 'link') - link.symlink_to(project_dir) - file = Path(link, 'file.txt') - matches = \ - _parse_gitignore_string('file.txt', fake_base_dir=str(link)) - self.assertTrue(matches(file)) - + except OSError: + e = "Current user does not have permissions to perform symlink." + raise SkipTest(e) + # Check the intended behavior according to + # https://git-scm.com/docs/gitignore#_notes: + # Symbolic links are not followed and are matched as if they were regular + # files. + self.assertTrue(matches(link)) def _parse_gitignore_string(data: str, fake_base_dir: str = None): with patch('builtins.open', mock_open(read_data=data)): @@ -229,3 +214,4 @@ def _parse_gitignore_string(data: str, fake_base_dir: str = None): if __name__ == '__main__': main() + From 3e7c177755c7583df2e1dfc2cc851ad17c42d930 Mon Sep 17 00:00:00 2001 From: Elifarley Date: Fri, 20 Dec 2024 13:53:42 -0300 Subject: [PATCH 2/2] Add .editorconfig from #31 by @fslds --- .editorconfig | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..24e229c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,25 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# Not the top-most EditorConfig file, only contains settings for this project. +root = false + +# Unix-style newlines for all files +[*] +end_of_line = lf + +# Set default charset +[*.{py,md,cfg,yml,gitignore}] +charset = utf-8 + +# Style for Python +[*.py] +indent_style = space +indent_size = 4 +insert_final_newline = true + +# Style for YAML +[*.{yml,yaml}] +indent_style = space +indent_size = 2 +insert_final_newline = true +