Skip to content
Log in Sign up My account

6.1 and 6.2 Python and JSON

Blueprint 6.1: Interpret basic Python components and scripts. Blueprint 6.2: Construct valid JSON-encoded files

  • 6.1 Read a short Python script and say what it prints, what it sends, or why it fails: data types, variables, lists and dictionaries, conditionals, loops, functions, modules, exceptions, and the requests and json libraries
  • 6.2 Write or fix a JSON document: the six value types, quoting rules, nesting, and how JSON maps to Python objects and to YANG-modelled payloads

The verbs are interpret and construct. You will not write a program from scratch on the exam. You will be shown 10 to 25 lines of Python and asked what happens, or shown a JSON body and asked which version is valid or what is wrong with it. Read code the way the interpreter does: top to bottom, one line at a time, keeping track of every variable.

Python building blocks

Data types

Type Example Mutable Notes
str "GigabitEthernet1" No Single or double quotes are both fine in Python
int 1500 No Whole numbers
float 99.5 No Decimal numbers
bool True, False No Capital first letter in Python
None None No "No value", the Python equivalent of JSON null
list ["Gi1", "Gi2"] Yes Ordered, indexed from 0, duplicates allowed
tuple ("10.1.1.1", 22) No Ordered, cannot be changed after creation
dict {"name": "Gi1", "mtu": 1500} Yes Key-value pairs, looked up by key
set {10, 20, 30} Yes Unordered, no duplicates

type(x) returns the type of a value, and len(x) returns the number of items in a string, list, tuple, dict or set.

Indexing and slicing

Lists and strings are indexed from 0. Negative indexes count from the end, so -1 is the last item. A slice [start:stop] includes start and stops before stop.

vlans = [10, 20, 30, 40, 50]
print(vlans[0])      # 10
print(vlans[-1])     # 50
print(vlans[1:3])    # [20, 30]
print(len(vlans))    # 5

name = "GigabitEthernet1"
print(name[:2])      # Gi

Dictionaries

A dictionary maps keys to values. Network APIs return almost everything as nested dictionaries and lists, so this is the type you will read most.

device = {
    "hostname": "R1",
    "mgmt_ip": "192.168.100.1",
    "interfaces": ["Gi1", "Gi2", "Gi3"],
    "platform": {"model": "C8000V", "version": "17.12"},
}

print(device["hostname"])               # R1
print(device["platform"]["version"])    # 17.12
print(device["interfaces"][1])          # Gi2
print(device.get("serial", "unknown"))  # unknown, no KeyError
device["site"] = "HQ"                    # add a new key
for key, value in device.items():
    print(key, "=", value)

device["serial"] would raise a KeyError because the key does not exist. device.get("serial") returns None instead, or the default you pass as the second argument.

Conditionals, loops and functions

Python uses indentation to mark blocks. A line ending in a colon (if, for, while, def, try) starts a block, and every line in that block must be indented the same amount. Mixed indentation raises an IndentationError before anything runs.

def classify(util):
    """Return a label for an interface utilisation percentage."""
    if util >= 90:
        return "critical"
    elif util >= 70:
        return "warning"
    else:
        return "ok"


readings = {"Gi1": 45, "Gi2": 91, "Gi3": 70}
for intf, util in readings.items():
    print(f"{intf}: {classify(util)}")

Output:

Gi1: ok
Gi2: critical
Gi3: warning

Gi3 is exactly 70, and >= includes 70, so it is a warning. Only the first branch that is true runs; once util >= 90 fails and util >= 70 succeeds, the else is skipped.

Construct What it does Typical trap
for x in items: Runs the block once per item Iterating a dict gives its keys unless you use .items() or .values()
for i in range(3): Counts 0, 1, 2 range(3) never reaches 3
while cond: Repeats while cond is true Missing update to cond is an infinite loop
break / continue Leave the loop / skip to the next item break ends only the innermost loop
def f(a, b=5): Function with a default argument A function with no return returns None
and, or, not Boolean logic == compares values; = assigns

