split pvl.dns-zone into pvl.dns.zone
authorTero Marttila <terom@paivola.fi>
Tue, 10 Sep 2013 17:17:57 +0300
changeset 247 08a63738f2d1
parent 246 819320b0cf81
child 248 cce9cf4933ca
split pvl.dns-zone into pvl.dns.zone
bin/pvl.dns-zone
pvl/dns/zone.py
--- a/bin/pvl.dns-zone	Tue Sep 10 12:13:36 2013 +0300
+++ b/bin/pvl.dns-zone	Tue Sep 10 17:17:57 2013 +0300
@@ -1,22 +1,19 @@
 #!/usr/bin/env python
 
 """
-    Process zonefiles.
+    Process bind zonefiles.
 """
 
-__version__ = '0.0.1-dev'
-
-import optparse
 import codecs
-from datetime import datetime
-import logging
+import optparse
 
-import ipaddr
+import pvl.args
+import pvl.dns.zone
+from pvl.dns import __version__
+from pvl.dns.zone import ZoneRecord, reverse_ipv4, reverse_ipv6, fqdn
 
-log = logging.getLogger('main')
+import logging; log = logging.getLogger('main')
 
-# command-line options, global state
-options = None
 
 def parse_options (argv) :
     """
@@ -35,13 +32,7 @@
     )
 
     # logging
-    general = optparse.OptionGroup(parser, "General Options")
-
-    general.add_option('-q', '--quiet',     dest='loglevel', action='store_const', const=logging.ERROR, help="Less output")
-    general.add_option('-v', '--verbose',   dest='loglevel', action='store_const', const=logging.INFO,  help="More output")
-    general.add_option('-D', '--debug',     dest='loglevel', action='store_const', const=logging.DEBUG, help="Even more output")
-
-    parser.add_option_group(general)
+    parser.add_option_group(pvl.args.parser(parser))
 
     # input/output
     parser.add_option('-c', '--input-charset',  metavar='CHARSET',  default='utf-8', 
@@ -87,14 +78,8 @@
     parser.add_option('--reverse-zone',         metavar='NET',
             help="Generate forward zone for given subnet (x.z.y | a:b:c:d)")
 
-    # 
-    parser.add_option('--doctest',              action='store_true',
-            help="Run module doctests")
-
     # defaults
     parser.set_defaults(
-        loglevel            = logging.WARN,
-
         # XXX: combine
         check_exempt        = [],
         meta_ignore         = [],
@@ -103,460 +88,11 @@
     # parse
     options, args = parser.parse_args(argv[1:])
 
-    # configure
-    logging.basicConfig(
-        format  = prog + ': %(name)s: %(levelname)s %(funcName)s : %(message)s',
-        level   = options.loglevel,
-    )
+    # apply
+    pvl.args.apply(options, prog)
 
     return options, args
 
-class ZoneError (Exception) :
-    pass
-
-class ZoneLineError (ZoneError) :
-    """
-        ZoneLine-related error
-    """
-
-    def __init__ (self, line, msg, *args, **kwargs) :
-        super(ZoneLineError, self).__init__("%s: %s" % (line, msg.format(*args, **kwargs)))
-
-class ZoneLine (object) :
-    """
-        A line in a zonefile.
-    """
-
-    file = None
-    lineno = None
-
-    # data
-    indent = None # was the line indented?
-    data = None
-    parts = None # split line fields
-
-    # optional
-    timestamp = None
-    comment = None
-
-    PARSE_DATETIME_FORMAT = '%Y-%m-%d'
-
-    @classmethod
-    def parse (cls, file, lineno, line, line_timestamp_prefix=False) :
-        """
-            Parse out given line and build.
-        """
-
-        log.debug("parse: %s:%d: %s", file, lineno, line)
-
-        ts = None
-
-        if line_timestamp_prefix :
-            if ': ' not in line :
-                raise ZoneError("%s:%d: Missing timestamp prefix: %s" % (file, lineno, line))
-
-            # split prefix
-            prefix, line = line.split(': ', 1)
-
-            # parse it out
-            ts = datetime.strptime(prefix, cls.PARSE_DATETIME_FORMAT)
-
-            log.debug("  ts=%r", ts)
-
-        # was line indented?
-        indent = line.startswith(' ') or line.startswith('\t')
-        
-        # strip
-        line = line.strip()
-        
-        log.debug("  indent=%r, line=%r", indent, line)
-
-        # parse comment out?
-        if ';' in line :
-            line, comment = line.split(';', 1)
-
-            line = line.strip()
-            comment = comment.strip()
-
-        else :
-            line = line.strip()
-            comment = None
-        
-        log.debug("  line=%r, comment=%r", line, comment)
-
-        # parse fields
-        if '"' in line :
-            pre, data, post = line.split('"', 2)
-            parts = pre.split() + [data] + post.split()
-           
-        else :
-            parts = line.split()
-
-        log.debug("  parts=%r", parts)
-
-        # build
-        return cls(file, lineno, indent, line, parts, timestamp=ts, comment=comment)
-
-    def __init__ (self, file, lineno, indent, data, parts, timestamp=None, comment=None) :
-        self.file = file
-        self.lineno = lineno
-
-        self.indent = indent
-        self.data = data
-        self.parts = parts
-
-        self.timestamp = timestamp
-        self.comment = comment
-
-    def __str__ (self) :
-        return "{file}:{lineno}".format(file=self.file, lineno=self.lineno)
-
-class ZoneRecord (object) :
-    """
-        A record from a zonefile.
-    """
-
-    # the underlying line
-    line = None
-
-    # possible $ORIGIN context
-    origin = None
-
-    # record fields
-    name = None
-    type = None
-
-    # list of data fields
-    data = None
-
-    # optional
-    ttl = None
-    cls = None
-
-    @classmethod
-    def parse (cls, line, parts=None, origin=None) :
-        """
-            Parse from ZoneLine. Returns None if there is no record on the line..
-        """
-
-        if parts is None :
-            parts = list(line.parts)
-
-        if not parts :
-            # skip
-            return
-        
-        # XXX: indented lines keep name from previous record
-        if line.indent :
-            name = None
-
-        else :
-            name = parts.pop(0)
-        
-        log.debug("  name=%r, origin=%r", name, origin)
-
-        if len(parts) < 2 :
-            raise ZoneLineError(line, "Too few parts to parse: {0!r}", line.data)
-
-        # parse ttl/cls/type
-        ttl = _cls = None
-
-        if parts and parts[0][0].isdigit() :
-            ttl = parts.pop(0)
-
-        if parts and parts[0].upper() in ('IN', 'CH') :
-            _cls = parts.pop(0)
-
-        # always have type
-        type = parts.pop(0)
-
-        # remaining parts are data
-        data = parts
-
-        log.debug("  ttl=%r, cls=%r, type=%r, data=%r", ttl, _cls, type, data)
-
-        return cls(name, type, data,
-            origin  = origin,
-            ttl     = ttl,
-            cls     = _cls,
-            line    = line,
-        )
-
-    def __init__ (self, name, type, data, origin=None, ttl=None, cls=None, line=None, comment=None) :
-        self.name = name
-        self.type = type
-        self.data = data
-        
-        self.ttl = ttl
-        self.cls = cls
-        
-        self.origin = origin
-        self.line = line
-
-        # XXX: within line
-        self._comment = comment
-
-    def build_line (self) :
-        """
-            Construct a zonefile-format line..."
-        """
-
-        # XXX: comment?
-        if self._comment :
-            comment = '\t; ' + self._comment
-        else :
-            comment = ''
-            
-        return u"{name:25} {ttl:4} {cls:2} {type:5} {data}{comment}".format(
-                name    = self.name or '',
-                ttl     = self.ttl or '',
-                cls     = self.cls or '',
-                type    = self.type,
-                data    = ' '.join(unicode(data) for data in self.data),
-                comment = comment,
-        )
-
-    def __str__ (self) :
-        return ' '.join((self.name or '', self.type, ' '.join(self.data)))
-
-class TXTRecord (ZoneRecord) :
-    """
-        TXT record.
-    """
-
-    def __init__ (self, name, text, **opts) :
-        return super(TXTRecord, self).__init__(name, 'TXT', 
-            [u'"{0}"'.format(text.replace('"', '\\"'))], 
-            **opts
-        )
-
-class OffsetValue (object) :
-    def __init__ (self, value) :
-        self.value = value
-
-    def __getitem__ (self, offset) :
-        value = self.value + offset
-
-        #log.debug("OffsetValue: %d[%d] -> %d", self.value, offset, value)
-
-        return value
-
-def parse_generate_field (line, field) :
-    """
-        Parse a $GENERATE lhs/rhs field:
-            $
-            ${<offset>[,<width>[,<base>]]}
-            \$
-            $$
-
-        Returns a wrapper that builds the field-value when called with the index.
-        
-        >>> parse_generate_field(None, "foo")(1)
-        'foo'
-        >>> parse_generate_field(None, "foo-$")(1)
-        'foo-1'
-        >>> parse_generate_field(None, "foo-$$")(1)
-        'foo-$'
-        >>> parse_generate_field(None, "\$")(1)
-        '$'
-        >>> parse_generate_field(None, "10.0.0.${100}")(1)
-        '10.0.0.101'
-        >>> parse_generate_field(None, "foo-${0,2,d}")(1)
-        'foo-01'
-
-    """
-
-    input = field
-    expr = []
-
-    while '$' in field :
-        # defaults
-        offset = 0
-        width = 0
-        base = 'd'
-        escape = False
-
-        # different forms
-        if '${' in field :
-            pre, body = field.split('${', 1)
-            body, post = body.split('}', 1)
-
-            # parse body
-            parts = body.split(',')
-
-            # offset
-            offset = int(parts.pop(0))
-
-            # width
-            if parts :
-                width = int(parts.pop(0))
-
-            # base
-            if parts :
-                base = parts.pop(0)
-            
-            if parts:
-                # fail
-                raise ZoneLineError(line, "extra data in ${...} body: {0!r}", parts)
-
-        elif '$$' in field :
-            pre, post = field.split('$$', 1)
-            escape = True
-
-        elif '\\$' in field :
-            pre, post = field.split('\\$', 1)
-            escape = True
-
-        else :
-            pre, post = field.split('$', 1)
-        
-        expr.append(pre)
-
-        if escape :
-            expr.append('$')
-
-        else :
-            # meta-format
-            fmt = '{value[%d]:0%d%s}' % (offset, width, base)
-
-            log.debug("field=%r -> pre=%r, fmt=%r, post=%r", field, expr, fmt, post)
-
-            expr.append(fmt)
-
-        field = post
-
-    # final
-    if field :
-        expr.append(field)
-    
-    # combine
-    expr = ''.join(expr)
-
-    log.debug("%s: %s", input, expr)
-
-    # processed
-    def value_func (value) :
-        # magic wrapper to implement offsets
-        return expr.format(value=OffsetValue(value))
-    
-    return value_func
-
-def process_generate (line, origin, parts) :
-    """
-        Process a 
-            $GENERATE <start>-<stop>[/<step>] lhs [ttl] [class] type rhs [comment]
-        directive into a series of ZoneResource's.
-    """
-
-    range = parts.pop(0)
-
-    # parse range
-    if '/' in range :
-        range, step = range.split('/')
-        step = int(step)
-    else :
-        step = 1
-
-    start, stop = range.split('-')
-    start = int(start)
-    stop = int(stop)
-
-    log.debug("  range: start=%r, stop=%r, step=%r", start, stop, step)
-
-    # inclusive
-    range = xrange(start, stop + 1, step)
-
-    lhs_func = parse_generate_field(line, parts.pop(0))
-    rhs_func = parse_generate_field(line, parts.pop(-1))
-    body = parts
-
-    for i in range :
-        # build
-        parts = [lhs_func(i)] + body + [rhs_func(i)]
-
-        log.debug(" %03d: %r", i, parts)
-
-        # parse
-        yield ZoneRecord.parse(line, parts=parts, origin=origin)
-
-def parse_zone_records (file, origin=None, **opts) :
-    """
-        Parse ZoneRecord items from the given zonefile, ignoring non-record lines.
-    """
-
-    ttl = None
-
-    skip_multiline = False
-    
-    for lineno, raw_line in enumerate(file) :
-        # parse comment
-        if ';' in raw_line :
-            line, comment = raw_line.split(';', 1)
-        else :
-            line = raw_line
-            comment = None
-
-        # XXX: handle multi-line statements...
-        # start
-        if '(' in line :
-            skip_multiline = True
-            
-            log.warn("%s:%d: Start of multi-line statement: %s", file.name, lineno, raw_line)
-
-        # end?
-        if ')' in line :
-            skip_multiline = False
-            
-            log.warn("%s:%d: End of multi-line statement: %s", file.name, lineno, raw_line)
-            
-            continue
-
-        elif skip_multiline :
-            log.warn("%s:%d: Multi-line statement: %s", file.name, lineno, raw_line)
-
-            continue
-        
-        # parse
-        line = ZoneLine.parse(file.name, lineno, raw_line, **opts)
-
-        if not line.data :
-            log.debug("%s: skip empty line: %s", line, raw_line)
-
-            continue
-
-        elif line.data.startswith('$') :
-            # control record
-            type = line.parts[0]
-
-            if type == '$ORIGIN':
-                # update
-                origin = line.parts[1]
-                
-                log.info("%s: origin: %s", line, origin)
-            
-            elif type == '$GENERATE':
-                # process...
-                log.info("%s: generate: %s", line, line.parts)
-
-                for record in process_generate(line, origin, line.parts[1:]) :
-                    yield record
-
-            else :
-                log.warning("%s: skip control record: %s", line, line.data)
-            
-            # XXX: passthrough!
-            continue
-
-        # normal record?
-        record = ZoneRecord.parse(line, origin=origin)
-
-        if record :
-            yield record
-
-        else :
-            # unknown
-            log.warning("%s: skip unknown line: %s", line, line.data)
-
 def check_zone_hosts (zone, whitelist=None, whitelist_types=set(['TXT'])) :
     """
         Parse host/IP pairs from the zone, and verify that they are unique.
