Practical infra coding

Parse a small YAML inventory without a YAML library

mediumCSV, JSON and YAML

Problem statement

You must read an Ansible-style inventory on a locked-down host that has Python but no PyYAML, and you are not allowed to install packages. Write a parser for the small subset of YAML the file uses, then print each group's hosts and the shared variables.

The subset:

  • key: value and key: followed by an indented block. Indentation uses spaces only.
  • - value list items holding scalars.
  • Full-line # comments and blank lines.
  • Scalars: integers, true/false, null/~, quoted strings, otherwise plain strings.

Anything outside the subset must be rejected with line N: reason, not guessed at. That includes tabs, indentation that matches no open block, duplicate keys, and a mapping inside a list.

inventory.yml

YAML
# Ansible-style inventory
all:
children:
web:
hosts:
- web-01
- web-02
db:
hosts:
- db-01
vars:
ansible_user: deploy
ansible_port: 22
become: true

Examples

Example 1

Input: python solution.py inventory.yml

Output: group web: web-01, web-02 group db: db-01 vars: ansible_user='deploy', ansible_port=22, become=True 3 host(s)

Explanation: Variables print with repr, which shows the parsed types: 22 became an int and true became a boolean.

Example 2

Input: `python solution.py inventory-dup.yml` (the same file with ` ansible_user: root` added as line 15)

Output: inventory-dup.yml: line 15: duplicate key 'ansible_user'

Explanation: The message goes to stderr and the exit code is 1. Most YAML libraries silently keep the last duplicate. Rejecting it catches copy-paste mistakes.

Hints

Approach

Optimal

A line-by-line parser driven by an indentation stack.

  1. Skip blank and comment lines. Measure indentation as leading spaces; a leading tab is an error, as it is in real YAML.
  2. If the previous line was key: with no value (pending) and this line is indented deeper, create the child container now: a list if the line starts with - , otherwise a dict. Push it with its indentation. If the line is not deeper, the key keeps the value None.
  3. Pop the stack while the line is indented less than the top. The indentation must then equal the top's; otherwise it matches no open block.
  4. A - line appends a scalar to a list. A key: value line sets a dict entry, rejecting duplicates. split(": ", 1) splits on the first colon followed by a space, so url: http://a:80 keeps its value.

Each line is handled once. The error class carries the line number, so a mistake points to the exact line.

ComplexityTime O(n)Space O(n)
Python
import re
import sys
# Supported subset: "key: value", "key:" + indented block, "- scalar" list items,
# full-line "#" comments, space indentation. No flow style, anchors or multi-line strings.
class YamlSubsetError(ValueError):
def __init__(self, lineno, msg):
super().__init__(f"line {lineno}: {msg}")
def scalar(text):
if len(text) >= 2 and text[0] == text[-1] and text[0] in "'\"":
return text[1:-1]
if text in ("true", "false"):
return text == "true"
if text in ("null", "~"):
return None
if re.fullmatch(r"-?\d+", text):
return int(text)
return text # deliberately no YAML 1.1 "yes"/"no"/"on" booleans
def parse(text):
root = {}
stack = [(0, root)] # (indent, container) for each open block
pending = None # (indent, parent, key) for a "key:" with no value yet
for lineno, raw in enumerate(text.splitlines(), 1):
stripped = raw.strip()
if not stripped or stripped.startswith("#"):
continue
body = raw.lstrip(" ")
if body.startswith("\t"):
raise YamlSubsetError(lineno, "tab used for indentation")
indent = len(raw) - len(body)
body = body.rstrip()
if pending:
p_indent, parent, key = pending
pending = None
if indent > p_indent: # the block under "key:" starts here
child = [] if body == "-" or body.startswith("- ") else {}
parent[key] = child
stack.append((indent, child))
while indent < stack[-1][0]:
stack.pop()
if indent != stack[-1][0]:
raise YamlSubsetError(lineno, "indentation does not match any open block")
container = stack[-1][1]
if body == "-" or body.startswith("- "):
if not isinstance(container, list):
raise YamlSubsetError(lineno, "list item where a key was expected")
container.append(scalar(body[1:].strip()))
continue
if not isinstance(container, dict):
raise YamlSubsetError(lineno, "key inside a list (only scalar items are supported)")
if body.endswith(":"):
key, value = body[:-1], ""
elif ": " in body:
key, value = body.split(": ", 1)
else:
raise YamlSubsetError(lineno, "expected 'key: value' or 'key:'")
key = key.strip()
if key in container:
raise YamlSubsetError(lineno, f"duplicate key {key!r}")
container[key] = scalar(value.strip()) if value.strip() else None
if not value.strip():
pending = (indent, container, key)
return root
def main(path):
with open(path, encoding="utf-8") as f:
try:
inv = parse(f.read())
except YamlSubsetError as e:
sys.exit(f"{path}: {e}")
top = inv.get("all") or {}
total = 0
for group, body in (top.get("children") or {}).items():
hosts = (body or {}).get("hosts") or []
total += len(hosts)
print(f"group {group}: {', '.join(hosts)}")
vars_ = top.get("vars") or {}
print("vars:", ", ".join(f"{k}={v!r}" for k, v in vars_.items()))
print(f"{total} host(s)")
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "inventory.yml")

Follow-up questions

  • Support inline comments (port: 22 # ssh) without breaking "a # b" inside quotes.
  • Support lists of mappings (- name: web), which Kubernetes manifests use everywhere.
  • Resolve Ansible group inheritance: a host in web also gets the variables of every parent group.

Frequently asked questions

Normally you should: yaml.safe_load handles the full spec and is what you would use in a real repo (never yaml.load without a safe loader on untrusted input). This task comes from a real constraint, a host where you cannot install packages, and it tests whether you can write a small, strict parser and state its limits clearly.

YAML 1.1, which PyYAML follows, treats yes, no, on, off and even NO as booleans. That is the source of the well-known "Norway problem", where the country code NO becomes false. This subset accepts only true and false, which is also the YAML 1.2 behaviour.

YAML is everywhere in infra: Ansible, Kubernetes, CI pipelines. Interviewers use a restricted parser to test careful state handling with an indentation stack and error reporting with line numbers. It also shows whether you know YAML's type-guessing traps.