askill
config-schema-migrator

config-schema-migratorSafety 100Repository

Expert at evolving Pydantic configuration schemas with backward compatibility and automated migrations

0 stars
1.2k downloads
Updated 2/5/2026

Package Files

Loading files...
SKILL.md

Config Schema Migrator Skill

When to Use This Skill

Activate this skill when you need to:

  • Evolve Pydantic configuration schemas (add fields, change types, restructure sections)
  • Maintain backward compatibility with existing config files
  • Write migration scripts to automate config updates
  • Implement environment variable substitution in config fields
  • Add deprecation warnings for old config patterns
  • Validate config schemas with field validators
  • Create discriminated unions for config type discrimination

Key Principles

  1. Backward Compatibility First: Never break existing configs without a migration path
  2. Deprecation Before Removal: Warn users before removing old config sections
  3. Automatic Migration: Provide scripts to automate config updates (don't force manual editing)
  4. Type Safety: Use Pydantic validators to catch config errors at load time
  5. Environment Variables: Support ${VAR_NAME} substitution for secrets
  6. Clear Messaging: Provide helpful error messages with migration instructions

Pattern 1: Adding New Config Sections with Backward Compatibility

Example: Adding adapters Section While Keeping cli_tools

Problem: You need to add a new config section (adapters) to replace an old one (cli_tools) without breaking existing configs.

Solution Pattern (from models/config.py):

from typing import Optional
from pydantic import BaseModel
import warnings

class Config(BaseModel):
    """Root configuration model."""

    # New section (preferred)
    adapters: Optional[dict[str, AdapterConfig]] = None

    # Legacy section (deprecated)
    cli_tools: Optional[dict[str, CLIToolConfig]] = None

    def model_post_init(self, __context):
        """Post-initialization validation."""
        # Ensure at least one section exists
        if self.adapters is None and self.cli_tools is None:
            raise ValueError(
                "Configuration must include either 'adapters' or 'cli_tools' section"
            )

        # Emit deprecation warning for old section
        if self.cli_tools is not None and self.adapters is None:
            warnings.warn(
                "The 'cli_tools' configuration section is deprecated. "
                "Please migrate to 'adapters' section with explicit 'type' field. "
                "See migration guide: docs/migration/cli_tools_to_adapters.md",
                DeprecationWarning,
                stacklevel=2,
            )

Key Techniques:

  • Use Optional for both old and new sections
  • Validate in model_post_init() that at least one exists
  • Emit DeprecationWarning when old section is used
  • Reference migration documentation in warning message
  • Allow both sections temporarily for gradual migration

Pattern 2: Type Discrimination with Discriminated Unions

Example: CLI vs HTTP Adapters

Problem: You have config objects that can be one of several types (CLI adapter, HTTP adapter, etc).

Solution Pattern (from models/config.py):

from typing import Annotated, Literal, Union
from pydantic import BaseModel, Field

class CLIAdapterConfig(BaseModel):
    """Configuration for CLI-based adapter."""
    type: Literal["cli"] = "cli"
    command: str
    args: list[str]
    timeout: int = 60

class HTTPAdapterConfig(BaseModel):
    """Configuration for HTTP-based adapter."""
    type: Literal["http"] = "http"
    base_url: str
    api_key: Optional[str] = None
    timeout: int = 60
    max_retries: int = 3

# Discriminated union - Pydantic uses 'type' field to determine which model
AdapterConfig = Annotated[
    Union[CLIAdapterConfig, HTTPAdapterConfig],
    Field(discriminator="type")
]

YAML Usage:

adapters:
  claude:
    type: cli  # Discriminator field
    command: "claude"
    args: ["-p", "{prompt}"]
    timeout: 60

  ollama:
    type: http  # Different type triggers HTTPAdapterConfig
    base_url: "http://localhost:11434"
    timeout: 120

Key Techniques:

  • Use Literal["value"] for discriminator field with default value
  • Create Annotated[Union[...], Field(discriminator="type")]
  • Pydantic automatically routes to correct model based on type field
  • Each type has different required fields (validated automatically)

Pattern 3: Environment Variable Substitution

Example: API Keys and Secrets

Problem: You need to inject secrets from environment variables without hardcoding in YAML.

Solution Pattern (from models/config.py):

import os
import re
from pydantic import BaseModel, field_validator

class HTTPAdapterConfig(BaseModel):
    """Configuration for HTTP-based adapter."""
    base_url: str
    api_key: Optional[str] = None

    @field_validator("api_key", "base_url")
    @classmethod
    def resolve_env_vars(cls, v: Optional[str], info) -> Optional[str]:
        """Resolve ${ENV_VAR} references in string fields."""
        if v is None:
            return v

        # Pattern: ${VAR_NAME}
        pattern = r"\$\{([^}]+)\}"
        is_api_key = info.field_name == "api_key"

        def replacer(match):
            env_var = match.group(1)
            value = os.getenv(env_var)
            if value is None:
                # For optional fields like api_key, use sentinel
                if is_api_key:
                    return "__MISSING_API_KEY__"
                # For required fields, raise error
                raise ValueError(
                    f"Environment variable '{env_var}' is not set. "
                    f"Required for configuration."
                )
            return value

        result = re.sub(pattern, replacer, v)

        # If api_key has sentinel marker, return None (graceful degradation)
        if is_api_key and "__MISSING_API_KEY__" in result:
            return None

        return result

YAML Usage:

adapters:
  openrouter:
    type: http
    base_url: "https://openrouter.ai/api/v1"
    api_key: "${OPENROUTER_API_KEY}"  # Resolved from environment

Key Techniques:

  • Use @field_validator on fields that may contain env vars
  • Use info.field_name to customize behavior per field
  • Regex pattern r"\$\{([^}]+)\}" to find ${VAR_NAME}
  • For optional fields (api_key): return None if env var missing
  • For required fields (base_url): raise ValueError if env var missing
  • Always load .env file first with dotenv.load_dotenv() in load_config()

Pattern 4: Writing Migration Scripts

Example: CLI Tools to Adapters Migration

Problem: You need to migrate existing config files from old format to new format automatically.

Solution Pattern (from scripts/migrate_config.py):

#!/usr/bin/env python3
"""
Migration script: cli_tools → adapters

Migrates config.yaml from legacy cli_tools format to new adapters format
with explicit type fields.

Usage:
    python scripts/migrate_config.py [path/to/config.yaml]
"""

import shutil
import sys
from pathlib import Path
from typing import Any, Dict
import yaml

def migrate_config_dict(config: Dict[str, Any]) -> Dict[str, Any]:
    """
    Migrate config dictionary from cli_tools to adapters format.

    Returns:
        Migrated config with adapters section
    """
    # If already migrated, return as-is
    if "adapters" in config and "cli_tools" not in config:
        print("Info: Config already migrated (has 'adapters' section)")
        return config

    # If no cli_tools, nothing to migrate
    if "cli_tools" not in config:
        print("Warning: No 'cli_tools' section found, nothing to migrate")
        return config

    # Create new config with adapters
    migrated = config.copy()

    # Transform cli_tools → adapters
    adapters = {}
    for name, cli_config in config["cli_tools"].items():
        adapters[name] = {
            "type": "cli",  # Add explicit type discriminator
            "command": cli_config["command"],
            "args": cli_config["args"],
            "timeout": cli_config["timeout"],
        }

    migrated["adapters"] = adapters
    del migrated["cli_tools"]

    print(f"Success: Migrated {len(adapters)} CLI tools to adapters format")
    return migrated

def migrate_config_file(path: str) -> None:
    """
    Migrate config file from cli_tools to adapters format.

    Creates a backup at {path}.bak before modifying.
    """
    config_path = Path(path)

    if not config_path.exists():
        raise FileNotFoundError(f"Config file not found: {path}")

    # Create backup BEFORE modifying
    backup_path = Path(f"{path}.bak")
    shutil.copy2(config_path, backup_path)
    print(f"Created backup: {backup_path}")

    # Load config
    with open(config_path, "r") as f:
        config = yaml.safe_load(f)

    # Migrate
    migrated = migrate_config_dict(config)

    # Write migrated config
    with open(config_path, "w") as f:
        yaml.dump(migrated, f, default_flow_style=False, sort_keys=False)

    print(f"Migrated config written to: {config_path}")
    print(f"\nInfo: Review the changes and delete {backup_path} when satisfied.")

def main():
    """Main entry point."""
    config_path = sys.argv[1] if len(sys.argv) > 1 else "config.yaml"

    print(f"Migrating config: {config_path}")
    print("-" * 50)

    try:
        migrate_config_file(config_path)
        print("\nMigration complete!")
        print("\nNext steps:")
        print("1. Review the migrated config.yaml")
        print("2. Test loading: python -c 'from models.config import load_config; load_config()'")
        print("3. Delete backup if satisfied: rm config.yaml.bak")
    except Exception as e:
        print(f"\nError: Migration failed: {e}")
        sys.exit(1)

if __name__ == "__main__":
    main()

Key Techniques:

  • Always create backup before modifying config file (shutil.copy2)
  • Idempotent migrations: Check if already migrated, return early if so
  • Separate dict and file logic: migrate_config_dict() for logic, migrate_config_file() for I/O
  • Clear console output: Print status messages for user feedback
  • Testing instructions: Print validation commands after migration
  • Error handling: Catch exceptions, print helpful message, exit with code 1
  • YAML preservation: Use sort_keys=False to preserve key order

Pattern 5: Field Validation for Path Resolution

Example: Database Path with Environment Variables

Problem: You need to resolve relative paths and environment variables for config fields.

Solution Pattern (from models/config.py):

import os
import re
from pathlib import Path
from pydantic import BaseModel, field_validator

class DecisionGraphConfig(BaseModel):
    """Configuration for decision graph memory."""
    db_path: str = "decision_graph.db"

    @field_validator("db_path")
    @classmethod
    def resolve_db_path(cls, v: str) -> str:
        """
        Resolve db_path to absolute path relative to project root.

        Processing steps:
        1. Resolve ${ENV_VAR} environment variable references
        2. Convert relative paths to absolute paths relative to project root
        3. Keep absolute paths unchanged
        4. Return normalized absolute path as string

        Examples:
            "decision_graph.db" → "/path/to/project/decision_graph.db"
            "/tmp/foo.db" → "/tmp/foo.db" (unchanged)
            "${DATA_DIR}/graph.db" → "/var/data/graph.db" (if DATA_DIR=/var/data)
        """
        # Step 1: Resolve environment variables
        pattern = r"\$\{([^}]+)\}"

        def replacer(match):
            env_var = match.group(1)
            value = os.getenv(env_var)
            if value is None:
                raise ValueError(
                    f"Environment variable '{env_var}' is not set. "
                    f"Required for db_path configuration."
                )
            return value

        resolved = re.sub(pattern, replacer, v)

        # Step 2: Convert to Path object
        path = Path(resolved)

        # Step 3: If relative, make it relative to project root
        if not path.is_absolute():
            # This file is at: project_root/models/config.py
            # Project root is two levels up from this file
            project_root = Path(__file__).parent.parent
            path = (project_root / path).resolve()

        # Step 4: Return as string (normalized, absolute)
        return str(path)

Key Techniques:

  • Resolve env vars BEFORE path resolution
  • Use Path(__file__).parent.parent to find project root
  • Convert relative paths to absolute (prevents CWD issues)
  • Keep absolute paths unchanged
  • Return as string for serialization compatibility

Pattern 6: Deprecating Fields with Validation

Example: Deprecating similarity_threshold in Favor of tier_boundaries

Problem: You need to replace a single config field with a more complex structure.

Solution Pattern (from models/config.py):

from pydantic import BaseModel, Field, field_validator

class DecisionGraphConfig(BaseModel):
    """Configuration for decision graph memory."""

    # DEPRECATED field (kept for backward compatibility)
    similarity_threshold: float = Field(
        0.7,
        ge=0.0,
        le=1.0,
        description="DEPRECATED: Use tier_boundaries instead.",
    )

    # NEW field (preferred)
    tier_boundaries: dict[str, float] = Field(
        default_factory=lambda: {"strong": 0.75, "moderate": 0.60},
        description="Similarity score boundaries for tiered injection"
    )

    @field_validator("tier_boundaries")
    @classmethod
    def validate_tier_boundaries(cls, v: dict[str, float]) -> dict[str, float]:
        """Validate tier boundaries: strong > moderate > 0."""
        if not isinstance(v, dict) or "strong" not in v or "moderate" not in v:
            raise ValueError("tier_boundaries must have 'strong' and 'moderate' keys")

        if not (0.0 < v["moderate"] < v["strong"] <= 1.0):
            raise ValueError(
                f"tier_boundaries must satisfy: 0 < moderate ({v['moderate']}) "
                f"< strong ({v['strong']}) <= 1"
            )

        return v

YAML Usage:

decision_graph:
  # OLD (still works, but deprecated in field description)
  similarity_threshold: 0.7

  # NEW (preferred)
  tier_boundaries:
    strong: 0.75
    moderate: 0.60

Key Techniques:

  • Keep deprecated field with default value
  • Add "DEPRECATED" to field description
  • Validate new field structure with @field_validator
  • Document migration in code comments and CLAUDE.md
  • Eventually remove deprecated field in future major version

Testing Migration Scripts

Before Deployment Checklist

  1. Unit Test the Migration Logic:

    def test_migrate_config_dict():
        """Test migration transforms cli_tools to adapters."""
        old_config = {
            "cli_tools": {
                "claude": {
                    "command": "claude",
                    "args": ["-p", "{prompt}"],
                    "timeout": 60
                }
            }
        }
    
        migrated = migrate_config_dict(old_config)
    
        assert "adapters" in migrated
        assert "cli_tools" not in migrated
        assert migrated["adapters"]["claude"]["type"] == "cli"
        assert migrated["adapters"]["claude"]["command"] == "claude"
    
  2. Test Idempotency:

    def test_migrate_idempotent():
        """Test migrating already-migrated config is safe."""
        already_migrated = {
            "adapters": {
                "claude": {"type": "cli", "command": "claude"}
            }
        }
    
        result = migrate_config_dict(already_migrated)
        assert result == already_migrated  # No changes
    
  3. Manual Testing Steps:

    # 1. Create test config
    cp config.yaml config.test.yaml
    
    # 2. Run migration
    python scripts/migrate_config.py config.test.yaml
    
    # 3. Verify backup created
    ls -la config.test.yaml.bak
    
    # 4. Test loading migrated config
    python -c "from models.config import load_config; c = load_config('config.test.yaml'); print('OK')"
    
    # 5. Compare files
    diff config.test.yaml.bak config.test.yaml
    
    # 6. Clean up
    rm config.test.yaml config.test.yaml.bak
    
  4. Load-Time Validation:

    # After migration, always test that config loads without errors
    python -c "from models.config import load_config; load_config()"
    

Complete Migration Workflow

When you need to evolve a config schema:

Step 1: Update Pydantic Models

  1. Add new section/fields as Optional (don't break existing configs)
  2. Keep old section/fields for backward compatibility
  3. Add @field_validator for new field validation
  4. Add deprecation warnings in model_post_init()

Step 2: Write Migration Script

  1. Create scripts/migrate_*.py with clear docstring
  2. Implement migrate_config_dict() for logic (testable)
  3. Implement migrate_config_file() for I/O (backup, load, migrate, save)
  4. Add main() with CLI argument parsing
  5. Print clear instructions after migration

Step 3: Test Migration

  1. Write unit tests for migrate_config_dict()
  2. Test idempotency (running twice is safe)
  3. Test edge cases (missing sections, already migrated)
  4. Manually test on real config file
  5. Verify migrated config loads successfully

Step 4: Document Migration

  1. Update CLAUDE.md with migration instructions
  2. Add migration notes to config.yaml comments
  3. Reference migration script in deprecation warnings
  4. Update README if needed

Step 5: Deploy

  1. Commit schema changes + migration script together
  2. Announce deprecation to users
  3. Provide migration timeline (e.g., "deprecated in v2.0, removed in v3.0")
  4. Keep backward compatibility for at least one major version

Real-World Example: The cli_tools → adapters Migration

Context: AI Counsel needed to support both CLI and HTTP adapters, requiring type discrimination.

Changes Made:

  1. Schema Evolution (models/config.py):

    • Created CLIAdapterConfig and HTTPAdapterConfig with type discriminator
    • Made adapters and cli_tools both optional
    • Added validation that at least one exists
    • Added deprecation warning for cli_tools
  2. Migration Script (scripts/migrate_config.py):

    • Transforms cli_toolsadapters with type: "cli"
    • Creates backup before modifying
    • Idempotent (safe to run multiple times)
    • Clear user feedback and next steps
  3. Testing:

    • Unit tests for migrate_config_dict()
    • Integration tests for file I/O
    • Manual testing on production config
  4. Documentation:

    • Updated CLAUDE.md with migration guide
    • Added comments to config.yaml explaining both formats
    • Referenced migration script in deprecation warning

Result: Users can migrate seamlessly with one command, and old configs continue working with a warning.

Common Patterns Summary

PatternUse CaseKey Technique
Optional SectionsAdd new section while keeping oldOptional[T] + validation
Discriminated UnionType discrimination (CLI vs HTTP)Literal["type"] + Field(discriminator="type")
Env Var SubstitutionInject secrets from environment@field_validator + regex \$\{VAR\}
Path ResolutionResolve relative pathsPath(__file__).parent + resolve()
Deprecation WarningsSignal old patternswarnings.warn() in model_post_init()
Migration ScriptsAutomate config updatesBackup + dict transform + YAML dump
Field ValidationComplex field constraints@field_validator + custom logic

File References

  • Config Models: /Users/harrison/Github/ai-counsel/models/config.py
  • Migration Script: /Users/harrison/Github/ai-counsel/scripts/migrate_config.py
  • Config File: /Users/harrison/Github/ai-counsel/config.yaml
  • Project Docs: /Users/harrison/Github/ai-counsel/CLAUDE.md

Key Takeaways

  1. Never break existing configs - always provide migration path
  2. Automate migrations - don't force manual editing
  3. Use Pydantic validators - catch errors at load time
  4. Support env vars - never hardcode secrets
  5. Test thoroughly - unit + integration + manual testing
  6. Document clearly - in code, CLAUDE.md, and warnings
  7. Version carefully - deprecate → warn → remove (over multiple versions)

When you detect config schema evolution needs, activate this skill and follow these patterns to ensure smooth, backward-compatible migrations.

Install

Download ZIP
Requires askill CLI v1.0+

AI Quality Score

94/100Analyzed 2/7/2026

An excellent, high-density technical guide for managing Pydantic configuration schemas. It provides highly actionable patterns for backward compatibility, type discrimination, and automated migrations with clear code examples.

100
100
95
85
100

Metadata

Licenseunknown
Version-
Updated2/5/2026
Publishermajiayu000

Tags

apici-cddatabasegithubgithub-actionsllmpromptingtesting