shell-next 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 (54) hide show
  1. shell_next/__init__.py +71 -0
  2. shell_next/backends/__init__.py +1 -0
  3. shell_next/backends/bash/__init__.py +1 -0
  4. shell_next/backends/bash/authentication.py +64 -0
  5. shell_next/backends/bash/containment.py +34 -0
  6. shell_next/backends/bash/password_channel.py +43 -0
  7. shell_next/backends/bash/syntax.py +30 -0
  8. shell_next/backends/cmd/__init__.py +1 -0
  9. shell_next/backends/cmd/syntax.py +32 -0
  10. shell_next/backends/mock/__init__.py +1 -0
  11. shell_next/backends/mock/driver.py +198 -0
  12. shell_next/backends/mock/scenario.py +131 -0
  13. shell_next/backends/mock/session.py +151 -0
  14. shell_next/backends/mock/state.py +25 -0
  15. shell_next/backends/native/__init__.py +1 -0
  16. shell_next/backends/native/bridge.py +65 -0
  17. shell_next/backends/native/channels.py +116 -0
  18. shell_next/backends/native/containment.py +17 -0
  19. shell_next/backends/native/driver.py +215 -0
  20. shell_next/backends/native/preparation.py +85 -0
  21. shell_next/backends/native/process.py +184 -0
  22. shell_next/backends/native/syntax.py +66 -0
  23. shell_next/backends/native/termination.py +42 -0
  24. shell_next/backends/powershell/__init__.py +1 -0
  25. shell_next/backends/powershell/driver.ps1 +22 -0
  26. shell_next/backends/powershell/syntax.py +86 -0
  27. shell_next/backends/protocol.py +69 -0
  28. shell_next/backends/windows/__init__.py +1 -0
  29. shell_next/backends/windows/containment.py +83 -0
  30. shell_next/backends/windows/limits.py +41 -0
  31. shell_next/errors.py +142 -0
  32. shell_next/frontend/__init__.py +1 -0
  33. shell_next/frontend/capture.py +115 -0
  34. shell_next/frontend/execution.py +199 -0
  35. shell_next/frontend/finalization.py +51 -0
  36. shell_next/frontend/handle.py +228 -0
  37. shell_next/frontend/lease.py +40 -0
  38. shell_next/frontend/observation.py +35 -0
  39. shell_next/frontend/operations.py +78 -0
  40. shell_next/frontend/output.py +123 -0
  41. shell_next/frontend/session.py +313 -0
  42. shell_next/models/__init__.py +1 -0
  43. shell_next/models/capabilities.py +74 -0
  44. shell_next/models/commands.py +48 -0
  45. shell_next/models/config.py +174 -0
  46. shell_next/models/input.py +91 -0
  47. shell_next/models/privilege.py +80 -0
  48. shell_next/models/results.py +167 -0
  49. shell_next/models/state.py +49 -0
  50. shell_next/py.typed +0 -0
  51. shell_next-0.1.0.dist-info/METADATA +124 -0
  52. shell_next-0.1.0.dist-info/RECORD +54 -0
  53. shell_next-0.1.0.dist-info/WHEEL +4 -0
  54. shell_next-0.1.0.dist-info/licenses/LICENSE +21 -0