@@ -617,21 +153,21 @@
                 comment = r.line.comment
 
                 if comment :
-                    yield TXTRecord(None, comment, ttl=r.ttl)
+                    yield ZoneRecord.TXT(None, comment, ttl=r.ttl)
 
            
             # XXX: RP, do we need it?
 
             if mx :
-                # XXX: is this a good idea?
-                yield ZoneRecord(None, 'MX', [10, mx], ttl=r.ttl)
+                # XXX: is this even a good idea?
+                yield ZoneRecord.MX(None, 10, mx, ttl=r.ttl)
 
 def process_zone_meta (zone, ignore=None) :
     """
         Process zone metadata -> output.
     """
     
-    TIMESTAMP_FORMAT='%Y/%m/%d'
+    TIMESTAMP_FORMAT = '%Y/%m/%d'
     
     for r in zone :
         if ignore and r.name in ignore :
@@ -645,41 +181,8 @@
             timestamp = r.line.timestamp
 
             if timestamp :
-                yield TXTRecord(r.name, timestamp.strftime(TIMESTAMP_FORMAT), ttl=r.ttl)
+                yield ZoneRecord.TXT(r.name, timestamp.strftime(TIMESTAMP_FORMAT), ttl=r.ttl)
      
-def reverse_ipv4 (ip) :
-    """
-        Return in-addr.arpa reverse for given IPv4 prefix.
-    """
-
-    # parse
-    octets = tuple(int(part) for part in ip.split('.'))
-
-    for octet in octets :
-        assert 0 <= octet <= 255
-
-    return '.'.join([str(octet) for octet in reversed(octets)] + ['in-addr', 'arpa'])
-
-def reverse_ipv6 (ip6) :
-    """
-        Return ip6.arpa reverse for given IPv6 prefix.
-    """
-
-    parts = [int(part, 16) for part in ip6.split(':')]
-    parts = ['{0:04x}'.format(part) for part in parts]
-    parts = ''.join(parts)
-
-    return '.'.join(tuple(reversed(parts)) + ( 'ip6', 'arpa'))
-
-def fqdn (*parts) :
-    fqdn = '.'.join(parts)
-    
-    # we may be given an fqdn in parts
-    if not fqdn.endswith('.') :
-        fqdn += '.'
-    
-    return fqdn
-
 def process_zone_reverse (zone, origin, domain) :
     """
         Process zone data -> reverse zone data.
@@ -715,11 +218,12 @@
         host_domain = r.origin or domain
         host_fqdn = fqdn(name, host_domain)
 
-        yield ZoneRecord(ptr, 'PTR', [host_fqdn])
+        yield ZoneRecord.PTR(ptr, host_fqdn)
 
 def write_zone_records (file, zone) :
     for r in zone :
-        file.write(r.build_line() + u'\n')
+        file.write(unicode(r))
+        file.write('\n')
 
 def open_file (path, mode, charset) :
     """
