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