Jeśli pracujesz z YAML w Pythonie, prawie na pewno używasz PyYAML. To standardowa biblioteka, istnieje od 2006 roku i zawiera funkcję yaml.load(), która ma krytyczną lukę bezpieczeństwa, która wyrządziła wiele szkód wielu zespołom. Poprawka to jedno słowo — safe_load — ale musisz zrozumieć dlaczego, co tracisz i kiedy nowsza biblioteka ruamel.yaml jest lepszym wyborem.

Ten przewodnik omawia praktyczne parsowanie YAML w Pythonie: bezpieczne ładowanie, strumienie wielu dokumentów, zrzucanie obiektów Pythona z powrotem do YAML, wzorce plików konfiguracyjnych z wartościami domyślnymi oraz obsługę błędów. Wszystkie przykłady opierają się na scenariuszach z prawdziwego życia — bez danych zastępczych.

Instalacja

bash
pip install pyyaml

# For ruamel.yaml (covered later)
pip install ruamel.yaml

yaml.safe_load() — Jedyna Funkcja, Której Powinieneś Zawsze Używać

Najważniejsza rzecz, którą należy wiedzieć o PyYAML jest to, że yaml.load() może wykonywać dowolny kod Pythona osadzony w pliku YAML. To nie jest teoretyczne ryzyko — to dobrze udokumentowany wektor ataku. Zawsze używaj yaml.safe_load():

python
import yaml

# DANGEROUS — never use this with untrusted input
data = yaml.load(open('config.yaml'), Loader=yaml.FullLoader)

# SAFE — use this for any YAML from external sources
data = yaml.safe_load(open('config.yaml'))

# The attack: a YAML file could contain this, which executes Python
# !!python/object/apply:os.system ["rm -rf /important-dir"]
Uwaga bezpieczeństwa: yaml.safe_load() obsługuje tylko standardowe typy YAML: ciągi znaków, liczby, wartości logiczne, null, listy i słowniki. Wywoła ConstructorError, jeśli YAML zawiera tagi specyficzne dla Pythona, takie jak !!python/object. To dokładnie takie zachowanie, jakiego chcesz. yaml.full_load() jest bezpieczniejszy niż stary yaml.load(), ale nadal mniej restrykcyjny niż safe_load(). Zacznij od safe_load() i aktualizuj tylko wtedy, gdy naprawdę tego potrzebujesz.

Ładowanie Pliku Konfiguracyjnego YAML

Oto realistyczny wzorzec ładowania konfiguracji dla aplikacji webowej. Ładujemy plik konfiguracyjny YAML i używamy scalania słowników Pythona, aby wypełnić wartości domyślne dla wszystkiego, co nie zostało określone:

python
# config.yaml
database:
  host: postgres.internal
  port: 5432
  name: myapp_prod
  pool_size: 10

redis:
  host: redis.internal
  port: 6379

logging:
  level: INFO
  format: json
python
import yaml
from pathlib import Path
from typing import Any

DEFAULT_CONFIG = {
    'database': {
        'host': 'localhost',
        'port': 5432,
        'name': 'myapp',
        'pool_size': 5,
        'ssl': False,
    },
    'redis': {
        'host': 'localhost',
        'port': 6379,
        'db': 0,
    },
    'logging': {
        'level': 'DEBUG',
        'format': 'text',
    }
}

def deep_merge(base: dict, override: dict) -> dict:
    """Recursively merge override into base, returning a new dict."""
    result = base.copy()
    for key, value in override.items():
        if key in result and isinstance(result[key], dict) and isinstance(value, dict):
            result[key] = deep_merge(result[key], value)
        else:
            result[key] = value
    return result

def load_config(config_path: str | Path) -> dict[str, Any]:
    path = Path(config_path)
    if not path.exists():
        raise FileNotFoundError(f"Config file not found: {path}")

    with path.open('r', encoding='utf-8') as f:
        raw = yaml.safe_load(f)

    if raw is None:
        return DEFAULT_CONFIG.copy()

    return deep_merge(DEFAULT_CONFIG, raw)


config = load_config('config.yaml')
print(config['database']['host'])       # postgres.internal
print(config['database']['ssl'])        # False  (from defaults)
print(config['redis']['db'])            # 0  (from defaults)

Zrzucanie Obiektów Pythona do YAML

yaml.dump() serializuje słowniki Pythona, listy, ciągi znaków, liczby, wartości logiczne i None do YAML. Domyślnie używa stylu przepływu (nawiasy klamrowe inline) — ustaw default_flow_style=False dla czytelnego stylu blokowego:

python
import yaml
from dataclasses import dataclass, asdict

@dataclass
class ServiceConfig:
    name: str
    replicas: int
    image: str
    port: int
    tags: list[str]

service = ServiceConfig(
    name='payment-api',
    replicas=3,
    image='payment-api:2.4.1',
    port=8080,
    tags=['payments', 'backend', 'critical']
)

# Convert dataclass to dict first, then dump to YAML
output = yaml.dump(
    asdict(service),
    default_flow_style=False,
    sort_keys=False,            # preserve insertion order
    allow_unicode=True
)
print(output)
# image: payment-api:2.4.1
# name: payment-api
# port: 8080
# replicas: 3
# tags:
# - payments
# - backend
# - critical

# Write to file
with open('service-config.yaml', 'w', encoding='utf-8') as f:
    yaml.dump(asdict(service), f, default_flow_style=False, sort_keys=False)

Strumienie Wielu Dokumentów z load_all

