offgrep 0.0.2__tar.gz → 0.1.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.0.2
3
+ Version: 0.1.0
4
4
  Summary: A lousy ripgrep rip-off in pure Python
5
5
  Keywords: grep,recursive
6
6
  Author: Rainer Schwarzbach
@@ -40,6 +40,7 @@ Classifier: Intended Audience :: Developers
40
40
  Classifier: Intended Audience :: System Administrators
41
41
  Requires-Python: >=3.11
42
42
  Project-URL: Homepage, https://gitlab.com/blackstream-x/offgrep
43
+ Project-URL: Changelog, https://gitlab.com/blackstream-x/offgrep/-/blob/main/CHANGELOG.md
43
44
  Project-URL: CI, https://gitlab.com/blackstream-x/offgrep/-/pipelines
44
45
  Project-URL: Bug Tracker, https://gitlab.com/blackstream-x/offgrep/-/issues
45
46
  Project-URL: Repository, https://gitlab.com/blackstream-x/offgrep.git
@@ -5,7 +5,7 @@ build-backend = "uv_build"
5
5
 
6
6
  [project]
7
7
  name = "offgrep"
8
- version = "0.0.2"
8
+ version = "0.1.0"
9
9
  description = "A lousy ripgrep rip-off in pure Python"
10
10
  readme = "README.md"
