April 14, 2024

Работа с YAML-файлами с использованием библиотеки PyYAML

YAML - кто ты такой?


YAML (YAML Ain't Markup Language) - популярный формат для хранения данных, в особенности, конфигов. К примеру, конфигов для gitlab CI, для хуков линтеров в pre-commit описываются в YAML.

Формат легко читается, для восприятия легче, чем JSON, особенно когда структура сложная. Кроме того, YAML легко преобразуется в dict и с ним удобно работать через код.

Расширение файла может выглядеть и как ”.yml” и как “.yaml”, разницы нет.

Синтаксис и структура


Напишем небольшой конфиг и разберемся, как прописывать разные типы данных в YAML-файле. Представим, что у нас есть несколько сервисов с параметрами и база данных:

common_host: &data
  host: localhost

services:
  service_1:
    <<: *data
    port: 8080
    is_available: true
    details:
      - "integration"
      - "fast"
      - "awesome"
  service_2:
    <<: *data
    port: 8020
    is_available: true
    details:
      - "slow"
      - "not bad"
  service_3:
    <<: *data
    port: 7001
    is_available: false
    details:
      - null

database:
  <<: *data
  port: 5432
  user: admin
  password: admin

Итак, разберем детально что тут происходит:

  • строки. Можно писать как с кавычками ("integration") так и без (admin, localhost)
  • словари. Можно в одну строку: data: { ”key”: “value” }

можно блоками:

  <<: *data
  port: 5432
  user: admin
  password: admin
  • списки. Можно в одну строку: ["slow", not bad"]

Можно указать каждый элемент на новой строке через дефис:

      - "integration"
      - "fast"
      - "awesome"
  • якоря. Уникальная штука в YAML, которая позволяет избежать дублирования и дает возможность переиспользовать данные.

Для начала, якорь надо определить:

common_host: &data
  host: localhost

а потом ссылаться на него в других местах конфига:

  service_3:
    <<: *data

и вместо этой ссылки будет подставлено значение host: localhost .

Чтение и запись YAML-файлов с помощью библиотеки PyYaml


PyYaml

Очень популярная библиотека для работы с YAML. Она не встроенная, потому нам надо ее установить:

pip install pyyaml

Чтение YAML-файлов

Чтобы распарсить YAML-файл в словарь Python, в PyYaml присутствует функция safe_load() . Есть еще функция load(), но ее не рекомендуется использовать с данными из непроверенных источников, т.к. она может вызывать любой код на Python.

Давайте создадим файл config.yml и сохраним туда наш конфиг.

Также создадим файл yaml_parser.py, где и будем реализовывать логику работы с YAML.

Напишем функцию для чтения конфига:

from pathlib import Path
from pprint import pprint

import yaml

CWD = Path(__file__).parent
CONFIG_PATH = Path(CWD / "config.yml")

def open_config(config_path: Path = CONFIG_PATH) -> dict:
    if Path.exists(config_path) and config_path.is_file():
        with config_path.open(mode="r", encoding="utf-8") as file:
            return yaml.safe_load(file)
    raise FileNotFoundError("Please check config file path!")

Что здесь происходит:

  • С помощью библиотеки pathlib определяем текущую директорию (CWD) и путь до конфига (CONFIG_PATH)
  • Внутри функции def open_config() проверяем, что объект по указанному в аргументе config_path пути существует и является файлом. Если какое-то из этих условий не выполняется, вызываем исключение raise FileNotFoundError , страхуя себя от лишних ошибок
  • Далее открываем файл с помощью контекстного менеджера в режиме чтения, преобразуем наш конфиг в словарь и возвращаем его.

Посмотрим, что получится при вызове этой функции:

config_1 = open_config()
pprint(config_1, sort_dicts=False)

> {'common_host': {'host': 'localhost'},
 'services': {'service_1': {'host': 'localhost',
                            'port': 8080,
                            'is_available': True,
                            'details': ['integration', 'fast', 'awesome']},
              'service_2': {'host': 'localhost',
                            'port': 8020,
                            'is_available': True,
                            'details': ['slow', 'not bad']},
              'service_3': {'host': 'localhost',
                            'port': 7001,
                            'is_available': False,
                            'details': [None]}},
 'database': {'host': 'localhost',
              'port': 5432,
              'user': 'admin',
              'password': 'admin'}}

Итак, все работает! Теперь наш конфиг преобразован в словарь и с ним можно работать в коде.

Согласитесь, если описывать конфиги именно в таком формате, то читать их будет посложнее, чем YAML.

Запись YAML-файлов

Ну и финальное. Попробуем обратную процедуру, преобразуем и запишем словарь в YAML-файл.

Попробуем обратно преобразовать тот же конфиг из dict в YAML, затем сравним файлы и убедимся, что данные идентичны. Поехали!

Напишем функцию для записи словаря в YAML-файл:

def write_config(filename: Path, data: dict) -> None:
    with Path(CWD / filename).open(mode="w") as file:
        yaml.safe_dump(data=data, stream=file, sort_keys=False)

Что здесь происходит:

  • Функция принимает имя нового файла, включая расширение (filename) и словарь, который будем преобразовывать и сохранять (data)
  • С помощью контекстного менеджера открываем (и создаем, если файла нет) файл в режиме записи
  • с помощью функции safe_dump() преобразовываем и сохраняем данные без сортировки по ключам

Вызовем функцию и посмотрим, что происходит:

new_config_path = Path("new_config.yml")
write_config(filename=new_config_path, data=config_1)

После выполнения кода, в той же директории рядом с исходным файлом будет создан файл new_config.yml с таким содержимым:

common_host:
  host: localhost
services:
  service_1:
    host: localhost
    port: 8080
    is_available: true
    details:
    - integration
    - fast
    - awesome
  service_2:
    host: localhost
    port: 8020
    is_available: true
    details:
    - slow
    - not bad
  service_3:
    host: localhost
    port: 7001
    is_available: false
    details:
    - null
database:
  host: localhost
  port: 5432
  user: admin
  password: admin

Структура и данные абсолютно идентичны, мы справились с задачей!

Есть лишь маленькое “но”, здесь не будет якорей или явного указания строк в кавычках, могут немного скорректироваться отступы.

Давайте убедимся, что наши файлы идентичны на все 100%. Откроем оба сохраненных конфига и проверим их на равенство с помощью assert :

config_1 = open_config(config_path=CONFIG_PATH)
config_2 = open_config(config_path=new_config_path)

assert config_1 == config_2, "Config dicts are not equal!"

В случае неравенства будет вызвано исключение AssertionError с указанным текстом ошибки.

Этого не произошло, т.к. наши данные идентичны.