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.
Files changed (70) hide show
  1. {termish-0.2.2/termish.egg-info → termish-0.2.3}/PKG-INFO +1 -1
  2. {termish-0.2.2 → termish-0.2.3}/pyproject.toml +1 -1
  3. {termish-0.2.2 → termish-0.2.3}/termish/fs/conformance.py +36 -1
  4. {termish-0.2.2 → termish-0.2.3}/termish/fs/memory.py +55 -3
  5. {termish-0.2.2 → termish-0.2.3}/termish/fs/protocol.py +11 -0
  6. termish-0.2.3/termish/interpreter/commands/_dispatch.py +65 -0
  7. {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/_regex.py +8 -3
  8. {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/filesystem.py +3 -0
  9. {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/meta.py +39 -38
  10. {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/search.py +136 -74
  11. {termish-0.2.2 → termish-0.2.3}/termish/interpreter/core.py +44 -33
  12. {termish-0.2.2 → termish-0.2.3}/termish/interpreter/pathglob.py +3 -1
  13. {termish-0.2.2 → termish-0.2.3}/termish/parser.py +118 -16
  14. {termish-0.2.2 → termish-0.2.3}/termish/quote_masker.py +11 -4
  15. {termish-0.2.2 → termish-0.2.3/termish.egg-info}/PKG-INFO +1 -1
  16. {termish-0.2.2 → termish-0.2.3}/termish.egg-info/SOURCES.txt +2 -0
  17. {termish-0.2.2 → termish-0.2.3}/tests/test_binary.py +4 -3
  18. {termish-0.2.2 → termish-0.2.3}/tests/test_bre.py +18 -0
  19. {termish-0.2.2 → termish-0.2.3}/tests/test_command_injection.py +53 -8
  20. {termish-0.2.2 → termish-0.2.3}/tests/test_expansion.py +50 -0
  21. {termish-0.2.2 → termish-0.2.3}/tests/test_find.py +68 -0
  22. {termish-0.2.2 → termish-0.2.3}/tests/test_fs_conformance.py +35 -0
  23. termish-0.2.3/tests/test_grep_reads.py +214 -0
  24. {termish-0.2.2 → termish-0.2.3}/tests/test_heredoc.py +8 -0
  25. {termish-0.2.2 → termish-0.2.3}/tests/test_interpreter.py +43 -0
  26. {termish-0.2.2 → termish-0.2.3}/tests/test_memory_fs.py +88 -0
  27. {termish-0.2.2 → termish-0.2.3}/tests/test_parser.py +44 -0
  28. {termish-0.2.2 → termish-0.2.3}/tests/test_pathglob.py +4 -0
  29. {termish-0.2.2 → termish-0.2.3}/tests/test_printf.py +6 -0
  30. {termish-0.2.2 → termish-0.2.3}/tests/test_quote_masker.py +13 -0
  31. {termish-0.2.2 → termish-0.2.3}/tests/test_stderr.py +47 -0
  32. {termish-0.2.2 → termish-0.2.3}/LICENSE +0 -0
  33. {termish-0.2.2 → termish-0.2.3}/README.md +0 -0
  34. {termish-0.2.2 → termish-0.2.3}/setup.cfg +0 -0
  35. {termish-0.2.2 → termish-0.2.3}/termish/__init__.py +0 -0
  36. {termish-0.2.2 → termish-0.2.3}/termish/ast.py +0 -0
  37. {termish-0.2.2 → termish-0.2.3}/termish/context.py +0 -0
  38. {termish-0.2.2 → termish-0.2.3}/termish/errors.py +0 -0
  39. {termish-0.2.2 → termish-0.2.3}/termish/fs/__init__.py +0 -0
  40. {termish-0.2.2 → termish-0.2.3}/termish/interpreter/__init__.py +0 -0
  41. {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/__init__.py +0 -0
  42. {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/_argparse.py +0 -0
  43. {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/_util.py +0 -0
  44. {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/archive.py +0 -0
  45. {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/conditional.py +0 -0
  46. {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/control.py +0 -0
  47. {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/diff.py +0 -0
  48. {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/file.py +0 -0
  49. {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/io.py +0 -0
  50. {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/jq.py +0 -0
  51. {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/sed.py +0 -0
  52. {termish-0.2.2 → termish-0.2.3}/termish/interpreter/commands/text.py +0 -0
  53. {termish-0.2.2 → termish-0.2.3}/termish/jq/__init__.py +0 -0
  54. {termish-0.2.2 → termish-0.2.3}/termish/jq/ast.py +0 -0
  55. {termish-0.2.2 → termish-0.2.3}/termish/jq/eval.py +0 -0
  56. {termish-0.2.2 → termish-0.2.3}/termish/jq/parser.py +0 -0
  57. {termish-0.2.2 → termish-0.2.3}/termish/py.typed +0 -0
  58. {termish-0.2.2 → termish-0.2.3}/termish.egg-info/dependency_links.txt +0 -0
  59. {termish-0.2.2 → termish-0.2.3}/termish.egg-info/requires.txt +0 -0
  60. {termish-0.2.2 → termish-0.2.3}/termish.egg-info/top_level.txt +0 -0
  61. {termish-0.2.2 → termish-0.2.3}/tests/test_archive.py +0 -0
  62. {termish-0.2.2 → termish-0.2.3}/tests/test_conditional.py +0 -0
  63. {termish-0.2.2 → termish-0.2.3}/tests/test_control.py +0 -0
  64. {termish-0.2.2 → termish-0.2.3}/tests/test_dev_null.py +0 -0
  65. {termish-0.2.2 → termish-0.2.3}/tests/test_file.py +0 -0
  66. {termish-0.2.2 → termish-0.2.3}/tests/test_grep.py +0 -0
  67. {termish-0.2.2 → termish-0.2.3}/tests/test_jq.py +0 -0
  68. {termish-0.2.2 → termish-0.2.3}/tests/test_monkeyfs_compat.py +0 -0
  69. {termish-0.2.2 → termish-0.2.3}/tests/test_sed.py +0 -0
  70. {termish-0.2.2 → termish-0.2.3}/tests/test_text_commands.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termish
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Virtual terminal with shell-like commands over a pluggable filesystem.
5
5
  Author: ashenfad
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "termish"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  description = "Virtual terminal with shell-like commands over a pluggable filesystem."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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 if the parent directory of *path* does not exist."""
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 != path and parent not in self._dirs:
47
- raise FileNotFoundError(_errno.ENOENT, "No such directory", parent)
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 boundaries) become ``\b``.
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 in "<>":
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, PipeStream
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
- sub_ctx = CommandContext(
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(cmd_stdout.getvalue())
140
-
141
- if replace:
142
- # -I mode: run command once per item, substituting placeholder
143
- for item in items:
144
- cmd_args = [arg.replace(replace, item) for arg in cmd_base_args]
145
- execute_cmd(cmd_args)
146
- elif max_args:
147
- # -n mode: batch items
148
- for i in range(0, len(items), max_args):
149
- batch = items[i : i + max_args]
150
- execute_cmd(cmd_base_args + batch)
151
- else:
152
- # Default: all items as arguments to single command
153
- execute_cmd(cmd_base_args + items)
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