borescope 0.1.0.dev0__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,494 @@
1
+ """Filesystem commands implemented over the Pebble files API.
2
+
3
+ These exist as built-ins (rather than ``exec``) because they need either
4
+ shell-side state (paths relative to ``cwd``) or the Pebble files API
5
+ (``pull``/``push``/``list_files``/``make_dir``/``remove_path``) to work against a
6
+ rock with no shell or coreutils.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import fnmatch
12
+ import posixpath
13
+ import re
14
+ import sys
15
+ import time
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ from .. import pathutils
19
+ from ._args import parse_args
20
+ from .base import Command, Result
21
+
22
+ if TYPE_CHECKING:
23
+ from ...transport import Transport
24
+ from ..context import ShellContext
25
+
26
+
27
+ # --------------------------------------------------------------------------- #
28
+ # Helpers
29
+ # --------------------------------------------------------------------------- #
30
+ def _read_bytes(transport: Transport, path: str) -> bytes:
31
+ with transport.pull(path, encoding=None) as handle:
32
+ data = handle.read()
33
+ return data if isinstance(data, bytes) else data.encode("utf-8")
34
+
35
+
36
+ def _read_text(transport: Transport, path: str) -> str:
37
+ data = _read_bytes(transport, path)
38
+ return data.decode("utf-8", errors="replace")
39
+
40
+
41
+ def _is_dir(info: Any) -> bool:
42
+ return getattr(getattr(info, "type", None), "name", "") == "DIRECTORY"
43
+
44
+
45
+ def _int(value: str | None, default: int) -> int:
46
+ try:
47
+ return int(value) if value is not None else default
48
+ except ValueError:
49
+ return default
50
+
51
+
52
+ def _mode_str(perm: int | None) -> str:
53
+ if perm is None:
54
+ return "---------"
55
+ out = ""
56
+ for shift in (6, 3, 0):
57
+ bits = (perm >> shift) & 7
58
+ out += "r" if bits & 4 else "-"
59
+ out += "w" if bits & 2 else "-"
60
+ out += "x" if bits & 1 else "-"
61
+ return out
62
+
63
+
64
+ def _type_char(info: Any) -> str:
65
+ name = getattr(getattr(info, "type", None), "name", "")
66
+ return {"DIRECTORY": "d", "SYMLINK": "l"}.get(name, "-")
67
+
68
+
69
+ def _long_format(info: Any) -> str:
70
+ size = getattr(info, "size", None) or 0
71
+ mtime = getattr(info, "last_modified", None)
72
+ when = mtime.strftime("%Y-%m-%d %H:%M") if mtime else ""
73
+ perms = _mode_str(getattr(info, "permissions", None))
74
+ return f"{_type_char(info)}{perms} {str(size).rjust(8)} {when:>16} {info.name}"
75
+
76
+
77
+ def _resolve(ctx: ShellContext, path: str) -> str:
78
+ return pathutils.resolve(ctx.cwd, path, home=ctx.home)
79
+
80
+
81
+ def _input_text(ctx: ShellContext, paths: list[str], stdin: str | None) -> str:
82
+ if paths:
83
+ return _read_text(ctx.transport, _resolve(ctx, paths[0]))
84
+ return stdin or ""
85
+
86
+
87
+ def _dest_path(ctx: ShellContext, dst: str, src: str) -> str:
88
+ """If *dst* is an existing directory, place *src*'s basename inside it."""
89
+ try:
90
+ infos = ctx.transport.list_files(dst, itself=True)
91
+ except Exception: # noqa: BLE001
92
+ return dst
93
+ if infos and _is_dir(infos[0]):
94
+ return posixpath.join(dst, posixpath.basename(src))
95
+ return dst
96
+
97
+
98
+ # --------------------------------------------------------------------------- #
99
+ # Commands
100
+ # --------------------------------------------------------------------------- #
101
+ class Ls(Command):
102
+ name = "ls"
103
+ summary = "List directory contents"
104
+ usage = "ls [-l] [-a] [path...]"
105
+
106
+ def run(
107
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
108
+ ) -> Result:
109
+ flags, _, paths = parse_args(args)
110
+ long, show_all = "l" in flags, "a" in flags
111
+ paths = paths or [ctx.cwd]
112
+ blocks: list[str] = []
113
+ errors: list[str] = []
114
+ for path in paths:
115
+ try:
116
+ infos = ctx.transport.list_files(_resolve(ctx, path))
117
+ except Exception as exc: # noqa: BLE001
118
+ errors.append(f"ls: {path}: {exc}")
119
+ continue
120
+ entries = sorted(infos, key=lambda i: i.name)
121
+ if not show_all:
122
+ entries = [i for i in entries if not i.name.startswith(".")]
123
+ rendered = "\n".join(_long_format(i) if long else i.name for i in entries)
124
+ blocks.append(f"{path}:\n{rendered}" if len(paths) > 1 else rendered)
125
+ return Result(
126
+ output="\n\n".join(b for b in blocks if b),
127
+ error="\n".join(errors),
128
+ code=1 if errors else 0,
129
+ )
130
+
131
+
132
+ class Cat(Command):
133
+ name = "cat"
134
+ summary = "Concatenate and print files"
135
+ usage = "cat [file...]"
136
+
137
+ def run(
138
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
139
+ ) -> Result:
140
+ _, _, paths = parse_args(args)
141
+ if not paths:
142
+ return Result.ok(stdin or "")
143
+ chunks: list[str] = []
144
+ errors: list[str] = []
145
+ for path in paths:
146
+ try:
147
+ chunks.append(_read_text(ctx.transport, _resolve(ctx, path)))
148
+ except Exception as exc: # noqa: BLE001
149
+ errors.append(f"cat: {path}: {exc}")
150
+ return Result(
151
+ output="".join(chunks), error="\n".join(errors), code=1 if errors else 0
152
+ )
153
+
154
+
155
+ class Head(Command):
156
+ name = "head"
157
+ summary = "Print the first lines of input"
158
+ usage = "head [-n N] [file]"
159
+
160
+ def run(
161
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
162
+ ) -> Result:
163
+ _, values, paths = parse_args(args, valued=("n",))
164
+ count = _int(values.get("n"), 10)
165
+ try:
166
+ text = _input_text(ctx, paths, stdin)
167
+ except Exception as exc: # noqa: BLE001
168
+ return Result.fail(f"head: {paths[0]}: {exc}")
169
+ return Result.ok("\n".join(text.splitlines()[:count]))
170
+
171
+
172
+ class Tail(Command):
173
+ name = "tail"
174
+ summary = "Print the last lines of input (-f to follow)"
175
+ usage = "tail [-n N] [-f] [file]"
176
+
177
+ def run(
178
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
179
+ ) -> Result:
180
+ flags, values, paths = parse_args(args, valued=("n",))
181
+ count = _int(values.get("n"), 10)
182
+ follow = "f" in flags
183
+
184
+ if not paths:
185
+ if follow:
186
+ return Result.fail("tail: -f requires a file")
187
+ return Result.ok("\n".join((stdin or "").splitlines()[-count:]))
188
+
189
+ path = _resolve(ctx, paths[0])
190
+ try:
191
+ text = _read_text(ctx.transport, path)
192
+ except Exception as exc: # noqa: BLE001
193
+ return Result.fail(f"tail: {paths[0]}: {exc}")
194
+
195
+ if not follow:
196
+ return Result.ok("\n".join(text.splitlines()[-count:]))
197
+ return self._follow(ctx, path, text, count)
198
+
199
+ @staticmethod
200
+ def _follow(ctx: ShellContext, path: str, text: str, count: int) -> Result:
201
+ initial = "\n".join(text.splitlines()[-count:])
202
+ if initial:
203
+ sys.stdout.write(initial + "\n")
204
+ sys.stdout.flush()
205
+ seen = len(text)
206
+ try:
207
+ while True:
208
+ time.sleep(1.0)
209
+ try:
210
+ current = _read_text(ctx.transport, path)
211
+ except Exception: # noqa: BLE001
212
+ continue
213
+ if len(current) > seen:
214
+ sys.stdout.write(current[seen:])
215
+ sys.stdout.flush()
216
+ seen = len(current)
217
+ except KeyboardInterrupt:
218
+ sys.stdout.write("\n")
219
+ return Result()
220
+
221
+
222
+ class Find(Command):
223
+ name = "find"
224
+ summary = "Walk the tree, filtering by name/type"
225
+ usage = "find [path] [-name PATTERN] [-type f|d]"
226
+
227
+ def run(
228
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
229
+ ) -> Result:
230
+ # find uses single-dash long options (-name, -type), so it parses its own
231
+ # args rather than going through the short-flag splitter.
232
+ start_arg: str | None = None
233
+ name_pat: str | None = None
234
+ type_filter: str | None = None
235
+ i = 0
236
+ while i < len(args):
237
+ arg = args[i]
238
+ if arg in ("-name", "-iname"):
239
+ i += 1
240
+ name_pat = args[i] if i < len(args) else None
241
+ elif arg == "-type":
242
+ i += 1
243
+ type_filter = args[i] if i < len(args) else None
244
+ elif not arg.startswith("-") and start_arg is None:
245
+ start_arg = arg
246
+ i += 1
247
+ start = _resolve(ctx, start_arg) if start_arg else ctx.cwd
248
+ results: list[str] = []
249
+ errors: list[str] = []
250
+
251
+ def walk(path: str) -> None:
252
+ try:
253
+ infos = ctx.transport.list_files(path)
254
+ except Exception as exc: # noqa: BLE001
255
+ errors.append(f"find: {path}: {exc}")
256
+ return
257
+ for info in sorted(infos, key=lambda i: i.name):
258
+ full = posixpath.join(path, info.name)
259
+ is_dir = _is_dir(info)
260
+ if self._matches(info.name, is_dir, name_pat, type_filter):
261
+ results.append(full)
262
+ if is_dir:
263
+ walk(full)
264
+
265
+ walk(start)
266
+ return Result(
267
+ output="\n".join(results),
268
+ error="\n".join(errors),
269
+ code=1 if errors and not results else 0,
270
+ )
271
+
272
+ @staticmethod
273
+ def _matches(
274
+ name: str, is_dir: bool, name_pat: str | None, type_filter: str | None
275
+ ) -> bool:
276
+ if name_pat is not None and not fnmatch.fnmatch(name, name_pat):
277
+ return False
278
+ if type_filter == "d" and not is_dir:
279
+ return False
280
+ if type_filter == "f" and is_dir:
281
+ return False
282
+ return True
283
+
284
+
285
+ class Stat(Command):
286
+ name = "stat"
287
+ summary = "Show file metadata"
288
+ usage = "stat <path...>"
289
+
290
+ def run(
291
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
292
+ ) -> Result:
293
+ _, _, paths = parse_args(args)
294
+ if not paths:
295
+ return Result.fail("stat: usage: stat <path...>")
296
+ blocks: list[str] = []
297
+ errors: list[str] = []
298
+ for path in paths:
299
+ resolved = _resolve(ctx, path)
300
+ try:
301
+ info = ctx.transport.list_files(resolved, itself=True)[0]
302
+ except Exception as exc: # noqa: BLE001
303
+ errors.append(f"stat: {path}: {exc}")
304
+ continue
305
+ blocks.append(self._format(info, resolved))
306
+ return Result(
307
+ output="\n".join(blocks), error="\n".join(errors), code=1 if errors else 0
308
+ )
309
+
310
+ @staticmethod
311
+ def _format(info: Any, path: str) -> str:
312
+ perms = getattr(info, "permissions", None)
313
+ mode = f"{perms:o}" if perms is not None else "?"
314
+ size = getattr(info, "size", None)
315
+ owner = f"{getattr(info, 'user', '?')}:{getattr(info, 'group', '?')}"
316
+ mtime = getattr(info, "last_modified", None)
317
+ return (
318
+ f" File: {path}\n"
319
+ f" Type: {_type_char(info)} Mode: {mode} Owner: {owner}\n"
320
+ f" Size: {size if size is not None else '?'} "
321
+ f"Modified: {mtime.isoformat() if mtime else '?'}"
322
+ )
323
+
324
+
325
+ class Grep(Command):
326
+ name = "grep"
327
+ summary = "Search input for a pattern"
328
+ usage = "grep [-i] [-v] [-n] [-c] PATTERN [file...]"
329
+
330
+ def run(
331
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
332
+ ) -> Result:
333
+ flags, _, positionals = parse_args(args)
334
+ if not positionals:
335
+ return Result.fail(
336
+ "grep: usage: grep [-i] [-v] [-n] [-c] PATTERN [file...]"
337
+ )
338
+ pattern, files = positionals[0], positionals[1:]
339
+ try:
340
+ regex = re.compile(pattern, re.IGNORECASE if "i" in flags else 0)
341
+ except re.error as exc:
342
+ return Result.fail(f"grep: invalid pattern: {exc}")
343
+
344
+ invert, show_num, count_only = "v" in flags, "n" in flags, "c" in flags
345
+ sources: list[tuple[str | None, str]] = []
346
+ errors: list[str] = []
347
+ if files:
348
+ for path in files:
349
+ try:
350
+ sources.append(
351
+ (path, _read_text(ctx.transport, _resolve(ctx, path)))
352
+ )
353
+ except Exception as exc: # noqa: BLE001
354
+ errors.append(f"grep: {path}: {exc}")
355
+ else:
356
+ sources.append((None, stdin or ""))
357
+
358
+ multi = len(files) > 1
359
+ out: list[str] = []
360
+ matched = False
361
+ for label, text in sources:
362
+ count = 0
363
+ for num, line in enumerate(text.splitlines(), 1):
364
+ hit = bool(regex.search(line)) != invert
365
+ if not hit:
366
+ continue
367
+ matched = True
368
+ count += 1
369
+ if not count_only:
370
+ prefix = (f"{label}:" if multi else "") + (
371
+ f"{num}:" if show_num else ""
372
+ )
373
+ out.append(prefix + line)
374
+ if count_only:
375
+ out.append((f"{label}:" if multi else "") + str(count))
376
+ return Result(
377
+ output="\n".join(out),
378
+ error="\n".join(errors),
379
+ code=0 if matched else 1,
380
+ )
381
+
382
+
383
+ class Cp(Command):
384
+ name = "cp"
385
+ summary = "Copy a file"
386
+ usage = "cp <src> <dst>"
387
+
388
+ def run(
389
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
390
+ ) -> Result:
391
+ _, _, paths = parse_args(args)
392
+ if len(paths) != 2:
393
+ return Result.fail("cp: usage: cp <src> <dst>")
394
+ src, dst = _resolve(ctx, paths[0]), _resolve(ctx, paths[1])
395
+ try:
396
+ data = _read_bytes(ctx.transport, src)
397
+ except Exception as exc: # noqa: BLE001
398
+ return Result.fail(f"cp: {paths[0]}: {exc}")
399
+ try:
400
+ ctx.transport.push(_dest_path(ctx, dst, src), data)
401
+ except Exception as exc: # noqa: BLE001
402
+ return Result.fail(f"cp: {paths[1]}: {exc}")
403
+ return Result()
404
+
405
+
406
+ class Mv(Command):
407
+ name = "mv"
408
+ summary = "Move or rename a file"
409
+ usage = "mv <src> <dst>"
410
+
411
+ def run(
412
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
413
+ ) -> Result:
414
+ _, _, paths = parse_args(args)
415
+ if len(paths) != 2:
416
+ return Result.fail("mv: usage: mv <src> <dst>")
417
+ src, dst = _resolve(ctx, paths[0]), _resolve(ctx, paths[1])
418
+ try:
419
+ data = _read_bytes(ctx.transport, src)
420
+ ctx.transport.push(_dest_path(ctx, dst, src), data)
421
+ ctx.transport.remove_path(src)
422
+ except Exception as exc: # noqa: BLE001
423
+ return Result.fail(f"mv: {exc}")
424
+ return Result()
425
+
426
+
427
+ class Rm(Command):
428
+ name = "rm"
429
+ summary = "Remove files or directories"
430
+ usage = "rm [-r] [-f] <path...>"
431
+
432
+ def run(
433
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
434
+ ) -> Result:
435
+ flags, _, paths = parse_args(args)
436
+ if not paths:
437
+ return Result.fail("rm: usage: rm [-r] [-f] <path...>")
438
+ recursive = "r" in flags or "R" in flags
439
+ force = "f" in flags
440
+ errors: list[str] = []
441
+ for path in paths:
442
+ try:
443
+ ctx.transport.remove_path(_resolve(ctx, path), recursive=recursive)
444
+ except Exception as exc: # noqa: BLE001
445
+ if not force:
446
+ errors.append(f"rm: {path}: {exc}")
447
+ return Result(error="\n".join(errors), code=1 if errors else 0)
448
+
449
+
450
+ class Mkdir(Command):
451
+ name = "mkdir"
452
+ summary = "Create directories"
453
+ usage = "mkdir [-p] <path...>"
454
+
455
+ def run(
456
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
457
+ ) -> Result:
458
+ flags, _, paths = parse_args(args)
459
+ if not paths:
460
+ return Result.fail("mkdir: usage: mkdir [-p] <path...>")
461
+ parents = "p" in flags
462
+ errors: list[str] = []
463
+ for path in paths:
464
+ try:
465
+ ctx.transport.make_dir(_resolve(ctx, path), make_parents=parents)
466
+ except Exception as exc: # noqa: BLE001
467
+ errors.append(f"mkdir: {path}: {exc}")
468
+ return Result(error="\n".join(errors), code=1 if errors else 0)
469
+
470
+
471
+ class Touch(Command):
472
+ name = "touch"
473
+ summary = "Create an empty file if it does not exist"
474
+ usage = "touch <path...>"
475
+
476
+ def run(
477
+ self, ctx: ShellContext, args: list[str], stdin: str | None = None
478
+ ) -> Result:
479
+ _, _, paths = parse_args(args)
480
+ if not paths:
481
+ return Result.fail("touch: usage: touch <path...>")
482
+ errors: list[str] = []
483
+ for path in paths:
484
+ resolved = _resolve(ctx, path)
485
+ try:
486
+ ctx.transport.list_files(resolved, itself=True)
487
+ continue # already exists; Pebble can't bump mtime, so leave it
488
+ except Exception: # noqa: BLE001
489
+ pass
490
+ try:
491
+ ctx.transport.push(resolved, "")
492
+ except Exception as exc: # noqa: BLE001
493
+ errors.append(f"touch: {path}: {exc}")
494
+ return Result(error="\n".join(errors), code=1 if errors else 0)