Strings and f-strings

line = "GigabitEthernet1   192.168.100.1   YES manual up   up"
fields = line.split()            # split on any whitespace
print(fields[0], fields[1])      # GigabitEthernet1 192.168.100.1
print(fields[-1] == "up")        # True
print(f"{fields[0]} has IP {fields[1]}")
print("Gi" in fields[0])         # True
print(line.startswith("Gig"))    # True

An f-string (f"...{expr}...") evaluates each expression in braces and inserts the result. Without the leading f, the braces are printed literally.

Modules and imports

Statement Effect
import json Use as json.loads(...)
from json import loads Use as loads(...) directly
import requests Third-party HTTP library, installed with pip
from ncclient import manager NETCONF client library
from netmiko import ConnectHandler SSH screen-scraping library for CLI devices
if __name__ == "__main__": Run this block only when the file is executed directly, not when it is imported

Exceptions

import json

raw = '{"hostname": "R1", "uptime": 86400}'
try:
    data = json.loads(raw)
    print(data["serial"])
except KeyError as err:
    print("missing key:", err)
except json.JSONDecodeError:
    print("not valid JSON")
finally:
    print("done")

The JSON is valid, so json.loads succeeds, then data["serial"] raises KeyError. The first matching except runs and prints missing key: 'serial', and finally always runs. Output: missing key: 'serial' then done.

Common exceptions you may be asked to identify:

Exception Cause
KeyError Dictionary key does not exist
IndexError List index past the end
TypeError Wrong type for the operation, such as "Gi" + 1 or indexing a list with a string
NameError Variable used before it was assigned, or misspelled
IndentationError / SyntaxError The file cannot be parsed, so nothing runs
json.JSONDecodeError json.loads was given text that is not valid JSON
requests.exceptions.ConnectionError No TCP connection to the server

Exam trap

A SyntaxError or IndentationError stops the whole script before the first line executes, so earlier print statements produce no output. A runtime error such as KeyError happens only when that line is reached, so everything printed before it still appears.

The requests library

requests is how Python scripts talk to REST APIs such as Catalyst Center, SD-WAN Manager and RESTCONF.

import requests

BASE = "https://192.168.100.1/restconf/data"
HEADERS = {"Accept": "application/yang-data+json"}

resp = requests.get(
    f"{BASE}/ietf-interfaces:interfaces",
    headers=HEADERS,
    auth=("admin", "Cisco123!"),   # HTTP Basic authentication
    verify=False,                  # lab only: skip certificate validation
    timeout=10,
)
print(resp.status_code)            # for example 200
resp.raise_for_status()            # raise an exception for 4xx or 5xx
data = resp.json()                 # parse the JSON body into dicts and lists
Part of the response Type What it holds
resp.status_code int HTTP status, such as 200 or 404
resp.ok bool True for any status below 400
resp.text str Raw body as text
resp.json() dict or list Body parsed as JSON; raises an error if the body is not JSON
resp.headers dict-like Response headers, case-insensitive keys
Call Body argument Sends
requests.get(url, params={...}) none Query string such as ?hostname=R1
requests.post(url, json=payload) json= Serialises a dict to JSON and sets Content-Type: application/json
requests.post(url, data=payload) data= Form-encoded fields (or a raw string if you pass a string)
requests.put(...), requests.patch(...), requests.delete(...) as above The other HTTP methods

Exam trap

json=payload and data=json.dumps(payload) both send a JSON body, but only json= sets the content type for you. RESTCONF needs Content-Type: application/yang-data+json, so a RESTCONF script sets that header explicitly in either case. data=payload with a dict sends form fields, which is exactly what SD-WAN Manager's login (j_security_check) expects and what a JSON API will reject.

JSON: the rules

JSON (RFC 8259) is a text format with six value types:

JSON type Example Python type after json.loads()
object {"name": "Gi1"} dict
array [10, 20, 30] list
string "up" str
number 1500, 99.5, -3, 1e3 int or float
boolean true, false True, False
null null None

