skip to content

OS Interaction, Processes, and CLIs

Scripts read the environment, shell out to other programs, and take arguments from a user — the os and sys surface, subprocess, and argparse. Interviewers read this area as script literacy.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

13

In argparse, what distinguishes a positional argument from an optional one?

level: juniorimportance: must knowfreq 70%

answer

  1. Look at the first character
  2. Order versus name
  3. One of them is required by default
  4. Where the parsed values land
  5. nargs="?" softens a positional

basics

~20 s

The leading dashes decide. add_argument("dataset") defines a positional argument: required, matched by its place on the command line. add_argument("--dataset") defines an optional argument: matched by name, and when absent it takes its default. Both land on the Namespace.

solid answer

~40 s

`ArgumentParser.add_argument()` branches on the first string you give it. No leading dash means a **positional** argument: it is matched by order, it is required, and the name you gave becomes the attribute name. A leading dash means an **optional** argument: it is matched by name anywhere on the line, it is not required unless you pass `required=True`, and when it is missing argparse stores its `default` (`None` if you gave none). `action="store_true"` builds a valueless flag whose default is `False`. `parse_args()` returns one `argparse.Namespace`; read it with attribute access, or `vars(args)` for a plain dict. A positional can be softened with `nargs="?"` or `nargs="*"`, and the generated `-h` lists the two kinds in separate groups, `positional arguments:` and `options:`.

code

python · 11 lines
python
import argparse

parser = argparse.ArgumentParser(prog="report")
parser.add_argument("dataset")                          # positional: required
parser.add_argument("--rows", type=int, default=6800)   # optional: has a default
parser.add_argument("--verbose", action="store_true")   # flag: default False

args = parser.parse_args(["sales", "--verbose"])
print(args)
print(args.dataset, args.rows, args.verbose)
print(vars(args))

go deeper

for a junior

Be ready to write a five-line parser from memory: one positional, one --option with a default, one store_true flag, then read them off the object parse_args() returns. Knowing that dashes make the difference is the whole answer here.

for a middle

Explain the mechanics: how the dest is derived for each kind, why a positional is required unless nargs relaxes it, and what argparse actually stores when an optional is absent. Mention that vars() turns the Namespace into a dict.

for a senior

Show judgement about the interface users will type. Positionals for the subject, options for modifiers, sane defaults so the common invocation is short, and help text that survives being read by someone at 3am debugging a failed job.

for a principal

Own the consistency story across a team's tools: which shape is a positional everywhere, whether abbreviations are allowed, and how flag names stay stable once scripts and schedulers depend on them. Renaming an option is a breaking change to every caller.

