offgrep 0.1.1__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.1
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.1"
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"
@@ -104,6 +104,7 @@ def find_in_files(
104
104
  def grep_files(arguments: Namespace) -> int:
105
105
  """Search for text in files"""
106
106
  # TODO: arguments.file → read regexps from the provided file
107
+ default_ignore_dirs = arguments.default_ignore_dirs.split(",")
107
108
  pattern_and_or_paths = list(arguments.pattern_and_or_paths)
108
109
  try:
109
110
  pattern = pattern_and_or_paths.pop(0)
@@ -129,11 +130,12 @@ def grep_files(arguments: Namespace) -> int:
129
130
  debug("Omitting ignore rules for file %s", single_path)
130
131
  target_paths.append(single_path)
131
132
  elif single_path.is_dir():
132
- try:
133
- target_paths.extend(iter_target_files(single_path))
134
- except IgnoredDirectoryError as id_error:
135
- error(str(id_error))
136
- #
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())
137
139
  #
138
140
  #
139
141
  find_in_files(target_paths, pattern, show_files=show_files)
@@ -142,6 +144,7 @@ def grep_files(arguments: Namespace) -> int:
142
144
 
143
145
  def list_files(arguments: Namespace) -> int:
144
146
  """List files that would be searched"""
147
+ default_ignore_dirs = arguments.default_ignore_dirs.split(",")
145
148
  paths = [Path(item) for item in arguments.pattern_and_or_paths]
146
149
  if not paths:
147
150
  paths.append(Path.cwd())
@@ -151,12 +154,13 @@ def list_files(arguments: Namespace) -> int:
151
154
  debug("Omitting ignore rules for file %s", single_path)
152
155
  print(single_path)
153
156
  elif single_path.is_dir():
154
- try:
155
- for target_path in iter_target_files(single_path):
156
- print(target_path)
157
- #
158
- except IgnoredDirectoryError as id_error:
159
- 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)
160
164
  #
161
165
  else:
162
166
  warning("Ingored %s, neither a regular file nor a directory", single_path)
@@ -165,7 +169,10 @@ def list_files(arguments: Namespace) -> int:
165
169
  return 0
166
170
 
167
171
 
168
- # 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
169
176
 
170
177
 
171
178
  def get_arguments() -> Namespace:
@@ -182,7 +189,11 @@ def get_arguments() -> Namespace:
182
189
  main_parser = ArgumentParser(
183
190
  prog="offgrep", description="searches text in (text) files recursively"
184
191
  )
185
- 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
+ )
186
197
  main_parser.add_argument("-V", "--version", action="version", version=version)
187
198
  main_parser.add_argument(
188
199
  "--debug",
@@ -191,11 +202,32 @@ def get_arguments() -> Namespace:
191
202
  dest="loglevel",
192
203
  help="log with debug level",
193
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
+ )
194
221
  main_parser.add_argument(
195
222
  "--files",
196
223
  action="store_true",
197
- help="print each file that would be searched without actually"
198
- " 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.",
199
231
  )