The rules that exam questions test:

  1. Keys are always strings in double quotes. {name: "R1"} and {'name': 'R1'} are invalid.
  2. Strings use double quotes only.
  3. true, false and null are lowercase and unquoted. "true" is a string, not a boolean.
  4. Members and array items are separated by commas, with no trailing comma after the last one.
  5. No comments of any kind.
  6. Numbers have no leading zeros (010 is invalid), no hex, no + sign, and no quotes (quoting makes them strings).
  7. Whitespace and line breaks between tokens do not matter.
  8. Duplicate keys in one object are discouraged by the RFC and their handling is unpredictable, so treat them as an error.

Mapping Python to JSON

import json

payload = {
    "hostname": "R1",
    "vlans": (10, 20),          # tuple becomes a JSON array
    "enabled": True,            # becomes true
    "location": None,           # becomes null
}
text = json.dumps(payload, indent=2)
print(text)
back = json.loads(text)
print(type(back["vlans"]).__name__)   # list, not tuple

Output:

{
  "hostname": "R1",
  "vlans": [
    10,
    20
  ],
  "enabled": true,
  "location": null
}
list
Function Direction Mnemonic
json.loads(s) JSON string to Python load string
json.dumps(obj) Python to JSON string dump string
json.load(f) JSON file to Python
json.dump(obj, f) Python to JSON file

A Python set cannot be serialised (TypeError: Object of type set is not JSON serializable), and a tuple comes back as a list.

graph LR
  P["Python dict and list"] -->|"json.dumps"| S["JSON text"]
  S -->|"json.loads"| P
  S -->|"HTTP body"| API["REST API"]
  API -->|"HTTP body"| S

JSON in RESTCONF payloads

RESTCONF JSON follows RFC 7951, which adds a few rules on top of plain JSON:

  • The top-level member name is module-qualified: "ietf-interfaces:interfaces". A child from a different module (an augmentation) is also prefixed, as in "ietf-ip:ipv4" inside an ietf-interfaces interface.
  • A YANG container is a JSON object, a YANG list is a JSON array of objects, a leaf-list is an array of values, and a leaf is a single member.
  • Identity values carry the module name: "type": "iana-if-type:softwareLoopback".
  • 64-bit integer and decimal64 values are encoded as JSON strings so they survive parsers that use floating point.

Here is a valid payload that creates Loopback100 with the IETF model:

{
  "ietf-interfaces:interface": {
    "name": "Loopback100",
    "description": "Created by RESTCONF",
    "type": "iana-if-type:softwareLoopback",
    "enabled": true,
    "ietf-ip:ipv4": {
      "address": [
        {
          "ip": "10.100.100.1",
          "netmask": "255.255.255.255"
        }
      ]
    }
  }
}

address is a YANG list, so it is an array even with one entry. enabled is a boolean, so it is unquoted.

Exam trap

Questions that ask "which payload is valid" usually differ by one character: a single quote, a capitalised True, a trailing comma, a missing comma between members, or a list written as an object. Check those five things in that order and you will find the fault quickly.

Reading a whole script

This is the kind of script you will be asked to interpret. It uses a mocked response so it runs without a device.

import json

# Body of GET /restconf/data/ietf-interfaces:interfaces (trimmed)
body = """
{
  "ietf-interfaces:interfaces": {
    "interface": [
      {"name": "GigabitEthernet1", "enabled": true,
       "ietf-ip:ipv4": {"address": [{"ip": "192.168.100.1", "netmask": "255.255.255.0"}]}},
      {"name": "GigabitEthernet2", "enabled": false, "ietf-ip:ipv4": {}},
      {"name": "Loopback0", "enabled": true,
       "ietf-ip:ipv4": {"address": [{"ip": "10.255.255.1", "netmask": "255.255.255.255"}]}}
    ]
  }
}
"""

