hostctl 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.
Files changed (51) hide show
  1. hostctl/AGENTS.md +292 -0
  2. hostctl/README.md +248 -0
  3. hostctl/__init__.py +194 -0
  4. hostctl/__main__.py +6 -0
  5. hostctl/_async.py +169 -0
  6. hostctl/_cli.py +232 -0
  7. hostctl/executor/__init__.py +82 -0
  8. hostctl/executor/_common.py +234 -0
  9. hostctl/executor/_qga.py +646 -0
  10. hostctl/executor/container.py +171 -0
  11. hostctl/executor/local.py +91 -0
  12. hostctl/executor/psrp.py +145 -0
  13. hostctl/executor/qemu.py +305 -0
  14. hostctl/executor/serial.py +312 -0
  15. hostctl/executor/ssh.py +226 -0
  16. hostctl/executor/winrm.py +255 -0
  17. hostctl/host/__init__.py +93 -0
  18. hostctl/host/_common.py +804 -0
  19. hostctl/host/_local.py +258 -0
  20. hostctl/host/_ssh.py +587 -0
  21. hostctl/host/_winrm.py +1002 -0
  22. hostctl/host/composite_path.py +567 -0
  23. hostctl/host/container.py +606 -0
  24. hostctl/host/container_path.py +664 -0
  25. hostctl/host/qemu.py +1078 -0
  26. hostctl/host/serial.py +401 -0
  27. hostctl/host/system.py +809 -0
  28. hostctl/process/__init__.py +49 -0
  29. hostctl/process/_common.py +113 -0
  30. hostctl/process/container.py +265 -0
  31. hostctl/process/psrp.py +208 -0
  32. hostctl/process/qemu_serial.py +247 -0
  33. hostctl/process/serial.py +257 -0
  34. hostctl/process/ssh.py +165 -0
  35. hostctl/provider/__init__.py +33 -0
  36. hostctl/provider/_common.py +452 -0
  37. hostctl/provider/transports.py +303 -0
  38. hostctl/py.typed +0 -0
  39. hostctl/serial/__init__.py +285 -0
  40. hostctl/shell/__init__.py +123 -0
  41. hostctl/shell/_common.py +616 -0
  42. hostctl/shell/cmd.py +167 -0
  43. hostctl/shell/fish.py +63 -0
  44. hostctl/shell/posix.py +83 -0
  45. hostctl/shell/powershell.py +120 -0
  46. hostctl/sync.py +245 -0
  47. hostctl-0.1.0.dist-info/METADATA +302 -0
  48. hostctl-0.1.0.dist-info/RECORD +51 -0
  49. hostctl-0.1.0.dist-info/WHEEL +4 -0
  50. hostctl-0.1.0.dist-info/entry_points.txt +2 -0
  51. hostctl-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,616 @@