### The switch is the dash, not a keyword `argparse.ArgumentParser.add_argument()` inspects the first string you hand it. If that string begins with one of the parser's prefix characters — `-`, unless you changed `prefix_chars` — argparse builds an *optional* argument. If it does not, argparse builds a *positional* argument. Nothing else selects between the two: there is no `positional=True` keyword, and passing `required=` to a positional raises `TypeError: 'required' is an invalid argument for positionals`. Everything else about the two kinds follows from that one branch. ### What each kind implies A positional is matched by **place**. The parser consumes the non-flag words of the command line in the order the positionals were declared, and a missing one is a parse error: argparse prints the usage line plus `error: the following arguments are required: dataset` to standard error and exits with status 2. A positional is therefore *required by default*, and a `default=` on one is only reachable if you also relax `nargs`. An optional is matched by **name**, and may appear anywhere on the line, in any order relative to other optionals. Long options accept both `--rows 500` and `--rows=500`; single-letter options accept `-r500`, `-r 500` and bundling such as `-ab`. An optional is *not required by default*; when the user omits it, argparse stores whatever you passed as `default`, and `None` when you passed nothing. `required=True` is available but reads as a contradiction in the help text, where the argument still appears in the `options:` group. ### Where the value lands `parse_args()` returns a single `argparse.Namespace` — a plain attribute bag holding one entry per declared argument. You read `args.rows`, and `vars(args)` converts it to a dict, which is the idiomatic way to log the whole parse or to splat the values into a function call. The attribute name (argparse calls it the `dest`) is derived differently for the two kinds. For an optional, argparse takes the first long option string, strips the leading dashes and replaces internal hyphens with underscores, so `--dry-run` becomes `args.dry_run`. For a positional, the name you wrote **is** the dest verbatim: `add_argument("input-file")` produces a Namespace key `'input-file'`, which attribute syntax cannot reach at all. Name positionals with underscores, or pass an explicit `dest=`. ### Flags are just optionals with an action `action="store_true"` declares an optional that takes no value: present means `True`, absent means `False`, and you never write `default=False` yourself. Its mirror is `action="store_false"` (absent means `True`). `action="count"` gives `-vvv` semantics, `action="append"` collects repeats into a list, and `argparse.BooleanOptionalAction` (added in 3.9) generates the `--cache` / `--no-cache` pair from one declaration. ### Softening a positional `nargs` is how a positional stops being mandatory. `nargs="?"` accepts zero or one value and falls back to `default`; `nargs="*"` accepts any number and yields a list; `nargs="+"` accepts one or more and still errors on none. Those are the supported ways to make a positional omissible — `required=False` is rejected outright. ### Choosing between them when you design a CLI The convention users already know is: the positional is the *thing the command acts on*, and optionals are *modifiers of how it acts*. A report generator takes the dataset as a positional and `--rows`, `--since` and `--verbose` as optionals. Two or three positionals is usually the limit before order becomes a memory test; past that, name them. ### Generated help and two pitfalls Unless you pass `add_help=False`, argparse installs `-h/--help` for you and groups the two kinds under their own headings. The heading for optionals was renamed from `optional arguments:` to `options:` in Python 3.10, which is the difference you will notice comparing help output across versions. Two behaviours surprise people. First, `allow_abbrev` defaults to `True`, so any unambiguous prefix of a long option is accepted: `--verb` works when `--verbose` is the only match. Pass `allow_abbrev=False` if you want strict names. Second, a value that itself starts with a dash confuses the matcher — `--rows -1` looks like two options — so write `--rows=-1`. When you need the leftovers rather than an error on unknown words, `parse_known_args()` returns a `(namespace, remaining_list)` tuple instead of failing.

  • How do you make a positional argument that the user may omit?
    Relax `nargs`, not `required`. `nargs="?"` accepts zero or one value and falls back to `default`; `nargs="*"` accepts any number and yields a list, so `default=[]` is the sane pairing. Passing `required=False` to a positional is not supported — argparse raises `TypeError: 'required' is an invalid argument for positionals`.
  • Where does the Namespace attribute name for --dry-run come from, and when does that rule not apply?
    For an optional, argparse takes the first long option string, strips the leading dashes and replaces internal hyphens with underscores, so `--dry-run` becomes `args.dry_run`. Positionals are different: the name you passed is the dest verbatim, so `add_argument("input-file")` yields the key `'input-file'`, unreachable by attribute access. Name positionals with underscores or pass `dest=`.
  • When is required=True on an optional argument a design smell?
    When the argument is really the subject of the command. `report --dataset sales` with `required=True` forces users to type a name that carries no choice, and `-h` still shows it under `options:`, which reads as untrue. Make it a positional instead. `required=True` earns its place only for a genuinely modal input, such as an output target that has no safe default.

saying these in an interview costs you the question

  • Says a required= keyword is what makes an argument positional
  • Thinks a positional can never be omitted, ignoring nargs
  • Expects args.dry-run to work as attribute access
  • Believes a store_true flag defaults to None
  • Claims optional arguments must be given in declaration order
  • Reads sys.argv by hand after calling parse_args

context

open as a page

What is the difference between os.environ["DB_URL"] and os.getenv("DB_URL", default)?

level: juniorimportance: must knowfreq 70%

basics

~20 s

Subscripting os.environ raises KeyError when the variable is unset; os.getenv returns None, or the default you pass. Subscript settings the program cannot run without, so it fails loudly at startup, and use os.getenv only where a default is genuinely correct.

open as a page

What does subprocess.run() return, and how do you capture the child's output and fail on a non-zero exit?

