¿Cómo puedo actualizar un archivo .yml, ignorando la syntax Jinja preexistente, usando Python?

Tengo que hacer un preprocesamiento con algunos archivos .yml existentes; sin embargo, algunos de ellos tienen incrustada la syntax de la plantilla Jinja:

A: B: - ip: 1.2.3.4 - myArray: - {{ jinja.variable }} - val1 - val2 

Me gustaría leer en este archivo y agregar val3 en myArray como tal:

 A: B: - ip: 1.2.3.4 - myArray: - {{ jinja.variable }} - val1 - val2 - val 3 

Intenté escribir manualmente las plantillas jinja, pero se escribieron con comillas simples alrededor de ellas: '{{ jinja.variable }}'

¿Cuál es la forma recomendada para que lea dichos archivos .yml y los modifique, aunque con la syntax de Jinja preexistente? Me gustaría agregar información a estos archivos manteniendo todo lo demás igual.

Probé lo anterior usando PyYAML en Python 2.7+

La solución en esta respuesta se ha incorporado a ruamel.yaml mediante un mecanismo de complemento. Al final de este post hay instrucciones rápidas y sucias sobre cómo usar eso.

Hay tres aspectos en la actualización de un archivo YAML que contiene el “código” de jinja2:

  • haciendo que el código jinja2 sea aceptable para el analizador YAML
  • asegurarse de que se pueda revertir lo aceptable (es decir, los cambios deben ser únicos, de modo que solo se inviertan)
  • preservando el diseño del archivo YAML para que el archivo actualizado procesado por jinja2 todavía produzca un archivo YAML válido, que de nuevo se puede cargar.

Comencemos por hacer su ejemplo un poco más realista agregando una definición de variable jinja2 y for-loop y agregando algunos comentarios ( input.yaml ):

 # trying to update {% set xyz = "123" } A: B: - ip: 1.2.3.4 - myArray: - {{ jinja.variable }} - val1 - val2 # add a value after this one {% for d in data %} - phone: {{ d.phone }} name: {{ d.name }} {% endfor %} - {{ xyz }} # #% or ##% should not be in the file and neither <{ or <<{ 

