Se stai lavorando con YAML in Python, quasi certamente stai usando PyYAML. È la libreria standard, esiste dal 2006 e include una funzione chiamata yaml.load() che ha una vulnerabilità di sicurezza critica che ha colpito molti team. La soluzione è una parola sola — safe_load — ma devi capire perché, cosa si perde, e quando la libreria più recente ruamel.yaml è la scelta migliore.

Questa guida copre il parsing YAML pratico in Python: caricamento sicuro, stream multi-documento, serializzazione di oggetti Python in YAML, pattern per file di configurazione con valori predefiniti e gestione degli errori. Tutti gli esempi usano scenari reali — niente dati segnaposto.

Installazione

bash
pip install pyyaml

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

yaml.safe_load() — Quello che Dovresti Sempre Usare

La cosa più importante da sapere su PyYAML è che yaml.load() può eseguire codice Python arbitrario incorporato in un file YAML. Non è un rischio teorico — è un vettore di attacco ben documentato. Usa sempre 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"]
Nota sulla sicurezza: yaml.safe_load() supporta solo i tipi YAML standard: stringhe, numeri, booleani, null, liste e dizionari. Solleverà un ConstructorError se il YAML contiene tag specifici di Python come !!python/object. Questo è esattamente il comportamento che desideri. yaml.full_load() è più sicuro del vecchio yaml.load() senza argomenti, ma è comunque meno restrittivo di safe_load(). Parti da safe_load() e passa a qualcosa di più permissivo solo se ne hai davvero bisogno.

Caricare un File di Configurazione YAML

Ecco un pattern realistico per caricare una configurazione in un'applicazione web. Carichiamo un file YAML e usiamo il merge dei dizionari Python per applicare i valori predefiniti a tutto ciò che non è specificato:

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)

Serializzare Oggetti Python in YAML

yaml.dump() serializza dizionari, liste, stringhe, numeri, booleani e None di Python in YAML. Per impostazione predefinita usa lo stile a flusso (parentesi graffe inline) — imposta default_flow_style=False per ottenere lo stile a blocchi leggibile:

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)

Stream Multi-Documento con load_all

YAML supporta più documenti in un singolo file, separati da ---. Questo è comune nei manifest Kubernetes dove un singolo file può contenere un Deployment, un Service e una ConfigMap. Usa yaml.safe_load_all() per iterare su tutti i documenti:

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

Puoi anche scrivere più documenti in uno stream con 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 — Quando Devi Preservare i Commenti

PyYAML ha un limite significativo: rimuove i commenti durante il caricamento. Se carichi un file YAML, lo modifichi e lo riscrivi, tutti i commenti spariscono. Per i file di configurazione gestiti da esseri umani, perdere i commenti è un problema insuperabile.

ruamel.yaml implementa un parser round-trip che preserva commenti, ordine delle chiavi e formattazione — punta allo spec YAML 1.2 per impostazione predefinita. È la scelta giusta ogni volta che modifichi programmaticamente YAML che gli esseri umani leggeranno in seguito:

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
  • Usa PyYAML quando leggi YAML per consumarlo — parsing della configurazione nella tua app, caricamento di fixture di test, elaborazione programmatica di manifest Kubernetes.
  • Usa ruamel.yaml quando modifichi YAML gestito da esseri umani — aggiornamento di file di configurazione in-place, tooling che modifica configurazioni CI, qualsiasi situazione dove perdere i commenti sarebbe problematico.
  • ruamel.yaml è anche conforme a YAML 1.2 per impostazione predefinita, il che significa che il Norway Problem (NOfalse) non lo riguarda. PyYAML usa YAML 1.1 per impostazione predefinita.

Gestione degli Errori

Gli errori di parsing YAML sollevano yaml.YAMLError, che è la classe base per tutte le eccezioni PyYAML. Intercettalo sempre quando carichi YAML da fonti non attendibili o fornite dall'utente:

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
Valida la struttura dopo il caricamento. yaml.safe_load() garantisce solo la sintassi YAML valida — non valida la forma dei dati. Un file di configurazione con database: in cima va bene; anche un file di configurazione con una lista in cima è YAML valido. Aggiungi controlli di tipo e struttura dopo il caricamento, oppure usa una libreria di validazione dello schema come Pydantic per fare il parsing del dizionario caricato in un modello tipizzato.

Conclusione

PyYAML copre la stragrande maggioranza del lavoro YAML in Python: usa sempre yaml.safe_load() (non yaml.load()), usa yaml.safe_load_all() per gli stream multi-documento e usa yaml.dump() con default_flow_style=False per un output leggibile. Quando hai bisogno di preservare i commenti o ottenere la semantica YAML 1.2, passa a ruamel.yaml — è un upgrade immediato per la lettura e una piccola modifica dell'API per la scrittura. Per gli errori di sintassi prima ancora che il codice venga eseguito, il Validatore YAML ti dirà esattamente quale riga e colonna è errata.