termish 0.2.2__tar.gz → 0.2.3__tar.gz
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.
- {termish-0.2.2/termish.egg-info → termish-0.2.3}/PKG-INFO +1 -1
- {termish-0.2.2 → termish-0.2.3}/pyproject.toml +1 -1
- {termish-0.2.2 → termish-0.2.3}/termish/fs/conformance.py +36 -1
- {termish-0.2.2 → termish-0.2.3}/termish/fs/memory.py +55 -3
- {termish-0.2.2 → termish-0.2.3}/termish/fs/protocol.py +11 -0
- termish-0.2.3/termish/interpreter/commands/_dispatch.py +65 -0
- {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/_regex.py +8 -3
- {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/filesystem.py +3 -0
- {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/meta.py +39 -38
- {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/search.py +136 -74
- {termish-0.2.2 → termish-0.2.3}/termish/interpreter/core.py +44 -33
- {termish-0.2.2 → termish-0.2.3}/termish/interpreter/pathglob.py +3 -1
- {termish-0.2.2 → termish-0.2.3}/termish/parser.py +118 -16
- {termish-0.2.2 → termish-0.2.3}/termish/quote_masker.py +11 -4
- {termish-0.2.2 → termish-0.2.3/termish.egg-info}/PKG-INFO +1 -1
- {termish-0.2.2 → termish-0.2.3}/termish.egg-info/SOURCES.txt +2 -0
- {termish-0.2.2 → termish-0.2.3}/tests/test_binary.py +4 -3
- {termish-0.2.2 → termish-0.2.3}/tests/test_bre.py +18 -0
- {termish-0.2.2 → termish-0.2.3}/tests/test_command_injection.py +53 -8
- {termish-0.2.2 → termish-0.2.3}/tests/test_expansion.py +50 -0
- {termish-0.2.2 → termish-0.2.3}/tests/test_find.py +68 -0
- {termish-0.2.2 → termish-0.2.3}/tests/test_fs_conformance.py +35 -0
- termish-0.2.3/tests/test_grep_reads.py +214 -0
- {termish-0.2.2 → termish-0.2.3}/tests/test_heredoc.py +8 -0
- {termish-0.2.2 → termish-0.2.3}/tests/test_interpreter.py +43 -0
- {termish-0.2.2 → termish-0.2.3}/tests/test_memory_fs.py +88 -0
- {termish-0.2.2 → termish-0.2.3}/tests/test_parser.py +44 -0
- {termish-0.2.2 → termish-0.2.3}/tests/test_pathglob.py +4 -0
- {termish-0.2.2 → termish-0.2.3}/tests/test_printf.py +6 -0
- {termish-0.2.2 → termish-0.2.3}/tests/test_quote_masker.py +13 -0
- {termish-0.2.2 → termish-0.2.3}/tests/test_stderr.py +47 -0
- {termish-0.2.2 → termish-0.2.3}/LICENSE +0 -0
- {termish-0.2.2 → termish-0.2.3}/README.md +0 -0
- {termish-0.2.2 → termish-0.2.3}/setup.cfg +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish/__init__.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish/ast.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish/context.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish/errors.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish/fs/__init__.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish/interpreter/__init__.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/__init__.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/_argparse.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/_util.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/archive.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/conditional.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/control.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/diff.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/file.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/io.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/jq.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/sed.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/text.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish/jq/__init__.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish/jq/ast.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish/jq/eval.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish/jq/parser.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish/py.typed +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish.egg-info/dependency_links.txt +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish.egg-info/requires.txt +0 -0
- {termish-0.2.2 → termish-0.2.3}/termish.egg-info/top_level.txt +0 -0
- {termish-0.2.2 → termish-0.2.3}/tests/test_archive.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/tests/test_conditional.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/tests/test_control.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/tests/test_dev_null.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/tests/test_file.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/tests/test_grep.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/tests/test_jq.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/tests/test_monkeyfs_compat.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/tests/test_sed.py +0 -0
- {termish-0.2.2 → termish-0.2.3}/tests/test_text_commands.py +0 -0
|
@@ -72,7 +72,8 @@ def check_filesystem(fs: Any) -> None:
|
|
|
72
72
|
fs: An empty filesystem instance to exercise. All sixteen protocol
|
|
73
73
|
methods are called, including the ranged form of `read`, append
|
|
74
74
|
mode on `write`, and the `FileInfo.path` convention on
|
|
75
|
-
`list_detailed`.
|
|
75
|
+
`list_detailed`. The optional `read_many` is checked when the
|
|
76
|
+
filesystem has it.
|
|
76
77
|
|
|
77
78
|
Raises:
|
|
78
79
|
AssertionError: On the first deviation, with a message naming the
|
|
@@ -95,6 +96,8 @@ def check_filesystem(fs: Any) -> None:
|
|
|
95
96
|
_check_ranged_read(fs, base)
|
|
96
97
|
_check_stat(fs, base)
|
|
97
98
|
_check_listing(fs, base)
|
|
99
|
+
if callable(getattr(fs, "read_many", None)):
|
|
100
|
+
_check_read_many(fs, base)
|
|
98
101
|
_check_glob(fs, base)
|
|
99
102
|
_check_rename_and_remove(fs, base)
|
|
100
103
|
_check_chdir(fs, root, base)
|
|
@@ -291,6 +294,38 @@ def _check_listing(fs: Any, base: str) -> None:
|
|
|
291
294
|
_expect("list_detailed", "size of a 2-byte file", nested.size, 2)
|
|
292
295
|
|
|
293
296
|
|
|
297
|
+
def _check_read_many(fs: Any, base: str) -> None:
|
|
298
|
+
batch = posixpath.join(base, "batch")
|
|
299
|
+
fs.makedirs(posixpath.join(batch, "sub"))
|
|
300
|
+
one = posixpath.join(batch, "one.txt")
|
|
301
|
+
two = posixpath.join(batch, "sub/two.txt")
|
|
302
|
+
fs.write(one, b"1")
|
|
303
|
+
fs.write(two, b"22")
|
|
304
|
+
|
|
305
|
+
_expect(
|
|
306
|
+
"read_many",
|
|
307
|
+
"files map to their bytes; a directory and a missing path are left out",
|
|
308
|
+
fs.read_many(
|
|
309
|
+
[one, posixpath.join(batch, "sub"), two, posixpath.join(batch, "nope")]
|
|
310
|
+
),
|
|
311
|
+
{one: b"1", two: b"22"},
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# Keys are the paths as given, not a canonical spelling of them: a
|
|
315
|
+
# caller looks its own paths up in the answer, so a relative path that
|
|
316
|
+
# came back absolute (or the reverse) would read as a missing file.
|
|
317
|
+
rel = posixpath.relpath(batch, fs.getcwd())
|
|
318
|
+
rel_one = posixpath.join(rel, "one.txt")
|
|
319
|
+
dot_two = "./" + posixpath.join(rel, "sub/two.txt")
|
|
320
|
+
_expect(
|
|
321
|
+
"read_many",
|
|
322
|
+
"relative paths are keyed exactly as given",
|
|
323
|
+
fs.read_many([rel_one, dot_two, one]),
|
|
324
|
+
{rel_one: b"1", dot_two: b"22", one: b"1"},
|
|
325
|
+
)
|
|
326
|
+
_expect("read_many", "no paths, no files", fs.read_many([]), {})
|
|
327
|
+
|
|
328
|
+
|
|
294
329
|
def _check_glob(fs: Any, base: str) -> None:
|
|
295
330
|
globbing = posixpath.join(base, "globbing")
|
|
296
331
|
fs.makedirs(globbing)
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
import errno as _errno
|
|
10
10
|
import fnmatch
|
|
11
11
|
import posixpath
|
|
12
|
+
from collections.abc import Iterable
|
|
12
13
|
from datetime import datetime, timezone
|
|
13
14
|
|
|
14
15
|
from .protocol import FileInfo, FileMetadata
|
|
@@ -40,11 +41,27 @@ class MemoryFS:
|
|
|
40
41
|
path = self._cwd.rstrip("/") + "/" + path
|
|
41
42
|
return posixpath.normpath(path)
|
|
42
43
|
|
|
44
|
+
def _refuse_a_file_on_the_way(self, path: str) -> None:
|
|
45
|
+
"""Raise NotADirectoryError (ENOTDIR) if a component of *path* is
|
|
46
|
+
a file: a path below one can't be created without leaving the file
|
|
47
|
+
a directory as well."""
|
|
48
|
+
current = ""
|
|
49
|
+
for part in path.strip("/").split("/"):
|
|
50
|
+
current += "/" + part
|
|
51
|
+
if current in self._files:
|
|
52
|
+
raise NotADirectoryError(_errno.ENOTDIR, "Not a directory", current)
|
|
53
|
+
|
|
43
54
|
def _ensure_parent(self, path: str) -> None:
|
|
44
|
-
"""Raise
|
|
55
|
+
"""Raise unless the parent directory of *path* exists.
|
|
56
|
+
|
|
57
|
+
NotADirectoryError (ENOTDIR) when a file is in the way, and
|
|
58
|
+
FileNotFoundError (ENOENT) when a directory on the way is missing.
|
|
59
|
+
"""
|
|
45
60
|
parent = posixpath.dirname(path)
|
|
46
|
-
if parent
|
|
47
|
-
|
|
61
|
+
if parent == path or parent in self._dirs:
|
|
62
|
+
return
|
|
63
|
+
self._refuse_a_file_on_the_way(parent)
|
|
64
|
+
raise FileNotFoundError(_errno.ENOENT, "No such directory", parent)
|
|
48
65
|
|
|
49
66
|
def _touch_ts(self, path: str) -> None:
|
|
50
67
|
"""Update modified_at; set created_at if new."""
|
|
@@ -88,10 +105,28 @@ class MemoryFS:
|
|
|
88
105
|
return content[offset:]
|
|
89
106
|
return content[offset : offset + size]
|
|
90
107
|
|
|
108
|
+
def read_many(self, paths: Iterable[str]) -> dict[str, bytes]:
|
|
109
|
+
"""Read several whole files: each path, as given, to its bytes.
|
|
110
|
+
|
|
111
|
+
A path that is not a file -- missing, or a directory -- is left out.
|
|
112
|
+
"""
|
|
113
|
+
found: dict[str, bytes] = {}
|
|
114
|
+
for path in paths:
|
|
115
|
+
resolved = self._resolve(path)
|
|
116
|
+
if resolved in self._files:
|
|
117
|
+
found[path] = self._files[resolved]
|
|
118
|
+
return found
|
|
119
|
+
|
|
91
120
|
def write(self, path: str, content: bytes, mode: str = "w") -> None:
|
|
121
|
+
"""Write *content* to the file at *path*, creating any missing
|
|
122
|
+
directories on the way; a file on the way raises
|
|
123
|
+
NotADirectoryError, and a directory at *path* IsADirectoryError."""
|
|
92
124
|
path = self._resolve(path)
|
|
125
|
+
if path in self._dirs:
|
|
126
|
+
raise IsADirectoryError(_errno.EISDIR, "Is a directory", path)
|
|
93
127
|
parent = posixpath.dirname(path)
|
|
94
128
|
if parent != path and parent not in self._dirs:
|
|
129
|
+
self._refuse_a_file_on_the_way(parent)
|
|
95
130
|
self.makedirs(parent, exist_ok=True)
|
|
96
131
|
if mode == "a" and path in self._files:
|
|
97
132
|
self._files[path] = self._files[path] + content
|
|
@@ -152,6 +187,9 @@ class MemoryFS:
|
|
|
152
187
|
path = self._resolve(path)
|
|
153
188
|
if not exist_ok and path in self._dirs:
|
|
154
189
|
raise FileExistsError(f"Directory exists: '{path}'")
|
|
190
|
+
if path in self._files:
|
|
191
|
+
raise FileExistsError(f"Path exists as file: '{path}'")
|
|
192
|
+
self._refuse_a_file_on_the_way(path)
|
|
155
193
|
parts = path.strip("/").split("/")
|
|
156
194
|
current = ""
|
|
157
195
|
for part in parts:
|
|
@@ -187,9 +225,16 @@ class MemoryFS:
|
|
|
187
225
|
self._timestamps.pop(path, None)
|
|
188
226
|
|
|
189
227
|
def rename(self, src: str, dst: str) -> None:
|
|
228
|
+
"""Move *src* to *dst*, as rename(2) does: a file never replaces a
|
|
229
|
+
directory nor a directory a file, a directory replaces only an
|
|
230
|
+
empty one, and never moves into itself."""
|
|
190
231
|
src = self._resolve(src)
|
|
191
232
|
dst = self._resolve(dst)
|
|
233
|
+
if src == dst and self.exists(src):
|
|
234
|
+
return
|
|
192
235
|
if src in self._files:
|
|
236
|
+
if dst in self._dirs:
|
|
237
|
+
raise IsADirectoryError(_errno.EISDIR, "Is a directory", dst)
|
|
193
238
|
self._ensure_parent(dst)
|
|
194
239
|
self._files[dst] = self._files.pop(src)
|
|
195
240
|
ts = self._timestamps.pop(src, None)
|
|
@@ -197,6 +242,13 @@ class MemoryFS:
|
|
|
197
242
|
self._timestamps[dst] = ts
|
|
198
243
|
elif src in self._dirs:
|
|
199
244
|
src_prefix = src.rstrip("/") + "/"
|
|
245
|
+
if dst in self._files:
|
|
246
|
+
raise NotADirectoryError(_errno.ENOTDIR, "Not a directory", dst)
|
|
247
|
+
if dst.startswith(src_prefix):
|
|
248
|
+
raise OSError(_errno.EINVAL, "Cannot move a directory into itself", dst)
|
|
249
|
+
if dst in self._dirs and self.list(dst):
|
|
250
|
+
raise OSError(_errno.ENOTEMPTY, "Directory not empty", dst)
|
|
251
|
+
self._ensure_parent(dst)
|
|
200
252
|
self._dirs.discard(src)
|
|
201
253
|
self._dirs.add(dst)
|
|
202
254
|
# Move timestamp
|
|
@@ -54,6 +54,17 @@ class FileSystem(Protocol):
|
|
|
54
54
|
|
|
55
55
|
Any object implementing these methods can be passed to termish's
|
|
56
56
|
interpreter. No inheritance required — uses structural (duck) typing.
|
|
57
|
+
|
|
58
|
+
One method is optional, and is not part of the protocol, so a
|
|
59
|
+
filesystem without it still passes ``isinstance``:
|
|
60
|
+
|
|
61
|
+
``read_many(paths: Iterable[str]) -> dict[str, bytes]``
|
|
62
|
+
Read several whole files at once: each path, as given, to its
|
|
63
|
+
bytes. A path that is not a file -- missing, or a directory -- is
|
|
64
|
+
left out rather than raised on. ``grep`` reads the files it
|
|
65
|
+
searches through it when it is there, so a filesystem that fetches
|
|
66
|
+
remotely answers a batch in one round trip rather than one per
|
|
67
|
+
file. `termish.fs.check_filesystem` checks it when it is there.
|
|
57
68
|
"""
|
|
58
69
|
|
|
59
70
|
def getcwd(self) -> str:
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Running a command from an argv, as ``xargs`` and ``find -exec`` do."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
|
|
7
|
+
from termish.context import CommandContext, PipeStream
|
|
8
|
+
from termish.errors import TerminalError
|
|
9
|
+
from termish.fs import FileSystem
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Dispatched(NamedTuple):
|
|
13
|
+
"""What a command that succeeded produced."""
|
|
14
|
+
|
|
15
|
+
#: Its stdout, byte for byte.
|
|
16
|
+
output: bytes
|
|
17
|
+
#: What it said on stderr while succeeding (a warning), or "".
|
|
18
|
+
stderr: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def dispatch_argv(argv: list[str], fs: FileSystem, env: dict[str, str]) -> Dispatched:
|
|
22
|
+
"""Run ``argv`` (``[name, *args]``) with empty stdin.
|
|
23
|
+
|
|
24
|
+
The argv is dispatched as it is, never re-parsed by the shell, through
|
|
25
|
+
the same table the interpreter uses, injected commands included.
|
|
26
|
+
Raises ``TerminalError`` when the command is missing (127), fails, or
|
|
27
|
+
raises.
|
|
28
|
+
"""
|
|
29
|
+
# Deferred: core imports the command modules that import this one.
|
|
30
|
+
from termish.interpreter.core import _resolve_command
|
|
31
|
+
|
|
32
|
+
name, *args = argv
|
|
33
|
+
func = _resolve_command(name)
|
|
34
|
+
if func is None:
|
|
35
|
+
raise TerminalError(f"{name}: command not found", exit_code=127)
|
|
36
|
+
|
|
37
|
+
stdout = PipeStream()
|
|
38
|
+
try:
|
|
39
|
+
result = func(
|
|
40
|
+
CommandContext(args=args, stdin=PipeStream(), stdout=stdout, fs=fs, env=env)
|
|
41
|
+
)
|
|
42
|
+
except TerminalError:
|
|
43
|
+
raise
|
|
44
|
+
except Exception as e:
|
|
45
|
+
raise TerminalError(f"{name}: execution error: {e}")
|
|
46
|
+
if result is not None and result.exit_code != 0:
|
|
47
|
+
raise TerminalError(
|
|
48
|
+
f"{name}: {result.stderr}"
|
|
49
|
+
if result.stderr
|
|
50
|
+
else f"{name}: exited with code {result.exit_code}",
|
|
51
|
+
exit_code=result.exit_code,
|
|
52
|
+
stderr=result.stderr,
|
|
53
|
+
)
|
|
54
|
+
return Dispatched(stdout.getvalue(), result.stderr if result is not None else "")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def stderr_of(e: TerminalError) -> str:
|
|
58
|
+
"""What a failure shows on stderr ("" if it failed silently)."""
|
|
59
|
+
return e.message if e.stderr is None else e.stderr
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def join_stderr(parts: list[str]) -> str:
|
|
63
|
+
"""Several commands' stderr as one, each as written: a newline is
|
|
64
|
+
added only where one is missing, so blank lines survive."""
|
|
65
|
+
return "".join(p if p.endswith("\n") else p + "\n" for p in parts if p)
|
|
@@ -30,7 +30,9 @@ def bre_to_python(pattern: str) -> str:
|
|
|
30
30
|
- ``^`` anchors only at those same starting points and ``$`` only at
|
|
31
31
|
the end of the pattern, of a group or of an alternative; anywhere
|
|
32
32
|
else each is a literal character.
|
|
33
|
-
- ``\<`` and ``\>`` (GNU word
|
|
33
|
+
- ``\<`` and ``\>`` (GNU's start and end of a word) become
|
|
34
|
+
``\b(?=\w)`` and ``\b(?<=\w)``: a bare ``\b`` would match either
|
|
35
|
+
edge.
|
|
34
36
|
- Every other escape — ``\1``-``\9``, ``\.``, ``\*``, ``\w`` — is
|
|
35
37
|
kept as it is, since it means the same to ``re``.
|
|
36
38
|
|
|
@@ -59,8 +61,11 @@ def bre_to_python(pattern: str) -> str:
|
|
|
59
61
|
if nxt in _SWAPPED:
|
|
60
62
|
out.append(nxt)
|
|
61
63
|
at_start = nxt in "(|"
|
|
62
|
-
elif nxt
|
|
63
|
-
out.append(r"\b")
|
|
64
|
+
elif nxt == "<":
|
|
65
|
+
out.append(r"\b(?=\w)") # the start of a word
|
|
66
|
+
at_start = False
|
|
67
|
+
elif nxt == ">":
|
|
68
|
+
out.append(r"\b(?<=\w)") # the end of a word
|
|
64
69
|
at_start = False
|
|
65
70
|
else:
|
|
66
71
|
out.append("\\" + nxt)
|
|
@@ -346,6 +346,9 @@ def mv(ctx: CommandContext) -> CommandResult | None:
|
|
|
346
346
|
raise TerminalError("mv: too many arguments")
|
|
347
347
|
|
|
348
348
|
src, dst = parsed.paths
|
|
349
|
+
if fs.isdir(dst):
|
|
350
|
+
# an existing directory receives src under its own name
|
|
351
|
+
dst = posixpath.join(dst, posixpath.basename(src.rstrip("/")))
|
|
349
352
|
if parsed.no_clobber and fs.exists(dst):
|
|
350
353
|
return # silently skip
|
|
351
354
|
|
|
@@ -5,9 +5,11 @@ Meta commands that invoke other commands.
|
|
|
5
5
|
import contextvars
|
|
6
6
|
from typing import TYPE_CHECKING
|
|
7
7
|
|
|
8
|
-
from termish.context import CommandContext, CommandResult
|
|
8
|
+
from termish.context import CommandContext, CommandResult
|
|
9
9
|
from termish.errors import TerminalError
|
|
10
10
|
|
|
11
|
+
from ._dispatch import dispatch_argv, join_stderr, stderr_of
|
|
12
|
+
|
|
11
13
|
_MAX_XARGS_DEPTH = 16
|
|
12
14
|
_xargs_depth: contextvars.ContextVar[int] = contextvars.ContextVar(
|
|
13
15
|
"xargs_depth", default=0
|
|
@@ -102,52 +104,51 @@ def xargs(ctx: CommandContext) -> CommandResult | None:
|
|
|
102
104
|
if not items:
|
|
103
105
|
return
|
|
104
106
|
|
|
107
|
+
# What the commands say on stderr while succeeding becomes xargs's own
|
|
108
|
+
# stderr: shown, as it is when the command runs directly, and never
|
|
109
|
+
# fed down the pipe with the output.
|
|
110
|
+
warnings: list[str] = []
|
|
111
|
+
|
|
105
112
|
def execute_cmd(cmd_args: list[str]) -> None:
|
|
106
113
|
"""Execute command with given args and write output to stdout."""
|
|
107
114
|
if verbose:
|
|
108
115
|
stdout.write(f"{cmd_name} {' '.join(cmd_args)}\n")
|
|
109
116
|
|
|
110
|
-
cmd_stdin = PipeStream()
|
|
111
|
-
cmd_stdout = PipeStream()
|
|
112
117
|
token = _xargs_depth.set(depth + 1)
|
|
113
118
|
try:
|
|
114
|
-
|
|
115
|
-
args=cmd_args,
|
|
116
|
-
stdin=cmd_stdin,
|
|
117
|
-
stdout=cmd_stdout,
|
|
118
|
-
fs=fs,
|
|
119
|
-
env=ctx.env,
|
|
120
|
-
)
|
|
121
|
-
result = cmd_func(sub_ctx)
|
|
122
|
-
if result and result.exit_code != 0:
|
|
123
|
-
raise TerminalError(
|
|
124
|
-
f"{cmd_name}: {result.stderr}"
|
|
125
|
-
if result.stderr
|
|
126
|
-
else f"{cmd_name}: exited with code {result.exit_code}",
|
|
127
|
-
exit_code=result.exit_code,
|
|
128
|
-
stderr=result.stderr,
|
|
129
|
-
)
|
|
130
|
-
except TerminalError:
|
|
131
|
-
raise
|
|
132
|
-
except Exception as e:
|
|
133
|
-
raise TerminalError(f"{cmd_name}: execution error: {e}")
|
|
119
|
+
output, warning = dispatch_argv([cmd_name, *cmd_args], fs, ctx.env)
|
|
134
120
|
finally:
|
|
135
121
|
_xargs_depth.reset(token)
|
|
122
|
+
if warning:
|
|
123
|
+
warnings.append(warning)
|
|
136
124
|
# The sub-command's output is moved, not read: pass its bytes
|
|
137
125
|
# through so a binary-producing command survives xargs.
|
|
138
126
|
stdout.flush()
|
|
139
|
-
stdout.buffer.write(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
127
|
+
stdout.buffer.write(output)
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
if replace:
|
|
131
|
+
# -I mode: run command once per item, substituting placeholder
|
|
132
|
+
for item in items:
|
|
133
|
+
cmd_args = [arg.replace(replace, item) for arg in cmd_base_args]
|
|
134
|
+
execute_cmd(cmd_args)
|
|
135
|
+
elif max_args:
|
|
136
|
+
# -n mode: batch items
|
|
137
|
+
for i in range(0, len(items), max_args):
|
|
138
|
+
batch = items[i : i + max_args]
|
|
139
|
+
execute_cmd(cmd_base_args + batch)
|
|
140
|
+
else:
|
|
141
|
+
# Default: all items as arguments to single command
|
|
142
|
+
execute_cmd(cmd_base_args + items)
|
|
143
|
+
except TerminalError as e:
|
|
144
|
+
if not warnings:
|
|
145
|
+
raise
|
|
146
|
+
# the warnings of the runs before it are shown too
|
|
147
|
+
raise TerminalError(
|
|
148
|
+
e.message,
|
|
149
|
+
exit_code=e.exit_code,
|
|
150
|
+
stderr=join_stderr([*warnings, stderr_of(e)]),
|
|
151
|
+
)
|
|
152
|
+
if warnings:
|
|
153
|
+
return CommandResult(stderr=join_stderr(warnings))
|
|
154
|
+
return None
|