filekeeper-cli 0.1.0__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.
- filekeeper/__init__.py +35 -0
- filekeeper/cli/__init__.py +5 -0
- filekeeper/cli/app.py +866 -0
- filekeeper/cli/exit_codes.py +84 -0
- filekeeper/cli/output.py +244 -0
- filekeeper/core/__init__.py +51 -0
- filekeeper/core/config.py +133 -0
- filekeeper/core/database.py +226 -0
- filekeeper/core/diffing.py +127 -0
- filekeeper/core/errors.py +278 -0
- filekeeper/core/history/__init__.py +20 -0
- filekeeper/core/history/blobs.py +92 -0
- filekeeper/core/history/journal.py +430 -0
- filekeeper/core/history/repository.py +152 -0
- filekeeper/core/history/undo.py +358 -0
- filekeeper/core/models.py +293 -0
- filekeeper/core/operations/__init__.py +9 -0
- filekeeper/core/operations/directory.py +475 -0
- filekeeper/core/operations/edit.py +1239 -0
- filekeeper/core/operations/read.py +370 -0
- filekeeper/core/operations/search.py +198 -0
- filekeeper/core/operations/write.py +763 -0
- filekeeper/core/paths.py +236 -0
- filekeeper/core/safety/__init__.py +5 -0
- filekeeper/core/safety/atomic.py +129 -0
- filekeeper/core/safety/hashing.py +73 -0
- filekeeper/core/safety/sensitive.py +65 -0
- filekeeper/core/safety/textdata.py +142 -0
- filekeeper/core/safety/vcs.py +51 -0
- filekeeper/core/service.py +706 -0
- filekeeper/core/workspace.py +300 -0
- filekeeper_cli-0.1.0.dist-info/METADATA +498 -0
- filekeeper_cli-0.1.0.dist-info/RECORD +36 -0
- filekeeper_cli-0.1.0.dist-info/WHEEL +4 -0
- filekeeper_cli-0.1.0.dist-info/entry_points.txt +3 -0
- filekeeper_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Reading files and inspecting metadata.
|
|
2
|
+
|
|
3
|
+
Line numbers are 1-based and inclusive, everywhere: Core, CLI, docs and tests.
|
|
4
|
+
A bounded read never silently returns a prefix of the requested range: either the
|
|
5
|
+
whole range is returned or the result says it was truncated and where to resume.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fnmatch
|
|
11
|
+
import os
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from ..errors import (
|
|
16
|
+
FilekeeperError,
|
|
17
|
+
NotAFileError,
|
|
18
|
+
ValidationError,
|
|
19
|
+
storage_failure,
|
|
20
|
+
)
|
|
21
|
+
from ..models import (
|
|
22
|
+
DirectoryEntry,
|
|
23
|
+
DirectoryListing,
|
|
24
|
+
EntryKind,
|
|
25
|
+
ErrorDetail,
|
|
26
|
+
ExistsResult,
|
|
27
|
+
FileReadResult,
|
|
28
|
+
PathInfo,
|
|
29
|
+
ReadManyEntry,
|
|
30
|
+
)
|
|
31
|
+
from ..paths import resolve_path
|
|
32
|
+
from ..safety.hashing import hash_file
|
|
33
|
+
from ..safety.textdata import (
|
|
34
|
+
BINARY_SAMPLE_SIZE,
|
|
35
|
+
TextProfile,
|
|
36
|
+
decode_bytes,
|
|
37
|
+
looks_binary,
|
|
38
|
+
)
|
|
39
|
+
from ..workspace import WorkspaceHandle
|
|
40
|
+
|
|
41
|
+
_INFO_SAMPLE_SIZE = 65536
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _relative(handle: WorkspaceHandle, path: Path) -> str:
|
|
45
|
+
return path.relative_to(handle.root).as_posix()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read_window(
|
|
49
|
+
handle: WorkspaceHandle,
|
|
50
|
+
path: str,
|
|
51
|
+
*,
|
|
52
|
+
max_bytes: int | None,
|
|
53
|
+
encoding: str | None,
|
|
54
|
+
) -> tuple[str, int, bool, TextProfile]:
|
|
55
|
+
resolved = resolve_path(
|
|
56
|
+
handle.root,
|
|
57
|
+
path,
|
|
58
|
+
must_exist=True,
|
|
59
|
+
expect="file",
|
|
60
|
+
state_dir=handle.state_dir,
|
|
61
|
+
)
|
|
62
|
+
if resolved.absolute.is_dir():
|
|
63
|
+
raise NotAFileError("path is a directory, not a file", details={"path": path})
|
|
64
|
+
|
|
65
|
+
limit = handle.config.max_read_bytes if max_bytes is None else max_bytes
|
|
66
|
+
if limit <= 0:
|
|
67
|
+
raise ValidationError("max_bytes must be positive", details={"max_bytes": limit})
|
|
68
|
+
limit = min(limit, handle.config.max_read_bytes)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
size = resolved.absolute.stat().st_size
|
|
72
|
+
with resolved.absolute.open("rb") as file_handle:
|
|
73
|
+
data = file_handle.read(limit)
|
|
74
|
+
except OSError as exc:
|
|
75
|
+
raise storage_failure("read", resolved.relative, exc) from exc
|
|
76
|
+
truncated = size > len(data)
|
|
77
|
+
|
|
78
|
+
text, profile = decode_bytes(data, encoding=encoding, partial=truncated, path=resolved.relative)
|
|
79
|
+
return text, size, truncated, profile
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def read_file(
|
|
83
|
+
handle: WorkspaceHandle,
|
|
84
|
+
path: str,
|
|
85
|
+
*,
|
|
86
|
+
start_line: int | None = None,
|
|
87
|
+
end_line: int | None = None,
|
|
88
|
+
max_bytes: int | None = None,
|
|
89
|
+
encoding: str | None = None,
|
|
90
|
+
) -> FileReadResult:
|
|
91
|
+
"""Read the whole file or an inclusive 1-based line range."""
|
|
92
|
+
|
|
93
|
+
if start_line is not None and start_line < 1:
|
|
94
|
+
raise ValidationError(
|
|
95
|
+
"start_line is 1-based; line numbers below 1 do not exist",
|
|
96
|
+
details={"start_line": start_line},
|
|
97
|
+
)
|
|
98
|
+
if end_line is not None and start_line is not None and end_line < start_line:
|
|
99
|
+
raise ValidationError(
|
|
100
|
+
"end_line must be greater than or equal to start_line",
|
|
101
|
+
details={"start_line": start_line, "end_line": end_line},
|
|
102
|
+
)
|
|
103
|
+
if end_line is not None and start_line is None:
|
|
104
|
+
raise ValidationError("end_line requires start_line")
|
|
105
|
+
|
|
106
|
+
text, size, truncated, profile = _read_window(
|
|
107
|
+
handle, path, max_bytes=max_bytes, encoding=encoding
|
|
108
|
+
)
|
|
109
|
+
lines = text.splitlines(keepends=True)
|
|
110
|
+
loaded_line_count = len(lines)
|
|
111
|
+
total_lines: int | None = None if truncated else loaded_line_count
|
|
112
|
+
|
|
113
|
+
first = start_line if start_line is not None else 1
|
|
114
|
+
if first > loaded_line_count and loaded_line_count > 0:
|
|
115
|
+
raise ValidationError(
|
|
116
|
+
"start_line is beyond the end of the file",
|
|
117
|
+
details={"start_line": first, "total_lines": total_lines},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if end_line is None:
|
|
121
|
+
last = loaded_line_count
|
|
122
|
+
else:
|
|
123
|
+
if total_lines is not None and end_line > total_lines:
|
|
124
|
+
raise ValidationError(
|
|
125
|
+
"end_line is beyond the end of the file",
|
|
126
|
+
details={"end_line": end_line, "total_lines": total_lines},
|
|
127
|
+
)
|
|
128
|
+
last = min(end_line, loaded_line_count)
|
|
129
|
+
|
|
130
|
+
selected = lines[first - 1 : last] if last >= first else []
|
|
131
|
+
content = "".join(selected)
|
|
132
|
+
|
|
133
|
+
resolved = resolve_path(
|
|
134
|
+
handle.root, path, must_exist=True, expect="file", state_dir=handle.state_dir
|
|
135
|
+
)
|
|
136
|
+
next_start_line = last + 1 if truncated and last == loaded_line_count else None
|
|
137
|
+
|
|
138
|
+
return FileReadResult(
|
|
139
|
+
path=resolved.relative,
|
|
140
|
+
content=content,
|
|
141
|
+
start_line=first,
|
|
142
|
+
end_line=last,
|
|
143
|
+
returned_line_count=len(selected),
|
|
144
|
+
total_lines=total_lines,
|
|
145
|
+
size_bytes=size,
|
|
146
|
+
content_hash=hash_file(resolved.absolute, display_path=resolved.relative),
|
|
147
|
+
encoding=profile.encoding,
|
|
148
|
+
newline=profile.newline,
|
|
149
|
+
has_bom=profile.has_bom,
|
|
150
|
+
truncated=truncated,
|
|
151
|
+
truncation_reason=(
|
|
152
|
+
"read stopped at the byte safety limit; the file is larger than max_read_bytes"
|
|
153
|
+
if truncated
|
|
154
|
+
else None
|
|
155
|
+
),
|
|
156
|
+
next_start_line=next_start_line,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def read_multiple_files(
|
|
161
|
+
handle: WorkspaceHandle,
|
|
162
|
+
paths: list[str] | tuple[str, ...],
|
|
163
|
+
*,
|
|
164
|
+
max_bytes_per_file: int | None = None,
|
|
165
|
+
start_line: int | None = None,
|
|
166
|
+
end_line: int | None = None,
|
|
167
|
+
) -> tuple[ReadManyEntry, ...]:
|
|
168
|
+
"""Read several files in one call. A failure is returned, never raised."""
|
|
169
|
+
|
|
170
|
+
if not paths:
|
|
171
|
+
raise ValidationError("at least one path is required")
|
|
172
|
+
entries: list[ReadManyEntry] = []
|
|
173
|
+
for path in paths:
|
|
174
|
+
try:
|
|
175
|
+
result = read_file(
|
|
176
|
+
handle,
|
|
177
|
+
path,
|
|
178
|
+
start_line=start_line,
|
|
179
|
+
end_line=end_line,
|
|
180
|
+
max_bytes=max_bytes_per_file,
|
|
181
|
+
)
|
|
182
|
+
except FilekeeperError as exc:
|
|
183
|
+
entries.append(
|
|
184
|
+
ReadManyEntry(
|
|
185
|
+
path=str(path),
|
|
186
|
+
ok=False,
|
|
187
|
+
error=ErrorDetail(
|
|
188
|
+
code=str(exc.code),
|
|
189
|
+
message=exc.message,
|
|
190
|
+
retryable=exc.retryable,
|
|
191
|
+
details=exc.details,
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
else:
|
|
196
|
+
entries.append(ReadManyEntry(path=result.path, ok=True, result=result))
|
|
197
|
+
return tuple(entries)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def list_directory(
|
|
201
|
+
handle: WorkspaceHandle,
|
|
202
|
+
path: str = ".",
|
|
203
|
+
*,
|
|
204
|
+
recursive: bool = False,
|
|
205
|
+
glob: str | None = None,
|
|
206
|
+
max_entries: int | None = None,
|
|
207
|
+
) -> DirectoryListing:
|
|
208
|
+
"""List entries. Symlinks are reported, never followed during recursion."""
|
|
209
|
+
|
|
210
|
+
limit = handle.config.max_directory_entries if max_entries is None else max_entries
|
|
211
|
+
if limit <= 0:
|
|
212
|
+
raise ValidationError("max_entries must be positive", details={"max_entries": limit})
|
|
213
|
+
limit = min(limit, handle.config.max_directory_entries)
|
|
214
|
+
|
|
215
|
+
resolved = resolve_path(
|
|
216
|
+
handle.root, path, must_exist=True, expect="directory", state_dir=handle.state_dir
|
|
217
|
+
)
|
|
218
|
+
base = resolved.absolute
|
|
219
|
+
# Compare canonical state path so a differently spelled base still hides it.
|
|
220
|
+
canonical_state_dir = Path(os.path.realpath(handle.state_dir))
|
|
221
|
+
|
|
222
|
+
entries: list[DirectoryEntry] = []
|
|
223
|
+
truncated = False
|
|
224
|
+
|
|
225
|
+
def matches(relative: str, name: str) -> bool:
|
|
226
|
+
if glob is None:
|
|
227
|
+
return True
|
|
228
|
+
target = relative if "/" in glob else name
|
|
229
|
+
return fnmatch.fnmatchcase(target, glob)
|
|
230
|
+
|
|
231
|
+
def add(directory: Path, depth: int) -> None:
|
|
232
|
+
nonlocal truncated
|
|
233
|
+
if truncated:
|
|
234
|
+
return
|
|
235
|
+
try:
|
|
236
|
+
children = sorted(directory.iterdir(), key=lambda item: item.name)
|
|
237
|
+
except OSError as exc:
|
|
238
|
+
raise storage_failure(
|
|
239
|
+
"list directory", _relative(handle, directory) or ".", exc
|
|
240
|
+
) from exc
|
|
241
|
+
for child in children:
|
|
242
|
+
if len(entries) >= limit:
|
|
243
|
+
truncated = True
|
|
244
|
+
return
|
|
245
|
+
is_symlink = child.is_symlink()
|
|
246
|
+
if (
|
|
247
|
+
not is_symlink
|
|
248
|
+
and child.is_dir()
|
|
249
|
+
and Path(os.path.realpath(child)) == (canonical_state_dir)
|
|
250
|
+
):
|
|
251
|
+
continue
|
|
252
|
+
if is_symlink:
|
|
253
|
+
kind: EntryKind = "symlink"
|
|
254
|
+
elif child.is_dir():
|
|
255
|
+
kind = "directory"
|
|
256
|
+
elif child.is_file():
|
|
257
|
+
kind = "file"
|
|
258
|
+
else:
|
|
259
|
+
kind = "other"
|
|
260
|
+
relative = _relative(handle, child)
|
|
261
|
+
if matches(relative, child.name):
|
|
262
|
+
try:
|
|
263
|
+
size_bytes = child.lstat().st_size
|
|
264
|
+
except OSError as exc:
|
|
265
|
+
raise storage_failure("inspect", relative, exc) from exc
|
|
266
|
+
entries.append(
|
|
267
|
+
DirectoryEntry(
|
|
268
|
+
path=relative,
|
|
269
|
+
name=child.name,
|
|
270
|
+
kind=kind,
|
|
271
|
+
size_bytes=size_bytes,
|
|
272
|
+
is_symlink=is_symlink,
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
if recursive and kind == "directory" and depth + 1 <= handle.config.max_recursion_depth:
|
|
276
|
+
add(child, depth + 1)
|
|
277
|
+
|
|
278
|
+
add(base, 0)
|
|
279
|
+
return DirectoryListing(
|
|
280
|
+
path=resolved.relative or ".",
|
|
281
|
+
entries=tuple(entries),
|
|
282
|
+
truncated=truncated,
|
|
283
|
+
max_entries=limit,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def get_file_info(handle: WorkspaceHandle, path: str) -> PathInfo:
|
|
288
|
+
"""Metadata plus, for regular files, a hash and text characteristics."""
|
|
289
|
+
|
|
290
|
+
resolved = resolve_path(handle.root, path, must_exist=True, state_dir=handle.state_dir)
|
|
291
|
+
target = resolved.absolute
|
|
292
|
+
link_path = resolved.link_path
|
|
293
|
+
try:
|
|
294
|
+
info = link_path.lstat()
|
|
295
|
+
except OSError as exc:
|
|
296
|
+
raise storage_failure("inspect", resolved.relative, exc) from exc
|
|
297
|
+
|
|
298
|
+
if resolved.is_symlink:
|
|
299
|
+
kind: EntryKind = "symlink"
|
|
300
|
+
elif target.is_dir():
|
|
301
|
+
kind = "directory"
|
|
302
|
+
elif target.is_file():
|
|
303
|
+
kind = "file"
|
|
304
|
+
else:
|
|
305
|
+
kind = "other"
|
|
306
|
+
|
|
307
|
+
base = PathInfo(
|
|
308
|
+
path=resolved.relative,
|
|
309
|
+
kind=kind,
|
|
310
|
+
size_bytes=info.st_size,
|
|
311
|
+
mode=info.st_mode & 0o7777,
|
|
312
|
+
modified_at=datetime.fromtimestamp(info.st_mtime, UTC).replace(microsecond=0).isoformat(),
|
|
313
|
+
is_symlink=resolved.is_symlink,
|
|
314
|
+
symlink_target=str(link_path.readlink()) if resolved.is_symlink else None,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
if kind != "file":
|
|
318
|
+
return base
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
with target.open("rb") as file_handle:
|
|
322
|
+
sample = file_handle.read(BINARY_SAMPLE_SIZE)
|
|
323
|
+
except OSError as exc:
|
|
324
|
+
raise storage_failure("read", resolved.relative, exc) from exc
|
|
325
|
+
|
|
326
|
+
is_text = not looks_binary(sample)
|
|
327
|
+
encoding: str | None = None
|
|
328
|
+
newline: str | None = None
|
|
329
|
+
line_count: int | None = None
|
|
330
|
+
if is_text:
|
|
331
|
+
try:
|
|
332
|
+
with target.open("rb") as file_handle:
|
|
333
|
+
payload = file_handle.read(_INFO_SAMPLE_SIZE)
|
|
334
|
+
text, profile = decode_bytes(payload, path=resolved.relative)
|
|
335
|
+
except FilekeeperError:
|
|
336
|
+
is_text = False
|
|
337
|
+
else:
|
|
338
|
+
encoding = profile.encoding
|
|
339
|
+
newline = profile.newline
|
|
340
|
+
if info.st_size <= _INFO_SAMPLE_SIZE:
|
|
341
|
+
line_count = len(text.splitlines())
|
|
342
|
+
|
|
343
|
+
return PathInfo(
|
|
344
|
+
path=base.path,
|
|
345
|
+
kind=base.kind,
|
|
346
|
+
size_bytes=base.size_bytes,
|
|
347
|
+
mode=base.mode,
|
|
348
|
+
modified_at=base.modified_at,
|
|
349
|
+
is_symlink=base.is_symlink,
|
|
350
|
+
symlink_target=base.symlink_target,
|
|
351
|
+
is_text=is_text,
|
|
352
|
+
content_hash=hash_file(target, display_path=resolved.relative),
|
|
353
|
+
line_count=line_count,
|
|
354
|
+
encoding=encoding,
|
|
355
|
+
newline=newline,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def get_file_hash(handle: WorkspaceHandle, path: str) -> str:
|
|
360
|
+
resolved = resolve_path(
|
|
361
|
+
handle.root, path, must_exist=True, expect="file", state_dir=handle.state_dir
|
|
362
|
+
)
|
|
363
|
+
return hash_file(resolved.absolute, display_path=resolved.relative)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def file_exists(handle: WorkspaceHandle, path: str) -> ExistsResult:
|
|
367
|
+
resolved = resolve_path(handle.root, path, state_dir=handle.state_dir)
|
|
368
|
+
if not resolved.exists:
|
|
369
|
+
return ExistsResult(path=resolved.relative, exists=False)
|
|
370
|
+
return ExistsResult(path=resolved.relative, exists=True, kind=resolved.kind)
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Path search by glob and content search by literal or regular expression.
|
|
2
|
+
|
|
3
|
+
Recursion never follows symlinks and never descends into Filekeeper's own state
|
|
4
|
+
directory. Every search is capped and reports whether the cap was hit, so a
|
|
5
|
+
caller can page rather than believe a truncated list is complete.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fnmatch
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from ..errors import FilekeeperError, InvalidPatternError, ValidationError, storage_failure
|
|
17
|
+
from ..models import ContentMatch, ContentSearchResult, PathSearchResult
|
|
18
|
+
from ..paths import resolve_path
|
|
19
|
+
from ..safety.textdata import decode_bytes, looks_binary
|
|
20
|
+
from ..workspace import WorkspaceHandle
|
|
21
|
+
|
|
22
|
+
_BINARY_SAMPLE_SIZE = 8192
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _walk_files(handle: WorkspaceHandle, base: Path) -> Iterator[Path]:
|
|
26
|
+
canonical_state = Path(os.path.realpath(handle.state_dir))
|
|
27
|
+
if base.is_file() and not base.is_symlink():
|
|
28
|
+
yield base
|
|
29
|
+
return
|
|
30
|
+
stack: list[tuple[Path, int]] = [(base, 0)]
|
|
31
|
+
while stack:
|
|
32
|
+
directory, depth = stack.pop()
|
|
33
|
+
try:
|
|
34
|
+
children = sorted(directory.iterdir(), key=lambda item: item.name)
|
|
35
|
+
except OSError as exc:
|
|
36
|
+
if directory == base:
|
|
37
|
+
# The requested root is unreadable: an empty result would be a
|
|
38
|
+
# silent lie, so the failure is reported instead of skipped.
|
|
39
|
+
raise storage_failure(
|
|
40
|
+
"list directory", _relative(handle, base) or ".", exc
|
|
41
|
+
) from exc
|
|
42
|
+
continue
|
|
43
|
+
subdirectories: list[Path] = []
|
|
44
|
+
for child in children:
|
|
45
|
+
if child.is_symlink():
|
|
46
|
+
continue
|
|
47
|
+
try:
|
|
48
|
+
if child.is_dir():
|
|
49
|
+
if Path(os.path.realpath(child)) == canonical_state:
|
|
50
|
+
continue
|
|
51
|
+
if depth + 1 <= handle.config.max_recursion_depth:
|
|
52
|
+
subdirectories.append(child)
|
|
53
|
+
elif child.is_file():
|
|
54
|
+
yield child
|
|
55
|
+
except OSError: # pragma: no cover - races and permission errors
|
|
56
|
+
continue
|
|
57
|
+
for subdirectory in reversed(subdirectories):
|
|
58
|
+
stack.append((subdirectory, depth + 1))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _relative(handle: WorkspaceHandle, path: Path) -> str:
|
|
62
|
+
return path.relative_to(handle.root).as_posix()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _matches_glob(relative: str, name: str, pattern: str | None) -> bool:
|
|
66
|
+
if pattern is None:
|
|
67
|
+
return True
|
|
68
|
+
target = relative if "/" in pattern else name
|
|
69
|
+
return fnmatch.fnmatchcase(target, pattern)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def search_files(
|
|
73
|
+
handle: WorkspaceHandle,
|
|
74
|
+
path: str,
|
|
75
|
+
glob: str,
|
|
76
|
+
*,
|
|
77
|
+
max_results: int | None = None,
|
|
78
|
+
) -> PathSearchResult:
|
|
79
|
+
"""Find paths whose workspace-relative path or file name matches a glob."""
|
|
80
|
+
|
|
81
|
+
if not glob:
|
|
82
|
+
raise ValidationError("glob pattern must not be empty")
|
|
83
|
+
limit = handle.config.max_search_results if max_results is None else max_results
|
|
84
|
+
if limit <= 0:
|
|
85
|
+
raise ValidationError("max_results must be positive", details={"max_results": limit})
|
|
86
|
+
limit = min(limit, handle.config.max_search_results)
|
|
87
|
+
|
|
88
|
+
resolved = resolve_path(handle.root, path, must_exist=True, state_dir=handle.state_dir)
|
|
89
|
+
matches: list[str] = []
|
|
90
|
+
truncated = False
|
|
91
|
+
for candidate in _walk_files(handle, resolved.absolute):
|
|
92
|
+
if len(matches) >= limit:
|
|
93
|
+
truncated = True
|
|
94
|
+
break
|
|
95
|
+
relative = _relative(handle, candidate)
|
|
96
|
+
if _matches_glob(relative, candidate.name, glob):
|
|
97
|
+
matches.append(relative)
|
|
98
|
+
|
|
99
|
+
return PathSearchResult(
|
|
100
|
+
path=resolved.relative or ".",
|
|
101
|
+
glob=glob,
|
|
102
|
+
paths=tuple(matches),
|
|
103
|
+
truncated=truncated,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def search_content(
|
|
108
|
+
handle: WorkspaceHandle,
|
|
109
|
+
path: str,
|
|
110
|
+
query: str,
|
|
111
|
+
*,
|
|
112
|
+
regex: bool = False,
|
|
113
|
+
glob: str | None = None,
|
|
114
|
+
max_results: int | None = None,
|
|
115
|
+
case_sensitive: bool = True,
|
|
116
|
+
) -> ContentSearchResult:
|
|
117
|
+
"""Search file contents line by line. Line numbers are 1-based."""
|
|
118
|
+
|
|
119
|
+
if query == "":
|
|
120
|
+
raise ValidationError("search query must not be empty")
|
|
121
|
+
|
|
122
|
+
limit = handle.config.max_search_results if max_results is None else max_results
|
|
123
|
+
if limit <= 0:
|
|
124
|
+
raise ValidationError("max_results must be positive", details={"max_results": limit})
|
|
125
|
+
limit = min(limit, handle.config.max_search_results)
|
|
126
|
+
|
|
127
|
+
pattern: re.Pattern[str] | None = None
|
|
128
|
+
if regex:
|
|
129
|
+
try:
|
|
130
|
+
pattern = re.compile(query, 0 if case_sensitive else re.IGNORECASE)
|
|
131
|
+
except re.error as exc:
|
|
132
|
+
raise InvalidPatternError(
|
|
133
|
+
f"invalid regular expression: {exc}", details={"query": query}
|
|
134
|
+
) from exc
|
|
135
|
+
|
|
136
|
+
resolved = resolve_path(handle.root, path, must_exist=True, state_dir=handle.state_dir)
|
|
137
|
+
|
|
138
|
+
matches: list[ContentMatch] = []
|
|
139
|
+
truncated = False
|
|
140
|
+
files_searched = 0
|
|
141
|
+
|
|
142
|
+
for candidate in _walk_files(handle, resolved.absolute):
|
|
143
|
+
if len(matches) >= limit:
|
|
144
|
+
truncated = True
|
|
145
|
+
break
|
|
146
|
+
relative = _relative(handle, candidate)
|
|
147
|
+
if not _matches_glob(relative, candidate.name, glob):
|
|
148
|
+
continue
|
|
149
|
+
try:
|
|
150
|
+
with candidate.open("rb") as file_handle:
|
|
151
|
+
raw = file_handle.read()
|
|
152
|
+
except OSError as exc:
|
|
153
|
+
if candidate == resolved.absolute:
|
|
154
|
+
raise storage_failure("read", relative or ".", exc) from exc
|
|
155
|
+
continue # nested entries that cannot be read are skipped
|
|
156
|
+
if looks_binary(raw[:_BINARY_SAMPLE_SIZE]):
|
|
157
|
+
continue
|
|
158
|
+
try:
|
|
159
|
+
text, _ = decode_bytes(raw, path=relative)
|
|
160
|
+
except FilekeeperError:
|
|
161
|
+
continue
|
|
162
|
+
files_searched += 1
|
|
163
|
+
|
|
164
|
+
for number, line in enumerate(text.splitlines(), start=1):
|
|
165
|
+
if len(matches) >= limit:
|
|
166
|
+
truncated = True
|
|
167
|
+
break
|
|
168
|
+
if pattern is not None:
|
|
169
|
+
found = pattern.search(line)
|
|
170
|
+
if found is None:
|
|
171
|
+
continue
|
|
172
|
+
column = found.start() + 1
|
|
173
|
+
else:
|
|
174
|
+
haystack = line if case_sensitive else line.lower()
|
|
175
|
+
needle = query if case_sensitive else query.lower()
|
|
176
|
+
index = haystack.find(needle)
|
|
177
|
+
if index < 0:
|
|
178
|
+
continue
|
|
179
|
+
column = index + 1
|
|
180
|
+
matches.append(
|
|
181
|
+
ContentMatch(
|
|
182
|
+
path=relative,
|
|
183
|
+
line_number=number,
|
|
184
|
+
line=line,
|
|
185
|
+
column=column,
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
if truncated:
|
|
189
|
+
break
|
|
190
|
+
|
|
191
|
+
return ContentSearchResult(
|
|
192
|
+
path=resolved.relative or ".",
|
|
193
|
+
query=query,
|
|
194
|
+
regex=regex,
|
|
195
|
+
matches=tuple(matches),
|
|
196
|
+
truncated=truncated,
|
|
197
|
+
files_searched=files_searched,
|
|
198
|
+
)
|