activities.py (144 lines of code) (raw):
from ruamel.yaml import YAML
from ruamel.yaml.comments import CommentedMap
from ruamel.yaml.scalarstring import LiteralScalarString
import argparse
import requests
import sys
from typing import Any, Dict
class YAMLValidator:
def __init__(self, file_path: str, fix: bool = False):
self.file_path = file_path
self.fix = fix
self.errors = []
self.modified_data = None
self.yaml = YAML()
self.yaml.preserve_quotes = True
self.yaml.width = float("inf") # prevent line breaks between key and value
def load_yaml(self) -> CommentedMap:
try:
with open(self.file_path, 'r') as file:
data = self.yaml.load(file)
return data
except Exception as e:
raise ValueError(f"Failed to load YAML: {e}") from e
def log_error(self, message: str, key: str = ""):
self.errors.append(f"Error in key '{key}': {message}")
def validate_top_level_keys(self, data: CommentedMap):
keys = list(data.keys())
if keys != sorted(keys):
if self.fix:
self.modified_data = CommentedMap(sorted(data.items()))
else:
self.log_error("Top-level keys must be in alphabetical order.")
def validate_literal_block(self, data: CommentedMap, key: str, field: str):
value = data.get(key, {}).get(field)
if value is not None:
if not isinstance(value, LiteralScalarString):
if self.fix:
data[key][field] = LiteralScalarString(value)
else:
self.log_error(f"'{field}' must use literal block syntax (|).", key)
elif not value.endswith('\n'):
if self.fix:
data[key][field] = LiteralScalarString(f"{value}\n")
else:
self.log_error(f"'{field}' must end with a newline to use '|' syntax.", key)
def validate_item(self, data: CommentedMap, key: str):
item = data[key]
if not isinstance(item, dict):
self.log_error(f"Item '{key}' must be a dictionary.", key)
return
# Validate required fields
if 'issue' not in item:
self.log_error("Missing required 'issue' key.", key)
elif not isinstance(item['issue'], int):
self.log_error(f"'issue' must be an integer, got {item['issue']}.", key)
elif item['issue'] >= 1110:
# Legacy item key restriction for issue numbers >= 1110
for legacy_key in ['position', 'venues']:
if legacy_key in item:
if self.fix:
del item[legacy_key]
else:
self.log_error(f"Legacy key '{legacy_key}' is not allowed for issue numbers >= 1110.", key)
# Validate literal block fields
for field in ['description', 'rationale']:
self.validate_literal_block(data, key, field)
# Optional fields validation
if 'id' in item and (not isinstance(item['id'], str) or ' ' in item['id']):
self.log_error(f"'id' must be a string without whitespace, got {item['id']}.", key)
if 'bug' in item and item['bug'] not in [None, '']:
if not isinstance(item['bug'], str) or not item['bug'].startswith("https://bugzilla.mozilla.org/show_bug.cgi?id="):
self.log_error(f"'bug' must be a URL starting with 'https://bugzilla.mozilla.org/show_bug.cgi?id=', got {item['bug']}.", key)
if 'caniuse' in item and item['caniuse'] not in [None, '']:
if not isinstance(item['caniuse'], str) or not item['caniuse'].startswith("https://caniuse.com/"):
self.log_error(f"'caniuse' must be a URL starting with 'https://caniuse.com/', got {item['caniuse']}.", key)
if 'mdn' in item and item['mdn'] not in [None, '']:
if not isinstance(item['mdn'], str) or not item['mdn'].startswith("https://developer.mozilla.org/en-US/"):
self.log_error(f"'mdn' must be a URL starting with 'https://developer.mozilla.org/en-US/', got {item['mdn']}.", key)
if 'position' in item and item['position'] not in {"positive", "neutral", "negative", "defer", "under consideration"}:
self.log_error(f"'position' must be one of the allowed values, got {item['position']}.", key)
if 'url' in item and not item['url'].startswith("https://"):
self.log_error(f"'url' must start with 'https://', got {item['url']}.", key)
if 'venues' in item:
allowed_venues = {"WHATWG", "W3C", "W3C CG", "IETF", "Ecma", "Unicode", "Proposal", "Other"}
if not isinstance(item['venues'], list) or not set(item['venues']).issubset(allowed_venues):
self.log_error(f"'venues' must be a list with allowed values, got {item['venues']}.", key)
def validate_data(self, data: CommentedMap):
for key in data:
self.validate_item(data, key)
self.validate_top_level_keys(data)
if self.fix and self.modified_data is None:
self.modified_data = data
def save_fixes(self):
if self.modified_data:
with open(self.file_path, 'w') as file:
self.yaml.dump(self.modified_data, file)
print(f"Fixes applied and saved to {self.file_path}")
def run(self):
data = self.load_yaml()
self.validate_data(data)
if self.errors:
print("YAML validation failed with the following errors:")
for error in self.errors:
print(error)
sys.exit(1)
elif self.fix:
self.save_fixes()
def add_issue(self, issue_num: int, description: str = None, rationale: str = None):
url = f"https://api.github.com/repos/mozilla/standards-positions/issues/{issue_num}"
response = requests.get(url)
if response.status_code != 200:
print(f"Failed to fetch issue {issue_num}: {response.status_code}")
sys.exit(1)
issue_data = response.json()
title = issue_data.get("title")
if not title:
print("No title found in the GitHub issue data.")
sys.exit(1)
data = self.load_yaml()
if title not in data:
data[title] = {"issue": issue_num}
if description:
data[title]["description"] = LiteralScalarString(f"{description}\n")
if rationale:
data[title]["rationale"] = LiteralScalarString(f"{rationale}\n")
# Sort keys alphabetically
self.modified_data = CommentedMap(sorted(data.items()))
with open(self.file_path, 'w') as file:
self.yaml.dump(self.modified_data, file)
print(f"Issue {issue_num} added or updated successfully.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Manage activities.yml.")
parser.add_argument("command", choices=["validate", "add"], help="Specify 'validate' to validate or 'add' to add or update an item.")
parser.add_argument("issue_num", nargs="?", type=int, help="Issue number for the 'add' command.")
parser.add_argument("--fix", action="store_true", help="Automatically fix issues.")
parser.add_argument("--description", type=str, help="Set the description for the issue.")
parser.add_argument("--rationale", type=str, help="Set the rationale for the issue.")
args = parser.parse_args()
validator = YAMLValidator("activities.yml", fix=args.fix)
if args.command == "validate":
try:
validator.run()
except ValueError as e:
print(f"Validation failed: {e}")
elif args.command == "add":
if args.issue_num is None:
print("Please provide an issue number for 'add'.")
sys.exit(1)
validator.add_issue(args.issue_num, description=args.description, rationale=args.rationale)