barecat 0.3.2__py3-none-any.whl

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.
Files changed (60) hide show
  1. barecat/__init__.py +108 -0
  2. barecat/__main__.py +5 -0
  3. barecat/_api.py +143 -0
  4. barecat/_version.py +24 -0
  5. barecat/cli/__init__.py +0 -0
  6. barecat/cli/commands.py +540 -0
  7. barecat/cli/completions.py +27 -0
  8. barecat/cli/impl.py +609 -0
  9. barecat/cli/legacy.py +361 -0
  10. barecat/cli/main.py +1355 -0
  11. barecat/cli/shell.py +827 -0
  12. barecat/completions/__init__.py +1 -0
  13. barecat/completions/barecat.bash +246 -0
  14. barecat/completions/barecat.zsh +268 -0
  15. barecat/core/__init__.py +0 -0
  16. barecat/core/barecat.py +1209 -0
  17. barecat/core/index.py +1983 -0
  18. barecat/core/paths.py +61 -0
  19. barecat/core/sharder.py +328 -0
  20. barecat/core/types.py +340 -0
  21. barecat/exceptions.py +92 -0
  22. barecat/formats/__init__.py +0 -0
  23. barecat/formats/archive_formats.py +234 -0
  24. barecat/formats/from_tar_stream.py +53 -0
  25. barecat/formats/to_tar_stream.py +54 -0
  26. barecat/io/__init__.py +5 -0
  27. barecat/io/codecs.py +622 -0
  28. barecat/io/copyfile.py +550 -0
  29. barecat/io/fileobj.py +556 -0
  30. barecat/io/open.py +41 -0
  31. barecat/maintenance/__init__.py +0 -0
  32. barecat/maintenance/complete_database.py +79 -0
  33. barecat/maintenance/defrag.py +541 -0
  34. barecat/maintenance/merge.py +613 -0
  35. barecat/maintenance/reshard.py +153 -0
  36. barecat/maintenance/rsync.py +1282 -0
  37. barecat/maintenance/upgrade_database.py +200 -0
  38. barecat/maintenance/upgrade_database2.py +74 -0
  39. barecat/man/barecat.1 +284 -0
  40. barecat/sql/indexes.sql +6 -0
  41. barecat/sql/schema.sql +55 -0
  42. barecat/sql/triggers.sql +172 -0
  43. barecat/tui/__init__.py +0 -0
  44. barecat/tui/browse.py +448 -0
  45. barecat/tui/ncdu.py +318 -0
  46. barecat/util/__init__.py +0 -0
  47. barecat/util/consumed_threadpool.py +119 -0
  48. barecat/util/glob_helper.py +584 -0
  49. barecat/util/glob_to_regex.py +353 -0
  50. barecat/util/list_files.py +12 -0
  51. barecat/util/misc.py +174 -0
  52. barecat/util/physical_order.py +77 -0
  53. barecat/util/progbar.py +40 -0
  54. barecat/util/threading.py +59 -0
  55. barecat-0.3.2.dist-info/METADATA +276 -0
  56. barecat-0.3.2.dist-info/RECORD +60 -0
  57. barecat-0.3.2.dist-info/WHEEL +5 -0
  58. barecat-0.3.2.dist-info/entry_points.txt +15 -0
  59. barecat-0.3.2.dist-info/licenses/LICENSE +21 -0
  60. barecat-0.3.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,540 @@
