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
pip install pyyaml
# For ruamel.yaml (covered later)
pip install ruamel.yamlyaml.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():
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"]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:
# config.yaml
database:
host: postgres.internal
port: 5432
name: myapp_prod
pool_size: 10
redis:
host: redis.internal
port: 6379
logging:
level: INFO
format: jsonimport 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:
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:
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-configMożesz również zapisać wiele dokumentów do strumienia za pomocą yaml.dump_all():
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: 80ruamel.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ć:
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 (
NO→false) 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ł:
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 datayaml.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.