pseek 3.5.0__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.5.0
3
+ Version: 3.6.0
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -75,6 +75,7 @@ Fast and powerful command-line search tool for finding files, directories, and t
75
75
  * [Exclude](#exclude)
76
76
  * [Size](#size-filtering)
77
77
  * [Depth](#depth)
78
+ * [Parallel Search](#parallel-search)
78
79
  * [Archive Search](#archive-search)
79
80
  * [Nested Archives](#nested-archives)
80
81
  * [Archive Filters](#archive-filters)
@@ -236,6 +237,7 @@ psk --exclude build
236
237
  | `--absolute-path` | Display full path of files and directories |
237
238
  | `--paths-only` | Only show matching file paths for content search |
238
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 |
239
241
 
240
242
  ## Specifying the Root Directory
241
243
 
@@ -720,6 +722,29 @@ project/ ← base directory
720
722
 
721
723
  If `--depth` is not specified, the search has no depth limit.
722
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
+
723
748
  ## Archive Search
724
749
 
725
750
  Enable archive support:
@@ -47,6 +47,7 @@ Fast and powerful command-line search tool for finding files, directories, and t
47
47
  * [Exclude](#exclude)
48
48
  * [Size](#size-filtering)
49
49
  * [Depth](#depth)
50
+ * [Parallel Search](#parallel-search)
50
51
  * [Archive Search](#archive-search)
51
52
  * [Nested Archives](#nested-archives)
52
53
  * [Archive Filters](#archive-filters)
@@ -208,6 +209,7 @@ psk --exclude build
208
209
  | `--absolute-path` | Display full path of files and directories |
209
210
  | `--paths-only` | Only show matching file paths for content search |
210
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 |
211
213
 
212
214
  ## Specifying the Root Directory
213
215
 
@@ -692,6 +694,29 @@ project/ ← base directory
692
694
 
693
695
  If `--depth` is not specified, the search has no depth limit.
694
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
+
695
720
  ## Archive Search
696
721
 
697
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
 
@@ -424,6 +430,9 @@ def check_rar_backend(archive_enabled: bool, tool_path: str, backend: str):
424
430
  @click.option('--timeout', metavar='FLOAT',
425
431
  type=click.FloatRange(min=0, min_open=True),
426
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.')
427
436
  def search(**kwargs):
428
437
  """Search for files, directories, and file content based on the query."""
429
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)
@@ -78,7 +86,8 @@ def search_file_and_dir(config, matches: dict, pattern, p: Path,
78
86
  FileDirResult(
79
87
  path=path_str,
80
88
  matches=name_matches
81
- )
89
+ ),
90
+ result_lock
82
91
  )
83
92
 
84
93
  # Search for files and directories name inside archive files if archive is active
@@ -90,14 +99,32 @@ def search_file_and_dir(config, matches: dict, pattern, p: Path,
90
99
  full_virtual_path = (path_str, *virtual_path, name_str)
91
100
 
92
101
  if not is_dir and config.file:
93
- 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
+ )
94
109
  elif is_dir:
95
- 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
+ )
96
117
 
97
118
  # We cannot use "full_virtual_path[-1]" because it may be limited by --arc-depth
98
119
  # and may not enter the archive at all to scan inside it
99
120
  if len(full_virtual_path) >= 3 and get_path_suffix(full_virtual_path[-2]) in ARCHIVE_EXTS[:-3]:
100
- 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
+ )
101
128
 
102
129
  arc_match_type = (
103
130
  'file' if config.file and not is_dir else
@@ -122,7 +149,8 @@ def search_file_and_dir(config, matches: dict, pattern, p: Path,
122
149
  path=path_str,
123
150
  matches=name_matches,
124
151
  virtual_path=[*virtual_path, name_str]
125
- )
152
+ ),
153
+ result_lock
126
154
  )
127
155
 
128
156
 
@@ -202,7 +230,7 @@ class ContextCollector:
202
230
 
203
231
 
204
232
  def search_content(config, matches: dict, pattern, binary_pattern,
205
- p: Path, p_ext: str, metrics, result_queue):
233
+ p: Path, p_ext: str, metrics, result_queue, result_lock):
206
234
  """Search within the contents of system files and files inside archive files"""
207
235
 
208
236
  path_str = str(p)
@@ -217,12 +245,19 @@ def search_content(config, matches: dict, pattern, binary_pattern,
217
245
  metrics,
218
246
  result_queue,
219
247
  'files_scanned',
220
- full_virtual_path if virtual_path else path_str
248
+ full_virtual_path if virtual_path else path_str,
249
+ result_lock
221
250
  )
222
251
 
223
252
  if (len(full_virtual_path) >= 3 and get_path_suffix(full_virtual_path[-2]) in ARCHIVE_EXTS[:-3] \
224
253
  or get_path_suffix(full_virtual_path[-1]) in ARCHIVE_EXTS[-3:]):
225
- 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
+ )
226
261
 
227
262
  if binary_pattern and not content_contains(content, binary_pattern):
228
263
  continue
@@ -245,7 +280,8 @@ def search_content(config, matches: dict, pattern, binary_pattern,
245
280
  ContentResult(
246
281
  path=path_str,
247
282
  virtual_path=virtual_path
248
- )
283
+ ),
284
+ result_lock
249
285
  )
250
286
  break
251
287
 
@@ -266,7 +302,8 @@ def search_content(config, matches: dict, pattern, binary_pattern,
266
302
  path=path_str,
267
303
  virtual_path=virtual_path,
268
304
  groups=groups
269
- )
305
+ ),
306
+ result_lock
270
307
  )
271
308
 
272
309
  # Skip next block to avoid searching the contents of archive files
@@ -297,7 +334,8 @@ def search_content(config, matches: dict, pattern, binary_pattern,
297
334
  matches,
298
335
  result_queue,
299
336
  'content',
300
- ContentResult(path=path_str)
337
+ ContentResult(path=path_str),
338
+ result_lock
301
339
  )
302
340
  return
303
341
 
@@ -317,7 +355,8 @@ def search_content(config, matches: dict, pattern, binary_pattern,
317
355
  ContentResult(
318
356
  path=path_str,
319
357
  groups=groups
320
- )
358
+ ),
359
+ result_lock
321
360
  )
322
361
 
323
362
 
@@ -353,6 +392,10 @@ def walk_logic(path: Path, config, depth: int, matcher, ancestors: set):
353
392
  # Prune explicitly excluded paths before further processing
354
393
  if entry_path in config.exclude:
355
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
356
399
 
357
400
  try:
358
401
  is_dir = entry.is_dir(follow_symlinks=config.follow)
@@ -409,6 +452,7 @@ def walk_logic(path: Path, config, depth: int, matcher, ancestors: set):
409
452
  ancestors.remove(dir_key)
410
453
 
411
454
 
455
+ # TODO: Make traversal parallelize just like seek func
412
456
  def walk(config):
413
457
  """Include paths define traversal roots, avoiding unnecessary traversal
414
458
  of unrelated parts of the tree"""
@@ -440,62 +484,158 @@ def walk(config):
440
484
  yield from walk_logic(root, config, depth, matcher, set())
441
485
 
442
486
 
443
- def seek(config, result_queue=None) -> dict:
444
- """Main search function"""
445
- pattern = parse_query_expression(config)
446
- # If expression is simple and is a single TermNode, we can use binary pattern
447
- if config.content:
448
- if isinstance(pattern, TermNode):
449
- binary_pattern = pattern.get_binary_pattern()
450
- else:
451
- binary_pattern = None
487
+ def process_candidate(item, config, pattern, binary_pattern,
488
+ matches, metrics, result_queue, result_lock):
489
+ p, metric_only = item
452
490
 
453
- matches = {'file': [], 'directory': [], 'content': []}
454
- 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
+ )
455
501
 
456
- for p, metric_only in walk(config):
457
- # Dir metrics must be added before should_skip,
458
- # as it can influence these metrics
459
- if config.stats and p.is_dir():
460
- add_metric(metrics, result_queue, 'directories_scanned', str(p))
502
+ if metric_only:
503
+ return
461
504
 
462
- if metric_only:
463
- 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
464
511
 
465
- try:
466
- p_ext = get_path_suffix(p)
467
- # Path.state follows symlinks, so we use lstate here
468
- p_size = p.lstat().st_size
469
- except OSError:
470
- continue
512
+ if should_skip(config, p, p_ext, p_size):
513
+ return
471
514
 
472
- if should_skip(config, p, p_ext, p_size):
473
- continue
515
+ if config.stats:
516
+ path_str = str(p)
474
517
 
475
- if config.stats:
476
- 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
+ )
477
526
 
478
- if (config.file or config.content) and p.is_file():
479
- 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
+ )
480
535
 
481
- if config.archive and p_ext in ARCHIVE_EXTS:
482
- 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,
483
619
  metrics,
484
620
  result_queue,
485
- 'archives_scanned',
486
- (path_str,)
621
+ result_lock
487
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()
488
636
 
489
- # Search for files and directories if requested
490
- if config.file or config.directory:
491
- search_file_and_dir(config, matches, pattern, p,
492
- p_ext, metrics, result_queue)
493
-
494
- # Search for content inside files if requested
495
- # Avoid empty files
496
- if config.content and not p.is_symlink() \
497
- and p.is_file() and p_size != 0:
498
- search_content(config, matches, pattern, binary_pattern, p,
499
- p_ext, metrics, result_queue)
637
+ # Wait for workers that haven't finished their work yet
638
+ for future in pending:
639
+ future.result()
500
640
 
501
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 = {
@@ -166,6 +169,7 @@ class SearchConfig:
166
169
  paths_only: bool
167
170
  stats: bool
168
171
  context: tuple[int, int]
172
+ threads: int
169
173
 
170
174
  def __post_init__(self):
171
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.5.0"
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