YAML obsługuje wiele dokumentów w jednym pliku, oddzielonych przez ---. Jest to powszechne w manifestach Kubernetes, gdzie jeden plik może zawierać Deployment, Service i ConfigMap. Użyj yaml.safe_load_all(), aby iterować po wszystkich dokumentach:

python
import yaml

# manifests.yaml contains multiple Kubernetes resources separated by ---
with open('manifests.yaml', 'r') as f:
    # safe_load_all returns a generator
    documents = list(yaml.safe_load_all(f))

for doc in documents:
    if doc is None:
        continue
    kind = doc.get('kind', 'Unknown')
    name = doc.get('metadata', {}).get('name', 'unnamed')
    print(f"{kind}: {name}")

# Deployment: payment-api
# Service: payment-api-svc
# ConfigMap: payment-api-config

Możesz również zapisać wiele dokumentów do strumienia za pomocą yaml.dump_all():

python
import yaml

documents = [
    {'kind': 'Deployment', 'metadata': {'name': 'api'}, 'spec': {'replicas': 2}},
    {'kind': 'Service', 'metadata': {'name': 'api-svc'}, 'spec': {'port': 80}},
]

output = yaml.dump_all(documents, default_flow_style=False)
print(output)
# kind: Deployment
# metadata:
#   name: api
# spec:
#   replicas: 2
# ---
# kind: Service
# metadata:
#   name: api-svc
# spec:
#   port: 80

ruamel.yaml — Kiedy Musisz Zachować Komentarze

PyYAML ma jedno istotne ograniczenie: usuwa komentarze podczas ładowania. Jeśli załadujesz plik YAML, zmodyfikujesz go i zapiszesz z powrotem, wszystkie komentarze znikają. W przypadku plików konfiguracyjnych utrzymywanych przez ludzi, utrata komentarzy jest nie do przyjęcia.

ruamel.yaml implementuje parser z pełnym przejściem, który zachowuje komentarze, kolejność kluczy i formatowanie — domyślnie jest zgodny ze specyfikacją YAML 1.2. To właściwy wybór, gdy programowo edytujesz YAML, który ludzie będą później czytać:

python
from ruamel.yaml import YAML

yaml = YAML()
yaml.preserve_quotes = True

# This config.yaml has important comments we need to keep:
# database:
#   host: localhost  # change this for production
#   port: 5432       # default PostgreSQL port
#   pool_size: 5     # increase under heavy load

with open('config.yaml', 'r') as f:
    config = yaml.load(f)

# Modify a value
config['database']['host'] = 'postgres.prod.internal'
config['database']['pool_size'] = 20

# Write back — comments and formatting are preserved!
with open('config.yaml', 'w') as f:
    yaml.dump(config, f)

# Result:
# database:
#   host: postgres.prod.internal  # change this for production
#   port: 5432                    # default PostgreSQL port
#   pool_size: 20                 # increase under heavy load
  • Używaj PyYAML, gdy czytasz YAML do konsumpcji — parsowanie konfiguracji do aplikacji, ładowanie fixtures testowych, przetwarzanie manifestów Kubernetes programowo.
  • Używaj ruamel.yaml, gdy edytujesz YAML utrzymywany przez ludzi — aktualizowanie plików konfiguracyjnych w miejscu, narzędzia modyfikujące konfiguracje CI, wszystko, gdzie utrata komentarzy byłaby problemem.
  • ruamel.yaml jest również domyślnie zgodny z YAML 1.2, co oznacza, że Problem Norwegii (NOfalse) go nie dotyczy. PyYAML domyślnie używa YAML 1.1.

Obsługa Błędów

Błędy parsowania YAML wywołują yaml.YAMLError, który jest klasą bazową dla wszystkich wyjątków PyYAML. Zawsze przechwytuj go podczas ładowania YAML z niezaufanych lub dostarczonych przez użytkownika źródeł:

python
import yaml
from pathlib import Path

def load_user_config(path: str) -> dict:
    try:
        with open(path, 'r', encoding='utf-8') as f:
            data = yaml.safe_load(f)
    except FileNotFoundError:
        raise FileNotFoundError(f"Config file not found: {path}")
    except yaml.scanner.ScannerError as e:
        # Includes line/column info in the error message
        raise ValueError(f"YAML syntax error in {path}:\n{e}")
    except yaml.YAMLError as e:
        raise ValueError(f"Invalid YAML in {path}: {e}")

    if data is None:
        return {}
    if not isinstance(data, dict):
        raise TypeError(f"Expected a YAML mapping at top level, got {type(data).__name__}")

    return data
Waliduj strukturę po załadowaniu. yaml.safe_load() gwarantuje tylko poprawną składnię YAML — nie waliduje kształtu danych. Plik konfiguracyjny z database: na górze jest w porządku; plik konfiguracyjny z listą na górze jest również poprawnym YAML. Dodaj sprawdzenia typów i struktury po załadowaniu lub użyj biblioteki walidacji schematów, takiej jak Pydantic, aby sparsować załadowany słownik do typowanego modelu.

Podsumowanie

PyYAML obsługuje zdecydowaną większość pracy z YAML w Pythonie: zawsze używaj yaml.safe_load() (nie yaml.load()), używaj yaml.safe_load_all() dla strumieni wielu dokumentów, i używaj yaml.dump() z default_flow_style=False dla czytelnych wyników. Gdy musisz zachować komentarze lub uzyskać semantykę YAML 1.2, przejdź na ruamel.yaml — to niemal bezproblemowe ulepszenie do odczytu i niewielka zmiana API do zapisu. W przypadku błędów składni przed uruchomieniem kodu, Walidator YAML powie ci dokładnie, która linia i kolumna są uszkodzone.