Parse a small YAML inventory without a YAML library
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: valueandkey:followed by an indented block. Indentation uses spaces only.- valuelist 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
# Ansible-style inventoryall: children: web: hosts: - web-01 - web-02 db: hosts: - db-01 vars: ansible_user: deploy ansible_port: 22 become: trueExamples
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.
- Skip blank and comment lines. Measure indentation as leading spaces; a leading tab is an error, as it is in real YAML.
- 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 valueNone. - 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.
- A
-line appends a scalar to a list. Akey: valueline sets a dict entry, rejecting duplicates.split(": ", 1)splits on the first colon followed by a space, sourl: http://a:80keeps its value.
Each line is handled once. The error class carries the line number, so a mistake points to the exact line.
O(n)Space O(n)import reimport 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
webalso 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.