Duplicated keys on Pyparsing Dict (dhcpd.conf)

Viewed 68

I'm trying to parse a dhcpd.conf file to python dict using pyparsing. Here is a syntax sample:

ddns-update-style none;
log-facility local7;
deny unknown-clients;
deny booting;

subnet 10.42.200.0 netmask 255.255.255.0 {
    
    max-lease-time 28800;
    option routers 10.42.200.1;
    option domain-name-servers 8.8.8.8, 8.8.4.4;
    range 10.42.200.10 10.42.200.200;

}

shared-network 224-29 {
    subnet 10.17.224.0 netmask 255.255.255.0 {
        option routers rtr-224.example.org;
    }
    subnet 10.0.29.0 netmask 255.255.255.0 {
        option routers rtr-29.example.org;
    }
    pool {
        allow members of "foo";
        range 10.17.224.10 10.17.224.250;
    }
    pool {
        deny members of "foo";
        range 10.0.29.10 10.0.29.230;
    }
}

As you can see multiple 'pool's can be created on this dhcpd.conf. But if I parse them as a dictionary one will overwrite the other because they would have the same key("pool"). Also multiple "deny" statements can happen and again will be overwriten while parsing resulting on only 1 "deny" statement per nest level.

The ideal parsed dict would be something like this:

{
    'ddns-update-style': 'none',
    'log-facility': 'local7',
    'deny': [
        'booting',
        'unknown-clients'
    ],
    'shared-networks': { 
        '224-29': {
            'subnet 10.0.29.0 netmask 255.255.255.0': {
                'options': { 
                    'routers': 'rtr-29.example.org'
                },
            },
            'subnet 10.17.224.0 netmask 255.255.255.0': {
                'options': {
                    'routers': 'rtr-224.example.org'
                }
            },
            'pools': [
                {
                    'allow': ['members of "foo"'],
                    'range': '10.17.224.10 10.17.224.250',
                },
                {
                    'deny': ['members of "foo"'],
                    'range': '10.0.29.10 10.0.29.230'
                }
                
            ]
        
            
        }
    },
    'subnet 10.42.200.0 netmask 255.255.255.0': {
        'max-lease-time': '28800',
        'options': { 
            'domain-name-servers': ['8.8.8.8', '8.8.4.4'],
            'routers': '10.42.200.1',
        }
        'range': '10.42.200.10 10.42.200.200'
    }
}

So far i was able to get around this by Combining named sections with their name. (Like options and shared networks) but this is no possible for pools or allow/deny statements.

This is my current output and code:

{
    'ddns-update-style': 'none',
    'deny': 'booting',
    'log-facility': 'local7',
    'shared-network 224-29': {
        'pool': {
            'deny': 'members of "foo"',
            'range': '10.0.29.10 10.0.29.230'
        },
        'subnet 10.0.29.0 netmask 255.255.255.0': {'option routers': 'rtr-29.example.org'},
        'subnet 10.17.224.0 netmask 255.255.255.0': {'option routers': 'rtr-224.example.org'}
    },
    'subnet 10.42.200.0 netmask 255.255.255.0': {
        'max-lease-time': '28800',
        'option domain-name-servers': ['8.8.8.8', '8.8.4.4'],
        'option routers': '10.42.200.1',
        'range': '10.42.200.10 10.42.200.200'
    }
}

Code:

from pprint import pprint
from pyparsing import (
    CharsNotIn,
    Literal,
    White,
    Word,
    Combine,
    ZeroOrMore,
    OneOrMore,
    Forward,
    Dict,
    ParseException,
    Group,
    Suppress,
    delimitedList,
    nums,
    restOfLine,
    alphanums,
    pyparsing_common,
)
prop = delimitedList(Word(alphanums + "-_.!@#$%^&*"))
ipAddress = pyparsing_common.ipv4_address | pyparsing_common.ipv6_address
macAddress = Combine(
    Word(alphanums, exact=2)
    + ":"
    + Word(alphanums, exact=2)
    + ":"
    + Word(alphanums, exact=2)
    + ":"
    + Word(alphanums, exact=2)
    + ":"
    + Word(alphanums, exact=2)
    + ":"
    + Word(alphanums, exact=2)
)

list_ipAddress = delimitedList(ipAddress)
name = Word(alphanums + "-_")
value = list_ipAddress | macAddress | prop | CharsNotIn(";")
comment = "#" + restOfLine

