Shell commands¶
In any script, a line that starts with $ is run as an external command. cliffy uses
pybash to translate these lines into subprocess calls, so you
can move between Python and shell freely.
The basics¶
hello: $ echo "hello from the shell"
mixed:
help: Mix Python and shell lines
params:
- name: str!
run: |
print("python first")
$ echo "then shell"
if name == "ada":
$ echo "shell lines can be indented"
$ echo "then shell" becomes subprocess.run(["echo", "then shell"]). The command is split into
arguments the way a shell would split it, and is then started directly, without a shell in between.
Shell lines follow Python indentation, so they can sit inside if blocks and loops.
$ lines work in run, pre_run, post_run, in callbacks, in functions written as a list, and
as steps in tests. Run cli render to see the translation.
Using Python values¶
f{expression} inserts the value of a Python expression into the command. The value is passed as a
single argument, whatever it contains: spaces, quotes and ; are just text.
say:
help: Pass a Python value to a shell command
params:
- message: str!
run: $ echo f{message} f{len(message)}
Everything an f-string accepts after the expression works too, and f{ } can be used inside
quotes:
price:
help: f{ } takes f-string format specs, also inside quotes
params:
- amount: float!
run: |
out = $ echo "total: f{amount:,.2f}"
print(out.decode().strip())
Double braces are Jinja's¶
pybash also has a {{ expression }} form, which inserts str(expression). In a manifest, cliffy
renders Jinja first, so {{ name }} is replaced with a var when the manifest
is loaded and never reaches pybash. That is what you want for vars:
For a value that only exists when the command runs, such as a param, use f{ }. If you prefer the
double-brace form, put the lines in a Jinja raw block so they are left alone:
whoami:
help: Double braces belong to Jinja unless they are inside a raw block
params:
- user: str!
run: |
{% raw %}
out = $ echo {{ user.title() }}
{% endraw %}
print(out.decode().strip())
To write the markers themselves as text, escape them: \f{ and, inside a raw block, \{{.
Capturing output¶
Assign a shell line to a variable, or wrap it in parentheses, to get its output as bytes.
$(command) does the same in the middle of an expression:
upper:
help: Capture shell output in Python
run: |
out = $ echo captured
print(out.decode().strip().upper())
inline:
help: Capture inline with parentheses
run: print(($ echo inline).decode().strip())
words:
help: Capture in the middle of an expression with $( )
run: print(len($(echo one two three).split()), "words")
A capture that is not wrapped in brackets runs to the end of the line.
Pipes and redirection¶
Pipes and redirects are carried out by the generated CLI itself, without a shell:
sorted:
help: Pipes work without a shell
run: $ printf 'b\na\n' | sort
save:
help: So does redirection
run: |
$ echo "saved" > shell-demo.txt
$ cat shell-demo.txt
errors-to-file:
help: Redirect stderr on its own, together with stdout, or into a pipe
run: |
$ ls no-such-file 2> shell-demo.txt
$ ls . no-such-file &> shell-demo.txt
$ ls . no-such-file > shell-demo.txt 2>&1
lines = $ ls no-such-file |& wc -l
print(int(lines), "line of errors")
| Operator | Meaning |
|---|---|
a | b |
Pipe the output of a into b |
a |& b |
Pipe output and errors |
< file |
Read input from a file |
> file, >> file |
Write or append output |
2> file, 2>> file |
Write or append errors |
&> file, &>> file |
Write or append both |
2>&1, >&2 |
Send errors to the output, or the output to the errors |
Chaining commands¶
&& runs the next command when the previous one succeeded, || when it failed, and ; in either
case:
chain:
help: "&&, || and ; decide what runs next, like in a shell"
run: |
out = $ true && echo "first worked" || echo "first failed"
print(out.decode().strip())
out = $ false || echo "fallback" ; echo "always"
print(out.decode().split())
Capturing a chain captures the output of every command that ran.
Long commands and comments¶
A $ or > line that ends with a backslash continues on the next line. An unquoted # starts a
comment that runs to the end of the line:
longest:
help: "A trailing backslash continues a command, a # starts a comment"
run: |
$ printf '%s\n' pear fig banana \
| sort \
| tail -n 1 > shell-demo.txt # the last one in alphabetical order
$ echo "a # inside quotes is text" >> shell-demo.txt
print(open("shell-demo.txt").read())
A captured command (out = $ ...) has to fit on one line.
When you need a real shell¶
$ lines never expand glob patterns (*.txt), ~ or environment variables ($HOME), and they do
not support background jobs (&) or here-documents (<<). For those, start the line with >
instead of $. The line is handed to the shell as written:
This becomes subprocess.run("ls *.txt", shell=True). A one-line command that starts with > must
be quoted, because > also has a meaning in YAML.
Values inserted with f{ } are quoted for the place they appear in, so a value is always one word
of data and never shell syntax. Here name can be ; rm -rf ~ and nothing is removed:
find:
help: A value in a > line is quoted for the shell
params:
- name: str!
run: |
> ls | grep -c f{name} || echo "nothing is called" f{name}
glob:
help: Splice a value in as shell syntax, here a glob pattern
params:
- pattern: str!
run: |
{% raw %}
> ls {{! pattern }}
{% endraw %}
{{! expression }} is the exception: it splices the value in unquoted, so that it can be a glob
pattern or several arguments. Like every double-brace form it has to sit in a raw block.
Never pass user input to {{! }}
A spliced value is interpreted by the shell. A parameter value such as ; rm -rf ~ would be
executed. Use f{ } for anything that comes from a user, and prefer $ lines, which have no
shell to inject into. See the Python documentation on
subprocess security.
Errors¶
A shell line that fails does not stop the script. Check the result yourself when it matters:
careless:
help: A failing command does not stop the script
run: |
$ false
print("still running")
careful:
help: Check the exit status yourself
run: |
result = subprocess.run(["false"])
if result.returncode != 0:
print("command failed")
raise typer.Exit(code=result.returncode)
Two exceptions:
- A command that does not exist raises
FileNotFoundError. - A captured command (
out = $ ...) raisessubprocess.CalledProcessErrorwhen it exits with a non-zero status.
A shell line that cannot be translated is reported when the manifest is loaded, with the command it is in, the line and the column:
$ cli load deploy.yaml
~ error in deploy.yaml: command `ship`, run, line 2, column 11: running in the background with & is not supported in a $ command; use a > shell command
$ sleep 5 &
^ 💔
Things to know¶
- Output of
$lines goes straight to the terminal. It is not part ofresult.outputin manifest tests; capture it andprintit if you want to assert on it. - A CLI that only uses plain commands imports nothing but
subprocess. Pipes, redirects, chains and values in>lines go through a small helper,pybash.runtime, which the generated code imports as_pybash.cli buildcopies that helper into the file it builds, so a built CLI does not need pybash. - Quoting follows POSIX shell rules: single quotes are literal, double quotes allow
\"and\\, and a backslash escapes the next character. Quote or escape an operator to pass it as text:$ echo 'a && b' \| c.
The complete example¶
name: sh
version: 0.1.0
help: Shell commands in run scripts
functions:
- |
def current_branch() -> str:
out = $ git rev-parse --abbrev-ref HEAD
return out.decode().strip()
commands:
hello: $ echo "hello from the shell"
mixed:
help: Mix Python and shell lines
params:
- name: str!
run: |
print("python first")
$ echo "then shell"
if name == "ada":
$ echo "shell lines can be indented"
say:
help: Pass a Python value to a shell command
params:
- message: str!
run: $ echo f{message} f{len(message)}
price:
help: f{ } takes f-string format specs, also inside quotes
params:
- amount: float!
run: |
out = $ echo "total: f{amount:,.2f}"
print(out.decode().strip())
whoami:
help: Double braces belong to Jinja unless they are inside a raw block
params:
- user: str!
run: |
{% raw %}
out = $ echo {{ user.title() }}
{% endraw %}
print(out.decode().strip())
upper:
help: Capture shell output in Python
run: |
out = $ echo captured
print(out.decode().strip().upper())
inline:
help: Capture inline with parentheses
run: print(($ echo inline).decode().strip())
words:
help: Capture in the middle of an expression with $( )
run: print(len($(echo one two three).split()), "words")
sorted:
help: Pipes work without a shell
run: $ printf 'b\na\n' | sort
save:
help: So does redirection
run: |
$ echo "saved" > shell-demo.txt
$ cat shell-demo.txt
errors-to-file:
help: Redirect stderr on its own, together with stdout, or into a pipe
run: |
$ ls no-such-file 2> shell-demo.txt
$ ls . no-such-file &> shell-demo.txt
$ ls . no-such-file > shell-demo.txt 2>&1
lines = $ ls no-such-file |& wc -l
print(int(lines), "line of errors")
chain:
help: "&&, || and ; decide what runs next, like in a shell"
run: |
out = $ true && echo "first worked" || echo "first failed"
print(out.decode().strip())
out = $ false || echo "fallback" ; echo "always"
print(out.decode().split())
longest:
help: "A trailing backslash continues a command, a # starts a comment"
run: |
$ printf '%s\n' pear fig banana \
| sort \
| tail -n 1 > shell-demo.txt # the last one in alphabetical order
$ echo "a # inside quotes is text" >> shell-demo.txt
print(open("shell-demo.txt").read())
texts:
help: Glob patterns need a real shell
run: "> ls *.txt"
find:
help: A value in a > line is quoted for the shell
params:
- name: str!
run: |
> ls | grep -c f{name} || echo "nothing is called" f{name}
glob:
help: Splice a value in as shell syntax, here a glob pattern
params:
- pattern: str!
run: |
{% raw %}
> ls {{! pattern }}
{% endraw %}
careless:
help: A failing command does not stop the script
run: |
$ false
print("still running")
careful:
help: Check the exit status yourself
run: |
result = subprocess.run(["false"])
if result.returncode != 0:
print("command failed")
raise typer.Exit(code=result.returncode)
tests:
- hello: assert result.exit_code == 0
- mixed ada: assert "python first" in result.output
- say hi: assert result.exit_code == 0
- upper: assert result.output == "CAPTURED\n"
- inline: assert result.output == "inline\n"
- words: assert result.output == "3 words\n"
- price 1234.5: 'assert result.output == "total: 1,234.50\n"'
- whoami ada: assert result.output == "Ada\n"
- sorted: assert result.exit_code == 0
- save: assert result.exit_code == 0
- errors-to-file: assert result.output == "1 line of errors\n"
- chain: assert result.output == "first worked\n['fallback', 'always']\n"
- longest: assert result.output.splitlines()[0] == "pear" and "inside quotes" in result.output
- find "; echo injected": assert result.exit_code == 0
- glob "*.yaml": assert result.exit_code == 0
- texts: assert result.exit_code == 0
- careless: assert result.output == "still running\n"
- careful: assert result.exit_code == 1
- $ rm -f shell-demo.txt