1
+ """Shared command implementations for CLI and shell."""
2
+
3
+ import fnmatch
4
+ import os.path as osp
5
+ import stat
6
+ import sys
7
+ from typing import Optional, Callable
8
+
9
+
10
+ # =============================================================================
11
+ # ls command
12
+ # =============================================================================
13
+
14
+
15
+ def list_entries(
16
+ index,
17
+ paths: list[str],
18
+ long_format: bool = False,
19
+ recursive: bool = False,
20
+ jsonl: bool = False,
21
+ output: Callable[[str], None] = print,
22
+ ):
23
+ """List archive entries. Mimics the `ls` command.
24
+
25
+ Args:
26
+ index: Barecat index
27
+ paths: Paths or glob patterns to list
28
+ long_format: Show detailed info (-l)
29
+ recursive: List recursively (-R)
30
+ jsonl: Output as JSON lines
31
+ output: Function to output each line
32
+ """
33
+ import json
34
+
35
+ def format_entry(mode, nlink, user, group, size, mtime_ns, name):
36
+ mode_str = _format_mode(mode)
37
+ time_str = _format_time(mtime_ns)
38
+ return f'{mode_str} {nlink:>4} {user:<8} {group:<8} {size:>8} {time_str} {name}'
39
+
40
+ def print_file(finfo, name=None):
41
+ if name is None:
42
+ name = finfo.path
43
+ if jsonl:
44
+ output(
45
+ json.dumps(
46
+ {
47
+ 'type': 'file',
48
+ 'path': finfo.path,
49
+ 'size': finfo.size,
50
+ 'mtime_ns': finfo.mtime_ns,
51
+ 'crc32c': finfo.crc32c,
52
+ 'mode': finfo.mode,
53
+ 'uid': finfo.uid,
54
+ 'gid': finfo.gid,
55
+ }
56
+ )
57
+ )
58
+ elif long_format:
59
+ user, group = _get_user_group(finfo.uid, finfo.gid)
60
+ output(format_entry(finfo.mode, 1, user, group, finfo.size, finfo.mtime_ns, name))
61
+ else:
62
+ output(name)
63
+
64
+ def print_dir(dinfo, name=None):
65
+ if name is None:
66
+ name = dinfo.path
67
+ if jsonl:
68
+ output(
69
+ json.dumps(
70
+ {
71
+ 'type': 'dir',
72
+ 'path': dinfo.path,
73
+ 'size_tree': dinfo.size_tree,
74
+ 'num_files_tree': dinfo.num_files_tree,
75
+ 'mtime_ns': dinfo.mtime_ns,
76
+ 'mode': dinfo.mode,
77
+ 'uid': dinfo.uid,
78
+ 'gid': dinfo.gid,
79
+ }
80
+ )
81
+ )
82
+ elif long_format:
83
+ user, group = _get_user_group(dinfo.uid, dinfo.gid)
84
+ nlink = (dinfo.num_subdirs or 0) + 2
85
+ output(
86
+ format_entry(dinfo.mode, nlink, user, group, dinfo.size_tree, dinfo.mtime_ns, name)
87
+ )
88
+ else:
89
+ # Add trailing / for directories in short format (like shell ls)
90
+ output(name + '/' if name else name)
91
+
92
+ paths = paths if paths else ['']
93
+
94
+ for path in paths:
95
+ path = _normalize_pattern(path)
96
+
97
+ # If path contains glob characters, use glob matching
98
+ if _is_glob(path):
99
+ has_doublestar = '**' in path
100
+ for info in index.iterglob_infos(path, recursive=has_doublestar):
101
+ if hasattr(info, 'shard'):
102
+ print_file(info)
103
+ else:
104
+ print_dir(info)
105
+ continue
106
+
107
+ if recursive:
108
+ if path == '' or index.isdir(path):
109
+ for dinfo, subdirs, files in index.walk_infos(path):
110
+ for finfo in files:
111
+ print_file(finfo)
112
+ elif index.isfile(path):
113
+ finfo = index.lookup_file(path)
114
+ print_file(finfo)
115
+ else:
116
+ if path == '' or index.isdir(path):
117
+ entries = []
118
+ total_size = 0
119
+ for name in sorted(index.iterdir_names(path)):
120
+ full_path = osp.join(path, name) if path else name
121
+ if index.isdir(full_path):
122
+ dinfo = index.lookup_dir(full_path)
123
+ entries.append(('dir', dinfo, name))
124
+ total_size += dinfo.size_tree or 0
125
+ else:
126
+ finfo = index.lookup_file(full_path)
127
+ entries.append(('file', finfo, name))
128
+ total_size += finfo.size or 0
129
+
130
+ if long_format and not jsonl:
131
+ output(f'total {total_size}')
132
+
133
+ for kind, info, name in entries:
134
+ if kind == 'dir':
135
+ print_dir(info, name)
136
+ else:
137
+ print_file(info, name)
138
+ elif index.isfile(path):
139
+ finfo = index.lookup_file(path)
140
+ print_file(finfo)
141
+ else:
142
+ output(f'ls: {path}: No such file or directory')
143
+
144
+
145
+ # =============================================================================
146
+ # find command
147
+ # =============================================================================
148
+
149
+
150
+ def find_entries(
151
+ index,
152
+ path: str = '',
153
+ name: Optional[str] = None,
154
+ pathpattern: Optional[str] = None,
155
+ ftype: Optional[str] = None, # 'f' or 'd'
156
+ size: Optional[str] = None, # e.g. '+1M', '-100k'
157
+ maxdepth: Optional[int] = None,
158
+ print0: bool = False,
159
+ output: Callable[[str], None] = print,
160
+ ):
161
+ """Find files in archive. Mimics the `find` command.
162
+
163
+ Args:
164
+ index: Barecat index
165
+ path: Starting path
166
+ name: Match basename against glob pattern
167
+ pathpattern: Match full path against glob pattern
168
+ ftype: File type ('f' for files, 'd' for directories)
169
+ size: Size filter ('+N' larger, '-N' smaller, 'N' exact)
170
+ maxdepth: Maximum depth to descend
171
+ print0: Use null separator
172
+ output: Function to output each line
173
+ """
174
+ root_path = path.rstrip('/') if path else ''
175
+ root_depth = root_path.count('/') + 1 if root_path else 0
176
+
177
+ # Parse size filter
178
+ size_op, size_val = None, None
179
+ if size:
180
+ if size[0] == '+':
181
+ size_op, size_val = 'gt', _parse_size(size[1:])
182
+ elif size[0] == '-':
183
+ size_op, size_val = 'lt', _parse_size(size[1:])
184
+ else:
185
+ size_op, size_val = 'eq', _parse_size(size)
186
+
187
+ # Build SQL query parts
188
+ conditions = []
189
+ params = []
190
+
191
+ if pathpattern:
192
+ pattern = _normalize_pattern(pathpattern)
193
+ conditions.append('path GLOB ?')
194
+ params.append(pattern)
195
+
196
+ if size_op == 'gt':
197
+ conditions.append('size > ?')
198
+ params.append(size_val)
199
+ elif size_op == 'lt':
200
+ conditions.append('size < ?')
201
+ params.append(size_val)
202
+ elif size_op == 'eq':
203
+ conditions.append('size = ?')
204
+ params.append(size_val)
205
+
206
+ if maxdepth is not None:
207
+ max_abs_depth = root_depth + maxdepth
208
+ depth_expr = "length(path) - length(replace(path, '/', '')) + (path != '')"
209
+ conditions.append(f'{depth_expr} <= ?')
210
+ params.append(max_abs_depth)
211
+
212
+ def out(p):
213
+ if print0:
214
+ sys.stdout.write(p + '\0')
215
+ else:
216
+ output(p)
217
+
218
+ # Query files
219
+ if ftype != 'd':
220
+ file_conditions = conditions.copy()
221
+ file_params = params.copy()
222
+
223
+ if root_path:
224
+ file_conditions.insert(0, '(path = ? OR path GLOB ?)')
225
+ file_params = [root_path, f'{root_path}/*'] + file_params
226
+
227
+ if file_conditions:
228
+ file_query = f"SELECT path, size FROM files WHERE {' AND '.join(file_conditions)}"
229
+ else:
230
+ file_query = 'SELECT path, size FROM files'
231
+
232
+ for row in index.fetch_iter(file_query, tuple(file_params)):
233
+ p = row['path']
234
+ if name and not fnmatch.fnmatch(osp.basename(p), name):
235
+ continue
236
+ out(p)
237
+
238
+ # Query directories
239
+ if ftype != 'f':
240
+ dir_conditions = conditions.copy()
241
+ dir_params = params.copy()
242
+
243
+ for i, cond in enumerate(dir_conditions):
244
+ if 'size' in cond:
245
+ dir_conditions[i] = cond.replace('size', 'size_tree')
246
+
247
+ if root_path:
248
+ dir_conditions.insert(0, '(path = ? OR path GLOB ?)')
249
+ dir_params = [root_path, f'{root_path}/*'] + dir_params
250
+
251
+ if dir_conditions:
252
+ dir_query = f"SELECT path, size_tree FROM dirs WHERE {' AND '.join(dir_conditions)}"
253
+ else:
254
+ dir_query = 'SELECT path, size_tree FROM dirs'
255
+
256
+ for row in index.fetch_iter(dir_query, tuple(dir_params)):
257
+ p = row['path'] or '.'
258
+ if name:
259
+ basename = osp.basename(p) if p != '.' else '.'
260
+ if not fnmatch.fnmatch(basename, name):
261
+ continue
262
+ out(p)
263
+
264
+
265
+ # =============================================================================
266
+ # tree command
267
+ # =============================================================================
268
+
269
+
270
+ def tree_entries(
271
+ index,
272
+ path: str = '',
273
+ level: Optional[int] = None,
274
+ dirs_only: bool = False,
275
+ output: Callable[[str], None] = print,
276
+ ):
277
+ """Display directory tree.
278
+
279
+ Args:
280
+ index: Barecat index
281
+ path: Starting path
282
+ level: Maximum depth (-L)
283
+ dirs_only: Show only directories (-d)
284
+ output: Function to output each line
285
+ """
286
+ root_path = path.rstrip('/') if path else ''
287
+
288
+ # Print root
289
+ output(root_path if root_path else '.')
290
+
291
+ dir_count = 1
292
+ file_count = 0
293
+
294
+ def print_tree(current_path, prefix, depth):
295
+ nonlocal dir_count, file_count
296
+
297
+ if level is not None and depth > level:
298
+ return
299
+
300
+ try:
301
+ entries = sorted(index.iterdir_names(current_path))
302
+ except (KeyError, Exception):
303
+ return
304
+
305
+ # Separate dirs and files
306
+ dirs = []
307
+ files = []
308
+ for name in entries:
309
+ full = osp.join(current_path, name) if current_path else name
310
+ if index.isdir(full):
311
+ dirs.append(name)
312
+ else:
313
+ files.append(name)
314
+
315
+ if dirs_only:
316
+ items = [(name, True) for name in dirs]
317
+ else:
318
+ items = [(name, True) for name in dirs] + [(name, False) for name in files]
319
+
320
+ for i, (name, is_dir) in enumerate(items):
321
+ is_last = i == len(items) - 1
322
+ connector = '└── ' if is_last else '├── '
323
+ child_prefix = prefix + (' ' if is_last else '│ ')
324
+ full_path = osp.join(current_path, name) if current_path else name
325
+
326
+ if is_dir:
327
+ output(f'{prefix}{connector}{name}')
328
+ dir_count += 1
329
+ print_tree(full_path, child_prefix, depth + 1)
330
+ else:
331
+ output(f'{prefix}{connector}{name}')
332
+ file_count += 1
333
+
334
+ print_tree(root_path, '', 1)
335
+
336
+ if dirs_only:
337
+ output(f'\n{dir_count} directories')
338
+ else:
339
+ output(f'\n{dir_count} directories, {file_count} files')
340
+
341
+
342
+ # =============================================================================
343
+ # du command
344
+ # =============================================================================
345
+
346
+
347
+ def du_entries(
348
+ index,
349
+ path: str = '',
350
+ all_files: bool = False,
351
+ summarize: bool = False,
352
+ human_readable: bool = False,
353
+ max_depth: Optional[int] = None,
354
+ output: Callable[[str], None] = print,
355
+ ):
356
+ """Show disk usage.
357
+
358
+ Args:
359
+ index: Barecat index
360
+ path: Starting path
361
+ all_files: Show all files (-a)
362
+ summarize: Show only total (-s)
363
+ human_readable: Human readable sizes (-H)
364
+ max_depth: Maximum depth (-d)
365
+ output: Function to output each line
366
+ """
367
+ root_path = path.rstrip('/') if path else ''
368
+
369
+ def fmt_size(size):
370
+ return _format_size(size, human_readable)
371
+
372
+ if summarize:
373
+ if root_path == '':
374
+ dinfo = index.lookup_dir('')
375
+ size = dinfo.size_tree or 0
376
+ elif index.isdir(root_path):
377
+ dinfo = index.lookup_dir(root_path)
378
+ size = dinfo.size_tree or 0
379
+ else:
380
+ finfo = index.lookup_file(root_path)
381
+ size = finfo.size or 0
382
+ display_path = root_path if root_path else '.'
383
+ output(f'{fmt_size(size)}\t{display_path}')
384
+ return
385
+
386
+ root_depth = root_path.count('/') + 1 if root_path else 0
387
+ max_abs_depth = root_depth + max_depth if max_depth is not None else None
388
+
389
+ depth_expr = "length(path) - length(replace(path, '/', '')) + (path != '')"
390
+ depth_filter = f' AND {depth_expr} <= ?' if max_abs_depth is not None else ''
391
+
392
+ # Query directories
393
+ if root_path:
394
+ dir_query = (
395
+ f'SELECT path, size_tree FROM dirs WHERE (path = ? OR path GLOB ?){depth_filter}'
396
+ )
397
+ dir_params = (root_path, f'{root_path}/*') + (
398
+ (max_abs_depth,) if max_abs_depth is not None else ()
399
+ )
400
+ else:
401
+ dir_query = (
402
+ f"SELECT path, size_tree FROM dirs{depth_filter.replace(' AND ', ' WHERE ', 1)}"
403
+ )
404
+ dir_params = (max_abs_depth,) if max_abs_depth is not None else ()
405
+
406
+ entries = [
407
+ (row['path'] or '.', row['size_tree'] or 0)
408
+ for row in index.fetch_iter(dir_query, dir_params)
409
+ ]
410
+
411
+ # Query files if -a flag
412
+ if all_files:
413
+ if root_path:
414
+ file_query = f'SELECT path, size FROM files WHERE path GLOB ?{depth_filter}'
415
+ file_params = (f'{root_path}/*',) + (
416
+ (max_abs_depth,) if max_abs_depth is not None else ()
417
+ )
418
+ else:
419
+ file_query = f'SELECT path, size FROM files WHERE 1{depth_filter}'
420
+ file_params = (max_abs_depth,) if max_abs_depth is not None else ()
421
+
422
+ entries += [
423
+ (row['path'], row['size'] or 0) for row in index.fetch_iter(file_query, file_params)
424
+ ]
425
+
426
+ # Sort: deeper paths first, then alphabetically
427
+ entries.sort(key=lambda x: (-x[0].count('/'), x[0]))
428
+
429
+ for p, size in entries:
430
+ output(f'{fmt_size(size)}\t{p}')
431
+
432
+
433
+ # =============================================================================
434
+ # Helper functions (internal)
435
+ # =============================================================================
436
+
437
+ _user_group_cache = {}
438
+
439
+
440
+ def _format_mode(mode):
441
+ """Format file mode like ls -l."""
442
+ if mode is None:
443
+ return '----------'
444
+
445
+ if stat.S_ISDIR(mode):
446
+ s = 'd'
447
+ elif stat.S_ISLNK(mode):
448
+ s = 'l'
449
+ else:
450
+ s = '-'
451
+
452
+ s += 'r' if mode & stat.S_IRUSR else '-'
453
+ s += 'w' if mode & stat.S_IWUSR else '-'
454
+ s += 'x' if mode & stat.S_IXUSR else '-'
455
+ s += 'r' if mode & stat.S_IRGRP else '-'
456
+ s += 'w' if mode & stat.S_IWGRP else '-'
457
+ s += 'x' if mode & stat.S_IXGRP else '-'
458
+ s += 'r' if mode & stat.S_IROTH else '-'
459
+ s += 'w' if mode & stat.S_IWOTH else '-'
460
+ s += 'x' if mode & stat.S_IXOTH else '-'
461
+ return s
462
+
463
+
464
+ def _format_time(mtime_ns):
465
+ """Format mtime like ls."""
466
+ from datetime import datetime
467
+ import locale
468
+
469
+ if mtime_ns is None:
470
+ return ' '
471
+
472
+ try:
473
+ locale.setlocale(locale.LC_TIME, '')
474
+ except locale.Error:
475
+ pass
476
+
477
+ dt = datetime.fromtimestamp(mtime_ns / 1e9)
478
+ now = datetime.now()
479
+
480
+ if dt.year == now.year:
481
+ return dt.strftime('%b %d %H:%M')
482
+ else:
483
+ return dt.strftime('%b %d %Y')
484
+
485
+
486
+ def _get_user_group(uid, gid):
487
+ """Get username and groupname from uid/gid."""
488
+ import pwd
489
+ import grp
490
+
491
+ cache = _user_group_cache
492
+
493
+ if uid not in cache:
494
+ try:
495
+ cache[uid] = pwd.getpwuid(uid).pw_name
496
+ except (KeyError, TypeError):
497
+ cache[uid] = str(uid) if uid is not None else '-'
498
+
499
+ if gid not in cache:
500
+ try:
501
+ cache[gid] = grp.getgrgid(gid).gr_name
502
+ except (KeyError, TypeError):
503
+ cache[gid] = str(gid) if gid is not None else '-'
504
+
505
+ return cache[uid], cache[gid]
506
+
507
+
508
+ def _format_size(size, human_readable=False):
509
+ """Format size for output."""
510
+ if not human_readable:
511
+ return str(size)
512
+
513
+ for unit in ['', 'K', 'M', 'G', 'T']:
514
+ if abs(size) < 1024:
515
+ if unit == '':
516
+ return str(size)
517
+ return f'{size:.1f}{unit}'
518
+ size /= 1024
519
+ return f'{size:.1f}P'
520
+
521
+
522
+ def _is_glob(path):
523
+ """Check if path contains glob characters."""
524
+ return any(c in path for c in '*?[')
525
+
526
+
527
+ def _normalize_pattern(pattern):
528
+ """Normalize glob pattern - trailing slash means match directory contents."""
529
+ if pattern.endswith('/') and not pattern.endswith('**/'):
530
+ return pattern + '**'
531
+ return pattern
532
+
533
+
534
+ def _parse_size(size_str):
535
+ """Parse size string like 1M, 500k, 2G."""
536
+ size_str = size_str.strip().upper()
537
+ multipliers = {'K': 1024, 'M': 1024**2, 'G': 1024**3, 'T': 1024**4}
538
+ if size_str and size_str[-1] in multipliers:
539
+ return int(float(size_str[:-1]) * multipliers[size_str[-1]])
540
+ return int(size_str)
@@ -0,0 +1,27 @@
1
+ """Shell completion script utilities."""
2
+
3
+ import importlib.resources
4
+
5
+
6
+ def get_completion_script(shell='bash'):
7
+ """Return the path to the shell completion script.
8
+
9
+ Args:
10
+ shell: 'bash' or 'zsh'
11
+
12
+ Returns:
13
+ Path to the completion script file.
14
+
15
+ Example:
16
+ # For bash, add to ~/.bashrc:
17
+ source $(python -c "import barecat; print(barecat.get_completion_script('bash'))")
18
+
19
+ # For zsh, add to ~/.zshrc:
20
+ source $(python -c "import barecat; print(barecat.get_completion_script('zsh'))")
21
+ """
22
+ if shell == 'bash':
23
+ return str(importlib.resources.files('barecat.completions').joinpath('barecat.bash'))
24
+ elif shell == 'zsh':
25
+ return str(importlib.resources.files('barecat.completions').joinpath('barecat.zsh'))
26
+ else:
27
+ raise ValueError(f"Unknown shell: {shell}. Use 'bash' or 'zsh'.")