1
+ """Target-shell command construction independent of the host transport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import abc
6
+ import collections.abc
7
+ import copy
8
+ import dataclasses
9
+ import enum
10
+ import inspect
11
+ import os
12
+ import re
13
+ import typing
14
+ import types
15
+ from pathlib import Path, PurePath
16
+
17
+ from ..executor import (
18
+ CommandArgument,
19
+ Executor,
20
+ ExecutorCapability,
21
+ ExecutorCommand,
22
+ )
23
+ from ..host._common import (
24
+ CaptureOutput,
25
+ Command,
26
+ Environment,
27
+ FileHandle,
28
+ Input,
29
+ PathLike,
30
+ )
31
+ from ..process import Process, TerminalRequest
32
+
33
+ _Result = typing.TypeVar("_Result", covariant=True)
34
+ _ENVIRONMENT_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
35
+
36
+ if typing.TYPE_CHECKING:
37
+ from ..host._common import Host
38
+
39
+
40
+ class ShellOperator(enum.Enum):
41
+ PIPE = "pipe"
42
+ AND = "and"
43
+ OR = "or"
44
+ REDIRECT = "redirect"
45
+ APPEND = "append"
46
+ SEQUENCE = "sequence"
47
+
48
+
49
+ ShellToken = typing.Union[Command, ShellOperator]
50
+
51
+ #: What a caller may pass for `env` on a `Shell` call. A mapping merges over
52
+ #: the shell's default per key; the default `{}` merges nothing and therefore
53
+ #: inherits it; `None` declines the shell's defaults, leaving whatever
54
+ #: environment the host itself provides (login profile, rc files, service
55
+ #: environment) untouched.
56
+ EnvironmentSelection = typing.Optional[Environment]
57
+
58
+ #: The `env` default: an empty mapping, meaning "merge nothing, inherit the
59
+ #: shell's environment". A module-level immutable value rather than a literal
60
+ #: `{}` in each signature, so the default can never be mutated by a caller.
61
+ _INHERIT_ENV: Environment = types.MappingProxyType({})
62
+
63
+
64
+ @dataclasses.dataclass(frozen=True)
65
+ class ShellCommand:
66
+ """A transport-ready command and any environment sent out of band."""
67
+
68
+ command: str
69
+ environment: typing.Optional[Environment]
70
+
71
+
72
+ class ShellSession(Process):
73
+ """A persistent process with shell-aware command submission."""
74
+
75
+ def __init__(self, flavour: ShellFlavour, process: Process) -> None:
76
+ self.flavour = flavour
77
+ self.process = process
78
+
79
+ @property
80
+ def returncode(self) -> typing.Optional[int]:
81
+ return self.process.returncode
82
+
83
+ def send(
84
+ self,
85
+ *cmds: ShellToken,
86
+ cwd: typing.Optional[PathLike] = None,
87
+ env: typing.Optional[Environment] = None,
88
+ ) -> None:
89
+ """Render and submit commands using this session's shell language."""
90
+ if not cmds and cwd is None and not env:
91
+ raise ValueError("send requires commands, cwd, or env")
92
+ script = self.flavour.script(cmds, cwd=cwd, env=env, for_session=True)
93
+ self.process.write(
94
+ script + self.flavour.command_separator + self.flavour.line_terminator
95
+ )
96
+
97
+ def write(self, data):
98
+ self.process.write(data)
99
+
100
+ def read(self, size: int = -1):
101
+ return self.process.read(size)
102
+
103
+ def read_stderr(self, size: int = -1):
104
+ return self.process.read_stderr(size)
105
+
106
+ def send_eof(self) -> None:
107
+ self.process.send_eof()
108
+
109
+ def resize(
110
+ self,
111
+ columns: int,
112
+ rows: int,
113
+ pixel_width: int = 0,
114
+ pixel_height: int = 0,
115
+ ) -> None:
116
+ self.process.resize(columns, rows, pixel_width, pixel_height)
117
+
118
+ def wait(self, timeout: typing.Optional[float] = None) -> int:
119
+ return self.process.wait(timeout)
120
+
121
+ def terminate(self) -> None:
122
+ self.process.terminate()
123
+
124
+ def kill(self) -> None:
125
+ self.process.kill()
126
+
127
+ def close(self) -> None:
128
+ self.process.close()
129
+
130
+ def __enter__(self) -> ShellSession:
131
+ self.process.__enter__()
132
+ return self
133
+
134
+ def __exit__(
135
+ self,
136
+ exc_type: typing.Optional[typing.Type[BaseException]],
137
+ exc_value: typing.Optional[BaseException],
138
+ traceback: typing.Optional[types.TracebackType],
139
+ ) -> bool:
140
+ return self.process.__exit__(exc_type, exc_value, traceback)
141
+
142
+
143
+ class ShellFlavour(abc.ABC):
144
+ """Construct scripts and commands for one explicitly selected target shell."""
145
+
146
+ name: str
147
+ default_executable: str
148
+ info_script: str
149
+ command_separator: str
150
+ line_terminator: str = "\n"
151
+ context_order = ("env", "cwd", "command")
152
+ structured_command_prefix = ""
153
+ path_flavor: type[PurePath] = PurePath
154
+
155
+ def command_path(self, value: PathLike) -> PurePath:
156
+ """Return a direct-command marker using the target shell's path syntax."""
157
+ return self.path_flavor(value)
158
+
159
+ @staticmethod
160
+ def _text(value: object) -> str:
161
+ """Normalize values and reject shell control characters."""
162
+ if isinstance(value, os.PathLike):
163
+ value = os.fspath(value)
164
+ elif isinstance(value, bytes):
165
+ value = value.decode("utf-8", "surrogateescape")
166
+ text = str(value)
167
+ if any(ord(char) < 32 or ord(char) == 127 for char in text):
168
+ raise ValueError("shell values cannot contain control characters")
169
+ return text
170
+
171
+ @abc.abstractmethod
172
+ def quote(self, value: object) -> str:
173
+ """Quote one structured argument for this shell."""
174
+
175
+ @abc.abstractmethod
176
+ def operator(self, value: ShellOperator) -> str:
177
+ """Render one supported command operator."""
178
+
179
+ def structured_command(self, values: typing.Iterable[object]) -> str:
180
+ return self.structured_command_prefix + " ".join(
181
+ self.quote(value) for value in values
182
+ )
183
+
184
+ @abc.abstractmethod
185
+ def environment_assignment(self, key: str, value: object) -> str:
186
+ """Render one validated environment assignment."""
187
+
188
+ def environment_script(self, env: Environment) -> str:
189
+ """Convert an environment mapping into a standalone shell script."""
190
+ assignments = []
191
+ for key, value in env.items():
192
+ if isinstance(key, bytes):
193
+ key = key.decode("utf-8", "surrogateescape")
194
+ if not isinstance(key, str) or not _ENVIRONMENT_KEY.fullmatch(key):
195
+ raise ValueError(f"invalid environment variable name: {key!r}")
196
+ assignments.append(self.environment_assignment(key, value))
197
+ return self.command_separator.join(assignments)
198
+
199
+ def command_text(self, value: Command) -> str:
200
+ if isinstance(value, (bytes, PurePath, Path, os.PathLike)):
201
+ return self.quote(value)
202
+ if isinstance(value, str):
203
+ return self._text(value)
204
+ if isinstance(value, collections.abc.Iterable):
205
+ values = tuple(value)
206
+ if not values:
207
+ raise ValueError("structured command must not be empty")
208
+ return self.structured_command(values)
209
+ return self._text(value)
210
+
211
+ def join(self, values: typing.Iterable[ShellToken]) -> str:
212
+ """Join commands, preserving raw strings and explicit operators."""
213
+ result = []
214
+ pending = self.command_separator
215
+ has_command = False
216
+ expecting_command = False
217
+ for value in values:
218
+ if isinstance(value, ShellOperator):
219
+ if not has_command or expecting_command:
220
+ raise ValueError("shell operator must appear between commands")
221
+ pending = self.operator(value)
222
+ expecting_command = True
223
+ continue
224
+ command = self.command_text(value)
225
+ if not command:
226
+ continue
227
+ if has_command:
228
+ result.append(pending)
229
+ result.append(command)
230
+ has_command = True
231
+ expecting_command = False
232
+ pending = self.command_separator
233
+ if expecting_command:
234
+ raise ValueError("shell operator must be followed by a command")
235
+ return "".join(result)
236
+
237
+ def script(
238
+ self,
239
+ cmds: typing.Iterable[ShellToken],
240
+ *,
241
+ cwd: typing.Optional[PathLike] = None,
242
+ env: typing.Optional[Environment] = None,
243
+ for_session: bool = False,
244
+ ) -> str:
245
+ """Build a script, applying environment and cwd consistently."""
246
+ command = self.join(cmds)
247
+ changed = self.change_directory(cwd) if cwd is not None else ""
248
+ rendered = {
249
+ "env": self.environment_script(env) if env else "",
250
+ "cwd": changed,
251
+ "command": command,
252
+ }
253
+ if (
254
+ "cwd" in self.context_order
255
+ and "command" in self.context_order
256
+ and cwd is not None
257
+ and command
258
+ ):
259
+ cwd_index = self.context_order.index("cwd")
260
+ command_index = self.context_order.index("command")
261
+ if command_index == cwd_index + 1:
262
+ rendered["cwd"] = self.join_cwd(changed, command)
263
+ rendered["command"] = ""
264
+ parts = [rendered[name] for name in self.context_order if rendered[name]]
265
+ script = self.command_separator.join(parts)
266
+ epilogue = getattr(self, "execution_epilogue", "")
267
+ if script and epilogue and not for_session:
268
+ script += epilogue
269
+ return script
270
+
271
+ @abc.abstractmethod
272
+ def change_directory(self, cwd: PathLike) -> str:
273
+ """Render a directory change which fails if the directory is absent."""
274
+
275
+ def join_cwd(self, changed: str, command: str) -> str:
276
+ """Join cwd setup and payload with the shell's AND operator."""
277
+ return f"{changed}{self.operator(ShellOperator.AND)}{command}"
278
+
279
+ @abc.abstractmethod
280
+ def command(
281
+ self,
282
+ cmds: typing.Iterable[ShellToken],
283
+ *,
284
+ executable: typing.Optional[str] = None,
285
+ cwd: typing.Optional[PathLike] = None,
286
+ env: typing.Optional[Environment] = None,
287
+ ) -> ShellCommand:
288
+ """Build the command submitted to an SSH exec channel."""
289
+
290
+ @abc.abstractmethod
291
+ def invocation(
292
+ self,
293
+ script: str,
294
+ *,
295
+ executable: typing.Optional[str] = None,
296
+ ) -> typing.Sequence[str]:
297
+ """Build local-process argv which invokes this shell for one script."""
298
+
299
+ def __str__(self) -> str:
300
+ return self.name
301
+
302
+
303
+ class Shell(Executor[_Result], typing.Generic[_Result]):
304
+ """Bind one shell language to a one-string callable or host executor."""
305
+
306
+ def __init__(
307
+ self,
308
+ flavour: ShellFlavour,
309
+ executor: typing.Union[Executor[_Result], Host],
310
+ *,
311
+ cwd: typing.Optional[PathLike] = None,
312
+ env: typing.Optional[Environment] = None,
313
+ encoding: typing.Optional[str] = None,
314
+ errors: typing.Optional[str] = None,
315
+ ) -> None:
316
+ self.flavour = flavour
317
+ #: Defaults applied to every `run`, `execute`, and `session` call that
318
+ #: does not pass its own value. `cwd`, `encoding`, and `errors`
319
+ #: override wholesale; `env` merges per key so a call can change one
320
+ #: variable without restating the rest (see `_resolve_env`).
321
+ self.cwd = cwd
322
+ self.env = dict(env) if env is not None else None
323
+ self.encoding = encoding
324
+ self.errors = errors
325
+ run = getattr(executor, "run", None)
326
+ spawn = getattr(executor, "spawn", None)
327
+ self._spawn = spawn if callable(spawn) else None
328
+ self._session: typing.Optional[ShellSession] = None
329
+ if callable(executor):
330
+ self._execute = executor
331
+ elif callable(run):
332
+ self._execute = run
333
+ else:
334
+ raise TypeError("executor must be callable or provide run(command)")
335
+ try:
336
+ self._executor_parameters = inspect.signature(self._execute).parameters
337
+ except (TypeError, ValueError):
338
+ self._executor_parameters = {}
339
+ self._executor_accepts_options = any(
340
+ item.kind is inspect.Parameter.VAR_KEYWORD
341
+ for item in self._executor_parameters.values()
342
+ )
343
+ published = getattr(executor, "executor_capabilities", None)
344
+ if published is None:
345
+ inferred = set()
346
+ if self._accepts_keyword("cwd"):
347
+ inferred.add(ExecutorCapability.CWD)
348
+ if self._accepts_keyword("env"):
349
+ inferred.add(ExecutorCapability.ENV)
350
+ if any(
351
+ item.kind is inspect.Parameter.VAR_POSITIONAL
352
+ for item in self._executor_parameters.values()
353
+ ):
354
+ inferred.add(ExecutorCapability.ARGS)
355
+ published = frozenset(inferred)
356
+ # One capability vocabulary, strings -- see `ExecutorCapability`.
357
+ # Its members subclass `str`, so a set published as enum members by a
358
+ # raw `Executor` and one published as plain strings by a `Host` (via
359
+ # `ExecutorProvider`) compare and hash identically. No conversion
360
+ # happens here, and none is needed at any other boundary.
361
+ self.executor_capabilities = frozenset(published)
362
+ self._executor_accepts_cwd = (
363
+ ExecutorCapability.CWD in self.executor_capabilities
364
+ )
365
+ self._executor_accepts_env = (
366
+ ExecutorCapability.ENV in self.executor_capabilities
367
+ )
368
+
369
+ def _accepts_keyword(self, name: str) -> bool:
370
+ parameter = self._executor_parameters.get(name)
371
+ return parameter is not None and parameter.kind in (
372
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
373
+ inspect.Parameter.KEYWORD_ONLY,
374
+ )
375
+
376
+ def _resolve_env(self, env: EnvironmentSelection) -> typing.Optional[Environment]:
377
+ """Merge a per-call environment over this shell's default.
378
+
379
+ The default `{}` merges nothing, so a call that says nothing about the
380
+ environment inherits the shell's. A mapping merges over that default
381
+ per key: the call wins for keys it names, and default-only keys
382
+ survive, so one variable can change without restating the rest.
383
+
384
+ `None` declines the shell's configured defaults. It does **not** mean
385
+ an empty environment: the command still runs with whatever the host
386
+ provides on its own -- a login profile, rc files, the service
387
+ environment -- because nothing is sent to override it. Requesting a
388
+ genuinely empty environment is a separate feature that does not exist
389
+ yet (see `.agents/plans/env_clear_semantics.md`).
390
+ """
391
+ if env is None:
392
+ return None
393
+ if self.env is None:
394
+ return dict(env) if env else None
395
+ merged = dict(self.env)
396
+ merged.update(env)
397
+ return merged
398
+
399
+ def _resolve_cwd(self, cwd: typing.Optional[PathLike]) -> typing.Optional[PathLike]:
400
+ return self.cwd if cwd is None else cwd
401
+
402
+ def execute(
403
+ self,
404
+ command: ExecutorCommand,
405
+ *args: CommandArgument,
406
+ stdin: typing.Optional[FileHandle] = None,
407
+ stdout: typing.Optional[FileHandle] = None,
408
+ stderr: typing.Optional[FileHandle] = None,
409
+ cwd: typing.Optional[PathLike] = None,
410
+ env: typing.Optional[Environment] = None,
411
+ capture_output: typing.Optional[CaptureOutput] = None,
412
+ check: typing.Optional[bool] = None,
413
+ encoding: typing.Optional[str] = None,
414
+ errors: typing.Optional[str] = None,
415
+ input: Input = None,
416
+ timeout: typing.Optional[float] = None,
417
+ text: typing.Optional[bool] = None,
418
+ ) -> _Result:
419
+ """Execute one command and forward supported execution context."""
420
+ executor_args = args
421
+ if args and ExecutorCapability.ARGS not in self.executor_capabilities:
422
+ command = self.flavour.structured_command((command, *args))
423
+ executor_args = ()
424
+ # Apply the shell's cwd/env defaults only where the executor can carry
425
+ # them natively. Where it cannot, the caller renders them into the
426
+ # script instead -- `run` does exactly that and then passes
427
+ # cwd=None/env=None here, so resolving again would forward a value this
428
+ # executor rejects. `execute` used directly against a capability-less
429
+ # executor therefore ignores the defaults by design: it dispatches one
430
+ # opaque command rather than building a script.
431
+ if self._executor_accepts_cwd:
432
+ cwd = self._resolve_cwd(cwd)
433
+ if self._executor_accepts_env:
434
+ env = self._resolve_env(env)
435
+ if encoding is None:
436
+ encoding = self.encoding
437
+ if errors is None:
438
+ errors = self.errors
439
+ options = {
440
+ name: value
441
+ for name, value in (
442
+ ("stdin", stdin),
443
+ ("stdout", stdout),
444
+ ("stderr", stderr),
445
+ ("capture_output", capture_output),
446
+ ("check", check),
447
+ ("encoding", encoding),
448
+ ("errors", errors),
449
+ ("input", input),
450
+ ("timeout", timeout),
451
+ ("text", text),
452
+ )
453
+ if value is not None
454
+ }
455
+ if cwd is not None:
456
+ if not self._executor_accepts_cwd:
457
+ raise TypeError("executor does not accept cwd")
458
+ options["cwd"] = cwd
459
+ if env is not None:
460
+ if not self._executor_accepts_env:
461
+ raise TypeError("executor does not accept env")
462
+ options["env"] = env
463
+ unsupported = [
464
+ name
465
+ for name in options
466
+ if not self._accepts_keyword(name) and not self._executor_accepts_options
467
+ ]
468
+ if unsupported:
469
+ raise TypeError(f"executor does not accept {sorted(unsupported)[0]}")
470
+ return self._execute(command, *executor_args, **options)
471
+
472
+ __call__ = execute
473
+
474
+ def run(
475
+ self,
476
+ *cmds: ShellToken,
477
+ stdin: typing.Optional[FileHandle] = None,
478
+ stdout: typing.Optional[FileHandle] = None,
479
+ stderr: typing.Optional[FileHandle] = None,
480
+ cwd: typing.Optional[PathLike] = None,
481
+ env: EnvironmentSelection = _INHERIT_ENV,
482
+ capture_output: typing.Optional[CaptureOutput] = None,
483
+ check: typing.Optional[bool] = None,
484
+ encoding: typing.Optional[str] = None,
485
+ errors: typing.Optional[str] = None,
486
+ input: Input = None,
487
+ timeout: typing.Optional[float] = None,
488
+ text: typing.Optional[bool] = None,
489
+ ) -> _Result:
490
+ """Build one script from all commands and pass it to the executor.
491
+
492
+ `env` merges over the shell's default per key; the default merges
493
+ nothing and so inherits it. Pass `None` to run without the shell's
494
+ configured environment, keeping whatever the host provides itself.
495
+ """
496
+ # Resolve defaults here rather than in `execute`: the script is
497
+ # rendered before dispatch, so an embedded `cd`/env assignment has to
498
+ # see the shell's defaults too.
499
+ cwd = self._resolve_cwd(cwd)
500
+ env = self._resolve_env(env)
501
+ script = self.flavour.script(
502
+ cmds,
503
+ cwd=None if self._executor_accepts_cwd else cwd,
504
+ env=None if self._executor_accepts_env else env,
505
+ )
506
+ return self.execute(
507
+ script,
508
+ cwd=cwd if self._executor_accepts_cwd else None,
509
+ env=env if self._executor_accepts_env else None,
510
+ stdin=stdin,
511
+ stdout=stdout,
512
+ stderr=stderr,
513
+ capture_output=capture_output,
514
+ check=check,
515
+ encoding=encoding,
516
+ errors=errors,
517
+ input=input,
518
+ timeout=timeout,
519
+ text=text,
520
+ )
521
+
522
+ def session(
523
+ self,
524
+ *cmds: ShellToken,
525
+ executable: typing.Optional[str] = None,
526
+ cwd: typing.Optional[PathLike] = None,
527
+ env: EnvironmentSelection = _INHERIT_ENV,
528
+ terminal: TerminalRequest = None,
529
+ encoding: typing.Optional[str] = None,
530
+ errors: typing.Optional[str] = None,
531
+ ) -> ShellSession:
532
+ """Open a persistent process using this shell language.
533
+
534
+ `env` merges over the shell's default per key; the default merges
535
+ nothing and so inherits it. Pass `None` to open the session without
536
+ the shell's configured environment, keeping whatever the host
537
+ provides itself.
538
+ """
539
+ if self._spawn is None:
540
+ raise NotImplementedError("executor does not provide persistent sessions")
541
+ cwd = self._resolve_cwd(cwd)
542
+ env = self._resolve_env(env)
543
+ session = ShellSession(
544
+ self.flavour,
545
+ self._spawn(
546
+ executable=executable,
547
+ terminal=terminal,
548
+ encoding=self.encoding if encoding is None else encoding,
549
+ errors=self.errors if errors is None else errors,
550
+ ),
551
+ )
552
+ # A shell default cwd/env applies to the session too: it is submitted
553
+ # once here so it persists for every later `send` in that shell.
554
+ if cmds or cwd is not None or env:
555
+ session.send(
556
+ *cmds,
557
+ cwd=cwd,
558
+ env=env,
559
+ )
560
+ return session
561
+
562
+ def configure(
563
+ self,
564
+ *,
565
+ cwd: typing.Optional[PathLike] = None,
566
+ env: EnvironmentSelection = _INHERIT_ENV,
567
+ encoding: typing.Optional[str] = None,
568
+ errors: typing.Optional[str] = None,
569
+ ) -> "Shell[_Result]":
570
+ """Return a copy of this shell with additional defaults applied.
571
+
572
+ `env` merges over this shell's default the same way a per-call `env`
573
+ does, so configuring twice layers rather than replaces, and `None`
574
+ produces a copy carrying no environment default at all. The original
575
+ shell is left unchanged, which keeps `host.shell` -- a fresh object
576
+ per access -- safe to configure without surprising another caller.
577
+ """
578
+ clone = copy.copy(self)
579
+ clone._session = None
580
+ clone.cwd = self.cwd if cwd is None else cwd
581
+ clone.env = self._resolve_env(env)
582
+ clone.encoding = self.encoding if encoding is None else encoding
583
+ clone.errors = self.errors if errors is None else errors
584
+ return clone
585
+
586
+ def __enter__(self) -> ShellSession:
587
+ """Open a default session, so ``with host.shell as session:`` works.
588
+
589
+ The session is closed on exit. `session(...)` remains the way to pass
590
+ a command, cwd, env, terminal, or encoding; this is the no-argument
591
+ shorthand. A `Shell` is not reusable as a context manager while a
592
+ session it opened is still active -- each `with` opens its own.
593
+ """
594
+ if self._session is not None:
595
+ raise RuntimeError("shell already has an active session")
596
+ session = self.session()
597
+ try:
598
+ # Enter the session so the underlying process sees a balanced
599
+ # __enter__/__exit__ pair; `session.__exit__` delegates to it.
600
+ session.__enter__()
601
+ except BaseException:
602
+ session.close()
603
+ raise
604
+ self._session = session
605
+ return session
606
+
607
+ def __exit__(
608
+ self,
609
+ exc_type: typing.Optional[typing.Type[BaseException]],
610
+ exc_value: typing.Optional[BaseException],
611
+ traceback: typing.Optional[types.TracebackType],
612
+ ) -> bool:
613
+ session, self._session = self._session, None
614
+ if session is None:
615
+ return False
616
+ return session.__exit__(exc_type, exc_value, traceback)