offgrep 0.1.1__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: offgrep
3
- Version: 0.1.1
3
+ Version: 0.3.0
4
4
  Summary: A lousy ripgrep rip-off in pure Python
5
5
  Keywords: grep,recursive
6
6
  Author: Rainer Schwarzbach
@@ -67,5 +67,4 @@ instead of **offgrep**.
67
67
 
68
68
  If you – however – are in a super restricted network (eg. government)
69
69
  without a chance to install **ripgrep**,
70
- but are able to install Python packages, **offgrep** might be an interesting
71
- alterative.
70
+ but are able to install Python packages, **offgrep** might be suitable for you.
@@ -18,5 +18,4 @@ instead of **offgrep**.
18
18
 
19
19
  If you – however – are in a super restricted network (eg. government)
20
20
  without a chance to install **ripgrep**,
21
- but are able to install Python packages, **offgrep** might be an interesting
22
- alterative.
21
+ but are able to install Python packages, **offgrep** might be suitable for you.
@@ -5,7 +5,7 @@ build-backend = "uv_build"
5
5
 
6
6
  [project]
7
7
  name = "offgrep"
8
- version = "0.1.1"
8
+ version = "0.3.0"
9
9
  description = "A lousy ripgrep rip-off in pure Python"
10
10
  readme = "README.md"
