Anentropic
Anentropic

Reputation: 33903

pyparsing how to SkipTo end of indented block?

I am trying to parse a structure like this with pyparsing:

identifier: some description text here which will wrap
    on to the next line. the follow-on text should be
    indented. it may contain identifier: and any text
    at all is allowed
next_identifier: more description, short this time
last_identifier: blah blah

I need something like:

import pyparsing as pp

colon = pp.Suppress(':')
term = pp.Word(pp.alphanums + "_")
description = pp.SkipTo(next_identifier)
definition = term + colon + description
grammar = pp.OneOrMore(definition)

But I am struggling to define the next_identifier of the SkipTo clause since the identifiers may appear freely in the description text.

It seems that I need to include the indentation in the grammar, so that I can SkipTo the next non-indented line.

I tried:

description = pp.Combine(
    pp.SkipTo(pp.LineEnd()) +
    pp.indentedBlock(
        pp.ZeroOrMore(
            pp.SkipTo(pp.LineEnd())
        ),
        indent_stack
    )
)

But I get the error:

ParseException: not a subentry (at char 55), (line:2, col:1)

Char 55 is at the very beginning of the run-on line:

...will wrap\n    on to the next line...
              ^

Which seems a bit odd, because that char position is clearly followed by the whitespace which makes it an indented subentry.

My traceback in ipdb looks like:

   5311     def checkSubIndent(s,l,t):
   5312         curCol = col(l,s)
   5313         if curCol > indentStack[-1]:
   5314             indentStack.append( curCol )
   5315         else:
-> 5316             raise ParseException(s,l,"not a subentry")
   5317

ipdb> indentStack
[1]
ipdb> curCol
1

I should add that the whole structure above that I'm matching may also be indented (by an unknown amount), so a solution like:

description = pp.Combine(
    pp.SkipTo(pp.LineEnd()) + pp.LineEnd() +
    pp.ZeroOrMore(
        pp.White(' ') + pp.SkipTo(pp.LineEnd()) + pp.LineEnd()
    )
)

...which works for the example as presented will not work in my case as it will consume the subsequent definitions.

Upvotes: 3

Views: 612

Answers (1)

PaulMcG
PaulMcG

Reputation: 63762

When you use indentedBlock, the argument you pass in is the expression for each line in the block, so it shouldn't be a indentedBlock(ZeroOrMore(line_expression), stack), just indentedBlock(line_expression, stack). Pyparsing includes a builtin expression for "everything from here to the end of the line", titled restOfLine, so we will just use that for the expression for each line in the indented block:

import pyparsing as pp

NL = pp.LineEnd().suppress()

label = pp.ungroup(pp.Word(pp.alphas, pp.alphanums+'_') + pp.Suppress(":"))

indent_stack = [1]
# see corrected version below
#description = pp.Group((pp.Empty() 
#                    + pp.restOfLine + NL
#                    + pp.ungroup(pp.indentedBlock(pp.restOfLine, indent_stack))))

description = pp.Group(pp.restOfLine + NL
                       + pp.Optional(pp.ungroup(~pp.StringEnd() 
                                                + pp.indentedBlock(pp.restOfLine, 
                                                                   indent_stack))))

labeled_text = pp.Group(label("label") + pp.Empty() + description("description"))

We use ungroup to remove the extra level of nesting created by indentedBlock but we also need to remove the per-line nesting that is created internally in indentedBlock. We do this with a parse action:

def combine_parts(tokens):
    # recombine description parts into a single list
    tt = tokens[0]
    new_desc = [tt.description[0]]
    new_desc.extend(t[0] for t in tt.description[1:])

    # reassign rebuild description into the parsed token structure 
    tt['description'] = new_desc
    tt[1][:] = new_desc

labeled_text.addParseAction(combine_parts)

At this point, we are pretty much done. Here is your sample text parsed and dumped:

parsed_data = (pp.OneOrMore(labeled_text)).parseString(sample)    
print(parsed_data[0].dump())

['identifier', ['some description text here which will wrap', 'on to the next line. the follow-on text should be', 'indented. it may contain identifier: and any text', 'at all is allowed']]
- description: ['some description text here which will wrap', 'on to the next line. the follow-on text should be', 'indented. it may contain identifier: and any text', 'at all is allowed']
- label: 'identifier'

Or this code to pull out the label and description fields:

for item in parsed_data:
    print(item.label)
    print('..' + '\n..'.join(item.description))
    print()

identifier
..some description text here which will wrap
..on to the next line. the follow-on text should be
..indented. it may contain identifier: and any text
..at all is allowed

next_identifier
..more description, short this time

last_identifier
..blah blah

Upvotes: 1

Related Questions