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.
- {offgrep-0.0.2 → offgrep-0.1.0}/PKG-INFO +2 -1
- {offgrep-0.0.2 → offgrep-0.1.0}/pyproject.toml +2 -1
- offgrep-0.1.0/src/offgrep/__main__.py +228 -0
- offgrep-0.1.0/src/offgrep/commons.py +41 -0
- offgrep-0.1.0/src/offgrep/filesystem.py +182 -0
- offgrep-0.1.0/src/offgrep/git.py +72 -0
- offgrep-0.1.0/src/offgrep/ignore.py +506 -0
- offgrep-0.0.2/src/offgrep/__main__.py +0 -107
- {offgrep-0.0.2 → offgrep-0.1.0}/LICENSE +0 -0
- {offgrep-0.0.2 → offgrep-0.1.0}/README.md +0 -0
- {offgrep-0.0.2 → offgrep-0.1.0}/src/offgrep/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: offgrep
|
|
3
|
-
Version: 0.0
|
|
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
|
|
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
|