11
11
  authors = [
@@ -43,6 +43,7 @@ og = "offgrep.__main__:main"
43
43
 
44
44
  [project.urls]
45
45
  Homepage = "https://gitlab.com/blackstream-x/offgrep"
46
+ Changelog = "https://gitlab.com/blackstream-x/offgrep/-/blob/main/CHANGELOG.md"
46
47
  CI = "https://gitlab.com/blackstream-x/offgrep/-/pipelines"
47
48
  "Bug Tracker" = "https://gitlab.com/blackstream-x/offgrep/-/issues"
48
49
  Repository = "https://gitlab.com/blackstream-x/offgrep.git"
@@ -0,0 +1,228 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ """
4
+ Call as script
5
+ """
6
+
7
+ import sys
8
+
9
+ from argparse import ArgumentParser, Namespace, REMAINDER
10
+ from importlib.metadata import PackageNotFoundError, version as metadata_version
11
+ from logging import DEBUG, WARNING, basicConfig, debug, error, warning
12
+ from pathlib import Path
13
+ from re import compile as re_compile
14
+ from tomllib import loads as tomli_loads
15
+
16
+ from .commons import UTF_8
17
+ from .filesystem import IgnoredDirectoryError, iter_target_files
18
+
19
+
20
+ PACKAGE_NAME = "offgrep"
21
+ """The package name for metadata inspection"""
22
+ ANSI_HI = "\x1b[1m"
23
+ ANSI_NORMAL = "\x1b[0m"
24
+
25
+
26
+ def get_file_contents(file_name: str, up_dirs: int = 0) -> str:
27
+ """get contents of a file in an ancestor directory
28
+ of the current file
29
+
30
+ ...
31
+ """
32
+ file_dir_path = Path(__file__).resolve()
33
+ for _ in range(up_dirs):
34
+ file_dir_path = file_dir_path.parent
35
+ #
36
+ file_path = file_dir_path / file_name
37
+ return file_path.read_text(encoding=UTF_8)
38
+
39
+
40
+ def get_metadata_version(
41
+ metadata_file_name: str = "pyproject.toml", up_dirs: int = 3
42
+ ) -> str:
43
+ """get version information from _metadata_file_name_ in an ancestor directory
44
+
45
+ ...
46
+ """
47
+ try:
48
+ metadata_file_contents = get_file_contents(metadata_file_name, up_dirs=up_dirs)
49
+ except IOError as io_error:
50
+ return str(io_error)
51
+ #
52
+ metadata = tomli_loads(metadata_file_contents)
53
+ try:
54
+ # logging.warning(str(metadata.items()))
55
+ version = metadata["project"]["version"]
56
+ except KeyError:
57
+ return f"Error: no version information in metadata from {metadata_file_name}"
58
+ #
59
+ return f"{version} (read directly from {metadata_file_name})"
60
+
61
+
62
+ def find_in_files(
63
+ target_paths: list[Path], pattern: str, show_files: bool = False
64
+ ) -> int:
65
+ """Find a regular expression in all files"""
66
+ compiled_re = re_compile(pattern)
67
+ previous_matches = 0
68
+ for single_file in target_paths:
69
+ findings: list[tuple[int, str]] = []
70
+ try:
71
+ with open(single_file, mode="rt", encoding=UTF_8) as input_file:
72
+ for lineno, line in enumerate(input_file, start=1):
73
+ if compiled_re.search(line):
74
+ findings.append((lineno, line.rstrip()))
75
+ #
76
+ #
77
+ #
78
+ except UnicodeDecodeError as unicode_error:
79
+ debug("assuming binary file %s because of %r", single_file, unicode_error)
80
+ except OSError as os_error:
81
+ error(str(os_error))
82
+ continue
83
+ #
84
+ line_numbers = [lineno for (lineno, line) in findings]
85
+ if line_numbers:
86
+ number_width = max(len(str(max(line_numbers))), 6)
87
+ if show_files:
88
+ if previous_matches:
89
+ print()
90
+ #
91
+ print(f"{ANSI_HI}{single_file} ::{ANSI_NORMAL}")
92
+ #
93
+ for lineno, line in findings:
94
+ print(f"{lineno:{number_width}d}: {line}")
95
+ #
96
+ previous_matches += 1
97
+ #
98
+ #
99
+ return previous_matches
100
+
101
+
102
+ def grep_files(arguments: Namespace) -> int:
103
+ """Search for text in files"""
104
+ # TODO: arguments.file → read regexps from the provided file
105
+ pattern_and_or_paths = list(arguments.pattern_and_or_paths)
106
+ try:
107
+ pattern = pattern_and_or_paths.pop(0)
108
+ except IndexError:
109
+ error("No pattern provided")
110
+ return 1
111
+ #
112
+ debug("Provided pattern: %r", pattern)
113
+ paths = [Path(single_path) for single_path in pattern_and_or_paths]
114
+ if not paths:
115
+ paths.append(Path.cwd())
116
+ #
117
+ if not paths:
118
+ raise NotImplementedError("Filter functionality is planned, but …")
119
+ #
120
+ show_files = True
121
+ if len(paths) == 1 and paths[0].is_file():
122
+ show_files = False
123
+ #
124
+ target_paths = []
125
+ for single_path in paths:
126
+ if single_path.is_file():
127
+ debug("Omitting ignore rules for file %s", single_path)
128
+ target_paths.append(single_path)
129
+ 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
+ #
135
+ #
136
+ #
137
+ find_in_files(target_paths, pattern, show_files=show_files)
138
+ return 0
139
+
140
+
141
+ def list_files(arguments: Namespace) -> int:
142
+ """List files that would be searched"""
143
+ paths = [Path(item) for item in arguments.pattern_and_or_paths]
144
+ if not paths:
145
+ paths.append(Path.cwd())
146
+ #
147
+ for single_path in paths:
148
+ if single_path.is_file():
149
+ debug("Omitting ignore rules for file %s", single_path)
150
+ print(single_path)
151
+ 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))
158
+ #
159
+ else:
160
+ warning("Ingored %s, neither a regular file nor a directory", single_path)
161
+ #
162
+ #
163
+ return 0
164
+
165
+
166
+ # pylint: enable=unused-argument
167
+
168
+
169
+ def get_arguments() -> Namespace:
170
+ """Parse commandline arguments and configure logging
171
+
172
+ Returns:
173
+ the parsed command line arguments
174
+ """
175
+ try:
176
+ version = metadata_version(PACKAGE_NAME)
177
+ except PackageNotFoundError:
178
+ version = get_metadata_version()
179
+ #
180
+ main_parser = ArgumentParser(
181
+ prog="offgrep", description="searches text in (text) files recursively"
182
+ )
183
+ main_parser.set_defaults(loglevel=WARNING)
184
+ main_parser.add_argument("-V", "--version", action="version", version=version)
185
+ main_parser.add_argument(
186
+ "--debug",
187
+ action="store_const",
188
+ const=DEBUG,
189
+ dest="loglevel",
190
+ help="log with debug level",
191
+ )
192
+ main_parser.add_argument(
193
+ "--files",
194
+ action="store_true",
195
+ help="print each file that would be searched without actually"
196
+ " performing the search.",
197
+ )
198
+ main_parser.add_argument(
199
+ "pattern_and_or_paths",
200
+ nargs=REMAINDER,
201
+ metavar="Pattern and/or path(s)",
202
+ help="Exact usage depends on the other options:"
203
+ " No pattern with --file … or -files,"
204
+ " no path(s) when used as a filter."
205
+ " With --type-list, these argumemts are ignored completely.",
206
+ )
207
+ arguments = main_parser.parse_args()
208
+ basicConfig(level=arguments.loglevel)
209
+ return arguments
210
+
211
+
212
+ def main() -> int:
213
+ """Main script interface
214
+
215
+ Returns:
216
+ the script rteurncode
217
+ """
218
+ arguments = get_arguments()
219
+ if arguments.files:
220
+ return list_files(arguments)
221
+ #
222
+ return grep_files(arguments)
223
+
224
+
225
+ if __name__ == "__main__":
226
+ sys.exit(main()) # pragma: no cover
227
+
228
+ # vim: fileencoding=utf-8 ts=4 sts=4 sw=4 autoindent expandtab syntax=python:
@@ -0,0 +1,41 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ """
4
+ Common constants and functions
5
+ """
6
+
7
+ ASTERISK = "*"
8
+ """ASCII star, fnmatch wildcard"""
9
+
10
+ BACKSLASH = "\\"
11
+ """ASCII backslash character"""
12
+
13
+ CARET = "^"
14
+ """ASCII caret character"""
15
+
16
+ CLOSING_BRACKET = "]"
17
+ """ASCII closing square bracket character"""
18
+
19
+ CRLF = "\r\n"
20
+ """Carriage return and line feed characters"""
21
+
22
+ DASH = "-"
23
+ """ASCII dash character"""
24
+
25
+ EMPTY = ""
26
+ """Empty string"""
27
+
28
+ EXCLAMATION_MARK = "!"
29
+ """ASCII exclamation mark character"""
30
+
31
+ OPENING_BRACKET = "["
32
+ """ASCII opening square bracket character"""
33
+
34
+ QUESTION_MARK = "?"
35
+ """ASCII question mark character, fnmatch wildcard for any single character"""
36
+
37
+ SLASH = "/"
38
+ """ASCII slash character (Path segments separator)"""
39
+
40
+ UTF_8 = "utf-8"
41
+ """Explicit default encoding"""
@@ -0,0 +1,182 @@
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
+ #
@@ -0,0 +1,72 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ """
4
+ Git related functionality
5
+ """
6
+
7
+ from configparser import ConfigParser, NoOptionError, NoSectionError
8
+ from os import getenv
9
+ from pathlib import Path
10
+ # from subprocess import PIPE, run as sp_run
11
+
12
+
13
+ GIT_COMMAND = "git"
14
+ """The git command"""
15
+
16
+ GIT_DIR = ".git"
17
+ """The git directory in workspaces"""
18
+
19
+ USER_HOME_PATH = Path.home()
20
+ """The user’s home directory"""
21
+
22
+
23
+ def get_configured_global_gitignore_path() -> Path:
24
+ """The user’s configured global gitignore path,
25
+ falling back to the default if there is no special configuration
26
+
27
+ Returns:
28
+ the configured (**.gitconfig** option `core.excludesFile`) or default
29
+ (`${XDG_CONFIG_HOME:-$HOME/.config}/git/ignore`)
30
+ global gitignore file path of the user
31
+ """
32
+ try:
33
+ return Path(get_git_config_value("core.excludesFile"))
34
+ except (NoSectionError, NoOptionError):
35
+ return get_user_default_global_gitignore_path()
36
+ #
37
+
38
+
39
+ def get_git_config_value(compound_key: str) -> str:
40
+ """Value of a Git config item from the user’s `~/.gitconfig` file
41
+
42
+ Args:
43
+ compound_key: the compound config key, eg. user.name
44
+
45
+ Returns:
46
+ the value of the option
47
+
48
+ Raises:
49
+ NoSectionError: if the `~/.gitconfig` file does not exist or
50
+ dos not contain the required section
51
+ NoOptionError: if the required option does not exist
52
+ in the config file section
53
+ """
54
+ user_gitconfig_path = USER_HOME_PATH / ".gitconfig"
55
+ config = ConfigParser()
56
+ config.read([str(user_gitconfig_path)])
57
+ return config.get(*compound_key.split("."))
58
+
59
+
60
+ def get_user_default_global_gitignore_path() -> Path:
61
+ """The user’s default global gitignore path:
62
+ `${XDG_CONFIG_HOME:-$HOME/.config}/git/ignore`
63
+
64
+ Returns:
65
+ the absolute path of the user’s global gitignore file
66
+ """
67
+ git_ignore_subpath = Path("git/ignore")
68
+ xdg_config_home = getenv("XDG_CONFIG_HOME")
69
+ if isinstance(xdg_config_home, str):
70
+ return Path(xdg_config_home) / git_ignore_subpath
71
+ #
72
+ return USER_HOME_PATH / ".config" / git_ignore_subpath
@@ -0,0 +1,506 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ """
4
+ Ignore (gitignore, tba: hgignore) implementation
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from enum import StrEnum
9
+ from functools import lru_cache
10
+ from logging import debug, warning
11
+ from os.path import normcase
12
+ from pathlib import Path
13
+ from re import compile as re_compile, escape
14
+ from typing import Self
15
+
16
+ from .commons import (
17
+ ASTERISK,
18
+ BACKSLASH,
19
+ CARET,
20
+ CLOSING_BRACKET,
21
+ CRLF,
22
+ DASH,
23
+ EMPTY,
24
+ EXCLAMATION_MARK,
25
+ OPENING_BRACKET,
26
+ QUESTION_MARK,
27
+ SLASH,
28
+ UTF_8,
29
+ )
30
+
31
+
32
+ CRE_SETOPS = re_compile(r"([&~|])")
33
+ """Precompiled regular expression matching a single
34
+ ampersand, tilde or pipe character
35
+ """
36
+
37
+ RE_ESCAPE = lru_cache(maxsize=512)(escape)
38
+ """LRU cached version of the
39
+ [re.escape()](https://docs.python.org/3/library/re.html#re.escape)
40
+ function
41
+ ´"""
42
+
43
+ RX_NO_SLASH = "[^/]"
44
+ """Regular expression pattern excluding a slash"""
45
+
46
+ RX_END_DELIMITED_PATH_SEGMENT = f"(?:{RX_NO_SLASH}+/)"
47
+ """Regular expression pattern matching one or more non-slash characters,
48
+ followed by a slash.
49
+ Suitable for matching relative path segments.
50
+ """
51
+
52
+
53
+ class Style(StrEnum):
54
+ """Ignore style"""
55
+
56
+ GITIGNORE = "gitignore"
57
+ """Git style ignore entry"""
58
+
59
+ GLOB = "glob"
60
+ """Mercurial ignore entry, non-anchored glob style"""
61
+
62
+ ROOTGLOB = "rootglob"
63
+ """Mercurial ignore entry, anchored glob style"""
64
+
65
+ REGEX = "regex"
66
+ """Mercurial ignore entry, regex style"""
67
+
68
+
69
+ class CompiledPattern:
70
+ """Pattern to match relative file paths"""
71
+
72
+ def __init__(
73
+ self, regex_pattern: str, negated: bool = False, use_normcase: bool = True
74
+ ) -> None:
75
+ """compile the RE pattern
76
+
77
+ 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`)
84
+ """
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)
89
+
90
+ def matches(self, relative_path: str) -> bool:
91
+ """Indicate if the pattern matches the relative path
92
+
93
+ Args:
94
+ relative_path: the relative path to match
95
+
96
+ Returns:
97
+ `True` if the path matches the pattern, `False` if not
98
+ """
99
+ if self.use_normcase:
100
+ relative_path = normcase(relative_path)
101
+ #
102
+ return self._cre.match(relative_path) is not None
103
+
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
109
+
110
+ 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`)
117
+
118
+ Returns:
119
+ a new instance
120
+
121
+ Raises:
122
+ ValueError: if the line ends in a single backslash
123
+ """
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:]
134
+ #
135
+ return cls(
136
+ gitignore_pattern_to_full_regex(gitignore_line),
137
+ negated=_negated,
138
+ use_normcase=use_normcase,
139
+ )
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class IgnoreFile:
144
+ """Ignorefile data class"""
145
+
146
+ handles_directory: Path
147
+ """The handled directory"""
148
+
149
+ rules: tuple[CompiledPattern, ...]
150
+ """The ignore rules set"""
151
+
152
+ @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)
157
+ 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
+ #
168
+ #
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))
172
+
173
+ 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"""
175
+ is_excluded = pre_excluded
176
+ relative_path = file_or_dir.relative_to(self.handles_directory)
177
+ comparable = relative_path.as_posix()
178
+ if relative_path.is_dir():
179
+ comparable = f"{comparable}/"
180
+ #
181
+ 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
+ #
198
+ #
199
+ #
200
+ return is_excluded
201
+
202
+
203
+ def translate(pattern: str, style: Style = Style.GITIGNORE) -> str:
204
+ """Dispatch pattern to the appropriate translation function,
205
+ according to the style argument
206
+
207
+ Args:
208
+ pattern: the ignore pattern to translate
209
+ style: the ignore pattern style
210
+
211
+ Returns:
212
+ the translation function result, ie. the resulting regex pattern
213
+
214
+ Raises:
215
+ NotImplementedError: if the style is not supported
216
+ (… yet; currently, only GITIGNORE and REGEX are supported)
217
+ """
218
+ match style:
219
+ case Style.GITIGNORE:
220
+ return gitignore_pattern_to_full_regex(pattern)
221
+ case Style.REGEX:
222
+ return pattern
223
+ case _:
224
+ raise NotImplementedError
225
+ #
226
+ #
227
+
228
+
229
+ def gitignore_pattern_to_full_regex(pattern: str) -> str:
230
+ """Translate a .gitignore pattern to a regular expression pattern
231
+
232
+ Args:
233
+ pattern: the gitignore pattern line (excluding a leading exclamation mark)
234
+
235
+ Returns:
236
+ the full regex pattern to match a file path relative to the .gitignore
237
+ file location
238
+
239
+ Raises:
240
+ ValueError: if the pattern includes consecutive slashes
241
+ """
242
+ if "//" in pattern:
243
+ raise ValueError(f"invalid pattern {pattern!r}")
244
+ #
245
+ if pattern == "**/**":
246
+ # Any file or directory, no leading slash
247
+ return "^[^/].*$"
248
+ #
249
+ if pattern == "**/**/":
250
+ return f"^{RX_END_DELIMITED_PATH_SEGMENT}+$"
251
+ #
252
+ collector = [
253
+ # Default Regex start: match any number of directories deep
254
+ f"{RX_END_DELIMITED_PATH_SEGMENT}*",
255
+ ]
256
+ if pattern.startswith("**/"):
257
+ pattern = pattern[3:]
258
+ elif SLASH in pattern[:-1]:
259
+ collector.clear()
260
+ if pattern.startswith(SLASH):
261
+ pattern = pattern[1:]
262
+ #
263
+ #
264
+ post_patterns = []
265
+ if pattern.endswith("/**"):
266
+ post_patterns.append(f"/(?:{RX_NO_SLASH}.*)?")
267
+ pattern = pattern[:-3]
268
+ elif not pattern.endswith(SLASH):
269
+ post_patterns.append("/?")
270
+ #
271
+ collector.append(
272
+ f"/{RX_END_DELIMITED_PATH_SEGMENT}*".join(
273
+ SLASH.join(
274
+ glob_segment_to_regex_segment(segment)
275
+ for segment in subpattern.split(SLASH)
276
+ )
277
+ for subpattern in pattern.split("/**/")
278
+ )
279
+ )
280
+ return f"^{EMPTY.join(collector + post_patterns)}$"
281
+
282
+
283
+ def glob_segment_to_regex_segment(glob_segment: str) -> str:
284
+ """Translate a single path glob segment to a regular expression pattern part
285
+
286
+ [fnmatch]: https://docs.python.org/3/library/fnmatch.html
287
+ !!! note
288
+ * Adapted from the standard library’s [fnmatch] module source
289
+ * function name was `_join_translated_parts`
290
+ * see <https://github.com/python/cpython/blob/v3.14.6/Lib/fnmatch.py#L186>
291
+
292
+ Patterns are Unix shell style:
293
+
294
+ * `*` matches everything
295
+ * `?` matches any single character
296
+ * `[seq]` matches any character in seq
297
+ * `[!seq]` matches any char not in seq
298
+
299
+ Args:
300
+ glob_segment: one segment of a path which may neither contain a
301
+ forward slash nor end in a single backslash
302
+
303
+ Returns:
304
+ a partial regular expression pattern matching a single path segment
305
+ in the same way as the glob segment
306
+
307
+ Raises:
308
+ ValueError: if the glob segment includes a slash,
309
+ or if it ends in a single backslash
310
+ """
311
+ if SLASH in glob_segment:
312
+ raise ValueError(
313
+ f"Path glob segments may not contain slashes: {glob_segment!r}"
314
+ )
315
+ #
316
+ if glob_segment.endswith(BACKSLASH) and not glob_segment[:-1].endswith(BACKSLASH):
317
+ raise ValueError(
318
+ f"Path glob segments may not end in a single backslash: {glob_segment!r}"
319
+ )
320
+ #
321
+ parts, star_indices = dissect_glob_segment(glob_segment)
322
+ if not star_indices:
323
+ return EMPTY.join(parts)
324
+ #
325
+ iter_star_indices = iter(star_indices)
326
+ star_index = next(iter_star_indices)
327
+ buffer = parts[:star_index] # fixed pieces at the start
328
+ chunk_index = star_index + 1
329
+ for star_index in iter_star_indices:
330
+ # Now deal with STAR fixed STAR fixed ...
331
+ # For an interior `STAR fixed` pairing, we want to do a minimal
332
+ # .*? match followed by `fixed`, with no possibility of backtracking.
333
+ # Atomic groups ("(?>...)") allow us to spell that directly.
334
+ # Note: people rely on the undocumented ability to join multiple
335
+ # translate() results together via "|" to build large regexps matching
336
+ # "one of many" shell patterns.
337
+ buffer.extend((f"(?>{RX_NO_SLASH}*?", *parts[chunk_index:star_index], ")"))
338
+ chunk_index = star_index + 1
339
+ #
340
+ buffer.extend((f"{RX_NO_SLASH}*", *parts[chunk_index:]))
341
+ return EMPTY.join(buffer)
342
+
343
+
344
+ def dissect_glob_segment(
345
+ glob_segment: str,
346
+ /,
347
+ *,
348
+ star_placeholder: str = ASTERISK,
349
+ question_mark_regex_replacement: str = RX_NO_SLASH,
350
+ ) -> tuple[list[str], list[int]]:
351
+ """Dissect a glob segment to chunks of regular expression parts
352
+ and a sequence of star positions
353
+
354
+ !!! note
355
+ * Adapted from the standard library’s [fnmatch] module source
356
+ * function name was `_translate`
357
+ * see <https://github.com/python/cpython/blob/v3.14.6/Lib/fnmatch.py#L109>
358
+
359
+ Args:
360
+ glob_segment: the glob segment to dissect
361
+ star_placeholder: the placeholder for a glob star wildcard
362
+ question_mark_regex_replacement: the regex replacement for a glob
363
+ question mark wildcard
364
+
365
+ Returns:
366
+ the sequence of regex chunks and the sequence of star positions
367
+ in the chunks sequence.
368
+ This result is post-processed in the
369
+ [glob_segment_to_regex_segment][offgrep.ignore.glob_segment_to_regex_segment]
370
+ function which calls `dissect_glob_segment()` internally.
371
+ """
372
+ re_chunks: list[str] = []
373
+ star_indices: list[int] = []
374
+ pos = 0
375
+ segment_length = len(glob_segment)
376
+ while pos < segment_length:
377
+ current_character = glob_segment[pos]
378
+ pos += 1
379
+ if current_character == ASTERISK:
380
+ # Multi-character wildcard,
381
+ # with positions stored extra to mitigate backtracking
382
+ # denial of service risks in the regular expression
383
+ # that is built in the calling function
384
+ star_indices.append(len(re_chunks))
385
+ re_chunks.append(star_placeholder)
386
+ # compress consecutive star wildcards into one
387
+ while pos < segment_length and glob_segment[pos] == ASTERISK:
388
+ pos += 1
389
+ #
390
+ elif current_character == QUESTION_MARK:
391
+ # Single character wildcard
392
+ re_chunks.append(question_mark_regex_replacement)
393
+ elif current_character == OPENING_BRACKET:
394
+ # Characters group in brackets
395
+ fwdpos = pos
396
+ for bracket_special_character in (EXCLAMATION_MARK, CLOSING_BRACKET):
397
+ if (
398
+ fwdpos < segment_length
399
+ and glob_segment[fwdpos] == bracket_special_character
400
+ ):
401
+ fwdpos += 1
402
+ #
403
+ #
404
+ while fwdpos < segment_length and glob_segment[fwdpos] != CLOSING_BRACKET:
405
+ fwdpos += 1
406
+ #
407
+ if fwdpos >= segment_length:
408
+ # unclosed bracket, interpret as literal [
409
+ re_chunks.append(RE_ESCAPE(OPENING_BRACKET))
410
+ else:
411
+ # add appropriate bracket replacement
412
+ brackets_content, pos = bracketed_glob_pattern_part_to_partial_regex(
413
+ glob_segment, pos, fwdpos
414
+ )
415
+ re_chunks.append(brackets_content)
416
+ #
417
+ else:
418
+ # Characters that are not special to globbing
419
+ re_chunks.append(RE_ESCAPE(current_character))
420
+ #
421
+ #
422
+ assert pos == segment_length
423
+ return re_chunks, star_indices
424
+
425
+
426
+ def bracketed_glob_pattern_part_to_partial_regex(
427
+ full_glob_segment: str, start: int, end: int
428
+ ) -> tuple[str, int]:
429
+ """Translate a file name glob pattern part inside square brackets
430
+ into a regular expression pattern part
431
+
432
+ !!! note
433
+ Factored out from the
434
+ [dissect_glob_segment()][offgrep.ignore.dissect_glob_segment]
435
+ function
436
+
437
+ Args:
438
+ full_glob_segment: the full glob segment
439
+ start: index of the first character of the bracket content
440
+ end: index of the last character of the bracket content
441
+
442
+ Returns:
443
+ the regular expression equivalent to the glob bracket expression
444
+ """
445
+ assert start > 0
446
+ assert start - 1 < end < len(full_glob_segment)
447
+ content = full_glob_segment[start:end]
448
+ assert f"[{content}]" == full_glob_segment[start - 1 : end + 1]
449
+ pos = start
450
+ if DASH in content:
451
+ chunks = []
452
+ fwdpos = pos + 2 if full_glob_segment[pos] == EXCLAMATION_MARK else pos + 1
453
+ while True:
454
+ fwdpos = full_glob_segment.find(DASH, fwdpos, end)
455
+ if fwdpos < 0:
456
+ break
457
+ #
458
+ chunks.append(full_glob_segment[pos:fwdpos])
459
+ pos = fwdpos + 1
460
+ fwdpos = fwdpos + 3
461
+ #
462
+ current_chunk = full_glob_segment[pos:end]
463
+ if current_chunk:
464
+ chunks.append(current_chunk)
465
+ else:
466
+ chunks[-1] += DASH
467
+ #
468
+ # Remove empty ranges from end to start
469
+ # because these are invalid in regular exoressions
470
+ for ccpos in range(len(chunks) - 1, 0, -1):
471
+ if chunks[ccpos - 1][-1] > chunks[ccpos][0]:
472
+ chunks[ccpos - 1] = chunks[ccpos - 1][:-1] + chunks[ccpos][1:]
473
+ del chunks[ccpos]
474
+ #
475
+ #
476
+ # Escape backslashes and hyphens for set difference (--).
477
+ # Hyphens that create ranges shouldn't be escaped.
478
+ content = DASH.join(
479
+ single_chunk.replace(BACKSLASH, RE_ESCAPE(BACKSLASH)).replace(
480
+ DASH, RE_ESCAPE(DASH)
481
+ )
482
+ for single_chunk in chunks
483
+ )
484
+ else:
485
+ content = content.replace(BACKSLASH, RE_ESCAPE(BACKSLASH))
486
+ #
487
+ pos = end + 1
488
+ if not content:
489
+ # [] (empty range): never match.
490
+ return "(?!)", pos
491
+ #
492
+ if content == EXCLAMATION_MARK:
493
+ # [!] (negated empty range): match any character except /
494
+ return RX_NO_SLASH, pos
495
+ #
496
+ # Escape set operations (&&, ~~ and ||).
497
+ content = CRE_SETOPS.sub(r"\\\1", content)
498
+ #
499
+ if content[0] == EXCLAMATION_MARK:
500
+ # Translate [!…] to [^…]
501
+ content = f"{CARET}{content[1:]}"
502
+ elif content[0] in (CARET, OPENING_BRACKET):
503
+ # Backslash escape
504
+ content = f"{RE_ESCAPE(content[0])}{content[1:]}"
505
+ #
506
+ return f"[{content}]", pos
@@ -1,107 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
-
3
- """
4
- Call as script
5
- """
6
-
7
- import sys
8
-
9
- from argparse import ArgumentParser, Namespace
10
- from importlib.metadata import PackageNotFoundError, version as metadata_version
11
- from logging import WARNING
12
- from pathlib import Path
13
- from tomllib import loads as tomli_loads
14
-
15
-
16
- PACKAGE_NAME = "offgrep"
17
- """The package name for metadata inspection"""
18
-
19
-
20
- def get_file_contents(file_name: str, up_dirs: int = 0) -> str:
21
- """get contents of a file in an ancestor directory
22
- of the current file
23
-
24
- ...
25
- """
26
- file_dir_path = Path(__file__).resolve()
27
- for _ in range(up_dirs):
28
- file_dir_path = file_dir_path.parent
29
- #
30
- file_path = file_dir_path / file_name
31
- return file_path.read_text(encoding="utf-8")
32
-
33
-
34
- def get_metadata_version(
35
- metadata_file_name: str = "pyproject.toml", up_dirs: int = 3
36
- ) -> str:
37
- """get version information from _metadata_file_name_ in an ancestor directory
38
-
39
- ...
40
- """
41
- try:
42
- metadata_file_contents = get_file_contents(metadata_file_name, up_dirs=up_dirs)
43
- except IOError as error:
44
- return str(error)
45
- #
46
- metadata = tomli_loads(metadata_file_contents)
47
- try:
48
- # logging.warning(str(metadata.items()))
49
- version = metadata["project"]["version"]
50
- except KeyError:
51
- return f"Error: no version information in metadata from {metadata_file_name}"
52
- #
53
- return f"{version} (read directly from {metadata_file_name})"
54
-
55
-
56
- # pylint: disable=unused-argument ; functions not implemented yet
57
-
58
-
59
- def grep_files(arguments: Namespace) -> int:
60
- """Search for text in files"""
61
- return 0
62
-
63
-
64
- def list_files(arguments: Namespace) -> int:
65
- """List files that would be searched"""
66
- return 0
67
-
68
-
69
- # pylint: enable=unused-argument
70
-
71
-
72
- def main() -> int:
73
- """Main script interface
74
-
75
- Returns:
76
- the script rteurncode
77
- """
78
- try:
79
- version = metadata_version(PACKAGE_NAME)
80
- except PackageNotFoundError:
81
- version = get_metadata_version()
82
- #
83
- main_parser = ArgumentParser(
84
- prog="offgrep", description="searches text in (text) files recursively"
85
- )
86
- main_parser.set_defaults(loglevel=WARNING, extra_ignore=".git/")
87
- main_parser.add_argument("-V", "--version", action="version", version=version)
88
- main_parser.add_argument(
89
- "--extra-ignore",
90
- help="extra ignore rules separated by comma (default: %(default)s)",
91
- )
92
- main_parser.add_argument(
93
- "--files",
94
- action="store_true",
95
- help="list the files that would be searched",
96
- )
97
- arguments = main_parser.parse_args()
98
- if arguments.files:
99
- return list_files(arguments)
100
- #
101
- return grep_files(arguments)
102
-
103
-
104
- if __name__ == "__main__":
105
- sys.exit(main()) # pragma: no cover
106
-
107
- # vim: fileencoding=utf-8 ts=4 sts=4 sw=4 autoindent expandtab syntax=python:
File without changes
File without changes
File without changes