Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ Then:
>>> matches('/home/michael/project/__pycache__')
True

Alternatively, you can use the `parse_gitignore_str` function:

>>> from gitignore_parser import parse_gitignore_str
>>> matches = parse_gitignore_str(
'__pycache__/\n*.py[cod]', base_dir='/home/michael/project')
>>> matches('/home/michael/project/main.py')
False
>>> matches('/home/michael/project/main.pyc')
True

## Motivation

I couldn't find a good library for doing the above on PyPI. There are
Expand Down
28 changes: 17 additions & 11 deletions gitignore_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import os
import re

from os.path import abspath, dirname
from os.path import abspath, dirname, join
from pathlib import Path
from typing import Reversible, Union

Expand All @@ -15,16 +15,22 @@ def handle_negation(file_path, rules: Reversible["IgnoreRule"]):
def parse_gitignore(full_path, base_dir=None):
if base_dir is None:
base_dir = dirname(full_path)
rules = []
with open(full_path) as ignore_file:
counter = 0
for line in ignore_file:
counter += 1
line = line.rstrip('\n')
rule = rule_from_pattern(line, base_path=_normalize_path(base_dir),
source=(full_path, counter))
if rule:
rules.append(rule)
return _parse_gitignore_lines(ignore_file, full_path, base_dir)

def parse_gitignore_str(gitignore_str, base_dir):
full_path = join(base_dir, '.gitignore')
lines = gitignore_str.splitlines()
return _parse_gitignore_lines(lines, full_path, base_dir)

def _parse_gitignore_lines(lines, full_path, base_dir):
base_dir = _normalize_path(base_dir)
rules = []
for line_no, line in enumerate(lines, start=1):
rule = rule_from_pattern(
line.rstrip('\n'), base_path=base_dir, source=(full_path, line_no))
if rule:
rules.append(rule)
if not any(r.negation for r in rules):
return lambda file_path: any(r.match(file_path) for r in rules)
else:
Expand Down Expand Up @@ -100,7 +106,7 @@ def rule_from_pattern(pattern, base_path=None, source=None):
negation=negation,
directory_only=directory_only,
anchored=anchored,
base_path=_normalize_path(base_path) if base_path else None,
base_path=base_path if base_path else None,
source=source
)

Expand Down
81 changes: 42 additions & 39 deletions tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,35 @@
from pathlib import Path
from tempfile import TemporaryDirectory

from gitignore_parser import parse_gitignore
from gitignore_parser import parse_gitignore, parse_gitignore_str

from unittest import TestCase, main


