Search code examples
pythonpyyamlruamel.yaml

How to comment out a YAML section using ruamel.yaml?


Recently I was trying to manage my docker-compose service configuration (namely docker-compose.yml) using ruamel.yaml.

I need to comment out & uncomment a service block when needed. Suppose I have the following file:

version: '2'
services:
    srv1:
        image: alpine
        container_name: srv1
        volumes:
            - some-volume:/some/path
    srv2:
        image: alpine
        container_name: srv2
        volumes_from:
            - some-volume
volumes:
    some-volume:

Is there some workaround to comment out the srv2 block? Like the following output:

version: '2'
services:
    srv1:
        image: alpine
        container_name: srv1
        volumes:
            - some-volume:/some/path
    #srv2:
    #    image: alpine
    #    container_name: srv2
    #    volumes_from:
    #        - some-volume
volumes:
    some-volume:

Moreover, is there a way to uncomment this block? (Suppose I have already hold the original srv2 block, I just need a method to delete these commented lines)


Solution

  • If srv2 is a key that is unique for all of the mappings in your YAML, then the "easy" way is to loop over de lines, test if de stripped version of the line starts with srv2:, note the number of leading spaces and comment out that and following lines until you notice a line that has equal or less leading spaces. The advantage of doing that, apart from being simple and fast is that it can deal with irregular indentation (as in your example: 4 positions before srv1 and 6 before some-volume).

    Doing this on the using ruamel.yaml is possible as well, but less straightforward. You have to know that when round_trip_loading, ruamel.yaml normally attaches a comment to the last structure (mapping/sequence) that has been processed and that as a consequence of that commenting out srv1 in your example works completely different from srv2 (i.e. the first key-value pair, if commented out, differs from all the other key-value pairs).

    If you normalize your expected output to four positions indent and add a comment before srv1 for analysis purposes, load that, you can search for where the comment ends up:

    from ruamel.yaml.util import load_yaml_guess_indent
    
    yaml_str = """\
    version: '2'
    services:
        #a
        #b
        srv1:
            image: alpine
            container_name: srv1
            volumes:
              - some-volume:/some/path
        #srv2:
        #    image: alpine
        #    container_name: srv2
        #    volumes_from:
        #      - some-volume
    volumes:
        some-volume:
    """
    
    data, indent, block_seq_indent = load_yaml_guess_indent(yaml_str)
    print('indent', indent, block_seq_indent)
    
    c0 = data['services'].ca
    print('c0:', c0)
    c0_0 = c0.comment[1][0]
    print('c0_0:', repr(c0_0.value), c0_0.start_mark.column)
    
    c1 = data['services']['srv1']['volumes'].ca
    print('c1:', c1)
    c1_0 = c1.end[0]
    print('c1_0:', repr(c1_0.value), c1_0.start_mark.column)
    

    which prints:

    indent 4 2
    c0: Comment(comment=[None, [CommentToken(), CommentToken()]],
      items={})
    c0_0: '#a\n' 4
    c1: Comment(comment=[None, None],
      items={},
      end=[CommentToken(), CommentToken(), CommentToken(), CommentToken(), CommentToken()])
    c1_0: '#srv2:\n' 4
    

    So you "only", have to create the first type comment (c0) if you comment out the first key-value pair and you have to create the other (c1) if you comment out any other key-value pair. The startmark is a StreamMark() (from ruamel/yaml/error.py) and the only important attribute of that instance when creating comments is column.

    Fortunately this is made slightly easier then shown above, as it is not necessary to attach the comments to the "end" of the value of volumes, attaching them to the end of the value of srv1 has the same effect.

    In the following comment_block expects a list of keys that is the path to the element to be commented out.

    import sys
    from copy import deepcopy
    from ruamel.yaml import round_trip_dump
    from ruamel.yaml.util import load_yaml_guess_indent
    from ruamel.yaml.error import StreamMark
    from ruamel.yaml.tokens import CommentToken
    
    
    yaml_str = """\
    version: '2'
    services:
        srv1:
            image: alpine
            container_name: srv1
            volumes:
              - some-volume:/some/path
        srv2:
            image: alpine
            container_name: srv2  # second container
            volumes_from:
              - some-volume
    volumes:
        some-volume:
    """
    
    
    def comment_block(d, key_index_list, ind, bsi):
        parent = d
        for ki in key_index_list[:-1]:
            parent = parent[ki]
        # don't just pop the value for key_index_list[-1] that way you lose comments
        # in the original YAML, instead deepcopy and delete what is not needed
        data = deepcopy(parent)
        keys = list(data.keys())
        found = False
        previous_key = None
        for key in keys:
            if key != key_index_list[-1]:
                if not found:
                    previous_key = key
                del data[key]
            else:
                found = True
        # now delete the key and its value
        del parent[key_index_list[-1]]
        if previous_key is None:
            if parent.ca.comment is None:
                parent.ca.comment = [None, []]
            comment_list = parent.ca.comment[1]
        else:
            comment_list = parent[previous_key].ca.end = []
            parent[previous_key].ca.comment = [None, None]
        # startmark can be the same for all lines, only column attribute is used
        start_mark = StreamMark(None, None, None, ind * (len(key_index_list) - 1))
        for line in round_trip_dump(data, indent=ind, block_seq_indent=bsi).splitlines(True):
            comment_list.append(CommentToken('#' + line, start_mark, None))
    
    for srv in ['srv1', 'srv2']:
        data, indent, block_seq_indent = load_yaml_guess_indent(yaml_str)
        comment_block(data, ['services', srv], ind=indent, bsi=block_seq_indent)
        round_trip_dump(data, sys.stdout,
                        indent=indent, block_seq_indent=block_seq_indent,
                        explicit_end=True,
        )
    

    which prints:

    version: '2'
    services:
        #srv1:
        #    image: alpine
        #    container_name: srv1
        #    volumes:
        #      - some-volume:/some/path
        srv2:
            image: alpine
            container_name: srv2  # second container
            volumes_from:
              - some-volume
    volumes:
        some-volume:
    ...
    version: '2'
    services:
        srv1:
            image: alpine
            container_name: srv1
            volumes:
              - some-volume:/some/path
        #srv2:
        #    image: alpine
        #    container_name: srv2      # second container
        #    volumes_from:
        #      - some-volume
    volumes:
        some-volume:
    ...
    

    (the explicit_end=True is not necessary, it is used here to get some demarcation between the two YAML dumps automatically).

    Removing the comments this way can be done as well. Recursively search the comment attributes (.ca) for a commented out candidate (maybe giving some hints on where to start). Strip the leading # from the comments and concatenate, then round_trip_load. Based on the column of the comments you can determine where to attach the uncommented key-value pair.