pseek 3.4.2__tar.gz → 3.6.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.4
2
2
  Name: pseek
3
- Version: 3.4.2
3
+ Version: 3.6.0
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -50,6 +50,7 @@ Fast and powerful command-line search tool for finding files, directories, and t
50
50
  * [Regular Expression (Regex)](#regular-expression-search)
51
51
  * [Fuzzy](#fuzzy-search)
52
52
  * [Fuzzy Similarity Threshold](#fuzzy-similarity-threshold)
53
+ * [Full Path](#full-path)
53
54
  * [Expression Queries](#expression-queries)
54
55
  * [Operators](#supported-operators)
55
56
  * [AND](#and)
@@ -74,6 +75,7 @@ Fast and powerful command-line search tool for finding files, directories, and t
74
75
  * [Exclude](#exclude)
75
76
  * [Size](#size-filtering)
76
77
  * [Depth](#depth)
78
+ * [Parallel Search](#parallel-search)
77
79
  * [Archive Search](#archive-search)
78
80
  * [Nested Archives](#nested-archives)
79
81
  * [Archive Filters](#archive-filters)
@@ -209,6 +211,7 @@ psk --exclude build
209
211
  | `--word` | Match the whole word only (except when `--expr` is enabled, in which case you can make it match whole word by putting `w` before term: `w"foo"`) |
210
212
  | `--expr` | Enable boolean query expressions. Example: `r"foo.*bar" and ("bar" or "baz") and not "qux"`. Prefixes: `r=regex`, `c=case-sensitive`, `w=whole-word`, `f=fuzzy` |
211
213
  | `--context` | Show N context lines before and after matches, or use N:M to specify before:after |
214
+ | `--full-path` | By default, query is only matched against the filename or directory name (this applies only when searching for files or directories, not file contents). Using this flag, query is matched against the full path (and, if `--absolute-path` flag is enabled, against the absolute path) |
212
215
  | `--depth` | Limit directory traversal to given depth range. By default, there is no limit on search depth |
213
216
  | `--timeout` | Stop the search after the specified number of seconds |
214
217
  | `--fuzzy` | Enable fuzzy search (Highlighting and counting matches are disabled in this mode if `--word` is not enabled to prevent the program from slowing down). except when `--expr` is enabled, in which case you can make it fuzzy by putting `f` before term: `f"foo"` |
@@ -234,6 +237,7 @@ psk --exclude build
234
237
  | `--absolute-path` | Display full path of files and directories |
235
238
  | `--paths-only` | Only show matching file paths for content search |
236
239
  | `--stats` | Show search statistics including result counts and search time |
240
+ | `--threads` | This flag sets the approximate number of threads to use. A value of 0 (which is the default) causes pseek to choose the thread count using heuristics |
237
241
 
238
242
  ## Specifying the Root Directory
239
243
 
@@ -351,6 +355,45 @@ Examples:
351
355
 
352
356
  Default: `80`
353
357
 
358
+ ### Full Path
359
+
360
+ By default, Pseek matches file and directory queries against the entry's name only. Use `--full-path` to match against its full path within the search scope instead. This option affects file and directory searches, not content searches.
361
+
362
+ For example, given the relative path `pseek/utils.py`, you can match part of the path rather than just the filename:
363
+
364
+ ```bash
365
+ # Match against the full relative path
366
+ psk "seek/utils" --full-path
367
+ ```
368
+
369
+ **Relative paths vs. absolute paths**
370
+
371
+ `--full-path` does not automatically include the absolute path. If the file's absolute path is:
372
+
373
+ ```text
374
+ /home/python/cli/pseek/utils.py
375
+ ```
376
+
377
+ a query such as `python/cli` will not match unless `--absolute-path` is also enabled:
378
+
379
+ ```bash
380
+ psk "python/cli" --full-path --absolute-path
381
+ ```
382
+
383
+ **Nested archives**
384
+
385
+ When searching nested archives, `--full-path` applies only to the path within the current archive level, not the entire nested archive chain.
386
+
387
+ For example:
388
+
389
+ ```text
390
+ archive.zip::folder/arc.zip::fd/file.txt
391
+ ```
392
+
393
+ With `--full-path`, you can match `fd/f` against the entry `fd/file.txt` inside the innermost archive. A query such as `zip::fd` does not match across archive boundaries. Without `--full-path`, matching is limited to the entry name, `file.txt`.
394
+
395
+ > **Note:** For ordinary queries without `--regex`, backslashes are treated literally, so Windows paths can be searched without additional escaping. When using `--expr`, quoted terms use JSON-style escaping; escape backslashes accordingly. For example, `"\\"` represents a single literal backslash, while `"\\n"` represents the literal sequence `\n`.
396
+
354
397
  ## Expression Queries
355
398
 
356
399
  Expression mode enables logical search expressions.
@@ -584,6 +627,8 @@ psk TODO \
584
627
 
585
628
  Only search inside those paths.
586
629
 
630
+ > **Note:** Paths manually included by the user take precedence over other filters; this means that even if a path conflicts with rules related to [--glob](#globs), [--exclude](#exclude-paths), etc., it will still be included in the results.
631
+
587
632
  #### Exclude Paths
588
633
 
589
634
  ```bash
@@ -677,6 +722,29 @@ project/ ← base directory
677
722
 
678
723
  If `--depth` is not specified, the search has no depth limit.
679
724
 
725
+ ## Parallel Search
726
+
727
+ Pseek can process multiple file paths concurrently to improve search performance, especially when searching large numbers of files.
728
+
729
+ Use `--threads` to control the maximum number of worker threads:
730
+
731
+ ```bash
732
+ # Use the default number of worker threads
733
+ psk TODO
734
+
735
+ # Use 4 worker threads
736
+ psk TODO --threads 4
737
+
738
+ # Use a single worker thread
739
+ psk TODO --threads 1
740
+ ```
741
+
742
+ If `--threads` is not specified, Pseek automatically selects the number of workers based on the available CPU count. Setting `--threads 1` disables parallel processing of candidate paths.
743
+
744
+ More threads do not always mean faster searches. Performance depends on factors such as file sizes, storage speed, and the type of search being performed. You can experiment with different values to find the best setting for your workload.
745
+
746
+ > **Note:** Parallel processing may change the order in which results are produced. Search filters and matching rules remain unchanged.
747
+
680
748
  ## Archive Search
681
749
 
682
750
  Enable archive support:
@@ -22,6 +22,7 @@ Fast and powerful command-line search tool for finding files, directories, and t
22
22
  * [Regular Expression (Regex)](#regular-expression-search)
23
23
  * [Fuzzy](#fuzzy-search)
24
24
  * [Fuzzy Similarity Threshold](#fuzzy-similarity-threshold)
25
+ * [Full Path](#full-path)
25
26
  * [Expression Queries](#expression-queries)
26
27
  * [Operators](#supported-operators)
27
28
  * [AND](#and)
@@ -46,6 +47,7 @@ Fast and powerful command-line search tool for finding files, directories, and t
46
47
  * [Exclude](#exclude)
47
48
  * [Size](#size-filtering)
48
49
  * [Depth](#depth)
50
+ * [Parallel Search](#parallel-search)
49
51
  * [Archive Search](#archive-search)
50
52
  * [Nested Archives](#nested-archives)
51
53
  * [Archive Filters](#archive-filters)
@@ -181,6 +183,7 @@ psk --exclude build
181
183
  | `--word` | Match the whole word only (except when `--expr` is enabled, in which case you can make it match whole word by putting `w` before term: `w"foo"`) |
182
184
  | `--expr` | Enable boolean query expressions. Example: `r"foo.*bar" and ("bar" or "baz") and not "qux"`. Prefixes: `r=regex`, `c=case-sensitive`, `w=whole-word`, `f=fuzzy` |
183
185
  | `--context` | Show N context lines before and after matches, or use N:M to specify before:after |
186
+ | `--full-path` | By default, query is only matched against the filename or directory name (this applies only when searching for files or directories, not file contents). Using this flag, query is matched against the full path (and, if `--absolute-path` flag is enabled, against the absolute path) |
184
187
  | `--depth` | Limit directory traversal to given depth range. By default, there is no limit on search depth |
185
188
  | `--timeout` | Stop the search after the specified number of seconds |
186
189
  | `--fuzzy` | Enable fuzzy search (Highlighting and counting matches are disabled in this mode if `--word` is not enabled to prevent the program from slowing down). except when `--expr` is enabled, in which case you can make it fuzzy by putting `f` before term: `f"foo"` |
@@ -206,6 +209,7 @@ psk --exclude build
206
209
  | `--absolute-path` | Display full path of files and directories |
207
210
  | `--paths-only` | Only show matching file paths for content search |
208
211
  | `--stats` | Show search statistics including result counts and search time |
212
+ | `--threads` | This flag sets the approximate number of threads to use. A value of 0 (which is the default) causes pseek to choose the thread count using heuristics |
209
213
 
210
214
  ## Specifying the Root Directory
211
215
 
@@ -323,6 +327,45 @@ Examples:
323
327
 
324
328
  Default: `80`
325
329
 
330
+ ### Full Path
331
+
332
+ By default, Pseek matches file and directory queries against the entry's name only. Use `--full-path` to match against its full path within the search scope instead. This option affects file and directory searches, not content searches.
333
+
334
+ For example, given the relative path `pseek/utils.py`, you can match part of the path rather than just the filename:
335
+
336
+ ```bash
337
+ # Match against the full relative path
338
+ psk "seek/utils" --full-path
339
+ ```
340
+
341
+ **Relative paths vs. absolute paths**
342
+
343
+ `--full-path` does not automatically include the absolute path. If the file's absolute path is:
344
+
345
+ ```text
346
+ /home/python/cli/pseek/utils.py
347
+ ```
348
+
349
+ a query such as `python/cli` will not match unless `--absolute-path` is also enabled:
350
+
351
+ ```bash
352
+ psk "python/cli" --full-path --absolute-path
353
+ ```
354
+
355
+ **Nested archives**
356
+
357
+ When searching nested archives, `--full-path` applies only to the path within the current archive level, not the entire nested archive chain.
358
+
359
+ For example:
360
+
361
+ ```text
362
+ archive.zip::folder/arc.zip::fd/file.txt
363
+ ```
364
+
365
+ With `--full-path`, you can match `fd/f` against the entry `fd/file.txt` inside the innermost archive. A query such as `zip::fd` does not match across archive boundaries. Without `--full-path`, matching is limited to the entry name, `file.txt`.
366
+
367
+ > **Note:** For ordinary queries without `--regex`, backslashes are treated literally, so Windows paths can be searched without additional escaping. When using `--expr`, quoted terms use JSON-style escaping; escape backslashes accordingly. For example, `"\\"` represents a single literal backslash, while `"\\n"` represents the literal sequence `\n`.
368
+
326
369
  ## Expression Queries
327
370
 
328
371
  Expression mode enables logical search expressions.
@@ -556,6 +599,8 @@ psk TODO \
556
599
 
557
600
  Only search inside those paths.
558
601
 
602
+ > **Note:** Paths manually included by the user take precedence over other filters; this means that even if a path conflicts with rules related to [--glob](#globs), [--exclude](#exclude-paths), etc., it will still be included in the results.
603
+
559
604
  #### Exclude Paths
560
605
 
561
606
  ```bash
@@ -649,6 +694,29 @@ project/ ← base directory
649
694
 
650
695
  If `--depth` is not specified, the search has no depth limit.
651
696
 
697
+ ## Parallel Search
698
+
699
+ Pseek can process multiple file paths concurrently to improve search performance, especially when searching large numbers of files.
700
+
701
+ Use `--threads` to control the maximum number of worker threads:
702
+
703
+ ```bash
704
+ # Use the default number of worker threads
705
+ psk TODO
706
+
707
+ # Use 4 worker threads
708
+ psk TODO --threads 4
709
+
710
+ # Use a single worker thread
711
+ psk TODO --threads 1
712
+ ```
713
+
714
+ If `--threads` is not specified, Pseek automatically selects the number of workers based on the available CPU count. Setting `--threads 1` disables parallel processing of candidate paths.
715
+
716
+ More threads do not always mean faster searches. Performance depends on factors such as file sizes, storage speed, and the type of search being performed. You can experiment with different values to find the best setting for your workload.
717
+
718
+ > **Note:** Parallel processing may change the order in which results are produced. Search filters and matching rules remain unchanged.
719
+
652
720
  ## Archive Search
653
721
 
654
722
  Enable archive support:
@@ -1,8 +1,17 @@
1
+ import bz2
2
+ import gzip
1
3
  import io
4
+ import lzma
5
+ import tarfile
6
+
7
+ # Archive modules
8
+ import zipfile
2
9
  from pathlib import Path
10
+
11
+ import py7zr
12
+ import rarfile
13
+
3
14
  from .utils import get_path_suffix, is_binary
4
- # Archive modules
5
- import zipfile, py7zr, tarfile, gzip, bz2, lzma, rarfile
6
15
 
7
16
  # Archive extensions that are allowed
8
17
  ARCHIVE_EXTS = ('zip', 'rar', '7z', 'tar', 'tar.gz', 'tar.bz2', 'tar.xz', 'gz', 'bz2', 'xz')
@@ -1,10 +1,16 @@
1
- import click, time, shutil, rarfile, platform
2
- from .searcher import seek
3
- from .structs import SearchConfig
4
- from multiprocessing import Process, Queue
5
- from queue import Empty
1
+ import platform
2
+ import shutil
3
+ import time
6
4
  from collections import defaultdict
5
+ from multiprocessing import Process, Queue
7
6
  from pathlib import Path
7
+ from queue import Empty
8
+
9
+ import click
10
+ import rarfile
11
+
12
+ from .searcher import seek
13
+ from .structs import SearchConfig
8
14
 
9
15
  BACKEND_PATH = Path(__file__).parent / "RARBackend"
10
16
 
@@ -336,12 +342,14 @@ def check_rar_backend(archive_enabled: bool, tool_path: str, backend: str):
336
342
  help='Fuzzy matching threshold (1-99). Higher values require closer matches.')
337
343
  @click.option('-C', '--context', metavar='BEFORE:AFTER',
338
344
  help='Show N context lines before and after matches, or use N:M to specify before:after.')
339
- @click.option('--depth', multiple=True, metavar='MIN:MAX',
345
+ @click.option('-p', '--full-path', is_flag=True,
346
+ help='By default, query is only matched against the filename or directory name '
347
+ '(this applies only when searching for files or directories, not file contents). '
348
+ 'Using this flag, query is matched against the full path '
349
+ '(and, if --absolute-path flag is enabled, against the absolute path).')
350
+ @click.option('-D', '--depth', multiple=True, metavar='MIN:MAX',
340
351
  help='Limit directory traversal to given depth range. '
341
352
  'By default, there is no limit on search depth.')
342
- @click.option('--timeout', metavar='FLOAT',
343
- type=click.FloatRange(min=0, min_open=True),
344
- help='Stop the search after the specified number of seconds.')
345
353
  # Automatic filtering
346
354
  @click.option('-u', '--unrestricted', is_flag=True,
347
355
  help='Disable all automatic filters except the binary file filter for content search.')
@@ -419,6 +427,12 @@ def check_rar_backend(archive_enabled: bool, tool_path: str, backend: str):
419
427
  @click.option('-l', '--paths-only', is_flag=True, help='Only show matching file paths for content search.')
420
428
  @click.option('-S', '--stats', is_flag=True,
421
429
  help='Show search statistics including result counts and search time.')
430
+ @click.option('--timeout', metavar='FLOAT',
431
+ type=click.FloatRange(min=0, min_open=True),
432
+ help='Stop the search after the specified number of seconds.')
433
+ @click.option('--threads', metavar='NUM', type=click.IntRange(min=0), default=0,
434
+ help='This flag sets the approximate number of threads to use. '
435
+ 'A value of 0 (which is the default) causes pseek to choose the thread count using heuristics.')
422
436
  def search(**kwargs):
423
437
  """Search for files, directories, and file content based on the query."""
424
438
 
@@ -1,8 +1,12 @@
1
- import re, click, json
1
+ import json
2
+ import re
3
+
4
+ import click
2
5
  from lark import Lark, Transformer
3
- from .utils import compile_regex
4
6
  from rapidfuzz import fuzz
5
7
 
8
+ from .utils import compile_regex
9
+
6
10
 
7
11
  class ExprNode:
8
12
  """Base class for expression tree nodes"""
@@ -1,12 +1,18 @@
1
- import mmap, os
1
+ import mmap
2
+ import os
3
+ from collections import defaultdict, deque
4
+ from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
2
5
  from errno import ELOOP
3
- from pseek import _pignore
6
+ from itertools import islice
4
7
  from pathlib import Path
5
- from collections import defaultdict, deque
6
- from .utils import get_path_suffix, is_binary
7
- from .parser import parse_query_expression, TermNode, find_matches
8
+ from threading import RLock
9
+
10
+ from pseek import _pignore
11
+
8
12
  from .archive import ARCHIVE_EXTS, extract_names_from_archive, extract_text_from_archive
9
- from .structs import FileDirResult, ContentResult, MatchGroup, Line
13
+ from .parser import TermNode, find_matches, parse_query_expression
14
+ from .structs import ContentResult, FileDirResult, Line, MatchGroup
15
+ from .utils import get_path_suffix, is_binary
10
16
 
11
17
 
12
18
  def should_skip(config, p: Path, file_ext: str, p_size: int) -> bool:
@@ -14,7 +20,7 @@ def should_skip(config, p: Path, file_ext: str, p_size: int) -> bool:
14
20
  Check whether the file/directory should be skipped based on various filters.
15
21
  Returns True if the path should be skipped.
16
22
  """
17
- if (config.ext and file_ext not in config.ext) \
23
+ return (config.ext and file_ext not in config.ext) \
18
24
  or (config.exclude_ext and file_ext in config.exclude_ext) \
19
25
  or (config.size and (p.is_dir() or not any( # .stat().st_size doesn't give actual size of dir.
20
26
  # To measure the actual size,
@@ -22,35 +28,37 @@ def should_skip(config, p: Path, file_ext: str, p_size: int) -> bool:
22
28
  # which is time-consuming
23
29
  minimum <= p_size <= maximum
24
30
  for minimum, maximum in config.size
25
- ))):
26
- return True
27
-
28
- # Filter by regex include and exclude
29
- str_p = str(p)
30
- if config.re_include:
31
- return not config.re_include.search(str_p)
32
- if config.re_exclude:
33
- return config.re_exclude.search(str_p) is not None
31
+ )))
34
32
 
35
- return False
36
33
 
37
-
38
- def add_result(matches, result_queue, match_type, value):
39
- if result_queue:
34
+ def add_result(matches, result_queue, match_type, value, lock):
35
+ if result_queue is not None:
36
+ # Queue.put() is thread-safe for standard Python queues
40
37
  result_queue.put((match_type, value))
41
- else:
38
+ return
39
+
40
+ if lock is None:
42
41
  matches[match_type].append(value)
42
+ else:
43
+ with lock:
44
+ matches[match_type].append(value)
43
45
 
44
46
 
45
- def add_metric(metrics, result_queue, name, value):
46
- if result_queue:
47
+ def add_metric(metrics, result_queue, name, value, lock):
48
+ if result_queue is not None:
49
+ # Queue.put() is thread-safe for standard Python queues
47
50
  result_queue.put(('metric', (name, value)))
48
- else:
51
+ return
52
+
53
+ if lock is None:
49
54
  metrics[name].add(value)
55
+ else:
56
+ with lock:
57
+ metrics[name].add(value)
50
58
 
51
59
 
52
60
  def search_file_and_dir(config, matches: dict, pattern, p: Path,
53
- p_ext: str, metrics, result_queue):
61
+ p_ext: str, metrics, result_queue, result_lock):
54
62
  """Search files and folders on the system and within archive files"""
55
63
 
56
64
  path_str = str(p)
@@ -60,14 +68,15 @@ def search_file_and_dir(config, matches: dict, pattern, p: Path,
60
68
  'directory' if config.directory and p.is_dir() else
61
69
  None
62
70
  )
71
+ p_name = p.name if not config.full_path else path_str
63
72
 
64
- if match_type and (pattern is None or pattern.evaluate(p.name)):
73
+ if match_type and (pattern is None or pattern.evaluate(p_name)):
65
74
  # Find matched query in the name
66
75
  name_matches = find_matches(
67
76
  pattern,
68
- p.name,
77
+ p_name,
69
78
  # Calculate number of chars that come before name of file or dir
70
- len(path_str) - len(p.name)
79
+ len(path_str) - len(p_name)
71
80
  )
72
81
 
73
82
  add_result(
@@ -77,37 +86,59 @@ def search_file_and_dir(config, matches: dict, pattern, p: Path,
77
86
  FileDirResult(
78
87
  path=path_str,
79
88
  matches=name_matches
80
- )
89
+ ),
90
+ result_lock
81
91
  )
82
92
 
83
93
  # Search for files and directories name inside archive files if archive is active
84
94
  if config.archive and p_ext in ARCHIVE_EXTS[:-3]:
85
95
  for virtual_path, name, is_dir in extract_names_from_archive(p, config):
96
+ name_str = str(name)
97
+
86
98
  if config.stats:
87
- full_virtual_path = (path_str, *virtual_path, str(name))
99
+ full_virtual_path = (path_str, *virtual_path, name_str)
88
100
 
89
101
  if not is_dir and config.file:
90
- add_metric(metrics, result_queue, 'files_scanned', full_virtual_path)
102
+ add_metric(
103
+ metrics,
104
+ result_queue,
105
+ 'files_scanned',
106
+ full_virtual_path,
107
+ result_lock
108
+ )
91
109
  elif is_dir:
92
- add_metric(metrics, result_queue, 'directories_scanned', full_virtual_path)
110
+ add_metric(
111
+ metrics,
112
+ result_queue,
113
+ 'directories_scanned',
114
+ full_virtual_path,
115
+ result_lock
116
+ )
93
117
 
94
118
  # We cannot use "full_virtual_path[-1]" because it may be limited by --arc-depth
95
119
  # and may not enter the archive at all to scan inside it
96
120
  if len(full_virtual_path) >= 3 and get_path_suffix(full_virtual_path[-2]) in ARCHIVE_EXTS[:-3]:
97
- add_metric(metrics, result_queue, 'archives_scanned', full_virtual_path)
121
+ add_metric(
122
+ metrics,
123
+ result_queue,
124
+ 'archives_scanned',
125
+ full_virtual_path,
126
+ result_lock
127
+ )
98
128
 
99
129
  arc_match_type = (
100
130
  'file' if config.file and not is_dir else
101
131
  'directory' if config.directory and is_dir else
102
132
  None
103
133
  )
134
+ virtual_name = name.name if not config.full_path else name_str
104
135
 
105
- if arc_match_type and (pattern is None or pattern.evaluate(name.name)):
136
+ if arc_match_type and (pattern is None or pattern.evaluate(virtual_name)):
106
137
  name_matches = find_matches(
107
138
  pattern,
108
- name.name,
139
+ virtual_name,
109
140
  # Calculate number of chars that come before name of file or dir
110
- len(str(name)) - len(name.name)
141
+ len(name_str) - len(virtual_name)
111
142
  )
112
143
 
113
144
  add_result(
@@ -117,8 +148,9 @@ def search_file_and_dir(config, matches: dict, pattern, p: Path,
117
148
  FileDirResult(
118
149
  path=path_str,
119
150
  matches=name_matches,
120
- virtual_path=[*virtual_path, str(name)]
121
- )
151
+ virtual_path=[*virtual_path, name_str]
152
+ ),
153
+ result_lock
122
154
  )
123
155
 
124
156
 
@@ -198,7 +230,7 @@ class ContextCollector:
198
230
 
199
231
 
200
232
  def search_content(config, matches: dict, pattern, binary_pattern,
201
- p: Path, p_ext: str, metrics, result_queue):
233
+ p: Path, p_ext: str, metrics, result_queue, result_lock):
202
234
  """Search within the contents of system files and files inside archive files"""
203
235
 
204
236
  path_str = str(p)
@@ -213,12 +245,19 @@ def search_content(config, matches: dict, pattern, binary_pattern,
213
245
  metrics,
214
246
  result_queue,
215
247
  'files_scanned',
216
- full_virtual_path if virtual_path else path_str
248
+ full_virtual_path if virtual_path else path_str,
249
+ result_lock
217
250
  )
218
251
 
219
252
  if (len(full_virtual_path) >= 3 and get_path_suffix(full_virtual_path[-2]) in ARCHIVE_EXTS[:-3] \
220
253
  or get_path_suffix(full_virtual_path[-1]) in ARCHIVE_EXTS[-3:]):
221
- add_metric(metrics, result_queue, 'archives_scanned', full_virtual_path)
254
+ add_metric(
255
+ metrics,
256
+ result_queue,
257
+ 'archives_scanned',
258
+ full_virtual_path,
259
+ result_lock
260
+ )
222
261
 
223
262
  if binary_pattern and not content_contains(content, binary_pattern):
224
263
  continue
@@ -241,7 +280,8 @@ def search_content(config, matches: dict, pattern, binary_pattern,
241
280
  ContentResult(
242
281
  path=path_str,
243
282
  virtual_path=virtual_path
244
- )
283
+ ),
284
+ result_lock
245
285
  )
246
286
  break
247
287
 
@@ -262,7 +302,8 @@ def search_content(config, matches: dict, pattern, binary_pattern,
262
302
  path=path_str,
263
303
  virtual_path=virtual_path,
264
304
  groups=groups
265
- )
305
+ ),
306
+ result_lock
266
307
  )
267
308
 
268
309
  # Skip next block to avoid searching the contents of archive files
@@ -293,7 +334,8 @@ def search_content(config, matches: dict, pattern, binary_pattern,
293
334
  matches,
294
335
  result_queue,
295
336
  'content',
296
- ContentResult(path=path_str)
337
+ ContentResult(path=path_str),
338
+ result_lock
297
339
  )
298
340
  return
299
341
 
@@ -313,7 +355,8 @@ def search_content(config, matches: dict, pattern, binary_pattern,
313
355
  ContentResult(
314
356
  path=path_str,
315
357
  groups=groups
316
- )
358
+ ),
359
+ result_lock
317
360
  )
318
361
 
319
362
 
@@ -349,6 +392,10 @@ def walk_logic(path: Path, config, depth: int, matcher, ancestors: set):
349
392
  # Prune explicitly excluded paths before further processing
350
393
  if entry_path in config.exclude:
351
394
  continue
395
+ # Filter by regex include and exclude
396
+ if (config.re_include and not config.re_include.search(entry_path.name)) \
397
+ or (config.re_exclude and config.re_exclude.search(entry_path.name) is not None):
398
+ continue
352
399
 
353
400
  try:
354
401
  is_dir = entry.is_dir(follow_symlinks=config.follow)
@@ -405,6 +452,7 @@ def walk_logic(path: Path, config, depth: int, matcher, ancestors: set):
405
452
  ancestors.remove(dir_key)
406
453
 
407
454
 
455
+ # TODO: Make traversal parallelize just like seek func
408
456
  def walk(config):
409
457
  """Include paths define traversal roots, avoiding unnecessary traversal
410
458
  of unrelated parts of the tree"""
@@ -436,62 +484,158 @@ def walk(config):
436
484
  yield from walk_logic(root, config, depth, matcher, set())
437
485
 
438
486
 
439
- def seek(config, result_queue=None) -> dict:
440
- """Main search function"""
441
- pattern = parse_query_expression(config)
442
- # If expression is simple and is a single TermNode, we can use binary pattern
443
- if config.content:
444
- if isinstance(pattern, TermNode):
445
- binary_pattern = pattern.get_binary_pattern()
446
- else:
447
- binary_pattern = None
487
+ def process_candidate(item, config, pattern, binary_pattern,
488
+ matches, metrics, result_queue, result_lock):
489
+ p, metric_only = item
448
490
 
449
- matches = {'file': [], 'directory': [], 'content': []}
450
- metrics = defaultdict(set)
491
+ # Dir metrics must be added before should_skip,
492
+ # as it can influence these metrics
493
+ if config.stats and p.is_dir():
494
+ add_metric(
495
+ metrics,
496
+ result_queue,
497
+ 'directories_scanned',
498
+ str(p),
499
+ result_lock
500
+ )
451
501
 
452
- for p, metric_only in walk(config):
453
- # Dir metrics must be added before should_skip,
454
- # as it can influence these metrics
455
- if config.stats and p.is_dir():
456
- add_metric(metrics, result_queue, 'directories_scanned', str(p))
502
+ if metric_only:
503
+ return
457
504
 
458
- if metric_only:
459
- continue
505
+ try:
506
+ p_ext = get_path_suffix(p)
507
+ # Path.state follows symlinks, so we use lstate here
508
+ p_size = p.lstat().st_size
509
+ except OSError:
510
+ return
460
511
 
461
- try:
462
- p_ext = get_path_suffix(p)
463
- # Path.state follows symlinks, so we use lstate here
464
- p_size = p.lstat().st_size
465
- except OSError:
466
- continue
512
+ if should_skip(config, p, p_ext, p_size):
513
+ return
467
514
 
468
- if should_skip(config, p, p_ext, p_size):
469
- continue
515
+ if config.stats:
516
+ path_str = str(p)
470
517
 
471
- if config.stats:
472
- path_str = str(p)
518
+ if (config.file or config.content) and p.is_file():
519
+ add_metric(
520
+ metrics,
521
+ result_queue,
522
+ 'files_scanned',
523
+ path_str,
524
+ result_lock
525
+ )
473
526
 
474
- if (config.file or config.content) and p.is_file():
475
- add_metric(metrics, result_queue, 'files_scanned', path_str)
527
+ if config.archive and p_ext in ARCHIVE_EXTS:
528
+ add_metric(
529
+ metrics,
530
+ result_queue,
531
+ 'archives_scanned',
532
+ (path_str,),
533
+ result_lock
534
+ )
476
535
 
477
- if config.archive and p_ext in ARCHIVE_EXTS:
478
- add_metric(
536
+ # Search for files and directories if requested
537
+ if config.file or config.directory:
538
+ search_file_and_dir(
539
+ config,
540
+ matches,
541
+ pattern,
542
+ p,
543
+ p_ext,
544
+ metrics,
545
+ result_queue,
546
+ result_lock
547
+ )
548
+
549
+ # Search for content inside files if requested
550
+ # Avoid empty files
551
+ if config.content and not p.is_symlink() \
552
+ and p.is_file() and p_size != 0:
553
+ search_content(
554
+ config,
555
+ matches,
556
+ pattern,
557
+ binary_pattern,
558
+ p,
559
+ p_ext,
560
+ metrics,
561
+ result_queue,
562
+ result_lock
563
+ )
564
+
565
+
566
+ def batched(iterable, size):
567
+ # Convert iterable to iterator to prevent
568
+ # islice from creating identical batches.
569
+ # Example of using iterable:
570
+ # items = [1, 2, 3, 4, 5, 6]
571
+ # list(islice(items, 3)) -> [1, 2, 3]
572
+ # list(islice(items, 3)) -> Again [1, 2, 3]!
573
+ iterator = iter(iterable)
574
+
575
+ # If no elements remain, the batch becomes [],
576
+ # which causes while loop to terminate
577
+ while batch := list(islice(iterator, size)):
578
+ yield batch
579
+
580
+
581
+ def process_batch(items, *args):
582
+ for item in items:
583
+ process_candidate(item, *args)
584
+
585
+
586
+ def seek(config, result_queue=None) -> dict:
587
+ """Main search function"""
588
+ pattern = parse_query_expression(config)
589
+ # If expression is simple and is a single TermNode, we can use binary pattern
590
+ binary_pattern = None
591
+ if config.content and isinstance(pattern, TermNode):
592
+ binary_pattern = pattern.get_binary_pattern()
593
+
594
+ matches = {
595
+ 'file': [],
596
+ 'directory': [],
597
+ 'content': []
598
+ }
599
+ metrics = defaultdict(set)
600
+ result_lock = RLock()
601
+
602
+ workers = config.threads or (os.cpu_count() or 1)
603
+
604
+ with ThreadPoolExecutor(
605
+ max_workers=workers,
606
+ thread_name_prefix='pseek'
607
+ ) as executor:
608
+ pending = set()
609
+
610
+ for batch in batched(walk(config), 16):
611
+ pending.add(
612
+ executor.submit(
613
+ process_batch,
614
+ batch,
615
+ config,
616
+ pattern,
617
+ binary_pattern,
618
+ matches,
479
619
  metrics,
480
620
  result_queue,
481
- 'archives_scanned',
482
- (path_str,)
621
+ result_lock
483
622
  )
623
+ )
624
+
625
+ # Limit queued work to avoid excessive memory usage
626
+ # Using workers as max number of pending items
627
+ if len(pending) >= workers:
628
+ done, pending = wait(
629
+ pending,
630
+ return_when=FIRST_COMPLETED
631
+ )
632
+
633
+ # Don't ignore exceptions. If an exception occurs, re-raise it in main thread
634
+ for future in done:
635
+ future.result()
484
636
 
485
- # Search for files and directories if requested
486
- if config.file or config.directory:
487
- search_file_and_dir(config, matches, pattern, p,
488
- p_ext, metrics, result_queue)
489
-
490
- # Search for content inside files if requested
491
- # Avoid empty files
492
- if config.content and not p.is_symlink() \
493
- and p.is_file() and p_size != 0:
494
- search_content(config, matches, pattern, binary_pattern, p,
495
- p_ext, metrics, result_queue)
637
+ # Wait for workers that haven't finished their work yet
638
+ for future in pending:
639
+ future.result()
496
640
 
497
641
  return matches, metrics
@@ -1,8 +1,11 @@
1
- from click import BadParameter
2
- import re, os
3
- from pathlib import Path
1
+ import os
2
+ import re
4
3
  from dataclasses import dataclass, field
5
4
  from math import isfinite
5
+ from pathlib import Path
6
+
7
+ from click import BadParameter
8
+
6
9
  from .utils import compile_regex
7
10
 
8
11
  SUFFIXES = {
@@ -162,9 +165,11 @@ class SearchConfig:
162
165
  arc_size: list[tuple]
163
166
  rar_backend: str | None
164
167
  absolute_path: bool
168
+ full_path: bool
165
169
  paths_only: bool
166
170
  stats: bool
167
171
  context: tuple[int, int]
172
+ threads: int
168
173
 
169
174
  def __post_init__(self):
170
175
  """Post-initialization processing to normalize and validate inputs"""
@@ -1,6 +1,8 @@
1
- import re, click
1
+ import re
2
2
  from pathlib import Path
3
3
 
4
+ import click
5
+
4
6
  EXTENSIONS_PATH = Path(__file__).parent / "compound_extensions"
5
7
  try:
6
8
  with open(EXTENSIONS_PATH, "r") as f:
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "pseek"
7
- version = "3.4.2"
7
+ version = "3.6.0"
8
8
  description = "Pseek is a Python CLI toolkit to search files, folders, and text"
9
9
  readme = "README.md"
10
10
  license = "MIT"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes