offgrep 0.2.0__tar.gz → 0.3.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.2.0
3
+ Version: 0.3.1
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
- alternative.
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
- alternative.
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.2.0"
8
+ version = "0.3.1"
9
9
  description = "A lousy ripgrep rip-off in pure Python"
10
10
  readme = "README.md"
11
11
  authors = [
@@ -0,0 +1,49 @@
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 .output import OutputOptions
12
+ from .pattern_matching import PatternOptions, find_in_files
13
+
14
+
15
+ def grep_files(
16
+ file_selection_opts: FileSelectionOptions,
17
+ pattern_opts: PatternOptions,
18
+ output_opts: OutputOptions,
19
+ ) -> int:
20
+ """Search for text in files"""
21
+ target_paths = list(iter_selected_files(file_selection_opts))
22
+ return find_in_files(
23
+ target_paths, pattern_opts, output_opts, encoding=file_selection_opts.encoding
24
+ )
25
+
26
+
27
+ def list_files(file_selection_opts: FileSelectionOptions) -> int:
28
+ """List files that would be searched"""
29
+ for single_file in iter_selected_files(file_selection_opts):
30
+ print(single_file)
31
+ #
32
+ return 0
33
+
34
+
35
+ def main() -> int:
36
+ """Main script interface
37
+
38
+ Returns:
39
+ the script returncode
40
+ """
41
+ file_selection_opts, maybe_pattern_opts, output_opts = parse_arguments()
42
+ if isinstance(maybe_pattern_opts, PatternOptions):
43
+ return grep_files(file_selection_opts, maybe_pattern_opts, output_opts)
44
+ #
45
+ return list_files(file_selection_opts)
46
+
47
+
48
+ if __name__ == "__main__":
49
+ sys.exit(main()) # pragma: no cover
@@ -0,0 +1,426 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ """
4
+ Command line argument parsing
5
+ """
6
+
7
+ from argparse import (
8
+ ArgumentParser,
9
+ Namespace,
10
+ RawDescriptionHelpFormatter,
11
+ SUPPRESS,
12
+ )
13
+ from importlib.metadata import PackageNotFoundError, version as metadata_version
14
+ from logging import CRITICAL, DEBUG, INFO, WARNING, basicConfig, debug, warning
15
+ from pathlib import Path
16
+ from sys import stdin
17
+ from tomllib import loads as tomli_loads
18
+
19
+ from .commons import EMPTY, UTF_8, split_without_empty_strings
20
+ from .filesystem import (
21
+ FileSelectionOptions,
22
+ IgnoreFileMode,
23
+ Priority,
24
+ VCSDirectory,
25
+ is_an_eligible_path,
26
+ )
27
+ from .output import (
28
+ OutputOptions,
29
+ HIGHLIGHTERS,
30
+ SUPPORTED_COLORS,
31
+ print_highlighter_examples,
32
+ )
33
+ from .pattern_matching import PatternOptions
34
+
35
+
36
+ DEFAULT_PRE_IGNORE = ",".join(f"{dirname}/" for dirname in VCSDirectory)
37
+ DEFAULT_HIGHLIGHTER_KEY = "u"
38
+
39
+ PACKAGE_NAME = "offgrep"
40
+ """The package name for metadata inspection"""
41
+
42
+ _EPILOG = """The provided options (or usage in a shell pipeline)
43
+ determine how the positional arguments are interpreted:
44
+
45
+ (1) offgrep [OPTIONS] PATTERN [PATH ...],
46
+ the first positional argument is interpreted as the pattern,
47
+ the remaining ones as paths.
48
+ (2) offgrep [OPTIONS] -e PATTERN ... [PATH ...]
49
+ all positional argument are interpreted as paths.
50
+ (3) offgrep [OPTIONS] -f PATTERNFILE ... [PATH ...]
51
+ all positional argument are interpreted as paths.
52
+ (4) offgrep [OPTIONS] --files [PATH ...]
53
+ all positional argument are interpreted as paths.
54
+ (5) offgrep [OPTIONS] --type-list
55
+ all positional arguments are ignored.
56
+ (6) command | offgrep [OPTIONS] PATTERN
57
+ the first positional argument is interpreted as the pattern
58
+ if no pattern has been provided through -e or -f,
59
+ the remaining positional arguments are ignored.
60
+
61
+ If no paths were provided as positional arguments in scenarios 1 through 4,
62
+ the current working directory will be used as the starting point.
63
+ """
64
+
65
+
66
+ def get_file_contents(file_name: str, up_dirs: int = 0) -> str:
67
+ """get contents of a file in an ancestor directory
68
+ of the current file
69
+
70
+ ...
71
+ """
72
+ file_dir_path = Path(__file__).resolve()
73
+ for _ in range(up_dirs):
74
+ file_dir_path = file_dir_path.parent
75
+ #
76
+ file_path = file_dir_path / file_name
77
+ return file_path.read_text(encoding=UTF_8)
78
+
79
+
80
+ def get_metadata_version(
81
+ metadata_file_name: str = "pyproject.toml", up_dirs: int = 3
82
+ ) -> str:
83
+ """get version information from _metadata_file_name_ in an ancestor directory
84
+
85
+ ...
86
+ """
87
+ try:
88
+ metadata_file_contents = get_file_contents(metadata_file_name, up_dirs=up_dirs)
89
+ except IOError as io_error:
90
+ return str(io_error)
91
+ #
92
+ metadata = tomli_loads(metadata_file_contents)
93
+ try:
94
+ # logging.warning(str(metadata.items()))
95
+ version = metadata["project"]["version"]
96
+ except KeyError:
97
+ return f"Error: no version information in metadata from {metadata_file_name}"
98
+ #
99
+ return f"{version} (read directly from {metadata_file_name})"
100
+
101
+
102
+ def determine_ignore_file_mode() -> IgnoreFileMode:
103
+ """dummy for now"""
104
+ return IgnoreFileMode.AUTOMATIC
105
+
106
+
107
+ def determine_types(types_arg: list[str] | None) -> list[str]:
108
+ """dummy for now"""
109
+ if isinstance(types_arg, list):
110
+ for single_type in types_arg:
111
+ warning("--type %s option not implemented yet", single_type)
112
+ #
113
+ #
114
+ return []
115
+
116
+
117
+ def print_supported_file_tyoes() -> None:
118
+ """Print the list of supported file types"""
119
+ warning("--type-list option not implemented yet")
120
+
121
+
122
+ def _add_output_options(parser: ArgumentParser) -> None:
123
+ """Add output options to a parser instance"""
124
+ output_group = parser.add_argument_group("Output options")
125
+ loglevel_mutex = output_group.add_mutually_exclusive_group()
126
+ loglevel_mutex.add_argument(
127
+ "--verbose",
128
+ action="store_true",
129
+ help="log with level INFO",
130
+ )
131
+ loglevel_mutex.add_argument(
132
+ "--debug",
133
+ action="store_true",
134
+ help="log with level DEBUG",
135
+ )
136
+ loglevel_mutex.add_argument(
137
+ "-q",
138
+ "--quiet",
139
+ action="store_true",
140
+ help="no output except for CRITICAL errors",
141
+ )
142
+ match_highlight_mutex = output_group.add_mutually_exclusive_group()
143
+ match_highlight_mutex.add_argument(
144
+ "--hi",
145
+ "--highlight-matches",
146
+ nargs="?",
147
+ choices=sorted(HIGHLIGHTERS),
148
+ const=DEFAULT_HIGHLIGHTER_KEY,
149
+ help="Highlight matches with the provided profile."
150
+ " Default ist %(default)r (no highlighting) if not specified,"
151
+ " or %(const)r if provided without a value."
152
+ " Use the --highlighters option for an overview with examples.",
153
+ )
154
+ for color in SUPPORTED_COLORS:
155
+ match_highlight_mutex.add_argument(
156
+ f"--{color}",
157
+ action="store_const",
158
+ dest="hi",
159
+ const=color[0],
160
+ help=f"Highlight matches in {color}.",
161
+ )
162
+ #
163
+ match_highlight_mutex.add_argument(
164
+ "--highlighters",
165
+ action="store_true",
166
+ help="Show highlighter effects on examples.",
167
+ )
168
+ output_group.add_argument(
169
+ "--stats",
170
+ action="store_true",
171
+ help="Collect and show statistics.",
172
+ )
173
+ output_group.add_argument(
174
+ "--files",
175
+ action="store_true",
176
+ help="Print the path of each file that would be searched,"
177
+ " without actually performing the search.",
178
+ )
179
+ output_group.add_argument(
180
+ "--type-list",
181
+ action="store_true",
182
+ help="NOT IMPLEMTED YET: print the list of supported file types.",
183
+ )
184
+
185
+
186
+ def _add_file_options(parser: ArgumentParser) -> None:
187
+ """Add file options to a parser instance"""
188
+ file_group = parser.add_argument_group("File selection options")
189
+ file_group.add_argument(
190
+ "--default-ignore-dirs",
191
+ metavar="DIRECTORY-NAMES",
192
+ help="DEPRECATED, please use --pre-ignore or --post-ignore instead."
193
+ " Original purpose: always ignore the directories named here,"
194
+ " separated by commas.",
195
+ )
196
+ file_group.add_argument(
197
+ "--default-ignore-priority",
198
+ choices=list(Priority),
199
+ type=Priority,
200
+ help="DEPRECATED, please use --pre-ignore or --post-ignore instead."
201
+ " Original purpose: priority for the ignore rules specified through"
202
+ " --default-ignore-dirs."
203
+ f" Choices: %(choices)s ({str(Priority.LOW)!r} is applied first but might"
204
+ " be overridden by eg. directory defined rules,"
205
+ f" while {str(Priority.HIGH)!r} is applied last but cannot be overridden anymore)."
206
+ f" The preset is {str(Priority.LOW)!r}.",
207
+ )
208
+ file_group.add_argument(
209
+ "--pre-ignore",
210
+ metavar="PATTERNS",
211
+ help="gitignore-style patterns for files and/or directories ignored"
212
+ " before applying found ignore file rules, joined with commas (,)."
213
+ " Defaults to VCS repository directories (%(default)r).",
214
+ )
215
+ file_group.add_argument(
216
+ "--post-ignore",
217
+ metavar="PATTERNS",
218
+ help="gitignore-style patterns for files and/or directories ignored"
219
+ " after applying found ignore file rules, joined with commas (,)."
220
+ " Empty by default (%(default)r).",
221
+ )
222
+ file_group.add_argument(
223
+ "-t",
224
+ "--type",
225
+ nargs="+",
226
+ metavar="FILETYPE",
227
+ help="NOT IMPLEMTED YET: search only in files of type %(metavar)s.",
228
+ # See --type-list output for supported file types.",
229
+ )
230
+
231
+
232
+ def _add_pattern_options(parser: ArgumentParser) -> None:
233
+ """Add pattern options to a parser instance"""
234
+ pattern_group = parser.add_argument_group("Pattern options")
235
+ pattern_group.add_argument(
236
+ "-e",
237
+ "--expression",
238
+ nargs="+",
239
+ dest="patterns",
240
+ metavar="PATTERN",
241
+ help="Patterns (by default, reular exoressions) to search for."
242
+ " Use this option with multiple arguments if you want to seatch for"
243
+ " multpile patterns at once.",
244
+ )
245
+ pattern_group.add_argument(
246
+ "-f",
247
+ "--file",
248
+ nargs="+",
249
+ type=Path,
250
+ dest="patternfiles",
251
+ metavar="PATTERNFILE",
252
+ help="Files containing patterns (by default, regular expressions",
253
+ )
254
+ pattern_group.add_argument(
255
+ "-F",
256
+ "--fixed-strings",
257
+ action="store_true",
258
+ help="treat the given pattern(s) as fixed strings instead of regular"
259
+ " expressions so special characters – .+*()[]{} – do not need to be escaped,"
260
+ " also saving some energy.",
261
+ )
262
+ pattern_group.add_argument(
263
+ "-i",
264
+ "--ignore-case",
265
+ action="store_false",
266
+ dest="case_sensitive",
267
+ help="case-insensitive matching",
268
+ )
269
+
270
+
271
+ def build_argument_parser() -> ArgumentParser:
272
+ """build the commandline argument parser"""
273
+ try:
274
+ version = metadata_version(PACKAGE_NAME)
275
+ except PackageNotFoundError:
276
+ version = get_metadata_version()
277
+ #
278
+ main_parser = ArgumentParser(
279
+ prog="offgrep",
280
+ description="searches text in (text) files recursively",
281
+ epilog=_EPILOG,
282
+ formatter_class=RawDescriptionHelpFormatter,
283
+ )
284
+ main_parser.set_defaults(
285
+ encoding=UTF_8,
286
+ hi="z",
287
+ pre_ignore=DEFAULT_PRE_IGNORE,
288
+ post_ignore=EMPTY,
289
+ )
290
+ main_parser.add_argument("-V", "--version", action="version", version=version)
291
+ main_parser.add_argument(
292
+ "--encoding",
293
+ help="Encoding of all files (default: %(default)s)",
294
+ )
295
+ _add_output_options(main_parser)
296
+ _add_file_options(main_parser)
297
+ _add_pattern_options(main_parser)
298
+ main_parser.add_argument(
299
+ "positional",
300
+ nargs="*",
301
+ help=SUPPRESS,
302
+ )
303
+ return main_parser
304
+
305
+
306
+ def make_file_selection_options(args: Namespace) -> FileSelectionOptions:
307
+ """Initailize an FSO instance from the arguments"""
308
+ pre_ignore = split_without_empty_strings(args.pre_ignore)
309
+ post_ignore = split_without_empty_strings(args.post_ignore)
310
+ if args.default_ignore_dirs:
311
+ ignore_list = [f"{dirname}/" for dirname in args.default_ignore_dirs]
312
+ match args.default_ignore_priority:
313
+ case Priority.LOW:
314
+ pre_ignore = ignore_list
315
+ case Priority.HIGH:
316
+ post_ignore = ignore_list
317
+ #
318
+ #
319
+ #
320
+ if args.files:
321
+ paths: list[Path] = list(map(Path, args.positional))
322
+ if not paths:
323
+ paths.append(Path.cwd())
324
+ #
325
+ return FileSelectionOptions(
326
+ paths=paths,
327
+ pre_ignore=pre_ignore,
328
+ post_ignore=post_ignore,
329
+ types=determine_types(args.type),
330
+ ignore_file_mode=determine_ignore_file_mode(),
331
+ encoding=args.encoding,
332
+ )
333
+ #
334
+ return FileSelectionOptions(
335
+ paths=[],
336
+ pre_ignore=pre_ignore,
337
+ post_ignore=post_ignore,
338
+ types=determine_types(args.type),
339
+ ignore_file_mode=determine_ignore_file_mode(),
340
+ encoding=args.encoding,
341
+ )
342
+
343
+
344
+ def initialize_logging(args: Namespace) -> None:
345
+ """Initialize logging"""
346
+ loglevel = WARNING
347
+ if args.debug:
348
+ loglevel = DEBUG
349
+ elif args.verbose:
350
+ loglevel = INFO
351
+ elif args.quiet:
352
+ loglevel = CRITICAL
353
+ #
354
+ basicConfig(level=loglevel)
355
+
356
+
357
+ def parse_arguments() -> tuple[
358
+ FileSelectionOptions, PatternOptions | None, OutputOptions
359
+ ]:
360
+ """Parse commandline arguments and configure logging
361
+
362
+ Returns:
363
+ the parsed command line arguments
364
+ """
365
+ main_parser = build_argument_parser()
366
+ args = main_parser.parse_args()
367
+ initialize_logging(args)
368
+ debug(repr(args))
369
+ if args.highlighters:
370
+ print_highlighter_examples()
371
+ main_parser.exit()
372
+ #
373
+ if args.type_list:
374
+ print_supported_file_tyoes()
375
+ main_parser.exit()
376
+ #
377
+ produce_output = not args.quiet
378
+ if args.files:
379
+ return (
380
+ make_file_selection_options(args),
381
+ None,
382
+ OutputOptions(show_files=produce_output),
383
+ )
384
+ #
385
+ fso = make_file_selection_options(args)
386
+ path_candidates = args.positional[:]
387
+ patterns: list[str] = []
388
+ if args.patterns:
389
+ patterns.extend(args.patterns)
390
+ elif args.patternfiles:
391
+ for single_file in args.patternfiles:
392
+ patterns.extend(single_file.read_text(encoding=args.encoding).splitlines())
393
+ #
394
+ else:
395
+ try:
396
+ patterns = [path_candidates.pop(0)]
397
+ except IndexError:
398
+ main_parser.exit(
399
+ status=1,
400
+ message="This command variant requires at least one pattern"
401
+ " as a positional argument.",
402
+ )
403
+ #
404
+ #
405
+ if stdin.isatty():
406
+ paths: list[Path] = list(map(Path, path_candidates))
407
+ if not paths:
408
+ paths.append(Path.cwd())
409
+ #
410
+ fso.paths = paths
411
+ #
412
+ po = PatternOptions(
413
+ patterns=patterns,
414
+ plaintext=args.fixed_strings,
415
+ case_sensitive=args.case_sensitive,
416
+ )
417
+ suppress_file_prefix = not fso.paths or (
418
+ len(fso.paths) == 1 and is_an_eligible_path(fso.paths[0])
419
+ )
420
+ oo = OutputOptions(
421
+ highlighter=HIGHLIGHTERS[args.hi],
422
+ show_files=not suppress_file_prefix,
423
+ show_results=produce_output,
424
+ show_stats=args.stats,
425
+ )
426
+ 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]