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.
- barecat/__init__.py +108 -0
- barecat/__main__.py +5 -0
- barecat/_api.py +143 -0
- barecat/_version.py +24 -0
- barecat/cli/__init__.py +0 -0
- barecat/cli/commands.py +540 -0
- barecat/cli/completions.py +27 -0
- barecat/cli/impl.py +609 -0
- barecat/cli/legacy.py +361 -0
- barecat/cli/main.py +1355 -0
- barecat/cli/shell.py +827 -0
- barecat/completions/__init__.py +1 -0
- barecat/completions/barecat.bash +246 -0
- barecat/completions/barecat.zsh +268 -0
- barecat/core/__init__.py +0 -0
- barecat/core/barecat.py +1209 -0
- barecat/core/index.py +1983 -0
- barecat/core/paths.py +61 -0
- barecat/core/sharder.py +328 -0
- barecat/core/types.py +340 -0
- barecat/exceptions.py +92 -0
- barecat/formats/__init__.py +0 -0
- barecat/formats/archive_formats.py +234 -0
- barecat/formats/from_tar_stream.py +53 -0
- barecat/formats/to_tar_stream.py +54 -0
- barecat/io/__init__.py +5 -0
- barecat/io/codecs.py +622 -0
- barecat/io/copyfile.py +550 -0
- barecat/io/fileobj.py +556 -0
- barecat/io/open.py +41 -0
- barecat/maintenance/__init__.py +0 -0
- barecat/maintenance/complete_database.py +79 -0
- barecat/maintenance/defrag.py +541 -0
- barecat/maintenance/merge.py +613 -0
- barecat/maintenance/reshard.py +153 -0
- barecat/maintenance/rsync.py +1282 -0
- barecat/maintenance/upgrade_database.py +200 -0
- barecat/maintenance/upgrade_database2.py +74 -0
- barecat/man/barecat.1 +284 -0
- barecat/sql/indexes.sql +6 -0
- barecat/sql/schema.sql +55 -0
- barecat/sql/triggers.sql +172 -0
- barecat/tui/__init__.py +0 -0
- barecat/tui/browse.py +448 -0
- barecat/tui/ncdu.py +318 -0
- barecat/util/__init__.py +0 -0
- barecat/util/consumed_threadpool.py +119 -0
- barecat/util/glob_helper.py +584 -0
- barecat/util/glob_to_regex.py +353 -0
- barecat/util/list_files.py +12 -0
- barecat/util/misc.py +174 -0
- barecat/util/physical_order.py +77 -0
- barecat/util/progbar.py +40 -0
- barecat/util/threading.py +59 -0
- barecat-0.3.2.dist-info/METADATA +276 -0
- barecat-0.3.2.dist-info/RECORD +60 -0
- barecat-0.3.2.dist-info/WHEEL +5 -0
- barecat-0.3.2.dist-info/entry_points.txt +15 -0
- barecat-0.3.2.dist-info/licenses/LICENSE +21 -0
- barecat-0.3.2.dist-info/top_level.txt +1 -0
barecat/cli/commands.py
ADDED
|
@@ -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'.")
|