level: juniorimportance: must knowfreq 76%

basics

~20 s

subprocess.run() blocks until the child exits and returns a CompletedProcess carrying args, returncode, stdout and stderr. Pass capture_output=True and text=True to get output as str, and check=True to raise CalledProcessError when the exit code is not zero.

open as a page

How should a Python CLI report failure — sys.exit codes, and sys.stderr versus sys.stdout?

level: middleimportance: must knowfreq 50%

basics

~20 s

Exit 0 for success and a small non-zero code for failure via sys.exit, which raises SystemExit. Send results a caller might pipe to sys.stdout, and diagnostics, warnings and errors to sys.stderr so they survive redirection.

open as a page

In subprocess.run, how is the args parameter interpreted with shell=True versus the default, and what does it cost?

level: middleimportance: must knowfreq 68%

basics

~20 s

With the default shell=False, args is a sequence and each element becomes exactly one argv entry, executed directly with no shell parsing. With shell=True on POSIX, args should be a single string handed to /bin/sh -c, so the shell re-parses it and metacharacters inside interpolated values take effect.

open as a page

How do you give an argparse CLI git-style subcommands and dispatch to the right handler?

level: middleimportance: should knowfreq 45%

basics

~10 s

Call parser.add_subparsers(dest="command", required=True), then add_parser("build") on the object it returns for each subcommand, giving each its own arguments. Attach a handler with set_defaults(func=build) so parse_args() leaves args.func ready to call.

open as a page

Why does argparse store the value of --retries 3 as a string, and what changes it?

level: middleimportance: should knowfreq 55%

basics

~20 s

A command line is text and argparse converts nothing unless asked. Adding type=int to add_argument("--retries") makes argparse call int() on the supplied value, so parse_args() hands back 3 instead of "3". Without type=, every parsed value stays a str.

open as a page

How do sys.platform, os.name and platform.system() differ, and when do you use each?

level: middleimportance: should knowfreq 45%

basics

~10 s

sys.platform is a constant fixed when the interpreter was built ('linux', 'darwin', 'win32'). os.name is coarser, only 'posix' or 'nt'. platform.system() asks the running system and returns 'Linux', 'Darwin' or 'Windows'.

open as a page

Why can writing to a subprocess.Popen pipe deadlock, and what does Popen.communicate() do about it?

level: middleimportance: should knowfreq 48%

basics

~20 s

An OS pipe has a small fixed kernel buffer. If the parent keeps writing to the child's stdin without draining its stdout, the child blocks on a full output pipe, stops reading input, and both sides wait forever. Popen.communicate() services both directions at once, so it cannot deadlock that way.

open as a page

Why does argparse replace an exception raised inside a custom type= callable with 'invalid value'?

level: seniorimportance: should knowfreq 30%

basics

~20 s

argparse calls the converter inside a try block: ValueError, TypeError and ArgumentTypeError become a parse error, printing the usage line and one message to standard error and exiting with status 2. Raise argparse.ArgumentTypeError to control that message.

open as a page

A collector's os.walk over a spool directory silently skips subtrees and hits missing files. Why?

level: seniorimportance: should knowfreq 35%

basics

~20 s

os.walk lists one directory at a time as you consume it, so a tree changing underneath is never a consistent snapshot — and by default it ignores listing errors, so an unreadable subtree looks empty instead of raising.

open as a page

When subprocess.run's timeout= expires in a nightly index rebuilder, what happens to the child and what can survive?

level: seniorimportance: should knowfreq 42%

basics

~20 s

subprocess.run kills the direct child, waits for it, then re-raises TimeoutExpired carrying the command and whatever output was captured. Only that one process dies: under shell=True the shell dies while the program it launched keeps running, so grandchildren survive and can hold the pipes open.

open as a page

How would you structure os.environ-based configuration across a fleet of Python services, and where does the environment stop being the right home for a setting?

level: principalimportance: should knowfreq 40%

basics

~20 s

Read every variable once at startup into a single validated, immutable config object, and exit non-zero naming the offending variable when validation fails. The environment suits small flat scalars only; structured, large or rotating payloads live behind a pointer.

open as a page