Files
TheRock/rocm-systems/projects/rocprofiler-compute/tools/format_yaml.py
T
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

540 lines
17 KiB
Python

#!/usr/bin/env python3
# Copyright (c) Advanced Micro Devices, Inc.
# SPDX-License-Identifier: MIT
"""YAML metric equation formatter for rocprofiler-compute pre-commit hook."""
from __future__ import annotations
import argparse
import re
import sys
from dataclasses import dataclass
from enum import Enum, auto
from pathlib import Path
from typing import Any
import yaml
# =============================================================================
# Configuration - Edit these to accommodate future YAML updates
# =============================================================================
# Path patterns that contain metric equations to check (substrings matched against path)
EQUATION_FILE_PATTERNS = [
"analysis_configs/gfx",
"rocprof_compute_tui/utils/gfx",
]
# Path suffixes to exclude from equation checking
EQUATION_FILE_EXCLUDE_SUFFIXES = [
"_template.yaml",
]
# Path patterns to skip entirely (substrings matched against path)
SKIP_FILE_PATTERNS = [
"/build/",
"profiling_config.yaml",
]
# YAML keys that contain metric equations
EQUATION_KEYS = {"value", "avg", "min", "max", "peak"}
# Literal string values that should not be parsed as equations
LITERAL_VALUES = {"N/A", "None", "null", "true", "false", "Peak (Empirical)"}
# Aggregation functions where constants should be factored out
AGGREGATION_FUNCTIONS = {"SUM", "MIN", "MAX", "AVG"}
# Operator precedence for parentheses minimization
OPERATOR_PRECEDENCE = {"+": 1, "-": 1, "*": 2, "/": 2}
# =============================================================================
# Tokenizer / Parser / AST
# =============================================================================
class TokenType(Enum):
NUMBER = auto()
IDENTIFIER = auto()
VARIABLE = auto()
OPERATOR = auto()
LPAREN = auto()
RPAREN = auto()
COMMA = auto()
EOF = auto()
@dataclass
class Token:
type: TokenType
value: str
pos: int
class Tokenizer:
"""Tokenize metric equations into tokens."""
OPERATORS = {"+", "-", "*", "/"}
def __init__(self, text: str):
self.text = text
self.pos = 0
def _peek(self) -> str:
return self.text[self.pos] if self.pos < len(self.text) else ""
def _advance(self) -> str:
ch = self._peek()
self.pos += 1
return ch
def _skip_whitespace(self) -> None:
while self.pos < len(self.text) and self.text[self.pos].isspace():
self.pos += 1
def _read_number(self) -> str:
start = self.pos
while self.pos < len(self.text) and self.text[self.pos] in "0123456789.":
self.pos += 1
if self.pos < len(self.text) and self.text[self.pos] in "eE":
self.pos += 1
if self.pos < len(self.text) and self.text[self.pos] in "+-":
self.pos += 1
while self.pos < len(self.text) and self.text[self.pos].isdigit():
self.pos += 1
return self.text[start : self.pos]
def _read_identifier(self) -> str:
start = self.pos
while self.pos < len(self.text) and (
self.text[self.pos].isalnum() or self.text[self.pos] == "_"
):
self.pos += 1
return self.text[start : self.pos]
def _read_variable(self) -> str:
start = self.pos
self._advance()
while self.pos < len(self.text) and (
self.text[self.pos].isalnum() or self.text[self.pos] == "_"
):
self.pos += 1
return self.text[start : self.pos]
def tokenize(self) -> list[Token]:
tokens = []
while self.pos < len(self.text):
self._skip_whitespace()
if self.pos >= len(self.text):
break
start_pos = self.pos
ch = self._peek()
if ch.isdigit() or (
ch == "."
and self.pos + 1 < len(self.text)
and self.text[self.pos + 1].isdigit()
):
tokens.append(Token(TokenType.NUMBER, self._read_number(), start_pos))
elif ch == "$":
tokens.append(
Token(TokenType.VARIABLE, self._read_variable(), start_pos)
)
elif ch.isalpha() or ch == "_":
tokens.append(
Token(TokenType.IDENTIFIER, self._read_identifier(), start_pos)
)
elif ch in self.OPERATORS:
self._advance()
tokens.append(Token(TokenType.OPERATOR, ch, start_pos))
elif ch == "(":
self._advance()
tokens.append(Token(TokenType.LPAREN, "(", start_pos))
elif ch == ")":
self._advance()
tokens.append(Token(TokenType.RPAREN, ")", start_pos))
elif ch == ",":
self._advance()
tokens.append(Token(TokenType.COMMA, ",", start_pos))
else:
raise ValueError(f"Unknown character '{ch}' at position {start_pos}")
tokens.append(Token(TokenType.EOF, "", self.pos))
return tokens
@dataclass
class ASTNode:
pass
@dataclass
class NumberNode(ASTNode):
value: str
@dataclass
class IdentifierNode(ASTNode):
name: str
@dataclass
class VariableNode(ASTNode):
name: str
@dataclass
class BinaryOpNode(ASTNode):
op: str
left: ASTNode
right: ASTNode
@dataclass
class UnaryOpNode(ASTNode):
op: str
operand: ASTNode
@dataclass
class FunctionCallNode(ASTNode):
name: str
args: list[ASTNode]
class Parser:
"""Recursive descent parser for metric equations."""
def __init__(self, tokens: list[Token]):
self.tokens = tokens
self.pos = 0
def _current(self) -> Token:
return self.tokens[self.pos]
def _advance(self) -> Token:
token = self._current()
if self.pos < len(self.tokens) - 1:
self.pos += 1
return token
def _expect(self, token_type: TokenType) -> Token:
token = self._current()
if token.type != token_type:
raise ValueError(f"Expected {token_type}, got {token.type}")
return self._advance()
def parse(self) -> ASTNode:
ast = self._parse_additive()
if self._current().type != TokenType.EOF:
curr = self._current()
raise ValueError(
f"Unexpected token after expression: {curr.type} at pos {curr.pos}"
)
return ast
def _parse_additive(self) -> ASTNode:
left = self._parse_multiplicative()
while (
self._current().type == TokenType.OPERATOR and self._current().value in "+-"
):
op = self._advance().value
left = BinaryOpNode(op, left, self._parse_multiplicative())
return left
def _parse_multiplicative(self) -> ASTNode:
left = self._parse_unary()
while (
self._current().type == TokenType.OPERATOR and self._current().value in "*/"
):
op = self._advance().value
left = BinaryOpNode(op, left, self._parse_unary())
return left
def _parse_unary(self) -> ASTNode:
if self._current().type == TokenType.OPERATOR and self._current().value == "-":
op = self._advance().value
return UnaryOpNode(op, self._parse_unary())
return self._parse_primary()
def _parse_primary(self) -> ASTNode:
token = self._current()
if token.type == TokenType.NUMBER:
self._advance()
return NumberNode(token.value)
if token.type == TokenType.VARIABLE:
self._advance()
return VariableNode(token.value)
if token.type == TokenType.IDENTIFIER:
name = self._advance().value
if self._current().type == TokenType.LPAREN:
self._advance()
args = self._parse_args()
self._expect(TokenType.RPAREN)
return FunctionCallNode(name, args)
return IdentifierNode(name)
if token.type == TokenType.LPAREN:
self._advance()
expr = self._parse_additive()
self._expect(TokenType.RPAREN)
return expr
raise ValueError(f"Unexpected token {token.type} at pos {token.pos}")
def _parse_args(self) -> list[ASTNode]:
if self._current().type == TokenType.RPAREN:
return []
args = [self._parse_additive()]
while self._current().type == TokenType.COMMA:
self._advance()
args.append(self._parse_additive())
return args
# =============================================================================
# Normalizer
# =============================================================================
def _normalize_ast(node: ASTNode) -> ASTNode:
"""Recursively normalize AST: factor constants out of aggregation functions."""
if isinstance(node, (NumberNode, IdentifierNode, VariableNode)):
return node
if isinstance(node, UnaryOpNode):
return UnaryOpNode(node.op, _normalize_ast(node.operand))
if isinstance(node, BinaryOpNode):
return BinaryOpNode(
node.op, _normalize_ast(node.left), _normalize_ast(node.right)
)
if isinstance(node, FunctionCallNode):
normalized_args = [_normalize_ast(arg) for arg in node.args]
func_node = FunctionCallNode(node.name, normalized_args)
if node.name in AGGREGATION_FUNCTIONS and len(normalized_args) == 1:
inner = normalized_args[0]
if isinstance(inner, BinaryOpNode) and inner.op == "*":
if isinstance(inner.left, NumberNode):
return BinaryOpNode(
"*", inner.left, FunctionCallNode(node.name, [inner.right])
)
if isinstance(inner.right, NumberNode):
return BinaryOpNode(
"*", inner.right, FunctionCallNode(node.name, [inner.left])
)
return func_node
return node
# =============================================================================
# Serializer
# =============================================================================
def _serialize_ast(
node: ASTNode, parent_op: str | None = None, is_right: bool = False
) -> str:
"""Serialize AST to string with minimal parentheses."""
if isinstance(node, NumberNode):
return node.value
if isinstance(node, IdentifierNode):
return node.name
if isinstance(node, VariableNode):
return node.name
if isinstance(node, UnaryOpNode):
operand_str = _serialize_ast(node.operand, node.op, False)
# Parenthesize binary operands to preserve semantics: -(a + b) not -a + b
if isinstance(node.operand, BinaryOpNode):
operand_str = f"({operand_str})"
return f"{node.op}{operand_str}"
if isinstance(node, BinaryOpNode):
left = _serialize_ast(node.left, node.op, False)
right = _serialize_ast(node.right, node.op, True)
result = f"{left} {node.op} {right}"
if parent_op is not None:
node_prec = OPERATOR_PRECEDENCE.get(node.op, 0)
parent_prec = OPERATOR_PRECEDENCE.get(parent_op, 0)
if node_prec < parent_prec:
return f"({result})"
if node_prec == parent_prec and is_right and parent_op in "-/":
return f"({result})"
return result
if isinstance(node, FunctionCallNode):
args = ", ".join(_serialize_ast(arg) for arg in node.args)
return f"{node.name}({args})"
return str(node)
def normalize_equation(equation: str) -> str:
"""Parse, normalize, and serialize a metric equation string."""
stripped = equation.strip()
if stripped in LITERAL_VALUES or stripped.startswith(('"', "'")):
return equation
try:
tokens = Tokenizer(equation).tokenize()
ast = Parser(tokens).parse()
return _serialize_ast(_normalize_ast(ast))
except (ValueError, IndexError):
return equation
def extract_metric_equations(
data: dict[str, Any], path: str = ""
) -> list[tuple[str, str, str]]:
"""Extract (path, key, value) tuples for equation fields from YAML data."""
equations = []
if isinstance(data, dict):
for key, value in data.items():
current_path = f"{path}.{key}" if path else key
if key in EQUATION_KEYS and isinstance(value, str):
equations.append((current_path, key, value))
elif isinstance(value, dict):
equations.extend(extract_metric_equations(value, current_path))
elif isinstance(value, list):
for i, item in enumerate(value):
if isinstance(item, dict):
equations.extend(
extract_metric_equations(item, f"{current_path}[{i}]")
)
return equations
def _should_check_equations(path: Path) -> bool:
"""Return True if the file contains metric equations to check."""
path_str = str(path)
for exclude_suffix in EQUATION_FILE_EXCLUDE_SUFFIXES:
if path_str.endswith(exclude_suffix):
return False
for pattern in EQUATION_FILE_PATTERNS:
if pattern in path_str:
return True
return False
def _should_skip_file(path: Path) -> bool:
"""Return True if the file should be skipped entirely."""
path_str = str(path)
for pattern in SKIP_FILE_PATTERNS:
if pattern in path_str:
return True
try:
with open(path, encoding="utf-8") as f:
if "AUTOGENERATED" in f.readline().upper():
return True
except (OSError, UnicodeDecodeError):
return True
return False
def _apply_fixes(content: str, equations: list[tuple[str, str, str]]) -> str:
"""Apply equation normalizations using regex to handle multi-line YAML."""
result = content
for _, _, original in equations:
normalized = normalize_equation(original)
if normalized != original:
pattern = re.escape(original).replace(r"\ ", r"\s+")
result = re.sub(pattern, normalized, result, count=1)
return result
def check_file(
path: Path, fix: bool = False, show_diff: bool = False
) -> tuple[bool, list[str]]:
"""Check a YAML file for formatting issues. Returns (is_valid, issues)."""
if _should_skip_file(path):
return True, []
try:
with open(path, encoding="utf-8") as f:
content = f.read()
data = yaml.safe_load(content)
except (yaml.YAMLError, OSError) as e:
return False, [f"Failed to parse YAML: {e}"]
if data is None or not _should_check_equations(path):
return True, []
equations = extract_metric_equations(data)
issues = []
equations_to_fix = []
for eq_path, key, value in equations:
normalized = normalize_equation(value)
if normalized != value:
equations_to_fix.append((eq_path, key, value))
issues.append(f" {eq_path}: equation needs normalization")
if show_diff:
issues.append(f" - {value}")
issues.append(f" + {normalized}")
if fix and equations_to_fix:
with open(path, "w", encoding="utf-8") as f:
f.write(_apply_fixes(content, equations_to_fix))
return True, [f" Fixed {len(equations_to_fix)} equation(s)"]
return len(issues) == 0, issues
def main() -> int:
parser = argparse.ArgumentParser(
description="YAML metric equation formatter for rocprofiler-compute"
)
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--check", action="store_true", help="Check without modifying")
mode.add_argument("--fix", action="store_true", help="Auto-fix in place")
mode.add_argument("--diff", action="store_true", help="Show proposed changes")
parser.add_argument("files", nargs="*", type=Path, help="Files to check")
parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args()
files = args.files or [Path(line.strip()) for line in sys.stdin if line.strip()]
if not files:
print("No files to check")
return 0
all_valid = True
total_issues = 0
for file_path in files:
if not file_path.exists() or file_path.suffix.lower() not in {".yaml", ".yml"}:
continue
is_valid, issues = check_file(file_path, fix=args.fix, show_diff=args.diff)
if not is_valid:
all_valid = False
total_issues += len(issues)
print(f"{file_path}:")
for issue in issues:
print(issue)
elif args.verbose:
print(f"{file_path}: OK")
if not all_valid:
print(f"\n{total_issues} formatting issue(s) found")
return 1
if args.verbose:
print(f"\nAll {len(files)} files passed format checks")
return 0
if __name__ == "__main__":
sys.exit(main())