Build a JSON query CLI with argparse subcommands
Problem statement
Deploy scripts often need one value out of a JSON document: the image tag, the replica count, a label. Write jq_lite.py, a tiny tool with two subcommands:
jq_lite.py get FILE PATH [--default VALUE]prints the value at a dottedPATHsuch asspec.containers.0.image. Numeric parts index lists (negative indexes count from the end). Strings print raw so the output can go straight into a shell variable; numbers, booleans,null, objects and lists print as compact JSON.jq_lite.py keys FILE [PATH]lists the keys of the object atPATH(sorted), or the indexes of a list.
FILE may be - to read stdin. Exit codes: 0 found, 1 path not found (or keys on something that is not an object or list), 2 for a missing file, invalid JSON or bad usage. Error messages go to stderr. With --default, a missing path prints the default and exits 0.
deploy.json
{ "metadata": {"name": "api", "labels": {"app": "api", "tier": "backend"}}, "spec": { "replicas": 3, "paused": false, "containers": [ {"name": "api", "image": "registry.local/api:1.14.2", "ports": [8080, 9090]}, {"name": "sidecar", "image": "registry.local/proxy:2.1"} ] }}Examples
Example 1
Input: python jq_lite.py get deploy.json spec.containers.0.image
Output: registry.local/api:1.14.2
Example 2
Input: ```bash
python jq_lite.py get deploy.json spec.containers.-1.name
python jq_lite.py get deploy.json spec.paused
python jq_lite.py get deploy.json spec.containers.0.ports
python jq_lite.py keys deploy.json metadata.labels
python jq_lite.py get deploy.json spec.strategy --default RollingUpdate
```
Output: sidecar
false
[8080, 9090]
app
tier
RollingUpdate
Explanation: false and the list print as JSON, not as Python's False or [8080, 9090] repr.
Example 3
Input: ```bash
python jq_lite.py get deploy.json spec.containers.5.image; echo "exit: $?"
python jq_lite.py get deploy.json spec.replicas.x; echo "exit: $?"
python jq_lite.py get nope.json spec; echo "exit: $?"
python jq_lite.py; echo "exit: $?"
```
Output: jq_lite.py: spec.containers.5.image: not found
exit: 1
jq_lite.py: spec.replicas.x: not found
exit: 1
jq_lite.py: nope.json: No such file or directory
exit: 2
usage: jq_lite.py [-h] {get,keys} ...
jq_lite.py: error: the following arguments are required: command
exit: 2
Explanation: Going inside the number 3 is reported as not found instead of a TypeError traceback.
Hints
Approach
Optimal
Command line. Two subparsers share a pattern: a file, then a path. required=True on the subparsers makes a bare jq_lite.py a usage error with exit 2 instead of silently doing nothing. --default only exists on get, so keys --default is rejected by argparse.
Lookup. lookup walks the dotted path. At each step the current node decides what the next part means: a dict needs the key, a list needs an integer in range (negative indexes allowed), and a string, number or null cannot be entered at all. Every failure returns the MISSING sentinel rather than raising. A sentinel is necessary because null is a valid value: get on a key whose value is null must print null and exit 0, not report "not found".
Output. render prints strings as-is and everything else through json.dumps, so booleans come out as false rather than Python's False. That keeps the output valid for other JSON tools and predictable for shell scripts (tag=$(jq_lite.py get deploy.json spec.containers.0.image)).
Errors. Reading problems (missing file, invalid JSON) exit 2 with one line on stderr. A missing path exits 1, which lets a script distinguish "this key is not set" from "the input is broken". Nothing ever goes to stdout on failure, so a caller capturing stdout never mistakes an error message for a value.
O(size of the file + depth)Space O(size of the file)import argparseimport jsonimport sys MISSING = object() def lookup(doc, path): # Walk a dotted path like "spec.containers.0.image". Returns MISSING if absent. node = doc if path in ("", "."): return node for part in path.split("."): if isinstance(node, dict): if part not in node: return MISSING node = node[part] elif isinstance(node, list): if not part.lstrip("-").isdigit(): return MISSING i = int(part) if not -len(node) <= i < len(node): return MISSING node = node[i] else: return MISSING # trying to go inside a string or number return node def render(value): # Strings print raw (easy to use in shell scripts); everything else as JSON. return value if isinstance(value, str) else json.dumps(value) def load(path): if path == "-": return json.load(sys.stdin) with open(path, encoding="utf-8") as f: return json.load(f) def main(argv=None): parser = argparse.ArgumentParser(prog="jq_lite.py", description="Read values from JSON files.") sub = parser.add_subparsers(dest="command", required=True) g = sub.add_parser("get", help="print the value at PATH") g.add_argument("file", help="JSON file, or - for stdin") g.add_argument("path", help="dotted path, e.g. spec.containers.0.image") g.add_argument("--default", help="print this instead of failing when PATH is missing") k = sub.add_parser("keys", help="list the keys (or indexes) at PATH") k.add_argument("file") k.add_argument("path", nargs="?", default="") args = parser.parse_args(argv) try: doc = load(args.file) except OSError as e: print(f"jq_lite.py: {args.file}: {e.strerror}", file=sys.stderr) return 2 except json.JSONDecodeError as e: print(f"jq_lite.py: {args.file}: invalid JSON at line {e.lineno}: {e.msg}", file=sys.stderr) return 2 value = lookup(doc, args.path) if args.command == "get": if value is MISSING: if args.default is not None: print(args.default) return 0 print(f"jq_lite.py: {args.path}: not found", file=sys.stderr) return 1 print(render(value)) return 0 if isinstance(value, dict): print("\n".join(sorted(value))) elif isinstance(value, list): print("\n".join(str(i) for i in range(len(value)))) else: what = "not found" if value is MISSING else f"is a {type(value).__name__}, not an object or list" print(f"jq_lite.py: {args.path or '.'}: {what}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": sys.exit(main())Follow-up questions
- Add a
setsubcommand that writes a value back and keeps the file's key order. - Support a wildcard,
spec.containers.*.image, that prints one value per line. - Read YAML too. What would you need, given the standard library has no YAML parser?
Frequently asked questions
Pulling values out of JSON (API responses, Terraform output, Kubernetes manifests) is constant glue work. Writing the tool shows you can design a small CLI: subcommands, exit codes that scripts can rely on, stderr versus stdout, and careful handling of missing data.
In real work, use jq when it is installed. It is not always available on minimal images or locked-down hosts, and in an interview the point is the design: path walking, the sentinel for missing values, and exit codes.
The dotted syntax cannot express it. Real tools add escaping or bracket syntax, such as metadata.labels["app.kubernetes.io/name"]. A simple extension is to accept the path as a JSON array ('["metadata","labels","app.kubernetes.io/name"]').