Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Is there a way to know how the user invoked a program from bash?

Here's the problem: I have this script foo.py, and if the user invokes it without the --bar option, I'd like to display the following error message:

Please add the --bar option to your command, like so:
    python foo.py --bar

Now, the tricky part is that there are several ways the user might have invoked the command:

  • They may have used python foo.py like in the example
  • They may have used /usr/bin/foo.py
  • They may have a shell alias frob='python foo.py', and actually ran frob
  • Maybe it's even a git alias flab=!/usr/bin/foo.py, and they used git flab

In every case, I'd like the message to reflect how the user invoked the command, so that the example I'm providing would make sense.

sys.argv always contains foo.py, and /proc/$$/cmdline doesn't know about aliases. It seems to me that the only possible source for this information would be bash itself, but I don't know how to ask it.

Any ideas?

UPDATE How about if we limit possible scenarios to only those listed above?

UPDATE 2: Plenty of people wrote very good explanation about why this is not possible in the general case, so I would like to limit my question to this:

Under the following assumptions:

  • The script was started interactively, from bash
  • The script was start in one of these 3 ways:
    1. foo <args> where foo is a symbolic link /usr/bin/foo -> foo.py
    2. git foo where alias.foo=!/usr/bin/foo in ~/.gitconfig
    3. git baz where alias.baz=!/usr/bin/foo in ~/.gitconfig

Is there a way to distinguish between 1 and (2,3) from within the script? Is there a way to distinguish between 2 and 3 from within the script?

I know this is a long shot, so I'm accepting Charles Duffy's answer for now.

UPDATE 3: So far, the most promising angle was suggested by Charles Duffy in the comments below. If I can get my users to have

trap 'export LAST_BASH_COMMAND=$(history 1)' DEBUG

in their .bashrc, then I can use something like this in my code:

like_so = None
cmd = os.environ['LAST_BASH_COMMAND']
if cmd is not None:
    cmd = cmd[8:]  # Remove the history counter
    if cmd.startswith("foo "):
        like_so = "foo --bar " + cmd[4:]
    elif cmd.startswith(r"git foo "):
        like_so = "git foo --bar " + cmd[8:]
    elif cmd.startswith(r"git baz "):
        like_so = "git baz --bar " + cmd[8:]
if like_so is not None:
    print("Please add the --bar option to your command, like so:")
    print("    " + like_so)
else:
    print("Please add the --bar option to your command.")

This way, I show the general message if I don't manage to get their invocation method. Of course, if I'm going to rely on changing my users' environment I might as well ensure that the various aliases export their own environment variables that I can look at, but at least this way allows me to use the same technique for any other script I might add later.

like image 371
itsadok Avatar asked Jul 12 '18 09:07

itsadok


People also ask

How do I see what processes are running in bash?

Bash commands to check running process: pgrep command – Looks through the currently running bash processes on Linux and lists the process IDs (PID) on screen. pidof command – Find the process ID of a running program on Linux or Unix-like system.

How can I tell what shell script is doing?

You need to find the log file associated with the application that is running, generally found in the /var/log folder. Then in a terminal window type in tail -f logfilename as that should be showing you the output of the file as it grows.

Which command is used to check the information about bash?

On Unix-like operating systems, test is a builtin command of the Bash shell that tests file attributes, and perform string and arithmetic comparisons.


2 Answers

No, there is no way to see the original text (before aliases/functions/etc).

Starting a program in UNIX is done as follows at the underlying syscall level:

int execve(const char *path, char *const argv[], char *const envp[]);

Notably, there are three arguments:

  • The path to the executable
  • An argv array (the first item of which -- argv[0] or $0 -- is passed to that executable to reflect the name under which it was started)
  • A list of environment variables

Nowhere in here is there a string that provides the original user-entered shell command from which the new process's invocation was requested. This is particularly true since not all programs are started from a shell at all; consider the case where your program is started from another Python script with shell=False.


It's completely conventional on UNIX to assume that your program was started through whatever name is given in argv[0]; this works for symlinks.

You can even see standard UNIX tools doing this:

$ ls '*.txt'         # sample command to generate an error message; note "ls:" at the front
ls: *.txt: No such file or directory
$ (exec -a foobar ls '*.txt')   # again, but tell it that its name is "foobar"
foobar: *.txt: No such file or directory
$ alias somesuch=ls             # this **doesn't** happen with an alias
$ somesuch '*.txt'              # ...the program still sees its real name, not the alias!
ls: *.txt: No such file 

If you do want to generate a UNIX command line, use pipes.quote() (Python 2) or shlex.quote() (Python 3) to do it safely.

