offgrep 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: offgrep
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: A lousy ripgrep rip-off in pure Python
5
5
  Keywords: grep,recursive
6
6
  Author: Rainer Schwarzbach
@@ -68,4 +68,4 @@ instead of **offgrep**.
68
68
  If you – however – are in a super restricted network (eg. government)
69
69
  without a chance to install **ripgrep**,
70
70
  but are able to install Python packages, **offgrep** might be an interesting
71
- alterative.
71
+ alternative.
@@ -19,4 +19,4 @@ instead of **offgrep**.
19
19
  If you – however – are in a super restricted network (eg. government)
20
20
  without a chance to install **ripgrep**,
21
21
  but are able to install Python packages, **offgrep** might be an interesting
22
- alterative.
22
+ alternative.
@@ -5,7 +5,7 @@ build-backend = "uv_build"
5
5
 
6
6
  [project]
7
7
  name = "offgrep"
8
- version = "0.1.0"
8
+ version = "0.2.0"
9
9
  description = "A lousy ripgrep rip-off in pure Python"
10
10
  readme = "README.md"
11
11
  authors = [
@@ -14,7 +14,7 @@ from re import compile as re_compile
14
14
  from tomllib import loads as tomli_loads
15
15
 
16
16
  from .commons import UTF_8
17
- from .filesystem import IgnoredDirectoryError, iter_target_files
17
+ from .filesystem import DEFAULT_IGNORED_DIRS, Priority, DirectoryWalker
18
18
 
19
19
 
20
20
  PACKAGE_NAME = "offgrep"
@@ -75,8 +75,10 @@ def find_in_files(
75
75
  #
76
76
  #
77
77
  #
78
- except UnicodeDecodeError as unicode_error:
79
- debug("assuming binary file %s because of %r", single_file, unicode_error)
78
+ except UnicodeDecodeError:
79
+ debug(
80
+ "assuming binary file %s because of unicode decode error", single_file
81
+ )
80
82
  except OSError as os_error:
81
83
  error(str(os_error))
82
84
  continue
@@ -102,6 +104,7 @@ def find_in_files(
102
104
  def grep_files(arguments: Namespace) -> int:
103
105
  """Search for text in files"""
104
106
  # TODO: arguments.file → read regexps from the provided file
107
+ default_ignore_dirs = arguments.default_ignore_dirs.split(",")
105
108
  pattern_and_or_paths = list(arguments.pattern_and_or_paths)
106
109
  try:
107
110
  pattern = pattern_and_or_paths.pop(0)
@@ -127,11 +130,12 @@ def grep_files(arguments: Namespace) -> int:
127
130
  debug("Omitting ignore rules for file %s", single_path)
128
131
  target_paths.append(single_path)
129
132
  elif single_path.is_dir():
130
- try:
131
- target_paths.extend(iter_target_files(single_path))
132
- except IgnoredDirectoryError as id_error:
133
- error(str(id_error))
134
- #
133
+ walker = DirectoryWalker(
134
+ single_path,
135
+ default_ignore_dirs=default_ignore_dirs,
136
+ default_ignore_priority=arguments.default_ignore_priority,
137
+ )
138
+ target_paths.extend(walker.iter_files())
135
139
  #
136
140
  #
137
141
  find_in_files(target_paths, pattern, show_files=show_files)
@@ -140,6 +144,7 @@ def grep_files(arguments: Namespace) -> int:
140
144
 
141
145
  def list_files(arguments: Namespace) -> int:
142
146
  """List files that would be searched"""
147
+ default_ignore_dirs = arguments.default_ignore_dirs.split(",")
143
148
  paths = [Path(item) for item in arguments.pattern_and_or_paths]
144
149
  if not paths:
145
150
  paths.append(Path.cwd())
@@ -149,12 +154,13 @@ def list_files(arguments: Namespace) -> int:
149
154
  debug("Omitting ignore rules for file %s", single_path)
150
155
  print(single_path)
151
156
  elif single_path.is_dir():
152
- try:
153
- for target_path in iter_target_files(single_path):
154
- print(target_path)
155
- #
156
- except IgnoredDirectoryError as id_error:
157
- error(str(id_error))
157
+ walker = DirectoryWalker(
158
+ single_path,
159
+ default_ignore_dirs=default_ignore_dirs,
160
+ default_ignore_priority=arguments.default_ignore_priority,
161
+ )
162
+ for target_path in walker.iter_files():
163
+ print(target_path)
158
164
  #
159
165
  else:
160
166
  warning("Ingored %s, neither a regular file nor a directory", single_path)
@@ -163,7 +169,10 @@ def list_files(arguments: Namespace) -> int:
163
169
  return 0
164
170
 
165
171
 
166
- # pylint: enable=unused-argument
172
+ def print_supported_file_tyoes() -> int:
173
+ """Print the list of supported file types"""
174
+ print("Not implemented yet")
175
+ return 0
167
176
 
168
177
 
169
178
  def get_arguments() -> Namespace:
@@ -180,7 +189,11 @@ def get_arguments() -> Namespace:
180
189
  main_parser = ArgumentParser(
181
190
  prog="offgrep", description="searches text in (text) files recursively"
182
191
  )
183
- main_parser.set_defaults(loglevel=WARNING)
192
+ main_parser.set_defaults(
193
+ loglevel=WARNING,
194
+ default_ignore_dirs=",".join(DEFAULT_IGNORED_DIRS),
195
+ default_ignore_priority=Priority.LOW,
196
+ )
184
197
  main_parser.add_argument("-V", "--version", action="version", version=version)
185
198
  main_parser.add_argument(
186
199
  "--debug",
@@ -189,11 +202,32 @@ def get_arguments() -> Namespace:
189
202
  dest="loglevel",
190
203
  help="log with debug level",
191
204
  )
205
+ main_parser.add_argument(
206
+ "--default-ignore-dirs",
207
+ help="Always ignore the directories named here, separated by commas."
208
+ " See also ---default-ignore-priority to specify when to ignore them."
209
+ " The preset is %(default)r – for various version control systems.",
210
+ )
211
+ main_parser.add_argument(
212
+ "--default-ignore-priority",
213
+ choices=list(Priority),
214
+ type=Priority,
215
+ help="Priority for the ignore rules specified through -default-ignore-dirs."
216
+ f" Choices: %(choices)s ({str(Priority.LOW)!r} is applied first but might"
217
+ " be overridden by eg. directory defined rules,"
218
+ f" while {str(Priority.HIGH)!r} is applied last but cannot be overridden anymore)."
219
+ f" The preset is {str(Priority.LOW)!r}.",
220
+ )
192
221
  main_parser.add_argument(
193
222
  "--files",
194
223
  action="store_true",
195
- help="print each file that would be searched without actually"
196
- " performing the search.",
224
+ help="Print the path of each file that would be searched,"
225
+ " without actually performing the search.",
226
+ )
227
+ main_parser.add_argument(
228
+ "--type-list",
229
+ action="store_true",
230
+ help="Print the list of supported file types.",
197
231
  )
198
232
  main_parser.add_argument(
199
233
  "pattern_and_or_paths",
@@ -216,6 +250,10 @@ def main() -> int:
216
250
  the script rteurncode
217
251
  """
218
252
  arguments = get_arguments()
253
+ if arguments.type_list:
254
+ print_supported_file_tyoes()
255
+ return 0
256
+ #
219
257
  if arguments.files:
220
258
  return list_files(arguments)
221
259
  #
@@ -0,0 +1,343 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ """
4
+ Filesystem related functionality
5
+ """
6
+
7
+ from collections.abc import Iterable, Iterator
8
+ from enum import StrEnum
9
+ from logging import debug, warning
10
+ from pathlib import Path
11
+
12
+ from .commons import EMPTY, SLASH
13
+ from .git import get_configured_global_gitignore_path
14
+ from .path_matching import NoRulesFound, RuleSet, iter_gitignore_rules_from_lines
15
+
16
+
17
+ GIT_IGNORE_FILE = ".gitignore"
18
+ MERCURIAL_IGNORE_FILE = ".hgignore"
19
+ GENERIC_IGNORE_FILE = ".ignore"
20
+ RIPGREP_IGNORE_FILE = ".rgignore"
21
+
22
+ DEFAULT_IGNORE_FILES = (GIT_IGNORE_FILE,)
23
+
24
+ IGNORE_FILES_SEQUENCE = (
25
+ GIT_IGNORE_FILE,
26
+ MERCURIAL_IGNORE_FILE,
27
+ GENERIC_IGNORE_FILE,
28
+ RIPGREP_IGNORE_FILE,
29
+ )
30
+
31
+
32
+ GIT_DIR = ".git"
33
+ MERCURIAL_DIR = ".hg"
34
+ BREEZY_DIR = ".bzr"
35
+ DARCS_DIR = "_darcs"
36
+ SUBVERSION_DIR = ".svn"
37
+
38
+ DEFAULT_IGNORED_DIRS = (GIT_DIR, MERCURIAL_DIR, BREEZY_DIR, DARCS_DIR, SUBVERSION_DIR)
39
+
40
+ IN_GIT_REPO = 0x1
41
+ IN_MERCURIAL_REPO = 0x2
42
+
43
+ _VCS_LOOKUP: dict[str, int] = {GIT_DIR: IN_GIT_REPO, MERCURIAL_DIR: IN_MERCURIAL_REPO}
44
+ _VCS_IGNORE_FILES: dict[int, str] = {
45
+ IN_GIT_REPO: GIT_IGNORE_FILE,
46
+ IN_MERCURIAL_REPO: MERCURIAL_IGNORE_FILE,
47
+ }
48
+
49
+
50
+ class IgnoreFileMode(StrEnum):
51
+ """Ignore file handling mode"""
52
+
53
+ ALWAYS = "always"
54
+ """Use all ignore files, always"""
55
+
56
+ AUTOMATIC = "automatic"
57
+ """Use .gitignore only in Git repositories,
58
+ .hgignore only in Mercurial repositories,
59
+ and all others always
60
+ """
61
+
62
+ NEVER = "never"
63
+ """Never use any ignore files"""
64
+
65
+
66
+ class Priority(StrEnum):
67
+ """Possible priorities of the default directory ruleset"""
68
+
69
+ LOW = "low"
70
+ HIGH = "high"
71
+
72
+
73
+ class DirectoryWalker:
74
+ """Walks through a directory recursively"""
75
+
76
+ def __init__(
77
+ self,
78
+ start_directory: Path,
79
+ default_ignore_dirs: list[str] | tuple[str, ...] = DEFAULT_IGNORED_DIRS,
80
+ default_ignore_priority: Priority = Priority.LOW,
81
+ ignore_file_mode: IgnoreFileMode = IgnoreFileMode.AUTOMATIC,
82
+ ) -> None:
83
+ """Initialize basic values"""
84
+ self.start_directory = start_directory.resolve()
85
+ debug("Start directory was resolved to %s", self.start_directory)
86
+ self.default_ignore_ruleset = RuleSet(
87
+ title=f"Default ignored directories (Priority {default_ignore_priority})",
88
+ anchored_at_dir=self.start_directory,
89
+ rules=tuple(
90
+ iter_gitignore_rules_from_lines(
91
+ f"{dirname.rstrip(SLASH)}{SLASH}" for dirname in default_ignore_dirs
92
+ )
93
+ ),
94
+ )
95
+ debug(str(self.default_ignore_ruleset))
96
+ self.default_ignore_priority = default_ignore_priority
97
+ self.cwd = Path.cwd()
98
+ self.use_relative_paths = self.start_directory.is_relative_to(self.cwd)
99
+ self.ignore_file_mode = ignore_file_mode
100
+
101
+ def iter_files(self) -> Iterator[Path]:
102
+ """Iterate over all files"""
103
+ vcs_bitmap, base_rulesets = self.compile_base_rulesets()
104
+ debug("Walking down from %s …", self.start_directory)
105
+ yield from self.walk_recursively(
106
+ self.start_directory, base_rulesets, vcs_bitmap
107
+ )
108
+
109
+ def compile_base_rulesets(self) -> tuple[int, tuple[RuleSet, ...]]:
110
+ """Compile the base rulesets"""
111
+ if self.ignore_file_mode == IgnoreFileMode.NEVER:
112
+ return 0, ()
113
+ #
114
+ vcs_bitmap = 0
115
+ vcs_rulesets: list[RuleSet] = [self.default_ignore_ruleset]
116
+ for indicator, check_for_dir, ignore_file_name in (
117
+ (IN_GIT_REPO, GIT_DIR, GIT_IGNORE_FILE),
118
+ (IN_MERCURIAL_REPO, MERCURIAL_DIR, MERCURIAL_IGNORE_FILE),
119
+ ):
120
+ additional_bitmap, additional_rulesets = self._compile_base_vcs_rulesets(
121
+ indicator,
122
+ check_for_dir=check_for_dir,
123
+ ignore_file_name=ignore_file_name,
124
+ )
125
+ vcs_bitmap |= additional_bitmap
126
+ vcs_rulesets.extend(additional_rulesets)
127
+ #
128
+ return vcs_bitmap, tuple(vcs_rulesets)
129
+
130
+ def _compile_base_vcs_rulesets(
131
+ self, vcs_bitmap_on_success: int, check_for_dir: str, ignore_file_name: str
132
+ ) -> tuple[int, tuple[RuleSet, ...]]:
133
+ """Compile the base VCS related rulesets"""
134
+ vcs_bitmap = 0
135
+ base_vcs_rulesets: list[RuleSet] = []
136
+ dirs_to_check = [self.start_directory, *self.start_directory.parents]
137
+ checked_dirs = []
138
+ for pointer in dirs_to_check:
139
+ debug("Checking %s for %s …", pointer, check_for_dir)
140
+ checked_dirs.append(pointer)
141
+ candidate = pointer / check_for_dir
142
+ if candidate.is_dir():
143
+ debug("… found at %s!", pointer)
144
+ vcs_bitmap = vcs_bitmap_on_success
145
+ if check_for_dir == GIT_DIR:
146
+ base_vcs_rulesets.extend(
147
+ compile_default_git_rulesets(
148
+ repository_root=pointer,
149
+ with_user_global_gitignore=True,
150
+ )
151
+ )
152
+ #
153
+ break
154
+ #
155
+ else:
156
+ debug(
157
+ "%s: No repository found in any parent,"
158
+ " continuing without (%s specific) exclusion rules",
159
+ check_for_dir,
160
+ ignore_file_name,
161
+ )
162
+ return 0, ()
163
+ #
164
+ # walk back up and handle intermediate ignore files,
165
+ # except for the start directory itself (that is done when walking it)
166
+ for idx in range(len(checked_dirs) - 1, 0, -1):
167
+ current_dir = checked_dirs[idx]
168
+ append_ruleset_from_path(
169
+ base_vcs_rulesets,
170
+ current_dir / ignore_file_name,
171
+ current_dir,
172
+ )
173
+ dir_to_check = checked_dirs[idx - 1]
174
+ is_excluded = False
175
+ for current_ignore_rule in base_vcs_rulesets:
176
+ is_excluded = current_ignore_rule.excludes_path(
177
+ dir_to_check, pre_excluded=is_excluded
178
+ )
179
+ #
180
+ if is_excluded:
181
+ warning(
182
+ "%s is excluded by the rulesets collected so far,"
183
+ " clearing rulesets",
184
+ dir_to_check,
185
+ )
186
+ return 0, ()
187
+ #
188
+ #
189
+ return vcs_bitmap, tuple(base_vcs_rulesets)
190
+
191
+ def _compile_local_ignore_files(
192
+ self,
193
+ directory_contents: Iterable[Path],
194
+ vcs_bitmap: int,
195
+ ) -> tuple[int, set[str]]:
196
+ """Compile local ignore files from the provided directory contents"""
197
+ match self.ignore_file_mode:
198
+ case IgnoreFileMode.ALWAYS:
199
+ return vcs_bitmap, set(IGNORE_FILES_SEQUENCE)
200
+ case IgnoreFileMode.NEVER:
201
+ return 0, set()
202
+ #
203
+ #
204
+ dir_names: set[str] = set()
205
+ file_names: set[str] = set()
206
+ for current_path in directory_contents:
207
+ item_name = current_path.name
208
+ if current_path.is_dir():
209
+ dir_names.add(item_name)
210
+ elif current_path.is_file():
211
+ file_names.add(item_name)
212
+ #
213
+ local_ignore_files = {GENERIC_IGNORE_FILE, RIPGREP_IGNORE_FILE}
214
+ for vcs_dir_name, indicator in _VCS_LOOKUP.items():
215
+ if vcs_dir_name in dir_names:
216
+ vcs_bitmap |= indicator
217
+ #
218
+ if vcs_bitmap & indicator:
219
+ local_ignore_files.add(_VCS_IGNORE_FILES[indicator])
220
+ #
221
+ #
222
+ return vcs_bitmap, local_ignore_files & file_names
223
+
224
+ def walk_recursively(
225
+ self,
226
+ working_directory: Path,
227
+ ignore_rulesets: tuple[RuleSet, ...],
228
+ vcs_bitmap: int,
229
+ ) -> Iterator[Path]:
230
+ """Walk through all contained files, respecting the ignore rules.
231
+
232
+ Yield paths relative to the start directory if the start directory
233
+ is the current working directory.
234
+ """
235
+ absolute_workdir = working_directory.resolve()
236
+ effective_ignore_rulesets = list(ignore_rulesets)
237
+ directory_contents = sorted(absolute_workdir.glob("*"), key=Path.as_posix)
238
+ vcs_bitmap, local_ignore_files = self._compile_local_ignore_files(
239
+ directory_contents, vcs_bitmap
240
+ )
241
+ for current_ignore_file in IGNORE_FILES_SEQUENCE:
242
+ if current_ignore_file not in local_ignore_files:
243
+ continue
244
+ #
245
+ append_ruleset_from_path(
246
+ effective_ignore_rulesets,
247
+ absolute_workdir / current_ignore_file,
248
+ absolute_workdir,
249
+ )
250
+ #
251
+ rulesets_to_pass: tuple[RuleSet, ...] = tuple(effective_ignore_rulesets)
252
+ match self.default_ignore_priority:
253
+ case Priority.LOW:
254
+ effective_ignore_rulesets.insert(0, self.default_ignore_ruleset)
255
+ case Priority.HIGH:
256
+ effective_ignore_rulesets.append(self.default_ignore_ruleset)
257
+ #
258
+ #
259
+ for contained_path in directory_contents:
260
+ # name = contained_path.name
261
+ if not (contained_path.is_dir() or contained_path.is_file()):
262
+ debug("Skipping %s, not a directory or regular file", contained_path)
263
+ continue
264
+ #
265
+ is_excluded = False
266
+ for current_ruleset in effective_ignore_rulesets:
267
+ is_excluded = current_ruleset.excludes_path(
268
+ contained_path, pre_excluded=is_excluded
269
+ )
270
+ #
271
+ if is_excluded:
272
+ debug("Ignoring %s", contained_path)
273
+ continue
274
+ #
275
+ if contained_path.is_file():
276
+ if self.use_relative_paths:
277
+ yield contained_path.relative_to(self.cwd)
278
+ else:
279
+ yield contained_path
280
+ #
281
+ #
282
+ if contained_path.is_dir():
283
+ debug("Descending into directory %s", contained_path.name)
284
+ yield from self.walk_recursively(
285
+ contained_path,
286
+ rulesets_to_pass,
287
+ vcs_bitmap,
288
+ )
289
+ #
290
+ #
291
+
292
+
293
+ def append_ruleset_from_path(
294
+ rulesets: list[RuleSet],
295
+ /,
296
+ path: Path,
297
+ anchored_at_dir: Path,
298
+ title: str = EMPTY,
299
+ ) -> bool:
300
+ """load a ruleset and append it to the list"""
301
+ if path.is_file():
302
+ try:
303
+ if path.name == MERCURIAL_IGNORE_FILE:
304
+ new_ruleset = RuleSet.from_hgignore_file(
305
+ path, anchored_at_dir, title=title
306
+ )
307
+ else:
308
+ new_ruleset = RuleSet.from_gitignore_file(
309
+ path, anchored_at_dir, title=title
310
+ )
311
+ #
312
+ except OSError as os_error:
313
+ debug(str(os_error))
314
+ return False
315
+ except NoRulesFound:
316
+ return False
317
+ #
318
+ rulesets.append(new_ruleset)
319
+ return True
320
+ #
321
+ return False
322
+
323
+
324
+ def compile_default_git_rulesets(
325
+ repository_root: Path,
326
+ with_user_global_gitignore: bool = False,
327
+ ) -> list[RuleSet]:
328
+ """Compile the default git rulesets"""
329
+ default_git_rulesets: list[RuleSet] = []
330
+ if with_user_global_gitignore:
331
+ append_ruleset_from_path(
332
+ default_git_rulesets,
333
+ path=get_configured_global_gitignore_path(),
334
+ anchored_at_dir=repository_root,
335
+ title="User global gitignore file",
336
+ )
337
+ #
338
+ append_ruleset_from_path(
339
+ default_git_rulesets,
340
+ path=repository_root / GIT_DIR / "info" / "exclude",
341
+ anchored_at_dir=repository_root,
342
+ )
343
+ return default_git_rulesets
@@ -13,9 +13,6 @@ from pathlib import Path
13
13
  GIT_COMMAND = "git"
14
14
  """The git command"""
15
15
 
16
- GIT_DIR = ".git"
17
- """The git directory in workspaces"""
18
-
19
16
  USER_HOME_PATH = Path.home()
20
17
  """The user’s home directory"""
21
18
 
@@ -4,13 +4,13 @@
4
4
  Ignore (gitignore, tba: hgignore) implementation
5
5
  """
6
6
 
7
+ from collections.abc import Iterable, Iterator
7
8
  from dataclasses import dataclass
8
9
  from enum import StrEnum
9
10
  from functools import lru_cache
10
11
  from logging import debug, warning
11
- from os.path import normcase
12
12
  from pathlib import Path
13
- from re import compile as re_compile, escape
13
+ from re import IGNORECASE, compile as re_compile, escape
14
14
  from typing import Self
15
15
 
16
16
  from .commons import (
@@ -50,6 +50,10 @@ Suitable for matching relative path segments.
50
50
  """
51
51
 
52
52
 
53
+ class NoRulesFound(Exception):
54
+ """Raised when no rules were found in a file"""
55
+
56
+
53
57
  class Style(StrEnum):
54
58
  """Ignore style"""
55
59
 
@@ -62,30 +66,45 @@ class Style(StrEnum):
62
66
  ROOTGLOB = "rootglob"
63
67
  """Mercurial ignore entry, anchored glob style"""
64
68
 
65
- REGEX = "regex"
66
- """Mercurial ignore entry, regex style"""
69
+ REGEXP = "regexp"
70
+ """Mercurial ignore entry, regexp style"""
67
71
 
68
72
 
69
- class CompiledPattern:
70
- """Pattern to match relative file paths"""
73
+ class ExclusionRule:
74
+ """Rule to match relative file paths"""
71
75
 
72
76
  def __init__(
73
- self, regex_pattern: str, negated: bool = False, use_normcase: bool = True
77
+ self, original_pattern: str, style: Style, case_sensitive: bool = True
74
78
  ) -> None:
75
79
  """compile the RE pattern
76
80
 
77
81
  Args:
78
- regex_pattern: the regular expression pattern
79
- negated: Flag indicating whether the pattern excludes (`False`)
80
- or re-includes a previously excluded path (`True`)
81
- use_normcase: Flag indicating whether to normalize the pattern
82
- and the relative paths according to the operating system
83
- defaults (`True`), or to always match case sensitive (`False`)
82
+ original_pattern: the roriginal pattern
83
+ style: the ignore rule style
84
+ case_sensitive: Flag indicating whether to use case sensitive
85
+ file name matching or not. Converts regex_pattern to lower case
86
+ if set `False`.
84
87
  """
85
- self.negated = negated
86
- self.regex_pattern = normcase(regex_pattern) if use_normcase else regex_pattern
87
- self.use_normcase = use_normcase
88
- self._cre = re_compile(regex_pattern)
88
+ self._negated = False
89
+ stripped_pattern = original_pattern.rstrip(CRLF)
90
+ if stripped_pattern.endswith(BACKSLASH) and not stripped_pattern[:-1].endswith(
91
+ BACKSLASH
92
+ ):
93
+ raise ValueError("Lines ending in a single backslash are not allowed")
94
+ #
95
+ self._original_pattern = stripped_pattern
96
+ effective_pattern = stripped_pattern
97
+ if style is Style.GITIGNORE and effective_pattern[0] == EXCLAMATION_MARK:
98
+ effective_pattern = effective_pattern[1:]
99
+ self._negated = True
100
+ #
101
+ self._style = style
102
+ regex_pattern = translate(effective_pattern, style=style)
103
+ self._cre = (
104
+ re_compile(regex_pattern)
105
+ if case_sensitive
106
+ else re_compile(regex_pattern, IGNORECASE)
107
+ )
89
108
 
90
109
  def matches(self, relative_path: str) -> bool:
91
110
  """Indicate if the pattern matches the relative path
@@ -96,110 +115,207 @@ class CompiledPattern:
96
115
  Returns:
97
116
  `True` if the path matches the pattern, `False` if not
98
117
  """
99
- if self.use_normcase:
100
- relative_path = normcase(relative_path)
118
+ if self._cre.match(relative_path):
119
+ debug(f"{relative_path!r} matched rule {self}")
120
+ return True
101
121
  #
102
- return self._cre.match(relative_path) is not None
122
+ return False
103
123
 
104
- @classmethod
105
- def from_gitignore_line(
106
- cls, gitignore_line: str, use_normcase: bool = True
107
- ) -> Self:
108
- """Factory method: compiled pattern from a `.gitignore` entry
124
+ def flip(self, relative_path: str, pre_excluded: bool) -> bool:
125
+ """Flips the exclude flag if required
126
+
127
+ Respecting the current exclusion state, matching is only performed
128
+ if required (no mathing of an already-excluded path against a rule that
129
+ could exclude it, and also no matching of an included path against a rule
130
+ that could re-include it).
131
+
132
+ * pre-excluded and self._negated -> flip if the rule matches
133
+ * not pre-excluded, self not negated -> flip if the rule matches
109
134
 
110
135
  Args:
111
- gitignore_line: a line from a `.gitignore` file
112
- (or `$GIT_DIR/info/exclude`, or the file specified by the
113
- `core.excludesFile` configuration directive)
114
- use_normcase: Flag indicating whether to normalize the pattern
115
- and the relative paths according to the operating system
116
- defaults (`True`), or to always match case sensitive (`False`)
136
+ relative_path: the pathc to match
137
+ pre_excluded: exclusion state before applying the rule
117
138
 
118
139
  Returns:
119
- a new instance
120
-
121
- Raises:
122
- ValueError: if the line ends in a single backslash
140
+ the new exclusion state
123
141
  """
124
- gitignore_line = gitignore_line.rstrip(CRLF)
125
- if gitignore_line.endswith(BACKSLASH) and not gitignore_line[:-1].endswith(
126
- BACKSLASH
127
- ):
128
- raise ValueError("Lines ending in a single backslash are not allowed")
129
- #
130
- _negated = False
131
- if gitignore_line.startswith("!"):
132
- _negated = True
133
- gitignore_line = gitignore_line[1:]
142
+ if self._negated != pre_excluded or not self.matches(relative_path):
143
+ return pre_excluded
134
144
  #
135
- return cls(
136
- gitignore_pattern_to_full_regex(gitignore_line),
137
- negated=_negated,
138
- use_normcase=use_normcase,
145
+ return not pre_excluded
146
+
147
+ def __str__(self) -> str:
148
+ """String display
149
+
150
+ Returns:
151
+ the representation of the original pattern, with an explicit
152
+ abbotation if it is case INsensitive.
153
+ """
154
+ cs_display = " (CASE INSENSITIVE)" if self._cre.flags & IGNORECASE else EMPTY
155
+ return f"{self._original_pattern!r}{cs_display}"
156
+
157
+ def __repr__(self) -> str:
158
+ """String representation
159
+
160
+ Returns:
161
+ the Python expression that would create an equivalent instance
162
+ """
163
+ cs_display = not self._cre.flags & IGNORECASE
164
+ return (
165
+ f"self.__class__.__name__({self._original_pattern!r},"
166
+ f" style={self._style}, case_sensitive={cs_display})"
139
167
  )
140
168
 
141
169
 
142
170
  @dataclass(frozen=True)
143
- class IgnoreFile:
144
- """Ignorefile data class"""
171
+ class RuleSet:
172
+ """Collection of ignore rules"""
173
+
174
+ title: str
175
+ """Ruleset title"""
145
176
 
146
- handles_directory: Path
147
- """The handled directory"""
177
+ anchored_at_dir: Path
178
+ """The directory where the rules are anchored"""
148
179
 
149
- rules: tuple[CompiledPattern, ...]
180
+ rules: tuple[ExclusionRule, ...]
150
181
  """The ignore rules set"""
151
182
 
152
183
  @classmethod
153
- def from_gitignore(cls, source: Path, handles: Path) -> Self:
154
- """Create an ignore file from the gitignore file"""
155
- rules_collector: list[CompiledPattern] = []
156
- debug("Reading gitignore file %s …", source)
184
+ def from_gitignore_file(
185
+ cls, source: Path, anchored_at_dir: Path, title: str = EMPTY
186
+ ) -> Self:
187
+ """Factory method: Create a new instance from the provided gitignore file
188
+
189
+ Args:
190
+ source: the gitignore file path
191
+ anchored_at_dir: the directory where the rules are anchored at
192
+ title: the title of the new ruleset
193
+
194
+ Returns:
195
+ the new instance
196
+
197
+ Raises:
198
+ NoRulesFound: if no rules were defined in the file
199
+ """
157
200
  with open(source, mode="rt", encoding=UTF_8) as gitignore_file:
158
- for line in gitignore_file:
159
- if line.startswith("#") or not line.strip():
160
- continue
161
- #
162
- try:
163
- rules_collector.append(CompiledPattern.from_gitignore_line(line))
164
- except ValueError as value_error:
165
- warning(str(value_error))
166
- #
167
- #
201
+ defined_rules = tuple(iter_gitignore_rules_from_lines(gitignore_file))
202
+ #
203
+ if not defined_rules:
204
+ raise NoRulesFound
205
+ #
206
+ if not title:
207
+ title = str(source)
168
208
  #
169
- n_rules = len(rules_collector)
170
- debug("Read %s rules from %s", n_rules, source)
171
- return cls(handles_directory=handles, rules=tuple(rules_collector))
209
+ debug("%s: defined %s rules", title, len(defined_rules))
210
+ return cls(title=title, anchored_at_dir=anchored_at_dir, rules=defined_rules)
211
+
212
+ @classmethod
213
+ def from_hgignore_file(
214
+ cls, source: Path, anchored_at_dir: Path, title: str = EMPTY
215
+ ) -> Self:
216
+ """Factory method: Create a new instance from the provided .hgignore file
217
+
218
+ Args:
219
+ source: the .hgignore file path
220
+ anchored_at_dir: the directory where the rules are anchored at
221
+ title: the title of the new ruleset
222
+
223
+ Returns:
224
+ the new instance
225
+
226
+ Raises:
227
+ NoRulesFound: if no rules were defined in the file
228
+ """
229
+ with open(source, mode="rt", encoding=UTF_8) as gitignore_file:
230
+ defined_rules = tuple(iter_hgignore_rules_from_lines(gitignore_file))
231
+ #
232
+ if not defined_rules:
233
+ raise NoRulesFound
234
+ #
235
+ if not title:
236
+ title = str(source)
237
+ #
238
+ debug("%s: defined %s rules", title, len(defined_rules))
239
+ return cls(title=title, anchored_at_dir=anchored_at_dir, rules=defined_rules)
172
240
 
173
241
  def excludes_path(self, file_or_dir: Path, pre_excluded: bool = False) -> bool:
174
- """Return True if the named f file or di is excluded"""
242
+ """Return True if the named file or dir is excluded"""
243
+ # fast path if no roles are stored
244
+ if not self.rules:
245
+ return pre_excluded
246
+ #
175
247
  is_excluded = pre_excluded
176
- relative_path = file_or_dir.relative_to(self.handles_directory)
248
+ relative_path = file_or_dir.relative_to(self.anchored_at_dir)
177
249
  comparable = relative_path.as_posix()
178
- if relative_path.is_dir():
250
+ if file_or_dir.is_dir():
179
251
  comparable = f"{comparable}/"
180
252
  #
253
+ last_matching_rule = self.rules[0]
181
254
  for single_rule in self.rules:
182
- if single_rule.matches(comparable):
183
- if is_excluded and single_rule.negated:
184
- debug(
185
- "Re-including %s because of negated %s",
186
- file_or_dir,
187
- single_rule.regex_pattern,
188
- )
189
- is_excluded = False
190
- else:
191
- debug(
192
- "Excluding %s because of %s",
193
- file_or_dir,
194
- single_rule.regex_pattern,
195
- )
196
- is_excluded = True
197
- #
255
+ flipped = single_rule.flip(comparable, is_excluded)
256
+ if flipped ^ is_excluded:
257
+ last_matching_rule = single_rule
258
+ is_excluded = flipped
198
259
  #
199
260
  #
261
+ if is_excluded ^ pre_excluded:
262
+ new_state = "excluded" if is_excluded else "re-included"
263
+ debug(f"{new_state} by rule {last_matching_rule} from {self.title}")
264
+ #
200
265
  return is_excluded
201
266
 
202
267
 
268
+ def iter_gitignore_rules_from_lines(lines: Iterable[str]) -> Iterator[ExclusionRule]:
269
+ """Create rules from the provided lines
270
+
271
+ Args:
272
+ lines: an Iterable of gitignore-style lines
273
+
274
+ Returns:
275
+ an Iterator over the created rules
276
+ """
277
+ for current_line in lines:
278
+ if current_line.startswith("#") or not current_line.strip():
279
+ continue
280
+ #
281
+ try:
282
+ yield ExclusionRule(current_line, style=Style.GITIGNORE)
283
+ except ValueError as value_error:
284
+ warning(str(value_error))
285
+ #
286
+ #
287
+
288
+
289
+ def iter_hgignore_rules_from_lines(lines: Iterable[str]) -> Iterator[ExclusionRule]:
290
+ """Create rules from the provided lines
291
+
292
+ Args:
293
+ lines: an Iterable of mercurial-style lines
294
+
295
+ Returns:
296
+ an Iterator over the created rules
297
+ """
298
+ current_style = Style.REGEXP
299
+ for current_line in lines:
300
+ if current_line.startswith("#") or not current_line.strip():
301
+ continue
302
+ #
303
+ if current_line.startswith("syntax: "):
304
+ try:
305
+ current_style = Style(current_line.split()[1])
306
+ except ValueError as value_error:
307
+ warning("Invalid syntax: %s", value_error)
308
+ #
309
+ continue
310
+ #
311
+ try:
312
+ yield ExclusionRule(current_line, style=current_style)
313
+ except ValueError as value_error:
314
+ warning(str(value_error))
315
+ #
316
+ #
317
+
318
+
203
319
  def translate(pattern: str, style: Style = Style.GITIGNORE) -> str:
204
320
  """Dispatch pattern to the appropriate translation function,
205
321
  according to the style argument
@@ -218,7 +334,7 @@ def translate(pattern: str, style: Style = Style.GITIGNORE) -> str:
218
334
  match style:
219
335
  case Style.GITIGNORE:
220
336
  return gitignore_pattern_to_full_regex(pattern)
221
- case Style.REGEX:
337
+ case Style.REGEXP:
222
338
  return pattern
223
339
  case _:
224
340
  raise NotImplementedError
@@ -366,7 +482,7 @@ def dissect_glob_segment(
366
482
  the sequence of regex chunks and the sequence of star positions
367
483
  in the chunks sequence.
368
484
  This result is post-processed in the
369
- [glob_segment_to_regex_segment][offgrep.ignore.glob_segment_to_regex_segment]
485
+ [glob_segment_to_regex_segment][offgrep.path_matching.glob_segment_to_regex_segment]
370
486
  function which calls `dissect_glob_segment()` internally.
371
487
  """
372
488
  re_chunks: list[str] = []
@@ -431,7 +547,7 @@ def bracketed_glob_pattern_part_to_partial_regex(
431
547
 
432
548
  !!! note
433
549
  Factored out from the
434
- [dissect_glob_segment()][offgrep.ignore.dissect_glob_segment]
550
+ [dissect_glob_segment()][offgrep.path_matching.dissect_glob_segment]
435
551
  function
436
552
 
437
553
  Args:
@@ -1,182 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
-
3
- """
4
- Filesystem related functionality
5
- """
6
-
7
- from collections.abc import Iterator
8
- from logging import debug
9
- from pathlib import Path
10
-
11
- from .git import GIT_DIR, get_configured_global_gitignore_path
12
- from .ignore import CompiledPattern, IgnoreFile
13
-
14
-
15
- GIT_IGNORE_FILE = ".gitignore"
16
-
17
- DEFAULT_IGNORE_FILES = (GIT_IGNORE_FILE,)
18
-
19
-
20
- class IgnoredDirectoryError(Exception):
21
- """raised if a directory was explicitly excluded"""
22
-
23
-
24
- def load_gitignore_file(path: Path, handles: Path, description: str) -> IgnoreFile:
25
- """load an ignore file"""
26
- if path.is_file():
27
- return IgnoreFile.from_gitignore(path, handles)
28
- #
29
- raise ValueError(f"{description} ignore file {path} not found")
30
-
31
-
32
- def iter_target_files(start_directory: Path) -> Iterator[Path]:
33
- """Iterate over target files"""
34
- absolute_start = start_directory.resolve()
35
- debug("Compiling ignore files for directory %s …", absolute_start)
36
- ignore_files = compile_ignore_files(absolute_start)
37
- debug("Walking down directory %s", absolute_start)
38
- yield from walk_files(absolute_start, absolute_start, ignore_files)
39
-
40
-
41
- # pylint: disable=too-many-locals ; refactoring candidate
42
-
43
-
44
- def compile_ignore_files(
45
- start_directory: Path, check_git: bool = True
46
- ) -> list[IgnoreFile]:
47
- """Compile ignore files for start_directory"""
48
- absolute_start = start_directory.resolve()
49
- assert absolute_start.is_dir()
50
- git_ignore_files: list[IgnoreFile] = []
51
- if check_git:
52
- dirs_to_check = [absolute_start, *absolute_start.parents]
53
- checked_dirs = []
54
- for pointer in dirs_to_check:
55
- debug("Checking %s for %s …", pointer, GIT_DIR)
56
- checked_dirs.append(pointer)
57
- candidate = pointer / GIT_DIR
58
- if candidate.is_dir():
59
- debug("… found! Compiling ignore rules …")
60
- git_ignore_files.append(
61
- IgnoreFile(
62
- handles_directory=pointer,
63
- rules=(CompiledPattern.from_gitignore_line(f"{GIT_DIR}/"),),
64
- )
65
- )
66
- for description, file_path in (
67
- ("user global", get_configured_global_gitignore_path()),
68
- ("repo private", candidate / "info" / "exclude"),
69
- ):
70
- try:
71
- git_ignore_files.append(
72
- load_gitignore_file(file_path, pointer, description)
73
- )
74
- except (OSError, ValueError) as error:
75
- debug(str(error))
76
- #
77
- #
78
- break
79
- #
80
- else:
81
- debug("No git repository found in any parent, continuing without …")
82
- #
83
- # walk back up and handle intermediate ignore files,
84
- # except for the start directory itself (that is done when walking it)
85
- if git_ignore_files:
86
- for idx in range(len(checked_dirs) - 1, 0, -1):
87
- current_dir = checked_dirs[idx]
88
- try:
89
- intermediate_ignore_file = load_gitignore_file(
90
- current_dir / GIT_IGNORE_FILE,
91
- current_dir,
92
- "intermediate",
93
- )
94
- except (OSError, ValueError) as error:
95
- debug(str(error))
96
- else:
97
- git_ignore_files.append(intermediate_ignore_file)
98
- #
99
- dir_to_check = checked_dirs[idx - 1]
100
- is_excluded = False
101
- for current_ignore_file in git_ignore_files:
102
- is_excluded = current_ignore_file.excludes_path(
103
- dir_to_check, pre_excluded=is_excluded
104
- )
105
- #
106
- if is_excluded:
107
- raise IgnoredDirectoryError(
108
- f"gitignore config excludes directory {dir_to_check},"
109
- " cannot continue"
110
- )
111
- #
112
- #
113
- #
114
- #
115
- return git_ignore_files
116
-
117
-
118
- def walk_files(
119
- start_directory: Path,
120
- working_directory: Path,
121
- ignore_files: list[IgnoreFile],
122
- use_ignore_files: tuple[str, ...] = DEFAULT_IGNORE_FILES,
123
- ) -> Iterator[Path]:
124
- """Walk through all contained files, respecting the ignore rules.
125
-
126
- Yield paths relative to the start directory if the start directory
127
- is the current working directory.
128
- """
129
- assert start_directory.is_absolute()
130
- use_relative_paths = False
131
- cwd = Path.cwd()
132
- anchor_directory = cwd
133
- if start_directory.is_relative_to(Path.cwd()):
134
- use_relative_paths = True
135
- #
136
- absolute_workdir = working_directory.resolve()
137
- effective_ignore_files = list(ignore_files)
138
- for ignore_file_candidate in use_ignore_files:
139
- try:
140
- effective_ignore_files.append(
141
- load_gitignore_file(
142
- absolute_workdir / ignore_file_candidate,
143
- absolute_workdir,
144
- "Directory level",
145
- )
146
- )
147
- except (OSError, ValueError) as error:
148
- debug(str(error))
149
- #
150
- #
151
- for contained_path in sorted(absolute_workdir.glob("*"), key=Path.as_posix):
152
- # name = contained_path.name
153
- if not (contained_path.is_dir() or contained_path.is_file()):
154
- debug("Skipping %s, not a directory or regular file", contained_path)
155
- continue
156
- #
157
- is_excluded = False
158
- for current_ignore_file in effective_ignore_files:
159
- is_excluded = current_ignore_file.excludes_path(
160
- contained_path, pre_excluded=is_excluded
161
- )
162
- #
163
- if is_excluded:
164
- continue
165
- #
166
- if contained_path.is_file():
167
- if use_relative_paths:
168
- yield contained_path.relative_to(anchor_directory)
169
- else:
170
- yield contained_path
171
- #
172
- #
173
- if contained_path.is_dir():
174
- debug("Descending into directory %s", contained_path.name)
175
- yield from walk_files(
176
- start_directory,
177
- contained_path,
178
- effective_ignore_files,
179
- use_ignore_files=use_ignore_files,
180
- )
181
- #
182
- #
File without changes
File without changes
File without changes