shell_next/__init__.py ADDED
@@ -0,0 +1,71 @@
1
+ """Persistent asynchronous shell sessions and deterministic downstream testing."""
2
+
3
+ from shell_next.backends.mock.scenario import (
4
+ Advance,
5
+ Emit,
6
+ Failure,
7
+ MockExpectation,
8
+ MockScenario,
9
+ Receive,
10
+ )
11
+ from shell_next.backends.mock.session import MockShellSession
12
+ from shell_next.frontend.handle import CommandHandle
13
+ from shell_next.frontend.session import ShellSession, use_shell_session
14
+ from shell_next.models.capabilities import SessionCapabilities
15
+ from shell_next.models.commands import Command, ProcessCommand, SessionScript
16
+ from shell_next.models.config import CaptureConfig, CommandOptions, SessionConfig, TimeoutPolicy
17
+ from shell_next.models.input import CloseStdin, Expect, InputPlan, InputSummary, Send, SendLine
18
+ from shell_next.models.privilege import PasswordProvider, PrivilegeReport, PrivilegeRequest
19
+ from shell_next.models.results import (
20
+ BackendStatus,
21
+ CleanupReport,
22
+ CommandResult,
23
+ CommandSnapshot,
24
+ OutputEvent,
25
+ OutputResult,
26
+ SessionSnapshot,
27
+ )
28
+ from shell_next.models.state import Backend, ConcurrencyPolicy, Outcome, SessionState, StdinMode
29
+
30
+ __all__ = [
31
+ "Advance",
32
+ "Backend",
33
+ "BackendStatus",
34
+ "CaptureConfig",
35
+ "CleanupReport",
36
+ "CloseStdin",
37
+ "Command",
38
+ "CommandHandle",
39
+ "CommandOptions",
40
+ "CommandResult",
41
+ "CommandSnapshot",
42
+ "ConcurrencyPolicy",
43
+ "Emit",
44
+ "Expect",
45
+ "Failure",
46
+ "InputPlan",
47
+ "InputSummary",
48
+ "MockExpectation",
49
+ "MockScenario",
50
+ "MockShellSession",
51
+ "Outcome",
52
+ "OutputEvent",
53
+ "OutputResult",
54
+ "PasswordProvider",
55
+ "PrivilegeReport",
56
+ "PrivilegeRequest",
57
+ "ProcessCommand",
58
+ "Receive",
59
+ "Send",
60
+ "SendLine",
61
+ "SessionCapabilities",
62
+ "SessionConfig",
63
+ "SessionScript",
64
+ "SessionSnapshot",
65
+ "SessionState",
66
+ "ShellSession",
67
+ "StdinMode",
68
+ "TimeoutPolicy",
69
+ "use_shell_session",
70
+ ]
71
+ """Curated public API names; transport modules remain implementation details."""
@@ -0,0 +1 @@
1
+ """Backends domain for shell-next."""
@@ -0,0 +1 @@
1
+ """Bash-native script and privilege semantics."""
@@ -0,0 +1,64 @@
1
+ """Dedicated sudo authentication in the business bridge's parent identity scope."""
2
+
3
+ import subprocess
4
+ from typing import BinaryIO
5
+
6
+
7
+ def authenticate(
8
+ target: str | None,
9
+ attempts: int,
10
+ prompt: bytes,
11
+ requests: BinaryIO | None,
12
+ responses: BinaryIO | None,
13
+ ) -> tuple[bool, int]:
14
+ """Authenticate sudo without ever sharing the business stdin transport.
15
+
16
+ Authentication and business sudo invocations must have the same parent
17
+ bridge so sudo's non-terminal parent-process timestamp scope remains valid.
18
+
19
+ :param target: Optional sudo target user.
20
+ :param attempts: Maximum password submissions.
21
+ :param prompt: Unique private ASCII password prompt.
22
+ :param requests: Separate password-request pipe, or None for noninteractive sudo.
23
+ :param responses: Separate secret response pipe, or None for noninteractive sudo.
24
+ :returns: Authentication success and password-submission count.
25
+ """
26
+ argv = ["sudo", *(["-u", target] if target is not None else [])]
27
+ if requests is None or responses is None:
28
+ code = subprocess.run(
29
+ [*argv, "-n", "-v"],
30
+ stdin=subprocess.DEVNULL,
31
+ stdout=subprocess.DEVNULL,
32
+ stderr=subprocess.DEVNULL,
33
+ ).returncode
34
+ return code == 0, 0
35
+ used = 0
36
+ with subprocess.Popen(
37
+ [*argv, "-S", "-p", prompt.decode("ascii"), "-v"],
38
+ stdin=subprocess.PIPE,
39
+ stdout=subprocess.DEVNULL,
40
+ stderr=subprocess.PIPE,
41
+ ) as process:
42
+ assert process.stdin is not None and process.stderr is not None
43
+ window = bytearray()
44
+ while data := process.stderr.read(1):
45
+ window.extend(data)
46
+ del window[: -len(prompt)]
47
+ if bytes(window) != prompt:
48
+ continue
49
+ if used >= attempts:
50
+ process.stdin.close()
51
+ break
52
+ requests.write(b"password\n")
53
+ requests.flush()
54
+ password = responses.readline(65536)
55
+ if not password.endswith(b"\n"):
56
+ process.stdin.close()
57
+ break
58
+ process.stdin.write(password)
59
+ process.stdin.flush()
60
+ del password
61
+ used += 1
62
+ window.clear()
63
+ process.stdin.close()
64
+ return process.wait() == 0, used
@@ -0,0 +1,34 @@
1
+ """POSIX process-group termination for persistent Bash sessions."""
2
+
3
+ import os
4
+ import signal
5
+ import sys
6
+
7
+
8
+ class PosixGroup:
9
+ """Own a process group whose leader was created with start_new_session=True.
10
+
11
+ :param pid: Interpreter PID, also its process-group identifier.
12
+ """
13
+
14
+ def __init__(self, pid: int) -> None:
15
+ """Retain the group identifier without acquiring another OS handle.
16
+
17
+ :param pid: Native process-group leader identifier.
18
+ """
19
+ self.pid = pid
20
+
21
+ def terminate(self, force: bool = True) -> None:
22
+ """Signal every member of the interpreter's process group.
23
+
24
+ :param force: SIGKILL if true, otherwise the cooperative SIGTERM request.
25
+ :raises OSError: Permissions or another OS error prevent termination.
26
+ """
27
+ if sys.platform != "win32":
28
+ try:
29
+ os.killpg(self.pid, signal.SIGKILL if force else signal.SIGTERM)
30
+ except ProcessLookupError:
31
+ pass
32
+
33
+ def close(self) -> None:
34
+ """Release bookkeeping; process-group IDs have no closeable OS handle."""
@@ -0,0 +1,43 @@
1
+ """Frontend authentication transport, isolated from business input and capture."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from shell_next.errors import PrivilegeAuthenticationError
6
+
7
+ if TYPE_CHECKING:
8
+ from shell_next.backends.native.channels import CommandChannel
9
+ from shell_next.frontend.handle import CommandHandle
10
+
11
+
12
+ async def supply_passwords(
13
+ handle: CommandHandle, requests: CommandChannel, responses: CommandChannel
14
+ ) -> None:
15
+ """Call a secret supplier only for explicit private sudo password requests.
16
+
17
+ :param handle: Command holding the configured asynchronous password provider.
18
+ :param requests: Private authentication request channel.
19
+ :param responses: Private secret response channel.
20
+ :raises PrivilegeAuthenticationError: The request or supplied secret is invalid.
21
+ """
22
+ request = handle.options.privilege
23
+ provider = request.password_provider
24
+ assert provider is not None
25
+ count = 0
26
+ buffer = bytearray()
27
+ while data := await requests.read(64):
28
+ buffer.extend(data)
29
+ if len(buffer) > 128:
30
+ raise PrivilegeAuthenticationError("Invalid authentication request")
31
+ while b"\n" in buffer:
32
+ line, _, remaining = buffer.partition(b"\n")
33
+ buffer[:] = remaining
34
+ if line != b"password" or count >= request.attempts:
35
+ raise PrivilegeAuthenticationError("Authentication attempt limit reached")
36
+ password = await provider()
37
+ if not isinstance(password, bytes) or any(
38
+ value in password for value in (b"\n", b"\r", b"\0")
39
+ ):
40
+ raise PrivilegeAuthenticationError("Password provider returned an invalid secret")
41
+ await responses.send(password + b"\n")
42
+ del password
43
+ count += 1
@@ -0,0 +1,30 @@
1
+ """Persistent Bash command wrappers with separate private control output."""
2
+
3
+ import shlex
4
+
5
+
6
+ def quote(value: str) -> str:
7
+ """Represent one literal Bash word using standard POSIX quoting.
8
+
9
+ :param value: Literal value.
10
+ :returns: Quoted native shell word.
11
+ """
12
+ return shlex.quote(value)
13
+
14
+
15
+ def wrapper(text: str, token: str, script: bool, channels: dict[str, str]) -> str:
16
+ """Execute in current Bash scope and report status on reserved descriptor 9.
17
+
18
+ :param text: Trusted private script or bridge invocation.
19
+ :param token: Random command control marker.
20
+ :param script: Whether business stream redirection is required.
21
+ :param channels: stdin, stdout, and stderr FIFO paths.
22
+ :returns: Native wrapper text.
23
+ """
24
+ if script:
25
+ redirect = " ".join(
26
+ f"{fd}{direction}{quote(channels[name])}"
27
+ for fd, direction, name in ((0, "<", "stdin"), (1, ">", "stdout"), (2, ">", "stderr"))
28
+ )
29
+ text = f"{{ printf '{token}:ready\\n' >&9; {text}; }} {redirect}"
30
+ return f"{text}\nsn_status=$?\nprintf '{token}:%s\\n' \"$sn_status\" >&9\n"
@@ -0,0 +1 @@
1
+ """cmd-compatible batch semantics and ERRORLEVEL reporting."""
@@ -0,0 +1,32 @@
1
+ """Persistent cmd batch wrappers and conservative control-path validation."""
2
+
3
+ from shell_next.errors import ConfigurationError
4
+
5
+
6
+ def quote(value: str) -> str:
7
+ """Quote a trusted batch-control path without expansion or command injection.
8
+
9
+ :param value: Literal path.
10
+ :returns: Double-quoted batch word.
11
+ :raises ConfigurationError: Batch expansion makes this path unsafe.
12
+ """
13
+ if any(character in value for character in '\r\n"%!'):
14
+ raise ConfigurationError("cmd control paths cannot contain quotes, %, !, CR or LF")
15
+ return '"' + value + '"'
16
+
17
+
18
+ def wrapper(text: str, token: str, script: bool, channels: dict[str, str]) -> str:
19
+ """Call a batch file in persistent state and preserve its ERRORLEVEL.
20
+
21
+ :param text: Private batch or bridge invocation.
22
+ :param token: Random command status marker.
23
+ :param script: Whether native stream redirection is required.
24
+ :param channels: Command-specific named pipe endpoints.
25
+ :returns: Native batch wrapper.
26
+ """
27
+ if script:
28
+ text += " " + " ".join(
29
+ f"{fd}{direction}{quote(channels[name])}"
30
+ for fd, direction, name in ((0, "<", "stdin"), (1, ">", "stdout"), (2, ">", "stderr"))
31
+ )
32
+ return f"@echo off\n{text}\necho {token}:%errorlevel%\n"
@@ -0,0 +1 @@
1
+ """Mock domain for shell-next."""
@@ -0,0 +1,198 @@
1
+ """In-memory backend adapter running the production command lifecycle."""
2
+
3
+ import asyncio
4
+ from collections import deque
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shell_next.backends.mock.scenario import (
8
+ Advance,
9
+ Emit,
10
+ MockExpectation,
11
+ MockScenario,
12
+ Receive,
13
+ )
14
+ from shell_next.errors import (
15
+ InputError,
16
+ MockUnexpectedInputError,
17
+ PrivilegeAuthenticationError,
18
+ SessionProtocolError,
19
+ )
20
+ from shell_next.models.commands import Command
21
+ from shell_next.models.privilege import PrivilegeReport
22
+ from shell_next.models.results import BackendStatus, CleanupReport
23
+
24
+ if TYPE_CHECKING:
25
+ from shell_next.frontend.handle import CommandHandle
26
+ from shell_next.frontend.session import ShellSession
27
+
28
+
29
+ class MockDriver:
30
+ """Deterministic transport that cannot start subprocesses or write files.
31
+
32
+ :param scenario: Ordered commands, input, and output expectations.
33
+ :param session: Owning frontend for simulated persistent state.
34
+ """
35
+
36
+ def __init__(self, scenario: MockScenario, session: ShellSession) -> None:
37
+ """Initialize pure in-memory execution state.
38
+
39
+ :param scenario: Strict expected behavior.
40
+ :param session: Owning frontend.
41
+ """
42
+ self.scenario = scenario
43
+ self.session = session
44
+ self.active: MockExpectation | None = None
45
+ self.inputs: deque[bytes | None] = deque()
46
+ self.changed = asyncio.Event()
47
+ self.handle: CommandHandle | None = None
48
+
49
+ async def start(self) -> None:
50
+ """Enter the mock without consulting or changing ambient process state."""
51
+
52
+ def reserve(self, command: Command) -> MockExpectation:
53
+ """Match and reserve the next command in deterministic FIFO order.
54
+
55
+ :param command: Submitted process or native script description.
56
+ :returns: The exact reserved expectation, even when other queued commands are stopped.
57
+ :raises MockUnexpectedCommandError: No strict expectation matches.
58
+ """
59
+ return self.scenario.reserve(command)
60
+
61
+ async def prepare(self, handle: CommandHandle) -> None:
62
+ """Adopt the reserved expectation without filesystem or subprocess work.
63
+
64
+ :param handle: Command receiving simulated transport behavior.
65
+ """
66
+ assert isinstance(handle.reservation, MockExpectation)
67
+ self.active = handle.reservation
68
+ self.handle = handle
69
+ self.inputs.clear()
70
+
71
+ async def execute(self, handle: CommandHandle) -> BackendStatus:
72
+ """Emit deterministic events, wait for input, and advance only virtual time.
73
+
74
+ :param handle: Command receiving output and readiness.
75
+ :returns: Configured backend-native status.
76
+ :raises MockUnexpectedInputError: Submitted input differs from the scenario.
77
+ :raises TimeoutError: Virtual execution reaches its configured deadline.
78
+ :raises InputError: An input failure was configured.
79
+ :raises OSError: A startup failure was configured.
80
+ :raises SessionProtocolError: A session loss was configured.
81
+ """
82
+ assert self.active is not None
83
+ if handle.privilege.requested:
84
+ request = handle.options.privilege
85
+ for _ in range(min(self.active.authentication_prompts, request.attempts)):
86
+ if request.password_provider is not None:
87
+ try:
88
+ await request.password_provider()
89
+ except Exception:
90
+ handle.privilege = PrivilegeReport(True, False)
91
+ raise PrivilegeAuthenticationError("Password provider failed") from None
92
+ self.session.record("authenticate", "<redacted>")
93
+ handle.privilege = PrivilegeReport(
94
+ True,
95
+ self.active.authenticated,
96
+ min(self.active.authentication_prompts, request.attempts),
97
+ )
98
+ if not self.active.authenticated:
99
+ handle.ready.set()
100
+ return BackendStatus(126)
101
+ handle.ready.set()
102
+ elapsed = 0.0
103
+ for step in self.active.steps:
104
+ if isinstance(step, Emit):
105
+ await handle.emit(step.stream, step.data)
106
+ elif isinstance(step, Receive):
107
+ await self.receive(step.data)
108
+ elif isinstance(step, Advance):
109
+ deadline = handle.options.timeouts.execution
110
+ if deadline is not None and elapsed + step.seconds >= deadline:
111
+ self.scenario.elapsed += max(0, deadline - elapsed)
112
+ raise TimeoutError("Virtual command deadline expired")
113
+ elapsed += step.seconds
114
+ self.scenario.elapsed += step.seconds
115
+ else:
116
+ if step.kind == "input":
117
+ raise InputError("Simulated input failure")
118
+ if step.kind == "startup":
119
+ raise OSError("Simulated startup failure")
120
+ if step.kind == "session":
121
+ raise SessionProtocolError("Simulated session loss")
122
+ handle.captures["stdout"].error = "Simulated capture failure"
123
+ if self.active.cwd is not None:
124
+ self.session.cwd = self.active.cwd
125
+ for name, value in self.active.env:
126
+ if value is None:
127
+ self.session.environment.pop(name, None)
128
+ else:
129
+ self.session.environment[name] = value
130
+ if any(self.inputs):
131
+ raise MockUnexpectedInputError("Unexpected input remained after the scenario ended")
132
+ return self.active.status
133
+
134
+ async def receive(self, expected: bytes | None) -> None:
135
+ """Match expected byte-stream input independently of transport chunk boundaries.
136
+
137
+ :param expected: Required bytes, or an explicit EOF marker.
138
+ :raises MockUnexpectedInputError: Bytes or EOF do not match the scenario.
139
+ """
140
+ offset = 0
141
+ while True:
142
+ while not self.inputs:
143
+ assert self.handle is not None
144
+ self.handle.virtual_blocked = True
145
+ self.changed.clear()
146
+ await self.changed.wait()
147
+ actual = self.inputs.popleft()
148
+ if expected is None:
149
+ if actual is not None:
150
+ raise MockUnexpectedInputError("Expected stdin closure")
151
+ return
152
+ if actual is None:
153
+ raise MockUnexpectedInputError("Input ended before the expected bytes")
154
+ count = min(len(actual), len(expected) - offset)
155
+ if actual[:count] != expected[offset : offset + count]:
156
+ raise MockUnexpectedInputError("Input did not match the scenario")
157
+ offset += count
158
+ if len(actual) > count:
159
+ self.inputs.appendleft(actual[count:])
160
+ if offset == len(expected):
161
+ return
162
+
163
+ async def send(self, data: bytes) -> None:
164
+ """Queue one in-memory input submission without retaining a history copy.
165
+
166
+ :param data: Raw expected business input.
167
+ """
168
+ self.inputs.append(data)
169
+ assert self.handle is not None
170
+ self.handle.virtual_blocked = False
171
+ self.changed.set()
172
+
173
+ async def close_stdin(self) -> None:
174
+ """Queue an explicit in-memory EOF operation."""
175
+ self.inputs.append(None)
176
+ assert self.handle is not None
177
+ self.handle.virtual_blocked = False
178
+ self.changed.set()
179
+
180
+ async def finish(self) -> None:
181
+ """Discard transient input payloads after finalization."""
182
+ self.inputs.clear()
183
+ self.active = None
184
+
185
+ async def stop(self) -> CleanupReport:
186
+ """Simulate forced session invalidation without signals or real processes.
187
+
188
+ :returns: Deterministic successful containment report.
189
+ """
190
+ return CleanupReport(forced=True)
191
+
192
+ async def close(self) -> CleanupReport:
193
+ """Release in-memory input state without any operating-system side effect.
194
+
195
+ :returns: Deterministic shutdown report.
196
+ """
197
+ self.inputs.clear()
198
+ return CleanupReport()
@@ -0,0 +1,131 @@
1
+ """Strict, deterministic command expectations without operating-system side effects."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Literal
5
+
6
+ from shell_next.errors import MockExpectationNotConsumedError, MockUnexpectedCommandError
7
+ from shell_next.models.commands import Command
8
+ from shell_next.models.config import validate_seconds
9
+ from shell_next.models.input import StreamName
10
+ from shell_next.models.results import BackendStatus
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Emit:
15
+ """Emit one raw mock output chunk.
16
+
17
+ :param data: Raw bytes delivered to capture and prompt matching.
18
+ :param stream: Destination transport, defaulting to stdout.
19
+ """
20
+
21
+ data: bytes
22
+ stream: StreamName = "stdout"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Receive:
27
+ """Require one input submission or explicit EOF.
28
+
29
+ :param data: Expected bytes, or None for stdin closure; always hidden in repr.
30
+ """
31
+
32
+ data: bytes | None = field(repr=False)
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Advance:
37
+ """Advance virtual command time without sleeping.
38
+
39
+ :param seconds: Nonnegative virtual duration in seconds.
40
+ """
41
+
42
+ seconds: float
43
+
44
+ def __post_init__(self) -> None:
45
+ """Reject virtual time moving backwards or becoming nonfinite.
46
+
47
+ :raises ConfigurationError: The virtual duration is invalid.
48
+ """
49
+ validate_seconds(self.seconds, "virtual seconds")
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Failure:
54
+ """Inject a transport or protocol failure deterministically.
55
+
56
+ :param kind: Input, output, startup, or session failure category.
57
+ """
58
+
59
+ kind: Literal["input", "output", "startup", "session"]
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class MockExpectation:
64
+ """One expected command with ordered interaction and simulated native status.
65
+
66
+ :param command: Exact expected structural process or script.
67
+ :param steps: Ordered output, input, time, and failure steps.
68
+ :param status: Native result after all interaction steps.
69
+ :param cwd: Optional simulated persistent directory change.
70
+ :param env: Simulated exported environment updates; None removes a variable.
71
+ :param authentication_prompts: Simulated private password requests; zero models a cache hit.
72
+ :param authenticated: Whether simulated privilege authentication succeeds.
73
+ """
74
+
75
+ command: Command
76
+ steps: tuple[Emit | Receive | Advance | Failure, ...] = ()
77
+ status: BackendStatus = field(default_factory=lambda: BackendStatus(0))
78
+ cwd: str | None = None
79
+ env: tuple[tuple[str, str | None], ...] = ()
80
+ authentication_prompts: int = 0
81
+ authenticated: bool = True
82
+
83
+
84
+ @dataclass
85
+ class MockScenario:
86
+ """FIFO expectations, strict by default, with deterministic observable history.
87
+
88
+ :param expectations: Ordered expected commands.
89
+ :param strict: Reject unexpected commands and unconsumed required expectations.
90
+ :param calls: Observable operations; secrets must already be redacted.
91
+ :param elapsed: Virtual monotonic seconds, starting at zero.
92
+ :param cursor: Number of expectations reserved by submissions.
93
+ """
94
+
95
+ expectations: list[MockExpectation] = field(default_factory=list)
96
+ strict: bool = True
97
+ calls: list[tuple[object, ...]] = field(default_factory=list)
98
+ elapsed: float = 0.0
99
+ cursor: int = 0
100
+
101
+ def reserve(self, command: Command) -> MockExpectation:
102
+ """Consume the next command expectation without starting any execution.
103
+
104
+ :param command: Submitted command description.
105
+ :returns: The matching expectation or permissive empty result.
106
+ :raises MockUnexpectedCommandError: Strict FIFO expectations do not match.
107
+ """
108
+ if self.cursor < len(self.expectations):
109
+ expected = self.expectations[self.cursor]
110
+ if expected.command == command:
111
+ self.cursor += 1
112
+ return expected
113
+ if self.strict:
114
+ raise MockUnexpectedCommandError("Command did not match the next scenario expectation")
115
+ return MockExpectation(command)
116
+
117
+ def assert_consumed(self) -> None:
118
+ """Require all declared command expectations to have been submitted.
119
+
120
+ :raises MockExpectationNotConsumedError: Strict expectations remain unused.
121
+ """
122
+ if self.strict and self.cursor != len(self.expectations):
123
+ raise MockExpectationNotConsumedError("Required command expectations remain unused")
124
+
125
+ def assert_called(self, command: Command) -> None:
126
+ """Assert that a command was submitted through the normal frontend.
127
+
128
+ :param command: Expected submitted description.
129
+ :raises AssertionError: No matching submission exists.
130
+ """
131
+ assert ("submit", command) in self.calls, "Command was not submitted"