Skip to content

Commit 5f075e5

Browse files
authored
Merge branch 'master' into add-windows-runner
2 parents c1fa2cb + 04cbcc3 commit 5f075e5

5 files changed

Lines changed: 119 additions & 73 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
runs-on: ${{ matrix.os }}
1212
strategy:
1313
matrix:
14-
python-version: ['3.12', '3.11', '3.10', '3.9', '3.8', '3.7']
14+
python-version: ['3.13', '3.12', '3.11', '3.10', '3.9']
1515
os: [ubuntu-latest, windows-latest]
1616
steps:
1717
- uses: actions/checkout@v2

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ Then:
2929
>>> matches('/home/michael/project/__pycache__')
3030
True
3131

32+
Alternatively, you can use the `parse_gitignore_str` function:
33+
34+
>>> from gitignore_parser import parse_gitignore_str
35+
>>> matches = parse_gitignore_str(
36+
'__pycache__/\n*.py[cod]', base_dir='/home/michael/project')
37+
>>> matches('/home/michael/project/main.py')
38+
False
39+
>>> matches('/home/michael/project/main.pyc')
40+
True
41+
3242
## Motivation
3343

3444
I couldn't find a good library for doing the above on PyPI. There are

gitignore_parser.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import os
33
import re
44

5-
from os.path import abspath, dirname
5+
from os.path import abspath, dirname, join
66
from pathlib import Path
77
import sys
88
from typing import Reversible, Union
@@ -16,16 +16,22 @@ def handle_negation(file_path, rules: Reversible["IgnoreRule"]):
1616
def parse_gitignore(full_path, base_dir=None):
1717
if base_dir is None:
1818
base_dir = dirname(full_path)
19-
rules = []
2019
with open(full_path) as ignore_file:
21-
counter = 0
22-
for line in ignore_file:
23-
counter += 1
24-
line = line.rstrip('\n')
25-
rule = rule_from_pattern(line, base_path=Path(base_dir).resolve(),
26-
source=(full_path, counter))
27-
if rule:
28-
rules.append(rule)
20+
return _parse_gitignore_lines(ignore_file, full_path, base_dir)
21+
22+
def parse_gitignore_str(gitignore_str, base_dir):
23+
full_path = join(base_dir, '.gitignore')
24+
lines = gitignore_str.splitlines()
25+
return _parse_gitignore_lines(lines, full_path, base_dir)
26+
27+
def _parse_gitignore_lines(lines, full_path, base_dir):
28+
base_dir = _normalize_path(base_dir)
29+
rules = []
30+
for line_no, line in enumerate(lines, start=1):
31+
rule = rule_from_pattern(
32+
line.rstrip('\n'), base_path=base_dir, source=(full_path, line_no))
33+
if rule:
34+
rules.append(rule)
2935
if not any(r.negation for r in rules):
3036
return lambda file_path: any(r.match(file_path) for r in rules)
3137
else:
@@ -42,8 +48,6 @@ def rule_from_pattern(pattern, base_path=None, source=None):
4248
Because git allows for nested .gitignore files, a base_path value
4349
is required for correct behavior. The base path should be absolute.
4450
"""
45-
if base_path and base_path != Path(base_path).resolve():
46-
raise ValueError('base_path must be absolute')
4751
# Store the exact pattern for our repr and string functions
4852
orig_pattern = pattern
4953
# Early returns follow
@@ -103,7 +107,7 @@ def rule_from_pattern(pattern, base_path=None, source=None):
103107
negation=negation,
104108
directory_only=directory_only,
105109
anchored=anchored,
106-
base_path=_normalize_path(base_path) if base_path else None,
110+
base_path=base_path if base_path else None,
107111
source=source
108112
)
109113

setup.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
description = 'A spec-compliant gitignore parser for Python 3.5+'
1010
setup(
1111
name='gitignore_parser',
12-
version='0.1.9',
12+
version='0.1.12',
1313
description=description,
1414
long_description=
1515
description + '\n\nhttps://github.com/mherrmann/gitignore_parser',
@@ -18,7 +18,7 @@
1818
url='https://github.com/mherrmann/gitignore_parser',
1919
py_modules=['gitignore_parser'],
2020
classifiers=[
21-
'Development Status :: 3 - Alpha',
21+
'Development Status :: 4 - Beta',
2222
'Intended Audience :: Developers',
2323

2424
'License :: OSI Approved :: MIT License',
@@ -34,6 +34,7 @@
3434
'Programming Language :: Python :: 3.9',
3535
'Programming Language :: Python :: 3.10',
3636
'Programming Language :: Python :: 3.11',
37+
'Programming Language :: Python :: 3.12',
3738

3839
'Topic :: Software Development :: Libraries',
3940
'Topic :: Software Development :: Libraries :: Python Modules'

tests.py

Lines changed: 88 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,35 @@
22
from pathlib import Path
33
from tempfile import TemporaryDirectory
44

5-
from gitignore_parser import parse_gitignore
5+
from gitignore_parser import parse_gitignore, parse_gitignore_str
66

77
from unittest import TestCase, main, SkipTest
88

99

1010
class Test(TestCase):
1111
def test_simple(self):
12-
matches = _parse_gitignore_string(
12+
matches = parse_gitignore_str(
1313
'__pycache__/\n'
1414
'*.py[cod]',
15-
fake_base_dir='/home/michael'
15+
base_dir='/home/michael'
1616
)
1717
self.assertFalse(matches('/home/michael/main.py'))
1818
self.assertTrue(matches('/home/michael/main.pyc'))
1919
self.assertTrue(matches('/home/michael/dir/main.pyc'))
2020
self.assertTrue(matches('/home/michael/__pycache__'))
2121

22+
def test_simple_parse_file(self):
23+
with patch('builtins.open', mock_open(read_data=
24+
'__pycache__/\n'
25+
'*.py[cod]')):
26+
matches = parse_gitignore('/home/michael/.gitignore')
27+
self.assertFalse(matches('/home/michael/main.py'))
28+
self.assertTrue(matches('/home/michael/main.pyc'))
29+
self.assertTrue(matches('/home/michael/dir/main.pyc'))
30+
self.assertTrue(matches('/home/michael/__pycache__'))
31+
2232
def test_incomplete_filename(self):
23-
matches = _parse_gitignore_string('o.py', fake_base_dir='/home/michael')
33+
matches = parse_gitignore_str('o.py', base_dir='/home/michael')
2434
self.assertTrue(matches('/home/michael/o.py'))
2535
self.assertFalse(matches('/home/michael/foo.py'))
2636
self.assertFalse(matches('/home/michael/o.pyc'))
@@ -29,9 +39,9 @@ def test_incomplete_filename(self):
2939
self.assertFalse(matches('/home/michael/dir/o.pyc'))
3040

3141
def test_wildcard(self):
32-
matches = _parse_gitignore_string(
42+
matches = parse_gitignore_str(
3343
'hello.*',
34-
fake_base_dir='/home/michael'
44+
base_dir='/home/michael'
3545
)
3646
self.assertTrue(matches('/home/michael/hello.txt'))
3747
self.assertTrue(matches('/home/michael/hello.foobar/'))
@@ -41,22 +51,22 @@ def test_wildcard(self):
4151
self.assertFalse(matches('/home/michael/helloX'))
4252

4353
def test_anchored_wildcard(self):
44-
matches = _parse_gitignore_string(
54+
matches = parse_gitignore_str(
4555
'/hello.*',
46-
fake_base_dir='/home/michael'
56+
base_dir='/home/michael'
4757
)
4858
self.assertTrue(matches('/home/michael/hello.txt'))
4959
self.assertTrue(matches('/home/michael/hello.c'))
5060
self.assertFalse(matches('/home/michael/a/hello.java'))
5161

5262
def test_trailingspaces(self):
53-
matches = _parse_gitignore_string(
63+
matches = parse_gitignore_str(
5464
'ignoretrailingspace \n'
5565
'notignoredspace\\ \n'
5666
'partiallyignoredspace\\ \n'
5767
'partiallyignoredspace2 \\ \n'
5868
'notignoredmultiplespace\\ \\ \\ ',
59-
fake_base_dir='/home/michael'
69+
base_dir='/home/michael'
6070
)
6171
self.assertTrue(matches('/home/michael/ignoretrailingspace'))
6272
self.assertFalse(matches('/home/michael/ignoretrailingspace '))
@@ -73,59 +83,66 @@ def test_trailingspaces(self):
7383
self.assertFalse(matches('/home/michael/notignoredmultiplespace'))
7484

7585
def test_comment(self):
76-
matches = _parse_gitignore_string(
86+
matches = parse_gitignore_str(
7787
'somematch\n'
7888
'#realcomment\n'
7989
'othermatch\n'
8090
'\\#imnocomment',
81-
fake_base_dir='/home/michael'
91+
base_dir='/home/michael'
8292
)
8393
self.assertTrue(matches('/home/michael/somematch'))
8494
self.assertFalse(matches('/home/michael/#realcomment'))
8595
self.assertTrue(matches('/home/michael/othermatch'))
8696
self.assertTrue(matches('/home/michael/#imnocomment'))
8797

8898
def test_ignore_directory(self):
89-
matches = _parse_gitignore_string('.venv/', fake_base_dir='/home/michael')
99+
matches = \
100+
parse_gitignore_str('.venv/', base_dir='/home/michael')
90101
self.assertTrue(matches('/home/michael/.venv'))
91102
self.assertTrue(matches('/home/michael/.venv/folder'))
92103
self.assertTrue(matches('/home/michael/.venv/file.txt'))
93104
self.assertFalse(matches('/home/michael/.venv_other_folder'))
94105
self.assertFalse(matches('/home/michael/.venv_no_folder.py'))
95106

96107
def test_ignore_directory_asterisk(self):
97-
matches = _parse_gitignore_string('.venv/*', fake_base_dir='/home/michael')
108+
matches = \
109+
parse_gitignore_str('.venv/*', base_dir='/home/michael')
98110
self.assertFalse(matches('/home/michael/.venv'))
99111
self.assertTrue(matches('/home/michael/.venv/folder'))
100112
self.assertTrue(matches('/home/michael/.venv/file.txt'))
101113

102114
def test_negation(self):
103-
matches = _parse_gitignore_string(
115+
matches = parse_gitignore_str(
104116
'''
105117
*.ignore
106118
!keep.ignore
107119
''',
108-
fake_base_dir='/home/michael'
120+
base_dir='/home/michael'
109121
)
110122
self.assertTrue(matches('/home/michael/trash.ignore'))
111123
self.assertFalse(matches('/home/michael/keep.ignore'))
112124
self.assertTrue(matches('/home/michael/waste.ignore'))
113125

114126
def test_literal_exclamation_mark(self):
115-
matches = _parse_gitignore_string('\\!ignore_me!', fake_base_dir='/home/michael')
127+
matches = parse_gitignore_str(
128+
'\\!ignore_me!', base_dir='/home/michael'
129+
)
116130
self.assertTrue(matches('/home/michael/!ignore_me!'))
117131
self.assertFalse(matches('/home/michael/ignore_me!'))
118132
self.assertFalse(matches('/home/michael/ignore_me'))
119133

120134
def test_double_asterisks(self):
121-
matches = _parse_gitignore_string('foo/**/Bar', fake_base_dir='/home/michael')
135+
matches = parse_gitignore_str(
136+
'foo/**/Bar', base_dir='/home/michael'
137+
)
122138
self.assertTrue(matches('/home/michael/foo/hello/Bar'))
123139
self.assertTrue(matches('/home/michael/foo/world/Bar'))
124140
self.assertTrue(matches('/home/michael/foo/Bar'))
125141
self.assertFalse(matches('/home/michael/foo/BarBar'))
126142

127143
def test_double_asterisk_without_slashes_handled_like_single_asterisk(self):
128-
matches = _parse_gitignore_string('a/b**c/d', fake_base_dir='/home/michael')
144+
matches = \
145+
parse_gitignore_str('a/b**c/d', base_dir='/home/michael')
129146
self.assertTrue(matches('/home/michael/a/bc/d'))
130147
self.assertTrue(matches('/home/michael/a/bXc/d'))
131148
self.assertTrue(matches('/home/michael/a/bbc/d'))
@@ -136,42 +153,50 @@ def test_double_asterisk_without_slashes_handled_like_single_asterisk(self):
136153
self.assertFalse(matches('/home/michael/a/bb/XX/cc/d'))
137154

138155
def test_more_asterisks_handled_like_single_asterisk(self):
139-
matches = _parse_gitignore_string('***a/b', fake_base_dir='/home/michael')
156+
matches = \
157+
parse_gitignore_str('***a/b', base_dir='/home/michael')
140158
self.assertTrue(matches('/home/michael/XYZa/b'))
141159
self.assertFalse(matches('/home/michael/foo/a/b'))
142-
matches = _parse_gitignore_string('a/b***', fake_base_dir='/home/michael')
160+
matches = \
161+
parse_gitignore_str('a/b***', base_dir='/home/michael')
143162
self.assertTrue(matches('/home/michael/a/bXYZ'))
144163
self.assertFalse(matches('/home/michael/a/b/foo'))
145164

146165
def test_directory_only_negation(self):
147-
matches = _parse_gitignore_string('''
166+
matches = parse_gitignore_str('''
148167
data/**
149168
!data/**/
150169
!.gitkeep
151170
!data/01_raw/*
152171
''',
153-
fake_base_dir='/home/michael'
172+
base_dir='/home/michael'
154173
)
155174
self.assertFalse(matches('/home/michael/data/01_raw/'))
156175
self.assertFalse(matches('/home/michael/data/01_raw/.gitkeep'))
157176
self.assertFalse(matches('/home/michael/data/01_raw/raw_file.csv'))
158177
self.assertFalse(matches('/home/michael/data/02_processed/'))
159178
self.assertFalse(matches('/home/michael/data/02_processed/.gitkeep'))
160-
self.assertTrue(matches('/home/michael/data/02_processed/processed_file.csv'))
179+
self.assertTrue(
180+
matches('/home/michael/data/02_processed/processed_file.csv')
181+
)
161182

162183
def test_single_asterisk(self):
163-
matches = _parse_gitignore_string('*', fake_base_dir='/home/michael')
184+
matches = parse_gitignore_str('*', base_dir='/home/michael')
164185
self.assertTrue(matches('/home/michael/file.txt'))
165186
self.assertTrue(matches('/home/michael/directory'))
166187
self.assertTrue(matches('/home/michael/directory-trailing/'))
167188

168189
def test_supports_path_type_argument(self):
169-
matches = _parse_gitignore_string('file1\n!file2', fake_base_dir='/home/michael')
190+
matches = parse_gitignore_str(
191+
'file1\n!file2', base_dir='/home/michael'
192+
)
170193
self.assertTrue(matches(Path('/home/michael/file1')))
171194
self.assertFalse(matches(Path('/home/michael/file2')))
172195

173196
def test_slash_in_range_does_not_match_dirs(self):
174-
matches = _parse_gitignore_string('abc[X-Z/]def', fake_base_dir='/home/michael')
197+
matches = parse_gitignore_str(
198+
'abc[X-Z/]def', base_dir='/home/michael'
199+
)
175200
self.assertFalse(matches('/home/michael/abcdef'))
176201
self.assertTrue(matches('/home/michael/abcXdef'))
177202
self.assertTrue(matches('/home/michael/abcYdef'))
@@ -180,37 +205,43 @@ def test_slash_in_range_does_not_match_dirs(self):
180205
self.assertFalse(matches('/home/michael/abcXYZdef'))
181206

182207
def test_symlink_to_another_directory(self):
183-
"""Test the behavior of a symlink to another directory.
184-
185-
The issue https://github.com/mherrmann/gitignore_parser/issues/29 describes how
186-
a symlink to another directory caused an exception to be raised during matching.
187-
188-
This test ensures that the issue is now fixed.
189-
"""
190-
with TemporaryDirectory() as project_dir, TemporaryDirectory() as another_dir:
208+
with TemporaryDirectory() as project_dir:
209+
project_dir = Path(project_dir).resolve()
210+
with TemporaryDirectory() as another_dir:
211+
another_dir = Path(another_dir).resolve()
212+
matches = parse_gitignore_str('link', base_dir=project_dir)
213+
214+
# Create a symlink to another directory.
215+
link = project_dir / 'link'
216+
target = another_dir / 'target'
217+
try:
218+
link.symlink_to(target)
219+
except OSError:
220+
raise SkipTest(
221+
"Current user does not have permissions to perform symlink."
222+
)
223+
224+
# Check the intended behavior according to
225+
# https://git-scm.com/docs/gitignore#_notes:
226+
# Symbolic links are not followed and are matched as if they
227+
# were regular files.
228+
self.assertTrue(matches(link))
229+
230+
def test_symlink_to_symlink_directory(self):
231+
with TemporaryDirectory() as project_dir:
191232
project_dir = Path(project_dir).resolve()
192-
another_dir = Path(another_dir).resolve()
193-
matches = _parse_gitignore_string('link', fake_base_dir=project_dir)
194-
195-
# Create a symlink to another directory.
196-
link = project_dir / 'link'
197-
target = another_dir / 'target'
198-
199-
try:
200-
link.symlink_to(target)
201-
except OSError:
202-
e = "Current user does not have permissions to perform symlink."
203-
raise SkipTest(e)
204-
# Check the intended behavior according to
205-
# https://git-scm.com/docs/gitignore#_notes:
206-
# Symbolic links are not followed and are matched as if they were regular
207-
# files.
208-
self.assertTrue(matches(link))
209-
210-
def _parse_gitignore_string(data: str, fake_base_dir: str = None):
211-
with patch('builtins.open', mock_open(read_data=data)):
212-
success = parse_gitignore(f'{fake_base_dir}/.gitignore', fake_base_dir)
213-
return success
233+
with TemporaryDirectory() as link_dir:
234+
link_dir = link_dir.resolve()
235+
link = link_dir / 'link'
236+
try:
237+
link.symlink_to(project_dir)
238+
except OSError:
239+
raise SkipTest(
240+
"Current user does not have permissions to perform symlink."
241+
)
242+
file = Path(link, 'file.txt')
243+
matches = parse_gitignore_str('file.txt', base_dir=str(link_dir))
244+
self.assertTrue(matches(file))
214245

215246
if __name__ == '__main__':
216247
main()

0 commit comments

Comments
 (0)