200
232
  main_parser.add_argument(
201
233
  "pattern_and_or_paths",
@@ -218,6 +250,10 @@ def main() -> int:
218
250
  the script rteurncode
219
251
  """
220
252
  arguments = get_arguments()
253
+ if arguments.type_list:
254
+ print_supported_file_tyoes()
255
+ return 0
256
+ #
221
257
  if arguments.files:
222
258
  return list_files(arguments)
223
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,12 +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
12
  from pathlib import Path
12
- from re import compile as re_compile, escape
13
+ from re import IGNORECASE, compile as re_compile, escape
13
14
  from typing import Self
14
15
 
15
16
  from .commons import (
@@ -49,6 +50,10 @@ Suitable for matching relative path segments.
49
50
  """
50
51
 
51
52
 
53
+ class NoRulesFound(Exception):
54
+ """Raised when no rules were found in a file"""
55
+
56
+
52
57
  class Style(StrEnum):
53
58
  """Ignore style"""
54
59
 
@@ -61,30 +66,45 @@ class Style(StrEnum):
61
66
  ROOTGLOB = "rootglob"
62
67
  """Mercurial ignore entry, anchored glob style"""
63
68
 
64
- REGEX = "regex"
65
- """Mercurial ignore entry, regex style"""
69
+ REGEXP = "regexp"
70
+ """Mercurial ignore entry, regexp style"""
66
71
 
67
72
 
68
- class CompiledPattern:
69
- """Pattern to match relative file paths"""
73
+ class ExclusionRule:
74
+ """Rule to match relative file paths"""
70
75
 
71
76
  def __init__(
72
- self, regex_pattern: str, negated: bool = False, case_sensitive: bool = True
77
+ self, original_pattern: str, style: Style, case_sensitive: bool = True
73
78
  ) -> None:
74
79
  """compile the RE pattern
75
80
 
76
81
  Args:
77
- regex_pattern: the regular expression pattern
78
- negated: Flag indicating whether the pattern excludes (`False`)
79
- or re-includes a previously excluded path (`True`)
82
+ original_pattern: the roriginal pattern
83
+ style: the ignore rule style
80
84
  case_sensitive: Flag indicating whether to use case sensitive
81
85
  file name matching or not. Converts regex_pattern to lower case
82
86
  if set `False`.
83
87
  """
84
- self.negated = negated
85
- self.regex_pattern = regex_pattern.lower() if case_sensitive else regex_pattern
86
- self.case_sensitive = case_sensitive
87
- 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
+ )
88
108
 
89
109
  def matches(self, relative_path: str) -> bool:
90
110
  """Indicate if the pattern matches the relative path
@@ -95,109 +115,207 @@ class CompiledPattern:
95
115
  Returns:
96
116
  `True` if the path matches the pattern, `False` if not
97
117
  """
98
- if self.case_sensitive:
99
- relative_path = relative_path.lower()
118
+ if self._cre.match(relative_path):
119
+ debug(f"{relative_path!r} matched rule {self}")
120
+ return True
100
121
  #
101
- return self._cre.match(relative_path) is not None
122
+ return False
102
123
 
103
- @classmethod
104
- def from_gitignore_line(
105
- cls, gitignore_line: str, case_sensitive: bool = True
106
- ) -> Self:
107
- """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
108
134
 
109
135
  Args:
110
- gitignore_line: a line from a `.gitignore` file
111
- (or `$GIT_DIR/info/exclude`, or the file specified by the
112
- `core.excludesFile` configuration directive)
113
- case_sensitive: Flag indicating whether to use case sensitive
114
- file name matching or not
136
+ relative_path: the pathc to match
137
+ pre_excluded: exclusion state before applying the rule
115
138
 
116
139
  Returns:
117
- a new instance
118
-
119
- Raises:
120
- ValueError: if the line ends in a single backslash
140
+ the new exclusion state
121
141
  """
122
- gitignore_line = gitignore_line.rstrip(CRLF)
123
- if gitignore_line.endswith(BACKSLASH) and not gitignore_line[:-1].endswith(
124
- BACKSLASH
125
- ):
126
- raise ValueError("Lines ending in a single backslash are not allowed")
142
+ if self._negated != pre_excluded or not self.matches(relative_path):
143
+ return pre_excluded
127
144
  #
128
- _negated = False
129
- if gitignore_line.startswith("!"):
130
- _negated = True
131
- gitignore_line = gitignore_line[1:]
132
- #
133
- return cls(
134
- gitignore_pattern_to_full_regex(gitignore_line),
135
- negated=_negated,
136
- case_sensitive=case_sensitive,
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})"
137
167
  )
138
168
 
139
169
 
140
170
  @dataclass(frozen=True)
141
- class IgnoreFile:
142
- """Ignorefile data class"""
171
+ class RuleSet:
172
+ """Collection of ignore rules"""
173
+
174
+ title: str
175
+ """Ruleset title"""
143
176
 
144
- handles_directory: Path
145
- """The handled directory"""
177
+ anchored_at_dir: Path
178
+ """The directory where the rules are anchored"""
146
179
 