data = json.loads(body)
up_count = 0
for intf in data["ietf-interfaces:interfaces"]["interface"]:
    addrs = intf["ietf-ip:ipv4"].get("address", [])
    ip = addrs[0]["ip"] if addrs else "unassigned"
    state = "up" if intf["enabled"] else "shutdown"
    if intf["enabled"]:
        up_count += 1
    print(f"{intf['name']:<18}{ip:<16}{state}")
print("enabled:", up_count)

Output:

GigabitEthernet1  192.168.100.1   up
GigabitEthernet2  unassigned      shutdown
Loopback0         10.255.255.1    up
enabled: 2

Walk through it the way an exam question expects:

  1. json.loads turns the string into a dict. The interfaces are a list under ["ietf-interfaces:interfaces"]["interface"].
  2. Gi2 has an empty ietf-ip:ipv4 object, so .get("address", []) returns an empty list, and the conditional expression picks "unassigned". Using intf["ietf-ip:ipv4"]["address"] instead would raise KeyError on Gi2.
  3. {ip:<16} left-aligns the value in a 16-character column. Formatting details like this are rarely tested, but they explain the spacing.
graph TB
  R["dict: top level"] --> I["key ietf-interfaces:interfaces, a dict"]
  I --> L["key interface, a list"]
  L --> E0["index 0: dict for GigabitEthernet1"]
  L --> E1["index 1: dict for GigabitEthernet2"]
  E0 --> V4["key ietf-ip:ipv4, a dict"]
  V4 --> A["key address, a list"]
  A --> A0["index 0: dict with ip and netmask"]

Lab: parse RESTCONF output with Python

Topology: the same two nodes as Sim 12: AUTO (Ubuntu, 192.168.100.50) connected to R1 (Catalyst 8000v, Gi1 192.168.100.1/24). IOSv does not support RESTCONF.

graph LR
  AUTO["AUTO Ubuntu<br/>192.168.100.50"] -- "eth0 192.168.100.0/24 Gi1" --- R1["R1 C8000v<br/>192.168.100.1"]
  1. Enable RESTCONF on R1:

    username admin privilege 15 secret Cisco123!
    ip http secure-server
    ip http authentication local
    restconf
    interface Loopback0
     ip address 10.255.255.1 255.255.255.255
    
  2. Verify the RESTCONF process. Wait a minute after restconf for the processes to start.

    R1# show platform software yang-management process
    confd            : Running
    nesd             : Running
    syncfd           : Running
    ncsshd           : Not Running
    dmiauthd         : Running
    nginx            : Running
    ndbmand          : Running
    pubd             : Running
    

    nginx is the HTTPS front end for RESTCONF and must be Running. ncsshd is the NETCONF SSH server, which stays down until you also configure netconf-yang. The exact process list varies a little between releases.

  3. On AUTO, save the interface data to a file:

    curl -sk -u admin:Cisco123! -H "Accept: application/yang-data+json" \
      https://192.168.100.1/restconf/data/ietf-interfaces:interfaces > intf.json
    
  4. Save the script from "Reading a whole script" as intf.py, replace the body = """...""" block with body = open("intf.json").read(), and run python3 intf.py. You should see one line per interface, including Loopback0 with 10.255.255.1.

  5. Break it on purpose: add a new interface with no IP address (interface Loopback5), fetch the file again, and change the script to use intf["ietf-ip:ipv4"]["address"]. Confirm you get a KeyError, then restore the .get() version.
  6. Write a JSON file by hand that creates Loopback100 (use the payload above), then check it with python3 -m json.tool lb100.json. A syntax error is reported with the line and column. Keep the file for the response codes lab.

Quick check

What does print(len({'a': [1, 2, 3], 'b': None})) print?

2. len() of a dictionary counts its keys, not the items inside the values.

A JSON body contains the member vlan with the unquoted value 010. Is that valid JSON?

No. JSON numbers cannot have leading zeros. Use 10, or "010" if a string is intended.

A script calls requests.post(url, data=payload) where payload is a dict, and a JSON API returns 400. What is the likely fix?

Use json=payload (or data=json.dumps(payload) with a JSON Content-Type header). data= with a dict sends form-encoded fields, not JSON.

References