Skip to content

ARGV holds strings and ENV is not a Hash

Level: 101 · anyone writing a script that takes arguments, reads the environment and has to exit with a status

One line: ARGV is an Array of Strings, $0 is the script name as it was invoked, ENV is a Hash-like object whose values must be Strings, exit n sets the status a parent reads from $?.exitstatus, and OptionParser turns flags into values in ten lines — the same shape as sys.argv, os.environ, sys.exit and argparse, with one inversion: exit false is 1 in Ruby and sys.exit(False) is 0 in Python.

A Ruby script sees its command line as ARGV, an ordinary Array of Strings — "1" stays "1" until you convert it — and the interpreter's own flags are already gone from it. $0 (alias $PROGRAM_NAME) is the script's name exactly as it was invoked, which is why if __FILE__ == $0 is Ruby's if __name__ == "__main__": — it is true in the file you ran and false in one you required. ENV looks like a Hash ([], fetch, each, to_h) but is a special object that talks to the process environment: reads return a String or nil, fetch raises KeyError for a missing name, and assigning anything but a String is a TypeError. exit raises SystemExit, and the number it carries — 0 for nothing, true, false or abort for 0, 0, 1, 1 — is what the parent sees in $?.exitstatus after system, Open3 or Process.wait; an uncaught exception exits 1. The program below cannot show any of this from the inside without the answer depending on how it was started, so it writes three small scripts into a temporary directory and runs each as a child Ruby, found through RbConfig.ruby, printing what the child printed and how it exited.

Verified output of argv_env_and_exit_status_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

 1. ruby child.rb 1 two -- ARGV holds Strings          ARGV=["1", "two"] classes=[String, String]
 2.   $0 is the script as invoked; __FILE__ == $0      __FILE__=child.rb $0=child.rb $PROGRAM_NAME=child.rb main?=true
 3.   ENV[] gives a String or nil                      ENV["DEMO_X"]="1" ENV["DEMO_MISSING"]=nil
 4.   ENV.fetch with a default                         fetch with defaults: "1" "d"
 5.   ENV.fetch without one                            KeyError: key not found: "DEMO_MISSING"
 6.   ENV values must be Strings                       TypeError: no implicit conversion of Integer into String
 7.   ENV is not a Hash, but it is Enumerable          ENV.class=Object is_a?(Hash)=false to_h.class=Hash each?=true
 8.   the child ran exit 2: $?.exitstatus, success?    2, false
 9.   the status object's class                        Process::Status
10. ruby main.rb, which require_relatives child.rb     __FILE__=child.rb $0=main.rb $PROGRAM_NAME=main.rb main?=false
11.   the helper is defined, the guarded part did not run helper from child.rb; in main.rb: $0=main.rb; exit=0
12. exit with no argument                              0
13. exit true                                          0
14. exit false                                         1
15. abort "bye": status, and stderr                    1, ["bye"]
16. an uncaught exception                              1
17. OptionParser: -n 3 --verbose in.txt                options={count: 3, verbose: true} ARGV=["in.txt"] exit=0
18.   an unknown option                                OptionParser::InvalidOption: invalid option: --nope exit=1
19.   a non-Integer for -n                             OptionParser::InvalidArgument: invalid argument: -n x exit=1
20.   -h prints the generated help and exits           0
      Usage: opts.rb [options] FILE
          -n, --count N                    How many times
          -v, --verbose                    Say more

Reading the output

Rows 1–9 are one run of child.rb 1 two with DEMO_X=1 in its environment: ARGV is ["1", "two"] and both are Strings (row 1); $0, $PROGRAM_NAME and __FILE__ all say child.rb, so the guard is true (row 2); ENV["DEMO_X"] is the String "1" and a missing name is nil (row 3), fetch takes a default (row 4) or raises KeyError naming the key (row 5), and ENV["N"] = 1 is a TypeError because the environment holds only Strings (row 6). Row 7 is the claim in the title: ENV.class is Object — it is not a Hash and is_a?(Hash) is false — but it is Enumerable and to_h gives a real Hash. The child ended with exit 2, and the parent's Process::Status reports exitstatus 2 and success? false (rows 8–9). Rows 10–11 run main.rb, which require_relatives the same file: now $0 is main.rb, __FILE__ == $0 is false inside child.rb, so the guarded block — including its exit 2 — does not run, while the method it defined is available, and main.rb exits 0. Rows 12–16 are the exit statuses: exit is 0, exit true 0, exit false 1, abort "bye" writes the message to stderr and exits 1, and an uncaught exception is 1 as well. Rows 17–20 are OptionParser: -n 3 --verbose in.txt becomes {count: 3, verbose: true} with in.txt left in ARGV (the Integer argument converted "3"), an unknown option and a non-integer are OptionParser::InvalidOption and OptionParser::InvalidArgument — subclasses of OptionParser::ParseError, which the script rescues and turns into exit 1 — and -h, which OptionParser handles for you, prints the help it generated from the on declarations and exits 0.