Las líneas que comienzan con {% no contienen YAML, por lo que las convertiremos en comentarios (suponiendo que los comentarios se conserven en el viaje de ida y vuelta, consulte a continuación). Dado que los escalares YAML no pueden comenzar con { sin cotizar, cambiaremos {{ a <{ . Esto se hace en el siguiente código llamando a sanitize() (que también almacena los patrones utilizados, y lo contrario se realiza en sanitize.reverse (utilizando los patrones almacenados).

La preservación de su código YAML (estilo de bloque, etc.) se hace mejor usando ruamel.yaml (descargo de responsabilidad: soy el autor de ese paquete), de esa manera no tiene que preocuparse por los elementos de estilo de flujo en la entrada que resulta dañada en un estilo de bloque como con el default_flow_style=False que usan las otras respuestas. ruamel.yaml también conserva los comentarios, tanto los que estaban originalmente en el archivo como los que se insertaron temporalmente para "comentar" las construcciones de jinja2 que comienzan con %{ .

El código resultante:

 import sys from ruamel.yaml import YAML yaml = YAML() class Sanitize: """analyse, change and revert YAML/jinja2 mixture to/from valid YAML""" def __init__(self): self.accacc = None self.accper = None def __call__(self, s): len = 1 for len in range(1, 10): pat = '<' * len + '{' if pat not in s: self.accacc = pat break else: raise NotImplementedError('could not find substitute pattern '+pat) len = 1 for len in range(1, 10): pat = '#' * len + '%' if pat not in s: self.accper = pat break else: raise NotImplementedError('could not find substitute pattern '+pat) return s.replace('{{', self.accacc).replace('{%', self.accper) def revert(self, s): return s.replace(self.accacc, '{{').replace(self.accper, '{%') def update_one(file_name, out_file_name=None): sanitize = Sanitize() with open(file_name) as fp: data = yaml.load(sanitize(fp.read())) myArray = data['A']['B'][1]['myArray'] pos = myArray.index('val2') myArray.insert(pos+1, 'val 3') if out_file_name is None: yaml.dump(data, sys.stdout, transform=sanitize.revert) else: with open(out_file_name, 'w') as fp: yaml.dump(data, out, transform=sanitize.revert) update_one('input.yaml') 

que imprime (especifique un segundo parámetro para update_one() para escribir en un archivo) utilizando Python 2.7:

 # trying to update {% set xyz = "123" } A: B: - ip: 1.2.3.4 - myArray: - {{ jinja.variable }} - val1 - val2 # add a value after this one - val 3 {% for d in data %} - phone: {{ d.phone }} name: {{ d.name }} {% endfor %} - {{ xyz }} # #% or ##% should not be in the file and neither <{ or <<{ 

Si ni #{ ni <{ están en ninguna de las entradas originales, la limpieza y la reversión se pueden realizar con funciones simples de una línea (consulte estas versiones de esta publicación ), y entonces no necesita la clase Sanitize

Su ejemplo está sangrado con una posición (clave B ) así como con dos posiciones (los elementos de la secuencia), ruamel.yaml no tiene ese control preciso sobre la sangría de salida (y no conozco ningún analizador YAML que tenga). La sangría (el valor predeterminado es 2) se aplica a ambas asignaciones YAML como a elementos de secuencia (medidos al principio del elemento, no al guión). Esto no tiene influencia en la re-lectura del YAML y sucedió también en la salida de los otros dos respondedores (sin que ellos indiquen este cambio).

También tenga en cuenta que YAML().load() es seguro (es decir, no carga objetos potencialmente maliciosos arbitrarios), mientras que yaml.load() tal como se usa en las otras respuestas es definitivamente inseguro , así lo dice en la documentación y es parejo. mencionado en el artículo de WikiPedia en YAML . Si usa yaml.load() , tendrá que revisar todos y cada uno de los archivos de entrada para asegurarse de que no haya objetos etiquetados que puedan hacer que su disco sea borrado (o peor).

Si necesita actualizar sus archivos repetidamente y tener control sobre las plantillas de jinja2, podría ser mejor cambiar los patrones de jinja2 una vez y no revertirlos, y luego especificar block_start_string , variable_start_string (y posible block_end_string y variable_end_string ) para jinja2.FileSystemLoader agregado como cargador al jinja2.Environment .


Si lo anterior parece complicado entonces en aa virtualenv haz:

 pip install ruamel.yaml ruamel.yaml.jinja2 

asumiendo que tienes el input.yaml desde antes de que puedas ejecutar:

 import os from ruamel.yaml import YAML yaml = YAML(typ='jinja2') with open('input.yaml') as fp: data = yaml.load(fp) myArray = data['A']['B'][1]['myArray'] pos = myArray.index('val2') myArray.insert(pos+1, 'val 3') with open('output.yaml', 'w') as fp: yaml.dump(data, fp) os.system('diff -u input.yaml output.yaml') 

para obtener la salida de diff :

 --- input.yaml 2017-06-14 23:10:46.144710495 +0200 +++ output.yaml 2017-06-14 23:11:21.627742055 +0200 @@ -8,6 +8,7 @@ - {{ jinja.variable }} - val1 - val2 # add a value after this one + - val 3 {% for d in data %} - phone: {{ d.phone }} name: {{ d.name }} 

ruamel.yaml 0.15.7 implementa un nuevo mecanismo de plug-in y ruamel.yaml.jinja2 es un plug-in que vuelve a envolver el código en esta respuesta de forma transparente para el usuario. Actualmente, la información para la reversión se adjunta a la instancia de YAML() , así que asegúrese de hacer yaml = YAML(typ='jinja2') para cada archivo que procese (esa información podría adjuntarse a la instancia de data nivel superior, al igual que Los comentarios de YAML son).

Una forma de hacer esto es usar el analizador jinja2 para analizar la plantilla y generar un formato alternativo.

Código Jinja2:

Este código se hereda de las clases de Parser , Lexer y Environment Jinja2 para analizar dentro de bloques de variables (generalmente {{ }} ). En lugar de evaluar las variables, este código cambia el texto a algo que yaml puede entender. El mismo código exacto se puede utilizar para revertir el proceso con un intercambio de los delimitadores. Por defecto, se traduce a los delimitadores sugeridos por snakecharmerb .

 import jinja2 import yaml class MyParser(jinja2.parser.Parser): def parse_tuple(self, *args, **kwargs): super(MyParser, self).parse_tuple(*args, **kwargs) if not isinstance(self.environment._jinja_vars, list): node_text = self.environment._jinja_vars self.environment._jinja_vars = None return jinja2.nodes.Const( self.environment.new_variable_start_string + node_text + self.environment.new_variable_end_string) class MyLexer(jinja2.lexer.Lexer): def __init__(self, *args, **kwargs): super(MyLexer, self).__init__(*args, **kwargs) self.environment = None def tokenize(self, source, name=None, filename=None, state=None): stream = self.tokeniter(source, name, filename, state) def my_stream(environment): for t in stream: if environment._jinja_vars is None: if t[1] == 'variable_begin': self.environment._jinja_vars = [] elif t[1] == 'variable_end': node_text = ''.join( [x[2] for x in self.environment._jinja_vars]) self.environment._jinja_vars = node_text else: environment._jinja_vars.append(t) yield t return jinja2.lexer.TokenStream(self.wrap( my_stream(self.environment), name, filename), name, filename) jinja2.lexer.Lexer = MyLexer class MyEnvironment(jinja2.Environment): def __init__(self, new_variable_start_string='<<', new_variable_end_string='>>', reverse=False, *args, **kwargs): if kwargs.get('loader') is None: kwargs['loader'] = jinja2.BaseLoader() super(MyEnvironment, self).__init__(*args, **kwargs) self._jinja_vars = None if reverse: self.new_variable_start_string = self.variable_start_string self.new_variable_end_string = self.variable_end_string self.variable_start_string = new_variable_start_string self.variable_end_string = new_variable_end_string else: self.new_variable_start_string = new_variable_start_string self.new_variable_end_string = new_variable_end_string self.lexer.environment = self def _parse(self, source, name, filename): return MyParser(self, source, name, jinja2._compat.encode_filename(filename)).parse() 

¿Cómo por qué?

El analizador jinja2 escanea el archivo de plantilla en busca de delimitadores. Cuando encuentra delimitadores, luego cambia para analizar el material apropiado entre los delimitadores. Los cambios en el código aquí se insertan en el lexer y el analizador para capturar el texto capturado durante la comstackción de la plantilla, y luego, al encontrar el delimitador de terminación, acumula los tokens analizados en una cadena y lo inserta como un nodo jinja2.nodes.Const parse , en lugar del código jinja comstackdo, de modo que cuando se representa la plantilla, la cadena se inserta en lugar de una expansión variable.

El código MyEnvironment () se usa para enlazar las extensiones del analizador personalizado y del lexer. Y mientras tanto, añadimos unos parámetros de procesamiento.

La principal ventaja de este enfoque es que debe ser bastante robusto para analizar lo que jinja analizará.

Codigo de usuario:

 def dict_from_yaml_template(template_string): env = MyEnvironment() template = env.from_string(template_string) return yaml.load(template.render()) def yaml_template_from_dict(template_yaml, **kwargs): env = MyEnvironment(reverse=True) template = env.from_string(yaml.dump(template_yaml, **kwargs)) return template.render() 

Código de prueba:

 with open('data.yml') as f: data = dict_from_yaml_template(f.read()) data['A']['B'][1]['myArray'].append('val 3') data['A']['B'][1]['myArray'].append('<< jinja.variable2 >>') new_yaml = yaml_template_from_dict(data, default_flow_style=False) print(new_yaml) 

data.yml

 A: B: - ip: 1.2.3.4 - myArray: - {{ x['}}'] }} - {{ [(1, 2, (3, 4))] }} - {{ jinja.variable }} - val1 - val2 

Resultados:

 A: B: - ip: 1.2.3.4 - myArray: - {{ x['}}'] }} - {{ [(1, 2, (3, 4))] }} - {{ jinja.variable }} - val1 - val2 - val 3 - {{ jinja.variable2 }} 

En su formato actual, sus archivos .yml son plantillas jinja que no serán válidas yaml hasta que se hayan procesado. Esto se debe a que la syntax del marcador de posición jinja entra en conflicto con la syntax yaml, ya que se pueden usar llaves ( { y } ) para representar las asignaciones en yaml.

 >>> yaml.load('foo: {{ bar }}') Traceback (most recent call last): ... yaml.constructor.ConstructorError: while constructing a mapping in "", line 1, column 6: foo: {{ bar }} ^ found unacceptable key (unhashable type: 'dict') in "", line 1, column 7: foo: {{ bar }} 

Una forma de solucionar esto es reemplazar los marcadores de posición jinja con otra cosa, procesar el archivo como yaml y luego restablecer los marcadores de posición.

 $ cat test.yml A: B: - ip: 1.2.3.4 - myArray: - {{ jinja_variable }} - val1 - val2 

Abra el archivo como un archivo de texto

 >>> with open('test.yml') as f: ... text = f.read() ... >>> print text A: B: - ip: 1.2.3.4 - myArray: - {{ jinja_variable }} - val1 - val2 

La expresión regular r'{{\s*(?P[a-zA-Z_][a-zA-Z0-9_]*)\s*}}' coincidirá con cualquier marcador de posición jinja en el texto; El grupo nombrado jinja en la expresión captura el nombre de la variable. La expresión regular es la misma que utiliza Jinja2 para hacer coincidir los nombres de las variables.

La función re.sub puede hacer referencia a grupos con nombre en su cadena de reemplazo usando la syntax \g . Podemos usar esta función para reemplazar la syntax de jinja con algo que no entre en conflicto con la syntax de yaml y que no aparezca en los archivos que está procesando. Por ejemplo, reemplace {{ ... }} con << ... >> .

 >>> import re >>> yml_text = re.sub(r'{{\s*(?P[a-zA-Z_][a-zA-Z0-9_]*)\s*}}', '<<\g>>', text) >>> print yml_text A: B: - ip: 1.2.3.4 - myArray: - <> - val1 - val2 

Ahora carga el texto como yaml:

 >>> yml = yaml.load(yml_text) >>> yml {'A': {'B': [{'ip': '1.2.3.4'}, {'myArray': ['<>', 'val1', 'val2']}]}} 

Agregue el nuevo valor:

 >>> yml['A']['B'][1]['myArray'].append('val3') >>> yml {'A': {'B': [{'ip': '1.2.3.4'}, {'myArray': ['<>', 'val1', 'val2', 'val3']}]}} 

Serializar de nuevo a una cadena yaml:

 >>> new_text = yaml.dump(yml, default_flow_style=False) >>> print new_text A: B: - ip: 1.2.3.4 - myArray: - <> - val1 - val2 - val3 

Ahora restablece la syntax de jinja.

 >>> new_yml = re.sub(r'<<(?P[a-zA-Z_][a-zA-Z0-9_]*)>>', '{{ \g }}', new_text) >>> print new_yml A: B: - ip: 1.2.3.4 - myArray: - {{ jinja_variable }} - val1 - val2 - val3 

Y escribe el yaml en el disco.

 >>> with open('test.yml', 'w') as f: ... f.write(new_yml) ... $cat test.yml A: B: - ip: 1.2.3.4 - myArray: - {{ jinja_variable }} - val1 - val2 - val3