147
- rules: tuple[CompiledPattern, ...]
180
+ rules: tuple[ExclusionRule, ...]
148
181
  """The ignore rules set"""
149
182
 
150
183
  @classmethod
151
- def from_gitignore(cls, source: Path, handles: Path) -> Self:
152
- """Create an ignore file from the gitignore file"""
153
- rules_collector: list[CompiledPattern] = []
154
- 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
+ """
155
200
  with open(source, mode="rt", encoding=UTF_8) as gitignore_file:
156
- for line in gitignore_file:
157
- if line.startswith("#") or not line.strip():
158
- continue
159
- #
160
- try:
161
- rules_collector.append(CompiledPattern.from_gitignore_line(line))
162
- except ValueError as value_error:
163
- warning(str(value_error))
164
- #
165
- #
201
+ defined_rules = tuple(iter_gitignore_rules_from_lines(gitignore_file))
202
+ #
203
+ if not defined_rules:
204
+ raise NoRulesFound
166
205
  #
167
- n_rules = len(rules_collector)
168
- debug("Read %s rules from %s", n_rules, source)
169
- return cls(handles_directory=handles, rules=tuple(rules_collector))
206
+ if not title:
207
+ title = str(source)
208
+ #
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)
170
240
 
171
241
  def excludes_path(self, file_or_dir: Path, pre_excluded: bool = False) -> bool:
172
- """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
+ #
173
247
  is_excluded = pre_excluded
174
- relative_path = file_or_dir.relative_to(self.handles_directory)
248
+ relative_path = file_or_dir.relative_to(self.anchored_at_dir)
175
249
  comparable = relative_path.as_posix()
176
- if relative_path.is_dir():
250
+ if file_or_dir.is_dir():
177
251
  comparable = f"{comparable}/"
178
252
  #
253
+ last_matching_rule = self.rules[0]
179
254
  for single_rule in self.rules:
180
- if single_rule.matches(comparable):
181
- if is_excluded and single_rule.negated:
182
- debug(
183
- "Re-including %s because of negated %s",
184
- file_or_dir,
185
- single_rule.regex_pattern,
186
- )
187
- is_excluded = False
188
- else:
189
- debug(
190
- "Excluding %s because of %s",
191
- file_or_dir,
192
- single_rule.regex_pattern,
193
- )
194
- is_excluded = True
195
- #
255
+ flipped = single_rule.flip(comparable, is_excluded)
256
+ if flipped ^ is_excluded:
257
+ last_matching_rule = single_rule
258
+ is_excluded = flipped
196
259
  #
197
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
+ #
198
265
  return is_excluded
199
266
 
200
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
+
201
319
  def translate(pattern: str, style: Style = Style.GITIGNORE) -> str:
202
320
  """Dispatch pattern to the appropriate translation function,
203
321
  according to the style argument
@@ -216,7 +334,7 @@ def translate(pattern: str, style: Style = Style.GITIGNORE) -> str:
216
334
  match style:
217
335
  case Style.GITIGNORE:
218
336
  return gitignore_pattern_to_full_regex(pattern)
219
- case Style.REGEX:
337
+ case Style.REGEXP:
220
338
  return pattern
221
339
  case _:
222
340
  raise NotImplementedError
@@ -364,7 +482,7 @@ def dissect_glob_segment(
364
482
  the sequence of regex chunks and the sequence of star positions
365
483
  in the chunks sequence.
366
484
  This result is post-processed in the
367
- [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]
368
486
  function which calls `dissect_glob_segment()` internally.
369
487
  """
370
488
  re_chunks: list[str] = []
@@ -429,7 +547,7 @@ def bracketed_glob_pattern_part_to_partial_regex(
429
547
 
430
548
  !!! note
431
549
  Factored out from the
432
- [dissect_glob_segment()][offgrep.ignore.dissect_glob_segment]
550
+ [dissect_glob_segment()][offgrep.path_matching.dissect_glob_segment]
433
551
  function
434
552
 
435
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