Compared with Python

The twin runs the same three scripts as child Pythons through subprocess.run([sys.executable, ...]). sys.argv holds strs too, but its first element is the script name (row 1) — Ruby keeps that in $0 and leaves ARGV for the arguments. The main-guard is __name__ == "__main__" (row 2), and in the imported module __name__ is child (row 10). os.environ is a _Environ, a Mapping rather than a dict (row 7), with the same String-only rule (row 6); a missing key is a KeyError (row 5) and .get is Ruby's fetch with a default (row 4). The exit status is a plain int (row 9), and here is the inversion: sys.exit(True) is 1 and sys.exit(False) is 0, because bool is a subclass of int (rows 13–14), where Ruby's exit false means failure (row 14 on the Ruby side). sys.exit("bye") is Ruby's abort — the string goes to stderr and the status is 1 (row 15). argparse parses the same command line to the same values (row 17), but on an error it prints usage to stderr and exits 2, not 1 (rows 18–19), and its -h text is not shown because its wording differs between Python 3.12 and 3.14 (row 20) — this library's keys must match on both. The Python library's -c is not the prompt ↗ measures how the interpreter's own flags and sys.argv relate.

Verified output of argv_env_and_exit_status_py.py — regenerated by tools/run_examples.py, never hand-typed.

 1. python child.py 1 two -- sys.argv holds strs       sys.argv=['child.py', '1', 'two'] classes=['str', 'str', 'str']
 2.   argv[0] is the script as invoked; __name__       __file__=child.py sys.argv[0]=child.py __name__=__main__ main?=True
 3.   os.environ.get gives a str or None               os.environ.get('DEMO_X')='1' os.environ.get('DEMO_MISSING')=None
 4.   os.environ.get with a default                    get with defaults: '1' 'd'
 5.   os.environ[] without one                         KeyError: 'DEMO_MISSING'
 6.   os.environ values must be strs                   TypeError
 7.   os.environ is not a dict, but it is a Mapping    type=_Environ isinstance(dict)=False dict(os.environ) is a dict iterable=True
 8.   the child ran sys.exit(2): returncode, ok?       2, False
 9.   the status is just an int                        int
10. python main.py, which imports child                __file__=child.py sys.argv[0]=main.py __name__=child main?=False
11.   the helper is defined, the guarded part did not run helper from child.py; in main.py: sys.argv[0]=main.py; exit=0
12. sys.exit() with no argument                        0
13. sys.exit(True) -- True is the int 1                1
14. sys.exit(False) -- False is the int 0              0
15. sys.exit('bye'): status, and stderr                1, ['bye']
16. an uncaught exception                              1
17. argparse: -n 3 --verbose in.txt                    count=3 verbose=True file='in.txt' exit=0
18.   an unknown option: exit, stderr starts with usage: 2, True
19.   a non-int for -n: exit, stderr starts with usage: 2, True
20.   -h prints the generated help and exits           0 (text differs between 3.12 and 3.14, so it is not shown)

Try it

  1. Write greet.rb: an OptionParser flag --greeting WORD (default Hello), one greeting line per name in ARGV, and exit status 2 when no name was given. Run it three ways as a child with Open3.capture2(RbConfig.ruby, ...) and print each run's output and status.
  2. Make the default greeting come from ENV.fetch("GREET_DEFAULT", "Hello"), and pass the variable to the child through the environment Hash that Open3.capture2 accepts as its first argument — without touching the parent's own ENV.
Solution to 1

argv_env_and_exit_status_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Kata: a greeting script with an OptionParser flag and a meaningful exit
# status, run three ways as a child Ruby; the parent prints each run's output
# and status.
require "rbconfig"
require "open3"
require "tmpdir"

SCRIPT = <<~'RB'
  require "optparse"
  greeting = "Hello"
  OptionParser.new { |o| o.on("-g", "--greeting WORD") { |w| greeting = w } }.parse!(ARGV)
  ARGV.each { |name| puts "#{greeting}, #{name}!" }
  exit(ARGV.empty? ? 2 : 0)
RB

Dir.mktmpdir do |dir|
  Dir.chdir(dir) do
    File.write("greet.rb", SCRIPT)
    [
      ["greet.rb", "Ada", "Alan"],
      ["greet.rb", "--greeting", "Hi", "Grace"],
      ["greet.rb"],
    ].each do |args|
      out, status = Open3.capture2(RbConfig.ruby, *args)
      puts "ruby #{args.join(" ")}"
      out.each_line { |line| puts "  #{line}" }
      puts "  exit status: #{status.exitstatus}"
    end
  end
end

Verified output of argv_env_and_exit_status_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

ruby greet.rb Ada Alan
  Hello, Ada!
  Hello, Alan!
  exit status: 0
ruby greet.rb --greeting Hi Grace
  Hi, Grace!
  exit status: 0
ruby greet.rb
  exit status: 2

See also