Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:26:08 +08:00
commit 8f22ddf339
295 changed files with 59710 additions and 0 deletions

View File

@@ -0,0 +1,432 @@
#!/usr/bin/env python3
"""
Detect breaking changes between API specification versions.
This skill analyzes two versions of an API spec and identifies:
- Breaking changes (remove endpoints, change types, etc.)
- Non-breaking changes (add endpoints, add optional fields, etc.)
"""
import sys
import json
import argparse
from pathlib import Path
from typing import Dict, Any, List, Tuple
# Add betty module to path
from betty.logging_utils import setup_logger
from betty.errors import format_error_response, BettyError
from betty.validation import validate_path
logger = setup_logger(__name__)
class CompatibilityChange:
"""Represents a compatibility change between spec versions."""
def __init__(
self,
change_type: str,
severity: str,
path: str,
description: str,
old_value: Any = None,
new_value: Any = None
):
self.change_type = change_type
self.severity = severity # "breaking" or "non-breaking"
self.path = path
self.description = description
self.old_value = old_value
self.new_value = new_value
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
result = {
"change_type": self.change_type,
"severity": self.severity,
"path": self.path,
"description": self.description
}
if self.old_value is not None:
result["old_value"] = self.old_value
if self.new_value is not None:
result["new_value"] = self.new_value
return result
class CompatibilityChecker:
"""Check compatibility between two API specs."""
def __init__(self, old_spec: Dict[str, Any], new_spec: Dict[str, Any]):
self.old_spec = old_spec
self.new_spec = new_spec
self.breaking_changes: List[CompatibilityChange] = []
self.non_breaking_changes: List[CompatibilityChange] = []
def check(self) -> Dict[str, Any]:
"""
Run all compatibility checks.
Returns:
Compatibility report
"""
# Check paths (endpoints)
self._check_paths()
# Check schemas
self._check_schemas()
# Check parameters
self._check_parameters()
# Check responses
self._check_responses()
return {
"compatible": len(self.breaking_changes) == 0,
"breaking_changes": [c.to_dict() for c in self.breaking_changes],
"non_breaking_changes": [c.to_dict() for c in self.non_breaking_changes],
"change_summary": {
"total_breaking": len(self.breaking_changes),
"total_non_breaking": len(self.non_breaking_changes),
"total_changes": len(self.breaking_changes) + len(self.non_breaking_changes)
}
}
def _check_paths(self):
"""Check for changes in API paths/endpoints."""
old_paths = set(self.old_spec.get("paths", {}).keys())
new_paths = set(self.new_spec.get("paths", {}).keys())
# Removed paths (BREAKING)
for removed_path in old_paths - new_paths:
self.breaking_changes.append(CompatibilityChange(
change_type="path_removed",
severity="breaking",
path=f"paths.{removed_path}",
description=f"Endpoint '{removed_path}' was removed",
old_value=removed_path
))
# Added paths (NON-BREAKING)
for added_path in new_paths - old_paths:
self.non_breaking_changes.append(CompatibilityChange(
change_type="path_added",
severity="non-breaking",
path=f"paths.{added_path}",
description=f"New endpoint '{added_path}' was added",
new_value=added_path
))
# Check operations on existing paths
for path in old_paths & new_paths:
self._check_operations(path)
def _check_operations(self, path: str):
"""Check for changes in HTTP operations on a path."""
old_operations = set(self.old_spec["paths"][path].keys()) - {"parameters"}
new_operations = set(self.new_spec["paths"][path].keys()) - {"parameters"}
# Removed operations (BREAKING)
for removed_op in old_operations - new_operations:
self.breaking_changes.append(CompatibilityChange(
change_type="operation_removed",
severity="breaking",
path=f"paths.{path}.{removed_op}",
description=f"Operation '{removed_op.upper()}' on '{path}' was removed",
old_value=removed_op
))
# Added operations (NON-BREAKING)
for added_op in new_operations - old_operations:
self.non_breaking_changes.append(CompatibilityChange(
change_type="operation_added",
severity="non-breaking",
path=f"paths.{path}.{added_op}",
description=f"New operation '{added_op.upper()}' on '{path}' was added",
new_value=added_op
))
def _check_schemas(self):
"""Check for changes in component schemas."""
old_schemas = self.old_spec.get("components", {}).get("schemas", {})
new_schemas = self.new_spec.get("components", {}).get("schemas", {})
old_schema_names = set(old_schemas.keys())
new_schema_names = set(new_schemas.keys())
# Removed schemas (BREAKING if they were referenced)
for removed_schema in old_schema_names - new_schema_names:
self.breaking_changes.append(CompatibilityChange(
change_type="schema_removed",
severity="breaking",
path=f"components.schemas.{removed_schema}",
description=f"Schema '{removed_schema}' was removed",
old_value=removed_schema
))
# Added schemas (NON-BREAKING)
for added_schema in new_schema_names - old_schema_names:
self.non_breaking_changes.append(CompatibilityChange(
change_type="schema_added",
severity="non-breaking",
path=f"components.schemas.{added_schema}",
description=f"New schema '{added_schema}' was added",
new_value=added_schema
))
# Check properties on existing schemas
for schema_name in old_schema_names & new_schema_names:
self._check_schema_properties(schema_name, old_schemas[schema_name], new_schemas[schema_name])
def _check_schema_properties(self, schema_name: str, old_schema: Dict[str, Any], new_schema: Dict[str, Any]):
"""Check for changes in schema properties."""
old_props = old_schema.get("properties") or {}
new_props = new_schema.get("properties") or {}
old_required = set(old_schema.get("required", []))
new_required = set(new_schema.get("required", []))
old_prop_names = set(old_props.keys())
new_prop_names = set(new_props.keys())
# Removed properties (BREAKING)
for removed_prop in old_prop_names - new_prop_names:
self.breaking_changes.append(CompatibilityChange(
change_type="property_removed",
severity="breaking",
path=f"components.schemas.{schema_name}.properties.{removed_prop}",
description=f"Property '{removed_prop}' was removed from schema '{schema_name}'",
old_value=removed_prop
))
# Added required properties (BREAKING)
for added_required in new_required - old_required:
if added_required in new_prop_names:
self.breaking_changes.append(CompatibilityChange(
change_type="property_made_required",
severity="breaking",
path=f"components.schemas.{schema_name}.required",
description=f"Property '{added_required}' is now required in schema '{schema_name}'",
new_value=added_required
))
# Added optional properties (NON-BREAKING)
for added_prop in new_prop_names - old_prop_names:
if added_prop not in new_required:
self.non_breaking_changes.append(CompatibilityChange(
change_type="property_added",
severity="non-breaking",
path=f"components.schemas.{schema_name}.properties.{added_prop}",
description=f"Optional property '{added_prop}' was added to schema '{schema_name}'",
new_value=added_prop
))
# Check for type changes on existing properties
for prop_name in old_prop_names & new_prop_names:
old_type = old_props[prop_name].get("type")
new_type = new_props[prop_name].get("type")
if old_type != new_type:
self.breaking_changes.append(CompatibilityChange(
change_type="property_type_changed",
severity="breaking",
path=f"components.schemas.{schema_name}.properties.{prop_name}.type",
description=f"Property '{prop_name}' type changed from '{old_type}' to '{new_type}' in schema '{schema_name}'",
old_value=old_type,
new_value=new_type
))
def _check_parameters(self):
"""Check for changes in path/query parameters."""
# Implementation for parameter checking
pass
def _check_responses(self):
"""Check for changes in response schemas."""
# Implementation for response checking
pass
def load_spec(spec_path: str) -> Dict[str, Any]:
"""
Load API specification from file.
Args:
spec_path: Path to specification file
Returns:
Parsed specification
Raises:
BettyError: If file cannot be loaded
"""
spec_file = Path(spec_path)
if not spec_file.exists():
raise BettyError(f"Specification file not found: {spec_path}")
try:
import yaml
with open(spec_file, 'r') as f:
spec = yaml.safe_load(f)
if not isinstance(spec, dict):
raise BettyError("Specification must be a valid YAML/JSON object")
logger.info(f"Loaded specification from {spec_path}")
return spec
except Exception as e:
raise BettyError(f"Failed to load specification: {e}")
def check_compatibility(
old_spec_path: str,
new_spec_path: str,
fail_on_breaking: bool = True
) -> Dict[str, Any]:
"""
Check compatibility between two API specifications.
Args:
old_spec_path: Path to old specification
new_spec_path: Path to new specification
fail_on_breaking: Whether to fail if breaking changes detected
Returns:
Compatibility report
Raises:
BettyError: If compatibility check fails
"""
# Load specifications
old_spec = load_spec(old_spec_path)
new_spec = load_spec(new_spec_path)
# Run compatibility check
checker = CompatibilityChecker(old_spec, new_spec)
report = checker.check()
# Add metadata
report["old_spec_path"] = old_spec_path
report["new_spec_path"] = new_spec_path
return report
def format_compatibility_output(report: Dict[str, Any]) -> str:
"""Format compatibility report for human-readable output."""
lines = []
lines.append("\n" + "=" * 60)
lines.append("API Compatibility Report")
lines.append("=" * 60)
lines.append(f"Old: {report.get('old_spec_path', 'unknown')}")
lines.append(f"New: {report.get('new_spec_path', 'unknown')}")
lines.append("=" * 60 + "\n")
# Breaking changes
breaking = report.get("breaking_changes", [])
if breaking:
lines.append(f"❌ BREAKING CHANGES ({len(breaking)}):")
for change in breaking:
lines.append(f" [{change.get('change_type', 'UNKNOWN')}] {change.get('description', '')}")
if change.get('path'):
lines.append(f" Path: {change['path']}")
lines.append("")
# Non-breaking changes
non_breaking = report.get("non_breaking_changes", [])
if non_breaking:
lines.append(f"✅ NON-BREAKING CHANGES ({len(non_breaking)}):")
for change in non_breaking:
lines.append(f" [{change.get('change_type', 'UNKNOWN')}] {change.get('description', '')}")
lines.append("")
# Summary
lines.append("=" * 60)
if report.get("compatible"):
lines.append("✅ BACKWARD COMPATIBLE")
else:
lines.append("❌ NOT BACKWARD COMPATIBLE")
lines.append("=" * 60 + "\n")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Detect breaking changes between API specification versions"
)
parser.add_argument(
"old_spec_path",
type=str,
help="Path to the old/previous API specification"
)
parser.add_argument(
"new_spec_path",
type=str,
help="Path to the new/current API specification"
)
parser.add_argument(
"--fail-on-breaking",
action="store_true",
default=True,
help="Exit with error code if breaking changes detected (default: true)"
)
parser.add_argument(
"--format",
type=str,
choices=["json", "human"],
default="json",
help="Output format (default: json)"
)
args = parser.parse_args()
try:
# Check if PyYAML is installed
try:
import yaml
except ImportError:
raise BettyError(
"PyYAML is required for api.compatibility. Install with: pip install pyyaml"
)
# Validate inputs
validate_path(args.old_spec_path)
validate_path(args.new_spec_path)
# Run compatibility check
logger.info(f"Checking compatibility between {args.old_spec_path} and {args.new_spec_path}")
report = check_compatibility(
old_spec_path=args.old_spec_path,
new_spec_path=args.new_spec_path,
fail_on_breaking=args.fail_on_breaking
)
# Output based on format
if args.format == "human":
print(format_compatibility_output(report))
else:
output = {
"status": "success",
"data": report
}
print(json.dumps(output, indent=2))
# Exit with error if breaking changes and fail_on_breaking is True
if args.fail_on_breaking and not report["compatible"]:
sys.exit(1)
except Exception as e:
logger.error(f"Compatibility check failed: {e}")
print(json.dumps(format_error_response(e), indent=2))
sys.exit(1)
if __name__ == "__main__":
main()