class Test(TestCase):
def test_simple(self):
matches = _parse_gitignore_string(
matches = parse_gitignore_str(
'__pycache__/\n'
'*.py[cod]',
fake_base_dir='/home/michael'
base_dir='/home/michael'
)
self.assertFalse(matches('/home/michael/main.py'))
self.assertTrue(matches('/home/michael/main.pyc'))
self.assertTrue(matches('/home/michael/dir/main.pyc'))
self.assertTrue(matches('/home/michael/__pycache__'))

def test_simple_parse_file(self):
with patch('builtins.open', mock_open(read_data=
'__pycache__/\n'
'*.py[cod]')):
matches = parse_gitignore('/home/michael/.gitignore')
self.assertFalse(matches('/home/michael/main.py'))
self.assertTrue(matches('/home/michael/main.pyc'))
self.assertTrue(matches('/home/michael/dir/main.pyc'))
self.assertTrue(matches('/home/michael/__pycache__'))

def test_incomplete_filename(self):
matches = _parse_gitignore_string('o.py', fake_base_dir='/home/michael')
matches = parse_gitignore_str('o.py', base_dir='/home/michael')
self.assertTrue(matches('/home/michael/o.py'))
self.assertFalse(matches('/home/michael/foo.py'))
self.assertFalse(matches('/home/michael/o.pyc'))
Expand All @@ -29,9 +39,9 @@ def test_incomplete_filename(self):
self.assertFalse(matches('/home/michael/dir/o.pyc'))

def test_wildcard(self):
matches = _parse_gitignore_string(
matches = parse_gitignore_str(
'hello.*',
fake_base_dir='/home/michael'
base_dir='/home/michael'
)
self.assertTrue(matches('/home/michael/hello.txt'))
self.assertTrue(matches('/home/michael/hello.foobar/'))
Expand All @@ -41,22 +51,22 @@ def test_wildcard(self):
self.assertFalse(matches('/home/michael/helloX'))

def test_anchored_wildcard(self):
matches = _parse_gitignore_string(
matches = parse_gitignore_str(
'/hello.*',
fake_base_dir='/home/michael'
base_dir='/home/michael'
)
self.assertTrue(matches('/home/michael/hello.txt'))
self.assertTrue(matches('/home/michael/hello.c'))
self.assertFalse(matches('/home/michael/a/hello.java'))

def test_trailingspaces(self):
matches = _parse_gitignore_string(
matches = parse_gitignore_str(
'ignoretrailingspace \n'
'notignoredspace\\ \n'
'partiallyignoredspace\\ \n'
'partiallyignoredspace2 \\ \n'
'notignoredmultiplespace\\ \\ \\ ',
fake_base_dir='/home/michael'
base_dir='/home/michael'
)
self.assertTrue(matches('/home/michael/ignoretrailingspace'))
self.assertFalse(matches('/home/michael/ignoretrailingspace '))
Expand All @@ -73,12 +83,12 @@ def test_trailingspaces(self):
self.assertFalse(matches('/home/michael/notignoredmultiplespace'))

def test_comment(self):
matches = _parse_gitignore_string(
matches = parse_gitignore_str(
'somematch\n'
'#realcomment\n'
'othermatch\n'
'\\#imnocomment',
fake_base_dir='/home/michael'
base_dir='/home/michael'
)
self.assertTrue(matches('/home/michael/somematch'))
self.assertFalse(matches('/home/michael/#realcomment'))
Expand All @@ -87,7 +97,7 @@ def test_comment(self):

def test_ignore_directory(self):
matches = \
_parse_gitignore_string('.venv/', fake_base_dir='/home/michael')
parse_gitignore_str('.venv/', 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'))
Expand All @@ -96,34 +106,34 @@ def test_ignore_directory(self):

def test_ignore_directory_asterisk(self):
matches = \
_parse_gitignore_string('.venv/*', fake_base_dir='/home/michael')
parse_gitignore_str('.venv/*', 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'))

def test_negation(self):
matches = _parse_gitignore_string(
matches = parse_gitignore_str(
'''
*.ignore
!keep.ignore
''',
fake_base_dir='/home/michael'
base_dir='/home/michael'
)
self.assertTrue(matches('/home/michael/trash.ignore'))
self.assertFalse(matches('/home/michael/keep.ignore'))
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_str(
'\\!ignore_me!', 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_str(
'foo/**/Bar', base_dir='/home/michael'
)
self.assertTrue(matches('/home/michael/foo/hello/Bar'))
self.assertTrue(matches('/home/michael/foo/world/Bar'))
Expand All @@ -132,7 +142,7 @@ def test_double_asterisks(self):

def test_double_asterisk_without_slashes_handled_like_single_asterisk(self):
matches = \
_parse_gitignore_string('a/b**c/d', fake_base_dir='/home/michael')
parse_gitignore_str('a/b**c/d', 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'))
Expand All @@ -144,22 +154,22 @@ def test_double_asterisk_without_slashes_handled_like_single_asterisk(self):

def test_more_asterisks_handled_like_single_asterisk(self):
matches = \
_parse_gitignore_string('***a/b', fake_base_dir='/home/michael')
parse_gitignore_str('***a/b', 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')
parse_gitignore_str('a/b***', base_dir='/home/michael')
self.assertTrue(matches('/home/michael/a/bXYZ'))
self.assertFalse(matches('/home/michael/a/b/foo'))

def test_directory_only_negation(self):
matches = _parse_gitignore_string('''
matches = parse_gitignore_str('''
data/**
!data/**/
!.gitkeep
!data/01_raw/*
''',
fake_base_dir='/home/michael'
base_dir='/home/michael'
)
self.assertFalse(matches('/home/michael/data/01_raw/'))
self.assertFalse(matches('/home/michael/data/01_raw/.gitkeep'))
Expand All @@ -171,21 +181,21 @@ def test_directory_only_negation(self):
)

def test_single_asterisk(self):
matches = _parse_gitignore_string('*', fake_base_dir='/home/michael')
matches = parse_gitignore_str('*', base_dir='/home/michael')
self.assertTrue(matches('/home/michael/file.txt'))
self.assertTrue(matches('/home/michael/directory'))
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_str(
'file1\n!file2', 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_str(
'abc[X-Z/]def', base_dir='/home/michael'
)
self.assertFalse(matches('/home/michael/abcdef'))
self.assertTrue(matches('/home/michael/abcXdef'))
Expand All @@ -197,8 +207,7 @@ def test_slash_in_range_does_not_match_dirs(self):
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)
matches = parse_gitignore_str('link', base_dir=project_dir)

# Create a symlink to another directory.
link = Path(project_dir, 'link')
Expand All @@ -217,15 +226,9 @@ def test_symlink_to_symlink_directory(self):
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))
matches = parse_gitignore_str('file.txt', base_dir=str(link_dir))
self.assertTrue(matches(file))


def _parse_gitignore_string(data: str, fake_base_dir: str = None):
with patch('builtins.open', mock_open(read_data=data)):
success = parse_gitignore(f'{fake_base_dir}/.gitignore', fake_base_dir)
return success

if __name__ == '__main__':
main()