Skip to content

Commit 6e57784

Browse files
traskcarlosalberto
andauthored
Yamlify the spec compliance matrix (#4631)
Part of #3976 (comment) The goal of this PR is to yamlify the spec compliance matrix without affecting the resulting markdown file (other than whitespace differences), so that folks can be sure the yaml files are correct extraction of the current state of the markdown file. --------- Co-authored-by: Carlos Alberto Cortez <[email protected]>
1 parent 206962f commit 6e57784

18 files changed

+8478
-337
lines changed

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@ For non-trivial changes, follow the [change proposal process](https://github.com
1010
* [ ] Related [OTEP(s)](https://github.com/open-telemetry/oteps) #
1111
* [ ] Links to the prototypes (when adding or changing features)
1212
* [ ] [`CHANGELOG.md`](https://github.com/open-telemetry/opentelemetry-specification/blob/main/CHANGELOG.md) file updated for non-trivial changes
13-
* [ ] [`spec-compliance-matrix.md`](https://github.com/open-telemetry/opentelemetry-specification/blob/main/spec-compliance-matrix.md) updated if necessary
13+
* [ ] [Spec compliance matrix](https://github.com/open-telemetry/opentelemetry-specification/blob/main/spec-compliance-matrix/template.yaml) updated if necessary

.github/scripts/compliance_matrix.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Generate spec-compliance-matrix.md from YAML files.
4+
"""
5+
6+
import re
7+
import yaml
8+
from pathlib import Path
9+
from typing import Dict, Any, Optional, List
10+
11+
class MarkdownGenerator:
12+
def __init__(self):
13+
self.template_data = None
14+
self.language_data = {}
15+
self.languages = []
16+
17+
def load_yaml_files(self, yaml_dir: Path):
18+
"""Load template.yaml and all language-specific YAML files."""
19+
template_file = yaml_dir / 'template.yaml'
20+
21+
with open(template_file, 'r', encoding='utf-8') as f:
22+
self.template_data = yaml.safe_load(f)
23+
24+
for lang_config in self.template_data['languages']:
25+
lang_name = lang_config['name']
26+
lang_file_path = yaml_dir / lang_config['location']
27+
28+
if not lang_file_path.exists():
29+
raise FileNotFoundError(f"Language file {lang_file_path} not found for {lang_name}")
30+
31+
with open(lang_file_path, 'r', encoding='utf-8') as f:
32+
self.language_data[lang_name] = yaml.safe_load(f)
33+
34+
self.languages = [lang['name'] for lang in self.template_data['languages']]
35+
36+
def update_markdown_content(self, md_file_path: Path) -> str:
37+
"""Update the markdown content with new tables generated from YAML data."""
38+
with open(md_file_path, 'r', encoding='utf-8') as f:
39+
content = f.read()
40+
41+
for section in self.template_data['sections']:
42+
section_name = section['name']
43+
new_table = self._generate_table(section_name, section)
44+
content = self._replace_section_table(content, section_name, new_table)
45+
46+
return content
47+
48+
def _generate_table(self, section_name: str, section_data: Dict[str, Any]) -> str:
49+
"""Generate markdown table for a section."""
50+
features = section_data['features']
51+
if not features:
52+
raise ValueError(f"Section '{section_name}' has no features defined")
53+
54+
# Use section-specific languages if available, otherwise fall back to global
55+
languages = section_data.get('languages', self.languages)
56+
57+
has_optional_column = not section_data.get('hide_optional_column', False)
58+
59+
# Build header
60+
header_columns = ['Feature']
61+
if has_optional_column:
62+
header_columns.append('Optional')
63+
header_columns.extend(languages)
64+
65+
rows = self._create_table_header(header_columns)
66+
67+
# Process features
68+
for feature in features:
69+
if 'features' in feature:
70+
# Subsection header
71+
heading = feature['heading']
72+
cells = [heading]
73+
if has_optional_column:
74+
cells.append('Optional')
75+
cells.extend(languages)
76+
rows.append(self._create_table_row(cells))
77+
78+
for sub_feature in feature['features']:
79+
rows.append(self._build_feature_row(sub_feature, languages, has_optional_column, section_name, heading))
80+
else:
81+
rows.append(self._build_feature_row(feature, languages, has_optional_column, section_name))
82+
83+
return '\n'.join(rows)
84+
85+
def _replace_section_table(self, content: str, section_name: str, new_table: str) -> str:
86+
"""Replace the table in a specific section."""
87+
# Pattern to match a section header and its content until the next section or end
88+
section_pattern = rf'(## {re.escape(section_name)}.*?\n)(.*?)(?=\n## |\Z)'
89+
90+
def replace_section(match):
91+
section_header = match.group(1)
92+
existing_content = match.group(2)
93+
94+
# Pattern to match markdown tables (lines starting and ending with |)
95+
table_pattern = r'\|[^\n]+\|(?:\n\|[^\n]+\|)*'
96+
97+
if re.search(table_pattern, existing_content):
98+
# Replace existing table
99+
new_content = re.sub(table_pattern, new_table, existing_content, count=1)
100+
else:
101+
# Add new table if none exists
102+
new_content = existing_content.rstrip() + '\n\n' + new_table + '\n'
103+
104+
return section_header + new_content
105+
106+
return re.sub(section_pattern, replace_section, content, flags=re.DOTALL)
107+
108+
def _build_feature_row(self, feature: Dict, languages: List[str], has_optional_column: bool, section_name: str, heading_name: str = None) -> str:
109+
"""Build a single feature row."""
110+
cells = [feature['name']]
111+
112+
if has_optional_column:
113+
cells.append(self._get_optional_marker(feature))
114+
115+
for lang in languages:
116+
cells.append(self._get_language_status(lang, section_name, feature['name'], heading_name))
117+
118+
return self._create_table_row(cells)
119+
120+
def _create_table_header(self, header_columns: List[str]) -> List[str]:
121+
"""Create markdown table header with both header row and separator row."""
122+
header_row = self._create_table_row(header_columns)
123+
separators = ['-' * len(col) for col in header_columns] # Sized to match header column widths
124+
separator_row = self._create_table_row(separators)
125+
return [header_row, separator_row]
126+
127+
def _create_table_row(self, cells: List[str]) -> str:
128+
"""Create a markdown table row from cells."""
129+
joined_cells = ' | '.join(cells)
130+
return f"| {joined_cells} |"
131+
132+
def _get_language_status(self, lang: str, section_name: str, feature_name: str, heading_name: str = None) -> str:
133+
"""Get the status of a feature for a specific language."""
134+
lang_sections = self.language_data[lang]['sections']
135+
136+
# Find the section with matching name
137+
lang_section = None
138+
for sect in lang_sections:
139+
if sect['name'] == section_name:
140+
lang_section = sect
141+
break
142+
143+
if lang_section:
144+
status = self._find_feature_status(lang_section['features'], feature_name, heading_name)
145+
return status if status is not None else ''
146+
147+
return ''
148+
149+
def _find_feature_status(self, features: List[Dict], feature_name: str, heading_name: str = None) -> Optional[str]:
150+
"""Search for a feature status in a flat or one-level hierarchical structure."""
151+
152+
for feature in features:
153+
if feature.get('name') == feature_name:
154+
# Direct feature match (for top-level features or when no heading specified)
155+
if heading_name is None:
156+
status = feature['status']
157+
return self._convert_status_to_symbol(status)
158+
elif 'features' in feature and feature.get('heading'):
159+
# This is a heading with nested features
160+
if heading_name is None or feature.get('heading') == heading_name:
161+
# Search within this heading if it matches or if no specific heading required
162+
for nested_feature in feature['features']:
163+
if nested_feature.get('name') == feature_name:
164+
status = nested_feature['status']
165+
return self._convert_status_to_symbol(status)
166+
167+
# If we were looking for a specific heading but didn't find it, try without heading constraint
168+
if heading_name is not None:
169+
return self._find_feature_status(features, feature_name, None)
170+
171+
return None # Return None when not found
172+
173+
def _get_optional_marker(self, feature_item: Dict) -> str:
174+
"""Get the optional marker for a feature."""
175+
if feature_item.get('optional') is True:
176+
return 'X'
177+
elif feature_item.get('optional_one_of_group_is_required') is True:
178+
return '*'
179+
elif 'optional' in feature_item:
180+
return str(feature_item['optional'])
181+
return ''
182+
183+
def _convert_status_to_symbol(self, status) -> str:
184+
"""Convert status values to markdown symbols."""
185+
if status == '?':
186+
return ''
187+
else:
188+
return status
189+
190+
def main():
191+
"""Main function to regenerate markdown from YAML files."""
192+
# Get the repository root (3 levels up from this script)
193+
repo_root = Path(__file__).parent.parent.parent
194+
yaml_dir = repo_root / 'spec-compliance-matrix'
195+
md_file = repo_root / 'spec-compliance-matrix.md'
196+
197+
generator = MarkdownGenerator()
198+
generator.load_yaml_files(yaml_dir)
199+
200+
# Generate the updated markdown content and write to file
201+
updated_content = generator.update_markdown_content(md_file)
202+
with open(md_file, 'w', encoding='utf-8') as f:
203+
f.write(updated_content)
204+
205+
if __name__ == '__main__':
206+
main()

.github/workflows/checks.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,18 @@ jobs:
6767
- name: validate markdown-toc
6868
run: git diff --exit-code ':*.md' || (echo 'Generated markdown Table of Contents is out of date, please run "make markdown-toc" and commit the changes in this PR.' && exit 1)
6969

70+
compliance-matrix-check:
71+
runs-on: ubuntu-latest
72+
steps:
73+
- name: check out code
74+
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
75+
76+
- name: run compliance-matrix
77+
run: make compliance-matrix
78+
79+
- name: validate compliance-matrix
80+
run: git diff --exit-code spec-compliance-matrix.md || (echo 'Generated compliance matrix is out of date, please run "make compliance-matrix" and commit the changes in this PR.' && exit 1)
81+
7082
misspell:
7183
runs-on: ubuntu-latest
7284
steps:

CONTRIBUTING.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,16 @@ To quickly fix typos, use
167167
make misspell-correction
168168
```
169169

170+
## Updating the Compliance Matrix
171+
172+
To update the [compliance matrix](./spec-compliance-matrix.md), edit the
173+
language YAML file in `spec-compliance-matrix/` (e.g., `go.yaml`, `java.yaml`, etc.)
174+
and regenerate the matrix:
175+
176+
```bash
177+
make compliance-matrix
178+
```
179+
170180
## Issue Triaging
171181

172182
The following diagram shows the initial triaging of new issues.

Makefile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ check: misspell markdownlint markdown-link-check
9696
fix: misspell-correction
9797
@echo "All autofixes complete"
9898

99+
# Generate spec compliance matrix from YAML source
100+
.PHONY: compliance-matrix
101+
compliance-matrix:
102+
pip install -U PyYAML
103+
python .github/scripts/compliance_matrix.py
104+
@echo "Compliance matrix generation complete"
105+
99106
.PHONY: install-tools
100107
install-tools: $(MISSPELL)
101108
npm install

0 commit comments

Comments
 (0)