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.
@@ -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
+ )