LBRACE, RBRACE, WHITE_SPACE = map(Suppress, "{} ")

struct = Forward()

hw_eth = Group(
    Combine(Literal("hardware") + White(" ") + name) + macAddress
)

name_value = Group(name + value)

subnet = Group(
    Combine(
        "subnet"
        + White(" ")
        + ipAddress
        + White(" ")
        + "netmask"
        + White(" ")
        + ipAddress
    )
    + struct
)

option = Group(Combine("option" + White(" ") + name) + value)

host = Group(Combine("host" + White(" ") + name) + struct)

ip_range = Group(
    Literal("range") + Combine(ipAddress + White(" ") + ipAddress)
)

shared_network = Group(
    Combine(Literal("shared-network") + White(" ") + name) + struct
)

allow_deny = Group(
    (Literal("allow") | Literal("deny"))
    + ZeroOrMore(WHITE_SPACE)
    + CharsNotIn(";")
)

named_struct = Group(name + struct)

struct << Dict(
    LBRACE
    + ZeroOrMore(
        subnet
        | host
        | shared_network
        | ip_range
        | option
        | hw_eth
        | named_struct
        | allow_deny
        | name_value
    )
    + RBRACE
)

parser = Dict(
    OneOrMore(
        subnet
        | host
        | shared_network
        | ip_range
        | option
        | hw_eth
        | named_struct
        | allow_deny
        | name_value
    )
)
parser.ignore(comment)
parser.ignore(";")

try:
    result = parser.parseString(data)
except ParseException as pe:
    str_pe = str(pe)
    if "found end of text" in str_pe:
        pass
    else:
        raise pe
pprint(result.asDict())
1 Answers

Here is an abbreviated grammar replacing Dict with MultiDict. You might be able to use MultiDict or something similar to address your problem. (Also, mac_address could be written more simply as Combine(Word(hexnums, exact=2) + (":" + Word(hexnums, exact=2))*5) or just use the one from pyparsing_common.)

import pyparsing as pp

ppc = pp.common
LBRACE, RBRACE = map(pp.Suppress, "{}")

_1to32 = pp.oneOf([str(i) for i in range(1, 32+1)])
_1to32 = pp.Regex(r"[12]\d|3[012]|[1-9]")
cidr = pp.Combine(ppc.ipv4_address + '/' + _1to32)

ALLOW, DENY, POOL, RANGE = map(pp.Keyword, "allow deny pool range".split())
range_expr = pp.Group(RANGE + (cidr | ppc.ipv4_address*2))

allow_expr = pp.Group(ALLOW - ppc.ipv4_address)
deny_expr = pp.Group(DENY - ppc.ipv4_address)


# kind of like a defaultdict version of Dict
class MultiDict(pp.ParseElementEnhance):
    def postParse(self, instring, loc, tokenlist):
        ret = pp.ParseResults([])

        for gp in tokenlist:
            key = gp[0]
            if key not in ret:
                ret[key] = pp.ParseResults([])
            ret[key].append(gp[1:])

        # flatten lists of single-item lists
        for key in ret.keys():
            if all(len(value) == 1 for value in ret[key]):
                ret[key] = pp.ParseResults([x[0] for x in ret[key]])
            else:
                ret[key] = pp.ParseResults(ret[key])
        return ret

# using the '&' operator creates an Each expression, which 
# will parse these expressions in any order, including if they
# are not all grouped
pool_def = LBRACE + MultiDict(
    allow_expr[...]
    & deny_expr[...]
    & range_expr
) + RBRACE

pool_expr = pp.Group(POOL - pp.Group(pool_def))
parser = MultiDict(pool_expr[...])

data = """\
pool { 
   allow 1.1.1.1
   range 3.3.3.3/24
   deny 4.4.4.4
   allow 2.2.2.2
}
pool {
   deny 4.4.4.4
   deny 5.5.5.5
   range 6.6.6.6 7.7.7.7
}
"""

result = parser.parse_string(data, parse_all=True)

import pprint
pprint.pprint(result.as_dict())

prints

{'pool': [{'allow': ['1.1.1.1', '2.2.2.2'],
           'deny': ['4.4.4.4'],
           'range': ['3.3.3.3/24']},
          {'deny': ['4.4.4.4', '5.5.5.5'], 'range': [['6.6.6.6', '7.7.7.7']]}]}

Related