11
11
  authors = [
@@ -0,0 +1,50 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ """
4
+ Call as script
5
+ """
6
+
7
+ import sys
8
+
9
+ from .arguments import parse_arguments
10
+ from .filesystem import FileSelectionOptions, iter_selected_files
11
+ from .pattern_matching import PatternOptions, OutputOptions, find_in_files
12
+
13
+
14
+ def grep_files(
15
+ file_selection_opts: FileSelectionOptions,
16
+ pattern_opts: PatternOptions,
17
+ output_opts: OutputOptions,
18
+ ) -> int:
19
+ """Search for text in files"""
20
+ target_paths = list(iter_selected_files(file_selection_opts))
21
+ return find_in_files(
22
+ target_paths, pattern_opts, output_opts, encoding=file_selection_opts.encoding
23
+ )
24
+
25
+
26
+ def list_files(file_selection_opts: FileSelectionOptions) -> int:
27
+ """List files that would be searched"""
28
+ for single_file in iter_selected_files(file_selection_opts):
29
+ print(single_file)
30
+ #
31
+ return 0
32
+
33
+
34
+ def main() -> int:
35
+ """Main script interface
36
+
37
+ Returns:
38
+ the script returncode
39
+ """
40
+ file_selection_opts, maybe_pattern_opts, output_opts = parse_arguments()
41
+ if isinstance(maybe_pattern_opts, PatternOptions):
42
+ return grep_files(file_selection_opts, maybe_pattern_opts, output_opts)
43
+ #
44
+ return list_files(file_selection_opts)
45
+
46
+
47
+ if __name__ == "__main__":
48
+ sys.exit(main()) # pragma: no cover
49
+
50
+ # vim: fileencoding=utf-8 ts=4 sts=4 sw=4 autoindent expandtab syntax=python:
@@ -0,0 +1,359 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ """
4
+ Command line argument parsing
5
+ """
6
+
7
+ from argparse import (
8
+ ArgumentParser,
9
+ Namespace,
10
+ RawDescriptionHelpFormatter,
11
+ REMAINDER,
12
+ SUPPRESS,
13
+ )
14
+ from importlib.metadata import PackageNotFoundError, version as metadata_version
15
+ from logging import DEBUG, WARNING, basicConfig, debug, warning
16
+ from pathlib import Path
17
+ from sys import stdin
18
+ from tomllib import loads as tomli_loads
19
+
20
+ from .commons import EMPTY, UTF_8, split_without_empty_strings
21
+ from .filesystem import FileSelectionOptions, IgnoreFileMode, Priority, VCSDirectory
22
+ from .pattern_matching import Highlighter, PatternOptions, OutputOptions
23
+
24
+ DEFAULT_PRE_IGNORE = ",".join(f"{dirname}/" for dirname in VCSDirectory)
25
+
26
+ PACKAGE_NAME = "offgrep"
27
+ """The package name for metadata inspection"""
28
+
29
+ _EPILOG = """The provided options (or usage in a shell pipeline)
30
+ determine how the positional arguments are interpreted:
31
+
32
+ (1) offgrep [OPTIONS] PATTERN [PATH ...],
33
+ the first positional argument is interpreted as the pattern,
34
+ the remaining ones as paths.
35
+ (2) offgrep [OPTIONS] -e PATTERN ... [PATH ...]
36
+ all positional argument are interpreted as paths.
37
+ (3) offgrep [OPTIONS] -f PATTERNFILE ... [PATH ...]
38
+ all positional argument are interpreted as paths.
39
+ (4) offgrep [OPTIONS] --files [PATH ...]
40
+ all positional argument are interpreted as paths.
41
+ (5) offgrep [OPTIONS] --type-list
42
+ all positional arguments are ignored.
43
+ (6) command | offgrep [OPTIONS] PATTERN
44
+ the first positional argument is interpreted as the pattern
45
+ if no pattern has been provided through -e or -f,
46
+ the remaining positional arguments are ignored.
47
+
48
+ If no paths were provided as positional arguments in scenarios 1 through 4,
49
+ the current working directory will be used as the starting point.
50
+ """
51
+
52
+
53
+ def get_file_contents(file_name: str, up_dirs: int = 0) -> str:
54
+ """get contents of a file in an ancestor directory
55
+ of the current file
56
+
57
+ ...
58
+ """
59
+ file_dir_path = Path(__file__).resolve()
60
+ for _ in range(up_dirs):
61
+ file_dir_path = file_dir_path.parent
62
+ #
63
+ file_path = file_dir_path / file_name
64
+ return file_path.read_text(encoding=UTF_8)
65
+
66
+
67
+ def get_metadata_version(
68
+ metadata_file_name: str = "pyproject.toml", up_dirs: int = 3
69
+ ) -> str:
70
+ """get version information from _metadata_file_name_ in an ancestor directory
71
+
72
+ ...
73
+ """
74
+ try:
75
+ metadata_file_contents = get_file_contents(metadata_file_name, up_dirs=up_dirs)
76
+ except IOError as io_error:
77
+ return str(io_error)
78
+ #
79
+ metadata = tomli_loads(metadata_file_contents)
80
+ try:
81
+ # logging.warning(str(metadata.items()))
82
+ version = metadata["project"]["version"]
83
+ except KeyError:
84
+ return f"Error: no version information in metadata from {metadata_file_name}"
85
+ #
86
+ return f"{version} (read directly from {metadata_file_name})"
87
+
88
+
89
+ def determine_ignore_file_mode() -> IgnoreFileMode:
90
+ """dummy for now"""
91
+ return IgnoreFileMode.AUTOMATIC
92
+
93
+
94
+ def determine_types(types_arg: list[str] | None) -> list[str]:
95
+ """dummy for now"""
96
+ if isinstance(types_arg, list):
97
+ for single_type in types_arg:
98
+ warning("--type %s option not implemented yet", single_type)
99
+ #
100
+ #
101
+ return []
102
+
103
+
104
+ def print_supported_file_tyoes() -> None:
105
+ """Print the list of supported file types"""
106
+ warning("--type-list option not implemented yet")
107
+
108
+
109
+ def build_argument_parser() -> ArgumentParser:
110
+ """build the commandline argument parser"""
111
+ try:
112
+ version = metadata_version(PACKAGE_NAME)
113
+ except PackageNotFoundError:
114
+ version = get_metadata_version()
115
+ #
116
+ main_parser = ArgumentParser(
117
+ prog="offgrep",
118
+ description="searches text in (text) files recursively",
119
+ epilog=_EPILOG,
120
+ formatter_class=RawDescriptionHelpFormatter,
121
+ )
122
+ main_parser.set_defaults(
123
+ loglevel=WARNING,
124
+ encoding=UTF_8,
125
+ pre_ignore=DEFAULT_PRE_IGNORE,
126
+ post_ignore=EMPTY,
127
+ )
128
+ main_parser.add_argument("-V", "--version", action="version", version=version)
129
+ main_parser.add_argument(
130
+ "--debug",
131
+ action="store_const",
132
+ const=DEBUG,
133
+ dest="loglevel",
134
+ help="log with debug level",
135
+ )
136
+ main_parser.add_argument(
137
+ "--default-ignore-dirs",
138
+ help="Always ignore the directories named here, separated by commas."
139
+ " DEPRECATED, please use --pre-ignore or --post-ignore instead.",
140
+ )
141
+ main_parser.add_argument(
142
+ "--default-ignore-priority",
143
+ choices=list(Priority),
144
+ type=Priority,
145
+ help="Priority for the ignore rules specified through -default-ignore-dirs."
146
+ f" Choices: %(choices)s ({str(Priority.LOW)!r} is applied first but might"
147
+ " be overridden by eg. directory defined rules,"
148
+ f" while {str(Priority.HIGH)!r} is applied last but cannot be overridden anymore)."
149
+ f" The preset is {str(Priority.LOW)!r}."
150
+ " DEPRECATED, please use --pre-ignore or --post-ignore instead.",
151
+ )
152
+ main_parser.add_argument(
153
+ "--pre-ignore",
154
+ help="gitignore-style patterns for files and/or directories ignored"
155
+ " before applying found ignore file rules, joined with commas (,)."
156
+ " Defaults to VCS repository directories (%(default)s).",
157
+ )
158
+ main_parser.add_argument(
159
+ "--post-ignore",
160
+ help="gitignore-style patterns for files and/or directories ignored"
161
+ " after applying found ignore file rules, joined with commas (,)."
162
+ " Empty by default (%(default)s).",
163
+ )
164
+ main_parser.add_argument(
165
+ "-e",
166
+ "--expression",
167
+ nargs="+",
168
+ dest="patterns",
169
+ metavar="PATTERN",
170
+ help="Patterns (by default, reular exoressions) to search for."
171
+ " Use this option with multiple arguments if you want to seatch for"
172
+ " multpile patterns at once.",
173
+ )
174
+ main_parser.add_argument(
175
+ "-f",
176
+ "--file",
177
+ nargs="+",
178
+ type=Path,
179
+ dest="patternfiles",
180
+ metavar="PATTERNFILE",
181
+ help="Files containing patterns (by default, regular expressions",
182
+ )
183
+ main_parser.add_argument(
184
+ "-F",
185
+ "--fixed-strings",
186
+ action="store_true",
187
+ help="treat the given pattern(s) as fixed strings instead of regular"
188
+ " expressions so special characters – .+*()[]{} – do not need to be escaped,"
189
+ " also saving some energy.",
190
+ )
191
+ main_parser.add_argument(
192
+ "-i",
193
+ "--ignore-case",
194
+ action="store_false",
195
+ dest="case_sensitive",
196
+ help="case-insensitive matching",
197
+ )
198
+ main_parser.add_argument(
199
+ "-t",
200
+ "--type",
201
+ nargs="+",
202
+ help="search only in files of type %(metavar)s. NOT IMPLEMENTED YET.",
203
+ # See --type-list output for supported file types.",
204
+ )
205
+ main_parser.add_argument(
206
+ "--encoding",
207
+ help="Encoding of all files (default: %(default)s)",
208
+ )
209
+ main_parser.add_argument(
210
+ "--highlight-matches",
211
+ "--hm",
212
+ nargs="?",
213
+ type=str.lower,
214
+ choices=[hi.name.lower() for hi in Highlighter],
215
+ default=Highlighter.NONE.name.lower(),
216
+ const=Highlighter.RED.name.lower(),
217
+ help="Highlight matches with the provided profile."
218
+ f" Defaults to {Highlighter.RED.value.enclose('%(const)s')}"
219
+ " if provided without a value.",
220
+ )
221
+ main_parser.add_argument(
222
+ "--files",
223
+ action="store_true",
224
+ help="Print the path of each file that would be searched,"
225
+ " without actually performing the search.",
226
+ )
227
+ main_parser.add_argument(
228
+ "--stats",
229
+ action="store_true",
230
+ help="Show statistics.",
231
+ )
232
+ main_parser.add_argument(
233
+ "--type-list",
234
+ action="store_true",
235
+ help="Print the list of supported file types. NOT IMPLEMENTED YET.",
236
+ )
237
+ main_parser.add_argument(
238
+ "positional_args",
239
+ nargs=REMAINDER,
240
+ help=SUPPRESS,
241
+ )
242
+ return main_parser
243
+
244
+
245
+ def remove_options_end_marker(args: Namespace, path_candidates: list[str]) -> list[str]:
246
+ """Remove an optins end marke if it is at the start of the paths list
247
+ after multiple-value options
248
+ """
249
+ if not path_candidates:
250
+ return []
251
+ if any((args.patterns, args.patternfiles)) and path_candidates[0] == "--":
252
+ return path_candidates[1:]
253
+ #
254
+ return path_candidates[:]
255
+
256
+
257
+ def make_file_selecton_options(args: Namespace) -> FileSelectionOptions:
258
+ """Initailize an FSO instance from the arguments"""
259
+ pre_ignore = split_without_empty_strings(args.pre_ignore)
260
+ post_ignore = split_without_empty_strings(args.post_ignore)
261
+ if args.default_ignore_dirs:
262
+ ignore_list = [f"{dirname}/" for dirname in args.default_ignore_dirs]
263
+ match args.default_ignore_priority:
264
+ case Priority.LOW:
265
+ pre_ignore = ignore_list
266
+ case Priority.HIGH:
267
+ post_ignore = ignore_list
268
+ #
269
+ #
270
+ #
271
+ if args.files:
272
+ paths: list[Path] = list(
273
+ map(Path, remove_options_end_marker(args, args.positional_args))
274
+ )
275
+ if not paths:
276
+ paths.append(Path.cwd())
277
+ #
278
+ return FileSelectionOptions(
279
+ paths=paths,
280
+ pre_ignore=pre_ignore,
281
+ post_ignore=post_ignore,
282
+ types=determine_types(args.type),
283
+ ignore_file_mode=determine_ignore_file_mode(),
284
+ encoding=args.encoding,
285
+ )
286
+ #
287
+ return FileSelectionOptions(
288
+ paths=[],
289
+ pre_ignore=pre_ignore,
290
+ post_ignore=post_ignore,
291
+ types=determine_types(args.type),
292
+ ignore_file_mode=determine_ignore_file_mode(),
293
+ encoding=args.encoding,
294
+ )
295
+
296
+
297
+ def parse_arguments() -> tuple[
298
+ FileSelectionOptions, PatternOptions | None, OutputOptions
299
+ ]:
300
+ """Parse commandline arguments and configure logging
301
+
302
+ Returns:
303
+ the parsed command line arguments
304
+ """
305
+ main_parser = build_argument_parser()
306
+ args = main_parser.parse_args()
307
+ basicConfig(level=args.loglevel)
308
+ debug(repr(args))
309
+ if args.type_list:
310
+ print_supported_file_tyoes()
311
+ main_parser.exit()
312
+ #
313
+ if args.files:
314
+ return make_file_selecton_options(args), None, OutputOptions()
315
+ #
316
+ fso = make_file_selecton_options(args)
317
+ path_candidates = args.positional_args[:]
318
+ patterns: list[str] = []
319
+ if args.patterns:
320
+ patterns.extend(args.patterns)
321
+ elif args.patternfiles:
322
+ for single_file in args.patternfiles:
323
+ patterns.extend(single_file.read_text(encoding=args.encoding).splitlines())
324
+ #
325
+ else:
326
+ try:
327
+ patterns = [path_candidates.pop(0)]
328
+ except IndexError:
329
+ main_parser.exit(
330
+ status=1,
331
+ message="Using offgrep requires at least one pattern"
332
+ " as a positional argument.",
333
+ )
334
+ #
335
+ #
336
+ if stdin.isatty():
337
+ paths: list[Path] = list(
338
+ map(Path, remove_options_end_marker(args, path_candidates))
339
+ )
340
+ if not paths:
341
+ paths.append(Path.cwd())
342
+ #
343
+ fso.paths = paths
344
+ #
345
+ po = PatternOptions(
346
+ patterns=patterns,
347
+ plaintext=args.fixed_strings,
348
+ case_sensitive=args.case_sensitive,
349
+ )
350
+ suppress_file_prefix = not fso.paths or (
351
+ len(fso.paths) == 1 and fso.paths[0].is_file()
352
+ )
353
+ oo = OutputOptions(
354
+ highlighter=Highlighter[args.highlight_matches.upper()],
355
+ show_files=not suppress_file_prefix,
356
+ show_results=True,
357
+ show_stats=args.stats,
358
+ )
359
+ return fso, po, oo
@@ -39,3 +39,8 @@ SLASH = "/"
39
39
 
40
40
  UTF_8 = "utf-8"
41
41
  """Explicit default encoding"""
42
+
43
+
44
+ def split_without_empty_strings(source: str, delimiter: str = ",") -> list[str]:
45
+ """Split result without empty strings"""
46
+ return [part for part in source.split(delimiter) if part]