How to do things safely in Bash (2018)
github.com
github.com
Seriously, if you can avoid it, just don't write shell scripts at all. Use a real programming language instead.
And every time I've ever seen something that's allegedly "a shell script replaced with a python script" it's way more complicated than an equivalent shell script would have been.
I just wonder, really, what "a shell script" even is to you if not something where you need to "shell out to external processes"?
All that said I like python and there are tasks I absolutely prefer it for, usually anything to do with scraping some web API that returns a complex piece of JSON or xml.
I am allowed to call 'mv' or 'cp' binaries from bash.
I am also allowed to call 'mv' or 'cp' binaries from python with subprocess!
(Edit: bad example binaries, 'df' or 'tar' would be probably better.)
I must choose not to install bash libraries for a bash script beyond builtins.
I can choose not to install python libraries beyond builtins.
It becomes a really simple tradeoff.
Nobody forces you to step out of python's stdlib. No extra dependencies. And you can still reap benefits of a proper programming language.
A python script written ten years ago probably doesn't even parse on python3.0, let alone python3.6 (because there have been backwards incompatible changes since 3.0 even!).
And that's also true vice versa.
So like, this is to the point that there's some "reasonable subset" of python you can use to make it portable (ie. no external packages so you don't need to worry about pip vs. setuptools or venvs or whatever, let alone whether they'll build). I'm asserting that there is not.
Python has its uses, but replacing shell scripts is not one of them IMO. It it was then the problem didn't need to be a shell script to begin with (doesn't shell out often, needs complex data structures, etc.).
Have you looked at the pathlib[1] module that was added in Python 3.4? It's fantastic. Makes things both more convenient and more correct, in my experience, and it lets you manipulate Windows paths on Unix and vice versa.
I always recommend Python anyway if you care about edge cases (what is not always). That longer script handles them on the obvious way, while the short and more readable shell script does something absolutely crazy every time a detail is different from planed. And if you try to correctly handle the edge cases in Bash (you'll fail), you'll get something much more complicated than the Python version anyway.
Rust:
fn add(a: i32, b: i32) -> i32 {
a + b
}
Python: def add(a, b):
return a + b
Arguably the Python function is "simpler" because it occupies fewer characters. And yet, the Rust function is more tightly specified; it does less. It can't throw exceptions, unlike the Python function. Invalid programs where you pass the wrong type to the Rust function won't even compile, whereas the Python function will blow up at runtime.I figure that these supposedly "simpler" Bash programs are not simple at all.
And like, `subprocess.run()` defaults its check argument (that will make it throw an exception on non-zero exit code) to false, which means that to get that same kind of behaviour as `set -e` in python means making sure you always pass check=False to every invocation, afaik.
Sometimes added complexity can introduce classes of errors.
The upthread point was that shell scripts are for coordinating the actions of separate programs run via their command lines and simple IPC like pipes. If that's not what you want to do, shell is the wrong language. If that *is* what you want to do, then you'll find Python and Rust are pretty severely handicapped.
I chose Rust for my example because its addition operation is tightly specified and thus the behavior is very easy to reason about. (I probably should have chosen a language other than Python for the counter example.)
As soon as shell scripts get mildly complex, like others in this thread I prefer to port them to Python.
Since I'm a FOSS programmer, I've often dealt with the misery of non-portable shell scripts. Userland incompatibilities across operating systems mean that the same bash program behaves differently because the userland programs bash calls behave differently.
In contrast, reasoning about the portability of a Python script is much easier than predicting which tangle of flags in a shell script will do the right thing.
But the counterpoint is that this is dependent on situation, and if the behavior you're trying to "reason" about is better captured by the operation of tools like "find", "objcopy", "openssl", "nc", "rsync", etc... than it is by a Python library-based attempt at the same stuff, then putting that behavior into a shell script is a net win regardless of "complexity".
I've done some ops work in C, Python, and C#. I'm also waiting for a good problem to try some Haskell shell monad. All the same old language pros and cons apply.
The thing about shell code is that there are plenty of cases where you don't care about things like error management, bad inputs, and etc. So it's not immediately obvious what language is simpler. When you care about those, the shell becomes the most complex choice by far, and that's the duality problem that generates all those different opinions you see around.
A replacement for a shell script may not itself be a shell script.
A fairly typical python-as-shell-replacement I have that (among other things) creates/manipulates test resources in AWS corresponding to git feature branches, shells out less than it would if it was a typical shell language (using boto3 rather than shelling to the AWS CLI, etc.) but still shells out to git. But it would be replacing a shell script even if I had a git-repository-interaction library that eliminated the need to shell out.
This may sound a little like "no true scotsman" I guess, but "what is the scotsman" is kind of the key issue that I'm saying gets glossed over when people say blanket things like "never write shell, always use python."
> Seriously, if you can avoid it, just don't write shell scripts at all. Use a real programming language instead.
This may be slightly weaker than "never use shell" or "always use python" but it's pretty close to the former (you can almost always "avoid" using shell somehow), and people seem to believe that python is somehow particularly uniquely suited to use for shell tasks that I think there's some degree of the latter implied.
So... I agree with you? I am saying that there is room for discussion around what constitutes a good use of shell script, and that there is a space in which that is true. I'm suggesting that a lot of people who believe otherwise may just mostly be writing things that aren't in that space, and I think this is actually a pretty charitable argument tbh.
If you are writing anything larger than about 60 lines, don't use Bash. Almost anything else will be better. Python, PHP, Ruby, JavaScript, even Go.
This also helps in eradicating bugs. Bash is very hard to get right (hence this thread in the first place), even for the simplest stuff. This simply won't happen in Python (or any other proper language).
In general, after 20 years of using Unix, I'm starting to sour on the "everything is a string" and "streams are just newline separated plain-text records". The shell is a pretty good REPL, and glue like seq, find, xargs, grep, etc. are very good. But honestly, I find myself reaching for purpose-built tools rather than ad-hoc pipelines more and more. fdfind does more of what I want on average than find; rg does more of what I want than 'find | xargs grep'. I also find myself using more and more structured data, and write more jq pipelines than I do bash pipelines. Finally, I am getting more and more frustrated at basic shell mechanics like history handling. I have 5 terminals open on average, and I don't understand why C-r in one won't find history in another (I know why, of course, but I don't like it). I'm getting pretty close to just writing my own shell and toolchain. I feel like if the Unix shell needed to be fundamentally reimagined, someone would have already done it. But they haven't. They just hacked Unicode and remote RPCs into shell prompts so that pressing "enter" on an empty shell prompt takes 25 seconds to run. I'm not sure why people are so focused on the polish and not the core inadequacies, but they are, and I feel like I'm going to have to fix that myself in the next couple years...
https://github.com/oilshell/oil/wiki/Alternative-Shells
Discussed recently: https://news.ycombinator.com/item?id=26121592
A lot of them are "fundamentally reimagining" shell -- actually I'd say MOST of them are; whether that's good or bad depends on the user's POV.
Oil is reimagining shell, but also providing a graceful upgrade path. Out of all the shells I'd say it's most focused on the fundamental language and runtime, and less on the interactive issues (right now).
> I feel like if the Unix shell needed to be fundamentally reimagined, someone would have already done it. But they haven't.
AFAICT this has a lot to do with the fact that most people who'd be able to "reimagine" / fundamentally improve the Unix shell are also the people that are so intimately familiar with the shell and all its quirks that they don't see any need for improvement. It has always been that way, so it must stay that way.
> I feel like I'm going to have to fix that myself in the next couple years...
Please let me know when you do. :)
> I feel like I spend 45 minutes every time I write a shell script to determine simple things like "is this environment variable set".
Adopt some best practices[1] and have a template to start new scripts with. ShellCheck also helps with avoiding the most common pitfalls.
> I find myself reaching for purpose-built tools rather than ad-hoc pipelines
It helps to distinguish day-to-day interactive work in the shell from writing portable shell scripts that will be robust and require little maintenance for years to come. For the former you want user friendly tools that help you the most with a particular task. For the latter you want to use battle-tested tools with a stable interface that are available on many systems or are preferably built-in to the shell.
> I am getting more and more frustrated at basic shell mechanics like history handling
That's mostly a solved problem in many shells. Zsh supports it natively[2].
I wouldn't want to discourage you from writing your own shell, good luck with that, but there are plenty of good POSIX compatible and alternative shells out there. We can agree that "real" programming languages are much more powerful, safer and friendlier, but plain old shell scripts are the right tool for the job in many situations and shouldn't be ignored at all cost.
[1]: http://redsymbol.net/articles/unofficial-bash-strict-mode/
[2]: https://nuclearsquid.com/writings/shared-history-in-zsh/
One thing I have started doing for stuff that need to go via ssh is I just do a here doc for an embedded shell script that I scp to the remote and then just use ssh to invoke it. The quoting and pseudotty stuff just got to be too dumb to understand.
Also, and maybe this is just for non-interactive scripts, I find everything from writing them to using them is a lot easier if you take pains to make them idempotent, so you can run them one or 100 times with the same effect.
Pipelines are performant; many algorithms can be expressed as pipelines. Bash has many built-in string manipulation mechanisms which do not require a sub shell.
Why do people who subscribe to the “avoid shell scripts at all costs” ideology feel so passionately about prescribing to others?
I like python, but if I am in a rush it’s shell.
Sysadmins/ system engineers might love Babashka: https://github.com/babashka/babashka which is a large subset of Clojure + some frequently used libraries as a native GraalVM image. It is portable, has very fast startup and if the script becomes a larger program, you can easily switch to ClojureScript + Node.js (e.g. for still very fast startup) or Clojure (on the normal JVM) or perhaps build your own GraalVM image. You might also just open a REPL and run it as a single session but that is rather unique in the sysadmin/ systems engineer space, where most things are launched on schedule e.g. each 5 minutes by a script and in case the startup time is somewhat long, it might dominate the execution time.
Btw. babashka seems to be about twice as fast to start on my Debian: time bb -e '(+ 1 1)' executes on average in about 11 ms vs time python3 -c 'print(1 + 1)' executes on average in about 23 ms
Are you asking why people make recommendations in general?
I work mostly in payment related code, so most often I use bash to hot fix in production. Like if we need to refund certain people, I'll convert the refund api call to a curl call, get the data from sql and then just run a simple loop.
http://www.oilshell.org/blog/2021/01/why-a-new-shell.html#sh...
tl;dr I think the main skill that is missing is being able to write a Python script that fits well within a shell script. The shell script often invokes tools written in other languages like C, Rust, JavaScript, etc. (most of which you didn't write)
Good book on this: https://www.amazon.com/UNIX-Programming-Addison-Wesley-Profe...
Online for free: http://www.catb.org/esr/writings/taoup/html/
Sometimes you just gotta eat. Python is a sit-down meal. Bash is a can of beans. I’m happy eating both for dinner but it depends who (if anyone) I’m sharing my meal with.
https://github.com/koalaman/shellcheck
If you’re interested I’ve written a git hook for it that runs a check when you git commit:
https://github.com/alblue/scripts/blob/main/shellcheck-pre-c...
You should also check out her Google shell script style guide:
Built-in autofixes and direct links to extensive documentation on each rule, with easy to grasp examples. It's just awesome.
[1]: https://marketplace.visualstudio.com/items?itemName=timonwon...
The nullglob option ('shopt -s nullglob') makes things like 'for f in .txt' work right when there are no matching files, but make other commands like 'grep somepattern .txt' change their behavior in ways that can cause serious trouble. grep (and many other commands), given no files as input, will read from standard input (up to the EOF); if it's running interactively and input hasn't been redirected, this causes the script to hang for no discernible reason. If input has been redirected, it steals input that was presumably meant to be read by some other command. In my opinion, the problems this causes are more serious than what it solves.
The errexit option ('set -e' or 'shopt -s errexit') can both fail to exit when you expect/want it to (several such situations are described in the guide) and also exit when you don't expect/want it to. The guide mentions suppressing unexpected exits with '|| true', but it's not always obvious when this is needed, and it's not even consistent between versions of bash. There's a good parable about this (and some examples) at http://mywiki.wooledge.org/BashFAQ/105
I really don't like trying to predict and work around unpredictable features like this; I'd much rather deal with explicit error handling, like 'commandThatMightFail || { echo "Aaaargh" >&2; exit 1; }'
osh$ empty=''
osh$ x='name with spaces.mp3'
This is like Bourne shell: $ argv $empty $x
['name', 'with', 'spaces.mp3'] # omit empty and split
$ argv "$empty" "$x"
['', 'name with spaces.mp3'] # unchanged
Opt into better behavior, also available with bin/oil: $ shopt --set oil:basic
$ argv $empty $x
['', 'name with spaces.mp3'] # no splitting/elision
If you want to omit empty strings, you can use the maybe() function, which returns a 0 or 1 length array for SPLICING with @: $ argv @maybe(empty) "$x"
['name with spaces.mp3'] # omitted empty string
Example of splicing arrays: $ array=("foo $x" '1 2')
$ argv $empty @array
['', 'foo name with spaces.mp3', '1 2']
This is called "Simple Word Evaluation": https://www.oilshell.org/release/latest/doc/simple-word-eval...Feedback appreciated!
(Interestingly zsh also doesn't split words, but it silently removes empty strings).
Should zsh users ever want that behavior they can enable it globally with the SH_WORD_SPLIT option, or more sensibly local to a given parameter with the = parameter expansion as in ${=var}. Also, it has the best comment in zsh's manpage: "SH_WORD_SPLIT [...] Note that this option has nothing to do with word splitting."
$ x='name with spaces.mp3'
$ print -l -- $=x # "print -l" displays one element per line
name
with
spaces.mp3
> it silently removes empty stringsYou can keep the empty strings too if needed, but it requires using the @ expansion flag as in ${(@)arr}. It all becomes superbly readable, here I'll prove it:
$ arr=($x '' 'old file')
$ print -l -- "${(@D)arr:A:gs/old/new/}"
~/Desktop/name with spaces.mp3
~/Desktop/new file
In all seriousness, the zsh default handling feels right for interactive usage in this case. I'd love the strictness of oil, so that I simply don't need to remember these things. Not yet quite ready to give up on the zshexpn(1) goodies though.I have heard the feedback that people like zsh expansion shortcuts, e.g. for globbing. Personally I am a find/xargs person, i.e. I select the files first with 'find' and then execute what I want with xargs.
It does require you to invert your thinking -- you're going from verb NOUNS to NOUNS verb. And you have to go back to the beginning of the command line and edit it. But I do find that it lets you test out the selection logic more naturally.
find can also be faster, e.g.
find . -name .git -a -prune -o -print
skips the even STATTING .git directory, not just printing it, as opposed to ** I believe.However find arguably has an even worse syntax (although it is explicit, just with some dumb shortcuts). One longstanding goal is to put a better syntax on the "evaluation model", which is pretty useful. It's basically a predicate that's evaluated over every node in the FS tree, but you can also customize the traversal.
It might be better for programs rather than interactively, but I started using it interactively too.
(When compiled with glibc, Oil also has bash/ksh-style extended globbing, which allows negation etc., but it seems only old scripts use that.)
zargs -- $crazy_glob -- $command
Much like the `find | xargs` version you can begin with `zargs -- $glob` until your filter is correct and then tack the command on when you're ready.Hmm, now I really like the idea of an oil-y version of this where you could do something like `oargs { $clearer_glob_DSL } { $command_block }`. It might even be possible right now using stest from dmenu¹ as a stand-in for a more advanced globbing alternative.
I believe part of the reason zsh users find the extended globbing functionality useful is the basic usage matches their expectations with other tools, unlike find's quirky syntax. The common filters use the same values as you'd see in `ls --classify` output. For example, you want executable files tack a `*` on or for sockets use a `=`.
FWIW, the find comparison isn't quite right. Out of the box `**/file` wouldn't traverse a .git directory anyway, unless the GLOB_DOTS option is set or you provide the equivalent flag as in `**/file(D)`. The less specific point is quite true though as adding negation and toggling flags in a single glob can be extremely difficult to read. See the examples at the bottom of zshexpn(1) for proof of that.
Edit to add: I hope this comes across as an attempt to be helpful as was intended, and not some awful stop motion attempt.
I disagree. This can be considered a design flaw in other places, like filesystems that allow filenames with spaces and other idiotic complexity-inducing things.
I prefer to be able to do "for i in `ls`..." in my shell than to have filenames in my disk with hard spaces. This could be solved at the filesystem level, by a mount option (say, "-o cleannames") that exposes filenames with spaces using a non-breaking unicode space. You will take my simple shell one-liners that break with ugly filenames from my cold, dead hands.
Trying to shoehorn in arbitrary restrictions to data identifiers because of the way it's serialized in some situations just leads to the "csv-problem" where you never reach a uniform standard because some prefer using spaces as separators, some use tab, some use comma, semicolon, quotes are always allowed, etc...
Just define a standard array-type once and for all and use it to pass data for everything. Take python for example os.listdir() is just one call away, same for any other high level language. (I am aware bash already has this and one should use globs instead of ls, though i wouldn't advocate bash for anything regardless, for multiple other reasons)
Why?
1. Portability (AWK is a part of POSIX)
2. Very clean syntax
3. Powerful associative arrays
4. Super-powerful string manipulations facilities
5. Easy shell interoperability
As an example here is a simplistic build tool [1] I’ve developed in AWK. As you can check [2] it runs unchanged under Linux/macOS/Win (via Git Bash).
[1] https://github.com/xonixx/makesure/blob/main/makesure.awk
[2] https://github.com/xonixx/makesure/actions/runs/702594092
def safe_call(command, **keywds):
return subprocess.check_call(f'set -euo pipefail; {command}', shell=True, env=keywds)
safe_call('command1 -- "$bar" | command2 --baz="$baz" | command3 > "$output_file"', bar=bar, baz=baz, output_file=output_file)Like 99% of other people who never used PoSh, I thought it was just a Windows shovelware replacement for Command Prompt. I was mistaken—the syntax is actually a lot simpler than bash, but it’s just as capable.
One of the cooler features about PoSh is that you can leverage Visual Basic/C#/.NET from it, and you can script GUIs (like Python’s tkinter library).
Safe ways to do things in bash - https://news.ycombinator.com/item?id=17057596 - May 2018 (240 comments)
It may not be the way but it is a way.
This is the way.
Familiarity is of course an important factor, though I would actually claim the problem is the opposite, people reach towards bash - because of familiarity of what they type on the cli. Not realizing that scripting is a complete different world from interactive use where you can inspect, correct and manually adjust every action step by step, with same input each time.
A language with only string as a data type (it has arrays and numbers but its so confusing and converts implicitly that you might as well ignore it, IFS anyone?), surprising quoting, data and input interpreted as code, globbing and expansions where you least expect it, global variable everything, no module-system, only error handling is either errexit or manually checking each command and every sub-pipe, functions make error-handling behave even weirder. It can't be made safe no matter how hard you try to familiarize yourself with it.
``` let g:syntastic_sh_checkers = ["shellcheck", "-e", "SC1090"] ```
Specifically, backticks are simply displayed, rather than marking a code snippet or block.
You can indent by four spaces for a code block as below:
This is a preformatted section preceded by
four spaces in the comment editor.And I’m over here still using `cmd`...
test -n "${VAR+x}"+ Installed everywhere
+ Even within 20% as concise as bash
I wish there was a super-fast shell-like scripting language everywhere that could serve as a general programming language. Like Perl, but simpler rules and readable syntax. But there is nothing. Who would have thought, I may have to learn Perl eventually.
It's not possible to implement such monstrosity as one simple, portable, small, fast, and secure binary.
Python while not the fastest thing out there, basically every implementation does a quick parse phase and run some optimized form.