@@ -741,15 +245,8 @@
         return codecs.open(path, mode, charset)
 
 def main (argv) :
-    global options
-    
     options, args = parse_options(argv)
 
-    if options.doctest :
-        import doctest
-        fail, total = doctest.testmod()
-        return fail
-
     if args :
         # open files
         input_files = [open_file(path, 'r', options.input_charset) for path in args]
@@ -764,7 +261,7 @@
     for file in input_files :
         log.info("Reading zone: %s", file)
 
-        zone += list(parse_zone_records(file, 
+        zone += list(pvl.dns.zone.parse_zone_records(file, 
             line_timestamp_prefix   = options.input_line_date,
         ))
 
@@ -829,5 +326,4 @@
 
 if __name__ == '__main__':
     import sys
-
     sys.exit(main(sys.argv))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pvl/dns/zone.py	Tue Sep 10 17:17:57 2013 +0300
@@ -0,0 +1,501 @@
+#!/usr/bin/env python
+
+"""
+    Process zonefiles.
+"""
+
+import codecs
+from datetime import datetime
+import logging
+
+log = logging.getLogger('pvl.dns.zone')
+
+class ZoneError (Exception) :
+    pass
+
+class ZoneLineError (ZoneError) :
+    """
+        ZoneLine-related error
+    """
+
+    def __init__ (self, line, msg, *args, **kwargs) :
+        super(ZoneLineError, self).__init__("%s: %s" % (line, msg.format(*args, **kwargs)))
+
+class ZoneLine (object) :
+    """
+        A line in a zonefile.
+    """
+
+    file = None
+    lineno = None
+
+    # data
+    indent = None # was the line indented?
+    data = None
+    parts = None # split line fields
+
+    # optional
+    timestamp = None
+    comment = None
+
+    PARSE_DATETIME_FORMAT = '%Y-%m-%d'
+
+    @classmethod
+    def parse (cls, file, lineno, line, line_timestamp_prefix=False) :
+        """
+            Parse out given line and build.
+        """
+
+        log.debug("parse: %s:%d: %s", file, lineno, line)
+
+        ts = None
+
+        if line_timestamp_prefix :
+            if ': ' not in line :
+                raise ZoneError("%s:%d: Missing timestamp prefix: %s" % (file, lineno, line))
+
+            # split prefix
+            prefix, line = line.split(': ', 1)
+
+            # parse it out
+            ts = datetime.strptime(prefix, cls.PARSE_DATETIME_FORMAT)
+
+            log.debug("  ts=%r", ts)
+
+        # was line indented?
+        indent = line.startswith(' ') or line.startswith('\t')
+        
+        # strip
+        line = line.strip()
+        
+        log.debug("  indent=%r, line=%r", indent, line)
+
+        # parse comment out?
+        if ';' in line :
+            line, comment = line.split(';', 1)
+
+            line = line.strip()
+            comment = comment.strip()
+
+        else :
+            line = line.strip()
+            comment = None
+        
+        log.debug("  line=%r, comment=%r", line, comment)
+
+        # parse fields
+        if '"' in line :
+            pre, data, post = line.split('"', 2)
+            parts = pre.split() + [data] + post.split()
+           
+        else :
+            parts = line.split()
+
+        log.debug("  parts=%r", parts)
+
+        # build
+        return cls(file, lineno, indent, line, parts, timestamp=ts, comment=comment)
+
+    def __init__ (self, file, lineno, indent, data, parts, timestamp=None, comment=None) :
+        self.file = file
+        self.lineno = lineno
+
+        self.indent = indent
+        self.data = data
+        self.parts = parts
+
+        self.timestamp = timestamp
+        self.comment = comment
+
+    def __str__ (self) :
+        return "{file}:{lineno}".format(file=self.file, lineno=self.lineno)
+
+class ZoneRecord (object) :
+    """
+        A record from a zonefile.
+    """
+
+    # the underlying line
+    line = None
+
+    # possible $ORIGIN context
+    origin = None
+
+    # record fields
+    name = None
+    type = None
+
+    # list of data fields
+    data = None
+
+    # optional
+    ttl = None
+    cls = None
+
+    @classmethod
+    def parse (cls, line, parts=None, origin=None) :
+        """
+            Parse from ZoneLine. Returns None if there is no record on the line..
+        """
+
+        if parts is None :
+            parts = list(line.parts)
+
+        if not parts :
+            # skip
+            return
+        
+        # XXX: indented lines keep name from previous record
+        if line.indent :
+            name = None
+
+        else :
+            name = parts.pop(0)
+        
+        log.debug("  name=%r, origin=%r", name, origin)
+
+        if len(parts) < 2 :
+            raise ZoneLineError(line, "Too few parts to parse: {0!r}", line.data)
+
+        # parse ttl/cls/type
+        ttl = _cls = None
+
+        if parts and parts[0][0].isdigit() :
+            ttl = parts.pop(0)
+
+        if parts and parts[0].upper() in ('IN', 'CH') :
+            _cls = parts.pop(0)
+
+        # always have type
+        type = parts.pop(0)
+
+        # remaining parts are data
+        data = parts
+
+        log.debug("  ttl=%r, cls=%r, type=%r, data=%r", ttl, _cls, type, data)
+
+        return cls(name, type, data,
+            origin  = origin,
+            ttl     = ttl,
+            cls     = _cls,
+            line    = line,
+        )
+
+    @classmethod
+    def TXT (cls, name, text, **opts) :
+        return cls(name, 'TXT',
+            [u'"{0}"'.format(text.replace('"', '\\"'))], 
+            **opts
+        )
+
+    @classmethod
+    def PTR (cls, name, ptr, **opts) :
+        return cls(name, 'PTR', [ptr], **opts)
+
+    @classmethod
+    def MX (cls, name, priority, mx, **opts) :
+        return cls(name, 'MX', [priority, mx], **opts)
+
+    def __init__ (self, name, type, data, origin=None, ttl=None, cls=None, line=None, comment=None) :
+        self.name = name
+        self.type = type
+        self.data = data
+        
+        self.ttl = ttl
+        self.cls = cls
+        
+        self.origin = origin
+        self.line = line
+
+        self.comment = comment
+
+    def __unicode__ (self) :
+        """
+            Construct a zonefile-format line..."
+        """
+
+        if self.comment :
+            comment = '\t; ' + self._comment
+        else :
+            comment = ''
+            
+        return u"{name:25} {ttl:4} {cls:2} {type:5} {data}{comment}".format(
+                name    = self.name or '',
+                ttl     = self.ttl or '',
+                cls     = self.cls or '',
+                type    = self.type,
+                data    = ' '.join(unicode(data) for data in self.data),
+                comment = comment,
+        )
+
+    def __repr__ (self) :
+        return '%s(%s)' % (self.__class__.__name__, ', '.join(repr(arg) for arg in (
+            self.name, self.type, self.data
+        )))
+
+class OffsetValue (object) :
+    """
+        Magic for $GENERATE offsets.
+
+        >>> OffsetValue(0)[1]
+        1
+        >>> OffsetValue(10)[5]
+        15
+    """
+
+    def __init__ (self, base) :
+        self.base = base
+
+    def __getitem__ (self, offset) :
+        return self.base + offset
+
+def parse_generate_field (line, field) :
+    """
+        Parse a $GENERATE lhs/rhs field:
+            $
+            ${<offset>[,<width>[,<base>]]}
+            \$
+            $$
+
+        Returns a wrapper that builds the field-value when called with the index.
+        
+        >>> parse_generate_field(None, "foo")(1)
+        'foo'
+        >>> parse_generate_field(None, "foo-$")(1)
+        'foo-1'
+        >>> parse_generate_field(None, "foo-$$")(1)
+        'foo-$'
+        >>> parse_generate_field(None, "\$")(1)
+        '$'
+        >>> parse_generate_field(None, "10.0.0.${100}")(1)
+        '10.0.0.101'
+        >>> parse_generate_field(None, "foo-${0,2,d}")(1)
+        'foo-01'
+
+    """
+
+    input = field
+    expr = []
+
+    while '$' in field :
+        # defaults
+        offset = 0
+        width = 0
+        base = 'd'
+        escape = False
+
+        # different forms
+        if '${' in field :
+            pre, body = field.split('${', 1)
+            body, post = body.split('}', 1)
+
+            # parse body
+            parts = body.split(',')
+
+            # offset
+            offset = int(parts.pop(0))
+
+            # width
+            if parts :
+                width = int(parts.pop(0))
+
+            # base
+            if parts :
+                base = parts.pop(0)
+            
+            if parts:
+                # fail
+                raise ZoneLineError(line, "extra data in ${...} body: {0!r}", parts)
+
+        elif '$$' in field :
+            pre, post = field.split('$$', 1)
+            escape = True
+
+        elif '\\$' in field :
+            pre, post = field.split('\\$', 1)
+            escape = True
+
+        else :
+            pre, post = field.split('$', 1)
+        
+        expr.append(pre)
+
+        if escape :
+            expr.append('$')
+
+        else :
+            # meta-format
+            fmt = '{value[%d]:0%d%s}' % (offset, width, base)
+
+            log.debug("field=%r -> pre=%r, fmt=%r, post=%r", field, expr, fmt, post)
+
+            expr.append(fmt)
+
+        field = post
+
+    # final
+    if field :
+        expr.append(field)
+    
+    # combine
+    expr = ''.join(expr)
+
+    log.debug("%s: %s", input, expr)
+
+    # processed
+    def value_func (value) :
+        # magic wrapper to implement offsets
+        return expr.format(value=OffsetValue(value))
+    
+    return value_func
+
+def process_generate (line, origin, parts) :
+    """
+        Process a 
+            $GENERATE <start>-<stop>[/<step>] lhs [ttl] [class] type rhs [comment]
+        directive into a series of ZoneResource's.
+    """
+
+    range = parts.pop(0)
+
+    # parse range
+    if '/' in range :
+        range, step = range.split('/')
+        step = int(step)
+    else :
+        step = 1
+
+    start, stop = range.split('-')
+    start = int(start)
+    stop = int(stop)
+
+    log.debug("  range: start=%r, stop=%r, step=%r", start, stop, step)
+
+    # inclusive
+    range = xrange(start, stop + 1, step)
+
+    lhs_func = parse_generate_field(line, parts.pop(0))
+    rhs_func = parse_generate_field(line, parts.pop(-1))
+    body = parts
+
+    for i in range :
+        # build
+        parts = [lhs_func(i)] + body + [rhs_func(i)]
+
+        log.debug(" %03d: %r", i, parts)
+
+        # parse
+        yield ZoneRecord.parse(line, parts=parts, origin=origin)
+
+def parse_zone_records (file, origin=None, **opts) :
+    """
+        Parse ZoneRecord items from the given zonefile, ignoring non-record lines.
+    """
+
+    ttl = None
+
+    skip_multiline = False
+    
+    for lineno, raw_line in enumerate(file) :
+        # parse comment
+        if ';' in raw_line :
+            line, comment = raw_line.split(';', 1)
+        else :
+            line = raw_line
+            comment = None
+
+        # XXX: handle multi-line statements...
+        # start
+        if '(' in line :
+            skip_multiline = True
+            
+            log.warn("%s:%d: Start of multi-line statement: %s", file.name, lineno, raw_line)
+
+        # end?
+        if ')' in line :
+            skip_multiline = False
+            
+            log.warn("%s:%d: End of multi-line statement: %s", file.name, lineno, raw_line)
+            
+            continue
+
+        elif skip_multiline :
+            log.warn("%s:%d: Multi-line statement: %s", file.name, lineno, raw_line)
+
+            continue
+        
+        # parse
+        line = ZoneLine.parse(file.name, lineno, raw_line, **opts)
+
+        if not line.data :
+            log.debug("%s: skip empty line: %s", line, raw_line)
+
+            continue
+
+        elif line.data.startswith('$') :
+            # control record
+            type = line.parts[0]
+
+            if type == '$ORIGIN':
+                # update
+                origin = line.parts[1]
+                
+                log.info("%s: origin: %s", line, origin)
+            
+            elif type == '$GENERATE':
+                # process...
+                log.info("%s: generate: %s", line, line.parts)
+
+                for record in process_generate(line, origin, line.parts[1:]) :
+                    yield record
+
+            else :
+                log.warning("%s: skip control record: %s", line, line.data)
+            
+            # XXX: passthrough!
+            continue
+
+        # normal record?
+        record = ZoneRecord.parse(line, origin=origin)
+
+        if record :
+            yield record
+
+        else :
+            # unknown
+            log.warning("%s: skip unknown line: %s", line, line.data)
+    
+def reverse_ipv4 (ip) :
+    """
+        Return in-addr.arpa reverse for given IPv4 prefix.
+    """
+
+    # parse
+    octets = tuple(int(part) for part in ip.split('.'))
+
+    for octet in octets :
+        assert 0 <= octet <= 255
+
+    return '.'.join([str(octet) for octet in reversed(octets)] + ['in-addr', 'arpa'])
+
+def reverse_ipv6 (ip6) :
+    """
+        Return ip6.arpa reverse for given IPv6 prefix.
+    """
+
+    parts = [int(part, 16) for part in ip6.split(':')]
+    parts = ['{0:04x}'.format(part) for part in parts]
+    parts = ''.join(parts)
+
+    return '.'.join(tuple(reversed(parts)) + ( 'ip6', 'arpa'))
+
+def fqdn (*parts) :
+    fqdn = '.'.join(parts)
+    
+    # we may be given an fqdn in parts
+    if not fqdn.endswith('.') :
+        fqdn += '.'
+    
+    return fqdn
+
+