Using shell-next¶
Use Python 3.14 or newer. Bash is supported on Linux, with RHEL 8 as the
reference system. Windows supports PowerShell 7 (pwsh) and cmd. Choose the
backend explicitly; PowerShell scripts and Bash scripts are different languages.
Persistent sessions¶
from shell_next import Backend, ProcessCommand, SessionConfig, SessionScript, use_shell_session
async def work(config: SessionConfig):
async with use_shell_session(config) as shell:
await shell.chdir("/srv/project")
await shell.set_env("BUILD_MODE", "release")
await shell.run(SessionScript("answer=42"), check=True) # Bash syntax
result = await shell.run(ProcessCommand("git", ("status", "--short")), check=True)
return result.stdout_str()
ProcessCommand preserves argument boundaries, including shell metacharacters.
SessionScript executes trusted native code in the existing shell scope. The
sn_ namespace and Bash descriptor 9 belong to the private session protocol.
Explicit shell termination or protocol damage invalidates the session.
get_cwd() and get_env(name=None) query the interpreter. snapshot() reads
cached state without executing a command, so native script changes may not yet
appear there. State queries require enough retained output for their JSON reply;
they raise SessionProtocolError if capture truncates it.
Ownership, input, and deadlines¶
run() owns the command through cleanup. command() provides a scoped live
handle. submit() immediately returns a session-owned handle; leaving it
unobserved does not abandon capture or cleanup.
The default stdin mode closes business stdin. Use StdinMode.MANUAL to call
send(), sendline(), and close_stdin(). Use StdinMode.PLAN with an
InputPlan of Expect, Send, SendLine, and CloseStdin steps for automation.
One logical writer serializes submissions. Cancelled or failed input is never
retried, and the result records accepted and submitted byte counts separately.
from shell_next import CommandOptions, Expect, InputPlan, SendLine, StdinMode, TimeoutPolicy
options = CommandOptions(
stdin=StdinMode.PLAN,
input_plan=InputPlan((Expect(b"Name: ", timeout=5), SendLine(b"Ada"))),
timeouts=TimeoutPolicy(execution=30, acquire=5, drain=2),
)
Prompt matching is literal, bounded, and byte-oriented; newline-free prompts
and prompts split across reads work. stream="either" searches stdout and
stderr independently. Host prompts, terminals, and full-screen programs are
outside the first release's capabilities.
handle.wait(wait_timeout=5) limits that observation. Cancelling or timing out
the wait leaves the command running. Cancelling run(), exiting a command
context exceptionally, calling stop(), or reaching an execution deadline
initiates cleanup. Preparation, acquisition, soft stop, force stop, output drain,
and capture finalization have independent budgets. Forced interruption breaks
the session, with no automatic restart.
ConcurrencyPolicy.REJECT rejects a second submission immediately.
ConcurrencyPolicy.QUEUE serializes commands in FIFO order. A scoped command
owner cannot recursively acquire the same session. Separate sessions can run
concurrently.
Results and capture¶
Use result.outcome for the lifecycle outcome and result.success for normalized
success. An ordinary nonzero exit still has outcome EXITED. PowerShell also
reports last-success, native exit code, and terminating-error status separately.
check=True raises a typed exception after the result is finalized; the exception
retains that result. Error classes are available from shell_next.errors.
result.command retains the exact submitted ProcessCommand (executable and
argument tuple) or SessionScript (including original whitespace), even on
failure or cancellation before execution. It contains no generated backend
wrapper and is excluded from the result's repr. The field defaults to None
only for compatibility with manually constructed legacy results.
result.stdout_str(encoding="utf-8", errors="replace") and
result.stderr_str(encoding="utf-8", errors="replace") decode the retained raw
tails. They preserve whitespace and never read capture files. An empty tail
returns an empty string. Replacement decoding tolerates malformed bytes and a
multibyte character cut by the tail boundary; use errors="strict" to raise
UnicodeDecodeError, or select another Python codec such as encoding="cp1252".
Raw bytes and completeness accounting remain available on stdout and stderr.
Configure SessionConfig(capture=CaptureConfig(tail_bytes=4096)) to keep the last
4,096 bytes independently for stdout and stderr. The default maximum is 65,536
bytes (64 KiB) per stream. Zero retains no tail, negative limits are rejected,
and output beyond the limit is still drained and counted. Tail truncation does
not by itself make execution unsuccessful. Full capture files and prompt windows
use their own existing policies.
import asyncio
from shell_next import (
CaptureConfig,
Emit,
MockExpectation,
MockScenario,
MockShellSession,
ProcessCommand,
SessionConfig,
use_shell_session,
)
async def inspect_result():
command = ProcessCommand("example", ("argument with spaces",))
scenario = MockScenario(
[
MockExpectation(command, (Emit(b"prefix:done"), Emit(b"warning", "stderr"))),
]
)
config = SessionConfig(capture=CaptureConfig(tail_bytes=4))
config._session_cls = MockShellSession.configured(scenario)
async with use_shell_session(config) as shell:
result = await shell.run(command)
assert result.command is command
assert result.stdout_str() == "done"
assert result.stderr_str() == "ning"
assert result.stdout.received == 11
assert not result.stdout.complete
asyncio.run(inspect_result())
Stdout and stderr have independent results. Memory tails, prompt windows, and
subscriber queues are bounded. To preserve full output, set
CaptureConfig(directory=existing_directory). Unique files are created without
overwriting existing files. committed counts bytes only after flush and fsync.
complete, sealed, end, and unread_possible distinguish truncation,
storage failure, and forced cleanup. Explicit discard still counts received bytes.
handle.events() observes future chunks. Slow subscribers receive
OutputSubscriberError; they cannot stop primary capture. Events include stream,
sequence, timestamp, and per-stream byte offset. They do not establish a global
ordering between independently produced stdout and stderr bytes.
Diagnostic representations¶
repr() on public commands, configuration, input steps, results, snapshots,
capabilities, and mock scenarios shows the class name and named fields. Enum
values appear as names such as Outcome.EXITED. Long strings, byte payloads,
collections, and nested records are abbreviated with ...; large byte previews
include the full byte count. Stored values and capture limits are unchanged.
Sessions show their identifier, backend, lifecycle state, and pending count. Command handles show their identifier, state, stdout/stderr byte counts, and whether the result is finalized. Repr reads in-memory state without executing commands, waiting, reading files, or calling password providers.
Input payloads, password providers, configuration and snapshot environments, mock environment updates, and the original command inside a result remain hidden. Commands and output previews may still contain caller-supplied sensitive text; repr is a diagnostic display, not a serialization or general-purpose redactor.
from shell_next import OutputResult, ProcessCommand
command = ProcessCommand("git", ("status", "--short"))
assert repr(command) == "ProcessCommand(executable='git', args=('status', '--short'))"
output = OutputResult(received=1000, tail=b"x" * 1000, complete=False)
assert "(1000 bytes)" in repr(output)
assert "complete=False" in repr(output)
assert len(output.tail) == 1000
Sudo¶
Supply a password before creating the session with
SessionConfig(sudo_password=known_password). Text is encoded as UTF-8; bytes
are used directly. An explicitly elevated request without its own provider uses
this secret through the existing private authentication channel. Ordinary
commands remain unelevated. A per-command provider takes precedence; without a
configured password, noninteractive requests keep their existing behavior.
The following deterministic example demonstrates repeated elevation, including a cache hit, without requiring sudo or any real password:
import asyncio
from shell_next import (
CommandOptions,
Emit,
MockExpectation,
MockScenario,
MockShellSession,
PrivilegeRequest,
ProcessCommand,
SessionConfig,
use_shell_session,
)
async def main():
command = ProcessCommand("id", ("-u",))
scenario = MockScenario(
[
MockExpectation(command, (Emit(b"0\n"),), authentication_prompts=prompts)
for prompts in (1, 0, 1)
]
)
config = SessionConfig(sudo_password="demonstration-only")
config._session_cls = MockShellSession.configured(scenario)
options = CommandOptions(privilege=PrivilegeRequest(requirement="elevated"))
async with use_shell_session(config) as shell:
for prompts in (1, 0, 1):
result = await shell.run(command, options=options, check=True)
assert result.stdout_str() == "0\n"
assert result.privilege.attempts == prompts
assert "sudo_password" not in config.to_dict()
assert "demonstration-only" not in repr(config)
assert "demonstration-only" not in repr(scenario.calls)
asyncio.run(main())
Put an elevated CommandOptions in SessionConfig.defaults if all commands
should elevate by default; an explicit inherited request opts individual commands
out. The password alone does not change defaults. See the
complete sudo example for native execution, upfront input,
on-demand providers, and elevated script state.
None disables the session password; empty text/bytes represent an empty
password. Invalid types, unencodable text, CR, LF, and NUL are rejected without
including the secret in the diagnostic. The caller's configuration retains
the secret in memory; closing the session does not erase caller-owned data.
Each submitted command captures the current value, so changing
shell.config.sudo_password affects later submissions only. Do not serialize
secrets with generic dataclass utilities; to_dict() is the supported projection
that omits this field. Windows active elevation remains unsupported.
from shell_next import CommandOptions, PrivilegeRequest
# Supply an application's asynchronous secret-store function, returning bytes.
def elevated_options(password_provider):
return CommandOptions(
privilege=PrivilegeRequest(
requirement="elevated",
interactive=True,
password_provider=password_provider,
attempts=1,
)
)
Bash supports interactive and noninteractive sudo. Authentication uses private channels and a unique prompt, independently of business stdin/stdout/stderr. The provider is called only when sudo requests a password; a valid credential cache needs no provider call. Passwords are absent from argv, results, and mock history. Provider failures expose a generic authentication error.
An elevated SessionScript runs in a separate elevated Bash process, so its
state changes do not persist in the ordinary session. target_identity selects
the sudo target. Privileged descendants cannot be guaranteed contained after
changing identity: cleanup reports remain conservative, and strict_cleanup=True
is rejected before execution. Windows active elevation is unsupported.
Application tests¶
Pass the same SessionConfig into production code and set only its injection
field in tests:
from shell_next import Emit, MockExpectation, MockScenario, MockShellSession, ProcessCommand
scenario = MockScenario(
[
MockExpectation(ProcessCommand("git", ("status", "--short")), (Emit(b" M README.md\n"),)),
]
)
config._session_cls = MockShellSession.configured(scenario)
Mock scripts do not parse shell syntax. Declare state changes using an
expectation's cwd and env fields. Receive matches the input byte stream
across transport chunks; Receive(None) expects EOF. Advance changes virtual
time, and Failure injects input, output, startup, or session failures.
Authentication expectations can model prompts, cache hits, and rejection.
Strict scenarios reject unexpected commands and input, and reject unused command
expectations at normal context exit. assert_called() and assert_consumed()
support explicit assertions. Calls are recorded in order; use secret=True on
manual or planned input to redact payloads. The mock does not create processes,
network connections, capture files, or real sleeps, and never changes the Python
process's directory or environment.