try:
    from pipes import quote # Python 2
except ImportError:
    from shlex import quote # Python 3

cmd = ' '.join(quote(s) for s in open('/proc/self/cmdline', 'r').read().split('\0')[:-1])
print("We were called as: {}".format(cmd))

Again, this won't "un-expand" aliases, revert to the code that was invoked to call a function that invoked your command, etc; there is no un-ringing that bell.


That can be used to look for a git instance in your parent process tree, and discover its argument list:

def find_cmdline(pid):
    return open('/proc/%d/cmdline' % (pid,), 'r').read().split('\0')[:-1]

def find_ppid(pid):
    stat_data = open('/proc/%d/stat' % (pid,), 'r').read()
    stat_data_sanitized = re.sub('[(]([^)]+)[)]', '_', stat_data)
    return int(stat_data_sanitized.split(' ')[3])

def all_parent_cmdlines(pid):
    while pid > 0:
        yield find_cmdline(pid)
        pid = find_ppid(pid)

def find_git_parent(pid):
    for cmdline in all_parent_cmdlines(pid):
        if cmdline[0] == 'git':
            return ' '.join(quote(s) for s in cmdline)
    return None
like image 147
Charles Duffy Avatar answered Oct 23 '22 06:10

Charles Duffy


See the Note at the bottom regarding the originally proposed wrapper script.

A new more flexible approach is for the python script to provide a new command line option, permitting users to specify a custom string they would prefer to see in error messages.

For example, if a user prefers to call the python script 'myPyScript.py' via an alias, they can change the alias definition from this:

  alias myAlias='myPyScript.py $@'

to this:

  alias myAlias='myPyScript.py --caller=myAlias $@'

If they prefer to call the python script from a shell script, it can use the additional command line option like so:

  #!/bin/bash
  exec myPyScript.py "$@" --caller=${0##*/}

Other possible applications of this approach:

  bash -c myPyScript.py --caller="bash -c myPyScript.py"

  myPyScript.py --caller=myPyScript.py

For listing expanded command lines, here's a script 'pyTest.py', based on feedback by @CharlesDuffy, that lists cmdline for the running python script, as well as the parent process that spawned it. If the new -caller argument is used, it will appear in the command line, although aliases will have been expanded, etc.

#!/usr/bin/env python

import os, re

with open ("/proc/self/stat", "r") as myfile:
  data = [x.strip() for x in str.split(myfile.readlines()[0],' ')]

pid = data[0]
ppid = data[3]

def commandLine(pid):
  with open ("/proc/"+pid+"/cmdline", "r") as myfile:
    return [x.strip() for x in str.split(myfile.readlines()[0],'\x00')][0:-1]

pid_cmdline = commandLine(pid)
ppid_cmdline = commandLine(ppid)

print "%r" % pid_cmdline
print "%r" % ppid_cmdline

After saving this to a file named 'pytest.py', and then calling it from a bash script called 'pytest.sh' with various arguments, here's the output:

$ ./pytest.sh a b "c d" e
['python', './pytest.py']
['/bin/bash', './pytest.sh', 'a', 'b', 'c d', 'e']

NOTE: criticisms of the original wrapper script aliasTest.sh were valid. Although the existence of a pre-defined alias is part of the specification of the question, and may be presumed to exist in the user environment, the proposal defined the alias (creating the misleading impression that it was part of the recommendation rather than a specified part of the user's environment), and it didn't show how the wrapper would communicate with the called python script. In practice, the user would either have to source the wrapper or define the alias within the wrapper, and the python script would have to delegate the printing of error messages to multiple custom calling scripts (where the calling information resided), and clients would have to call the wrapper scripts. Solving those problems led to a simpler approach, that is expandable to any number of additional use cases.

Here's a less confusing version of the original script, for reference:

#!/bin/bash
shopt -s expand_aliases
alias myAlias='myPyScript.py'

# called like this:
set -o history
myAlias $@
_EXITCODE=$?
CALL_HISTORY=( `history` )
_CALLING_MODE=${CALL_HISTORY[1]}

case "$_EXITCODE" in
0) # no error message required
  ;;
1)
  echo "customized error message #1 [$_CALLING_MODE]" 1>&2
  ;;
2)
  echo "customized error message #2 [$_CALLING_MODE]" 1>&2
  ;;
esac

Here's the output:

$ aliasTest.sh 1 2 3
['./myPyScript.py', '1', '2', '3']
customized error message #2 [myAlias]
like image 4
philwalk Avatar answered Oct 23 '22 08:10

philwalk