Async shell command library with operator-based DSL
Project description
shish
sh-ish
Async shell commands for Python with operator-based piping.
from shish import sh, fn, out, STDERR
# Pipelines: cat input.txt | grep error | wc -l
await (sh.cat("input.txt") | sh.grep("error") | sh.wc("-l"))
# Python functions as pipeline stages
@fn
async def upper(ctx):
async for line in ctx.stdin:
await ctx.stdout.write(line.upper())
return 0
await (sh.echo("hello") | upper | sh.cat())
# Redirect: > >> < << with optional (fd, target) for specific fds
await (sh.curl("http://example.com") > "page.html") # curl ... > page.html
await (sh.grep("error") < "input.txt") # grep error < input.txt
await (sh.grep("error") << "line1\nline2\n") # feed string to stdin
await (sh.make() > (STDERR, "err.log")) # make 2>err.log
# Capture output
stdout = await out(sh.ls("-la")) # stdout=$(ls -la)
# Environment and working directory: % @
await ({"FOO": "bar"} % sh.echo("$FOO") @ "/tmp") # FOO=bar echo $FOO (in /tmp)
# Kwargs to flags, subcommands via attribute access
await sh.git.commit(message="fix bug", amend=True) # git commit --message 'fix bug' --amend
Features
Async-native - Commands are lazy until awaited. Build pipelines, pass them around, execute when ready.
Concurrent pipelines - All stages run in parallel via os.pipe(), just like a real shell. No buffering entire outputs in memory.
Python functions as stages - Mix Python async functions into pipelines alongside shell commands. Text mode by default with configurable encoding, or raw bytes.
No shell injection - Always uses exec, never shell=True. No quoting or escaping bugs.
Per-fd control - Redirect, close, or feed any file descriptor, not just stdin/stdout/stderr. Tuple syntax targets specific fds: cmd > (STDERR, "file").
Process substitution - sub_in() / sub_out() resolve to /dev/fd/N at runtime, matching bash <(cmd) / >(cmd).
Pipefail by default - Returns the rightmost non-zero exit code from any pipeline stage, matching set -o pipefail.
Orphan cleanup - On error, all spawned processes are SIGKILL'd and reaped, shielded from cancellation. No zombie processes.
SIGPIPE handling - Early termination works naturally; killed processes report 128 + signal number.
Python function stages
fn wraps an async function as a pipeline stage. Text mode (utf-8) by default — the function receives TextStageCtx with async stdin/stdout streams:
from shish import fn
@fn
async def upper(ctx):
async for line in ctx.stdin:
await ctx.stdout.write(line.upper())
return 0
# Mix with shell commands
await (sh.echo("hello world") | upper | sh.cat())
# Custom encoding
@fn(encoding="latin-1")
async def process(ctx):
...
# Raw bytes — receives ByteStageCtx
@fn(encoding=None)
async def compress(ctx):
encoder = zlib.compressobj()
while chunk := await ctx.stdin.read(8192):
await ctx.stdout.write(encoder.compress(chunk))
await ctx.stdout.write(encoder.flush())
return 0
The return value is the exit code for pipefail semantics — return 0 for success.
Process substitution
sub_in / sub_out mirror bash's <(cmd) / >(cmd). They work as arguments or as redirect sources/targets:
# As arguments - diff <(sort a.txt) <(sort b.txt)
await sh.diff(sub_in(sh.sort("a.txt")), sub_in(sh.sort("b.txt")))
# As redirect sources/targets
await read(sh.cat(), sub_in(sh.sort("a.txt"))) # cat < <(sort a.txt)
await write(sh.echo("hi"), sub_out(sh.gzip() > "out.gz")) # echo hi > >(gzip > out.gz)
Combinators
Operators delegate to combinator functions. Use them directly for programmatic composition:
from shish import pipe, write, read, feed, close, sub_in, sub_out, env, cwd
pipe(sh.a(), sh.b(), sh.c()) # varargs pipeline
write(sh.make(), "err.log", fd=STDERR) # stderr to file
read(sh.cat(), "input.txt") # stdin from file
feed(sh.grep("error"), "line1\nline2\n") # stdin from string
close(sh.cmd(), STDERR) # close stderr
env(sh.echo(), FOO="bar") # set env vars
cwd(sh.pwd(), "/tmp") # set working directory
Builder Pattern
sh and operators are convenient but rely on __getattr__ and operator overloading. The IR layer (shish.ir) exposes the same functionality as frozen dataclasses with chainable builder methods - no magic, fully typed:
from shish.ir import cmd
from shish.fdops import STDERR
# Chainable builders on frozen dataclasses
grep = cmd("grep", "error").read("input.txt")
make = cmd("make").write("err.log", fd=STDERR)
pipeline = cmd("cat", "input.txt").pipe(cmd("grep", "error")).pipe(cmd("wc", "-l"))
await grep.run()
await pipeline.run()
stdout = await cmd("ls", "-la").out()
Control flow
Use Python:
# Sequential (&&)
if await sh.mkdir("dir") == 0:
await sh.touch("dir/file")
# Fallback (||)
if await sh.test("-f", "config.json") != 0:
await sh.cp("config.default.json", "config.json")
# Timeout
await asyncio.wait_for(sh.long_running(), timeout=30)
# Background
task = asyncio.create_task(sh.server())
Comparison with subprocess, sh, and plumbum
subprocess
subprocess.run is fine for one-off calls, but it doesn't scale well to larger scripts. Shuffling args through lists gets old, capturing output needs extra wiring (.stdout.read().decode()), and piping means wiring up fds and concurrent waits yourself. shell=True is tempting but then you're responsible for escaping every argument. I've ended up building abstractions on top in various projects to handle this, which is why I started looking elsewhere.
sh
shish borrows the magic sh.foo attribute access from sh. sh calls commands eagerly - sh.ls() executes immediately and returns the output. Piping via _in= runs the inner command to completion before starting the outer one, so large streams buffer entirely in memory. shish keeps commands lazy until awaited and pipes them concurrently. sh is also synchronous-only and dynamically typed.
plumbum
shish borrows the | operator piping from plumbum. plumbum uses bracket indexing (cmd["arg"]) rather than function calls, and doesn't support per-fd redirects or process substitution. plumbum is a larger toolkit (SSH remoting, CLI framework, ANSI colors) while shish stays focused on local async command execution.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file shish-0.0.2.tar.gz.
File metadata
- Download URL: shish-0.0.2.tar.gz
- Upload date:
- Size: 18.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
21ac8d235afdee47282c4f02c43dee20f7a318a236a5b7ea0748f2bbb054662f
|
|
| MD5 |
15f3703ca0f384f0f37df143e4a5f4f5
|
|
| BLAKE2b-256 |
972ffabd236f3cf66daf40de30ad36be21f10340d15fb149da70405cd5c23687
|
Provenance
The following attestation bundles were made for shish-0.0.2.tar.gz:
Publisher:
publish.yml on matthewmazzanti/shish
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
shish-0.0.2.tar.gz -
Subject digest:
21ac8d235afdee47282c4f02c43dee20f7a318a236a5b7ea0748f2bbb054662f - Sigstore transparency entry: 1007657516
- Sigstore integration time:
-
Permalink:
matthewmazzanti/shish@fbe6b0d9ee18ec574ea0764c5027c2f19dd2bdad -
Branch / Tag:
refs/tags/v0.0.2 - Owner: https://github.com/matthewmazzanti
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@fbe6b0d9ee18ec574ea0764c5027c2f19dd2bdad -
Trigger Event:
push
-
Statement type:
File details
Details for the file shish-0.0.2-py3-none-any.whl.
File metadata
- Download URL: shish-0.0.2-py3-none-any.whl
- Upload date:
- Size: 21.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e3af9bcd7a220c20a7cfaf401697096bd0c8a695e0fa0407593bcaa389e521ed
|
|
| MD5 |
96c39ac1e11b57456698e9aaa13dd3e4
|
|
| BLAKE2b-256 |
6d1bd3016fa51ac9a0291fce69d8db04bdfd17e11fe36f7df9f777e0c8971454
|
Provenance
The following attestation bundles were made for shish-0.0.2-py3-none-any.whl:
Publisher:
publish.yml on matthewmazzanti/shish
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
shish-0.0.2-py3-none-any.whl -
Subject digest:
e3af9bcd7a220c20a7cfaf401697096bd0c8a695e0fa0407593bcaa389e521ed - Sigstore transparency entry: 1007657573
- Sigstore integration time:
-
Permalink:
matthewmazzanti/shish@fbe6b0d9ee18ec574ea0764c5027c2f19dd2bdad -
Branch / Tag:
refs/tags/v0.0.2 - Owner: https://github.com/matthewmazzanti
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@fbe6b0d9ee18ec574ea0764c5027c2f19dd2bdad -
Trigger Event:
push
-
Statement type: