Skip to content

Commit 96a78f7

Browse files
committed
feat: Move test formatter from .github/workflows/format_diff.py to python-gardenlinux-lib
1 parent 9bc3c1f commit 96a78f7

File tree

742 files changed

+6230
-0
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

742 files changed

+6230
-0
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ sphinx-rtd-theme = "^3.0.2"
3636

3737
[tool.poetry.scripts]
3838
gl-cname = "gardenlinux.features.cname_main:main"
39+
gl-diff = "gardenlinux.features.difference_formatter_main:main"
3940
gl-features-parse = "gardenlinux.features.__main__:main"
4041
gl-flavors-parse = "gardenlinux.flavors.__main__:main"
4142
gl-gh-release = "gardenlinux.github.release.__main__:main"
Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
# -*- coding: utf-8 -*-
2+
3+
"""
4+
diff files Markdown generator for reproducibility checker workflow
5+
"""
6+
7+
import logging
8+
import os
9+
import pathlib
10+
import re
11+
from os import PathLike
12+
from typing import Optional
13+
14+
import networkx as nx
15+
import yaml
16+
from attr import dataclass
17+
from networkx.algorithms.traversal.depth_first_search import dfs_tree
18+
19+
from gardenlinux.features.parser import Parser
20+
21+
22+
@dataclass
23+
class Nightly:
24+
run_number: str
25+
id: str
26+
commit: str
27+
28+
29+
class Formatter(object):
30+
"""
31+
This class takes the differ_files results from the reproducibility check and generates a Result.md
32+
The differ_files contain paths of files which were different when building the flavor two times
33+
34+
:author: Garden Linux Maintainers
35+
:copyright: Copyright 2025 SAP SE
36+
:package: gardenlinux
37+
:subpackage: features
38+
:since: 1.0.0
39+
:license: https://www.apache.org/licenses/LICENSE-2.0
40+
Apache License, Version 2.0
41+
"""
42+
43+
remove_arch = re.compile("(-arm64|-amd64)$")
44+
45+
def __init__(
46+
self,
47+
flavors_matrix: dict[str, list[dict[str, str]]],
48+
bare_flavors_matrix: dict[str, list[dict[str, str]]],
49+
diff_dir: PathLike[str] = pathlib.Path("diffs"),
50+
nightly_stats: PathLike[str] = pathlib.Path("nightly_stats"),
51+
gardenlinux_root: Optional[str] = None,
52+
feature_dir_name: Optional[str] = "features",
53+
logger: Optional[logging.Logger] = None,
54+
):
55+
"""
56+
Constructor __init__(Formatter)
57+
58+
:param flavors_matrix: The flavors matrix to identify missing diff files
59+
:param bare_flavors_matrix: The bare flavors matrix to identify missing diff files
60+
:param diff_dir: Directory containing the diff files
61+
:param nightly_stats: File containing infos about nightly runs
62+
:param gardenlinux_root: GardenLinux root directory
63+
:param feature_dir_name: Name of the features directory
64+
:param logger: Logger instance
65+
66+
:since: 1.0.0
67+
"""
68+
69+
self._parser = Parser(gardenlinux_root, feature_dir_name, logger)
70+
if gardenlinux_root is None:
71+
gardenlinux_root = self._parser._GARDENLINUX_ROOT
72+
diff_dir = pathlib.Path(gardenlinux_root).joinpath(diff_dir)
73+
74+
self._all = set()
75+
self._flavors = os.listdir(diff_dir)
76+
self._nightly_stats = nightly_stats
77+
self._feature_dir_name = feature_dir_name
78+
79+
self._successful = []
80+
self._whitelist = []
81+
failed = {} # {flavor: [files...]}
82+
83+
self._expected_falvors = set(
84+
[
85+
f'{variant["flavor"]}-{variant["arch"]}'
86+
for variant in (
87+
flavors_matrix["include"] + bare_flavors_matrix["include"]
88+
)
89+
]
90+
)
91+
92+
for flavor in self._flavors:
93+
if flavor.endswith("-diff"):
94+
with open(diff_dir.joinpath(flavor), "r") as f:
95+
content = f.read()
96+
97+
flavor = flavor[:-5]
98+
self._all.add(flavor)
99+
if content == "\n":
100+
self._successful.append(flavor)
101+
elif content == "whitelist\n":
102+
self._successful.append(flavor)
103+
self._whitelist.append(flavor)
104+
else:
105+
failed[flavor] = content.split("\n")[:-1]
106+
107+
self._missing_flavors = self._expected_falvors - self._all
108+
self._unexpected_falvors = self._all - self._expected_falvors
109+
110+
# Map files to flavors
111+
affected = {} # {file: {flavors...}}
112+
for flavor in failed:
113+
for file in failed[flavor]:
114+
if file not in affected:
115+
affected[file] = set()
116+
affected[file].add(flavor)
117+
118+
# Merge files affected by the same flavors by mapping flavor sets to files
119+
self._bundled = {} # {{flavors...}: {files...}}
120+
for file in affected:
121+
if frozenset(affected[file]) not in self._bundled:
122+
self._bundled[frozenset(affected[file])] = set()
123+
self._bundled[frozenset(affected[file])].add(file)
124+
125+
def _node_key(self, node):
126+
"""
127+
Key order function to sort platforms before elements, platforms before flags and elements before flags
128+
129+
:param node: The node name (can be any of platform, element or flag)
130+
131+
:return: ("1-" || "2-" || "2-") + node
132+
:since: 1.0.0
133+
"""
134+
135+
with open(
136+
self._parser._feature_base_dir.joinpath(f"{node}/info.yaml"), "r"
137+
) as f:
138+
info = yaml.safe_load(f.read())
139+
if info["type"] == "platform":
140+
return "1-" + node
141+
elif info["type"] == "element":
142+
return "2-" + node
143+
else:
144+
return "3-" + node
145+
146+
def _generateIntersectionTrees(
147+
self,
148+
) -> dict[frozenset[str], tuple[frozenset[str], nx.DiGraph]]:
149+
"""
150+
Intersects all features of the affected flavors and removes all features from unaffected flavors to identify features causing the issue
151+
152+
:return: (dict[frozenset[str], tuple[frozenset[str], nx.DiGraph]]) Dict in the form of {{files...}: ({flavors..., intersectionTree})}
153+
:since: 1.0.0
154+
"""
155+
156+
trees = {}
157+
for flavors in self._bundled:
158+
first = True
159+
tree = None
160+
for flavor in flavors:
161+
if not flavor.startswith("bare-"):
162+
t = self._parser.filter(self.remove_arch.sub("", flavor))
163+
if first:
164+
first = False
165+
tree = t
166+
else:
167+
tree = nx.intersection(tree, t)
168+
169+
if tree is not None:
170+
unaffected = self._all - flavors
171+
for flavor in unaffected:
172+
if not flavor.startswith("bare-"):
173+
t = self._parser.filter(self.remove_arch.sub("", flavor))
174+
tree.remove_nodes_from(n for n in t)
175+
else:
176+
tree = nx.DiGraph()
177+
178+
trees[frozenset(self._bundled[flavors])] = (flavors, tree)
179+
180+
return trees
181+
182+
def _treeStr(self, graph: nx.DiGraph, found=None) -> tuple[str, set]:
183+
"""
184+
Returns a string representation of the graph containg each node exactly once
185+
186+
:param graph: Graph to be converted
187+
:param found: Nodes excluded for further rendering
188+
189+
:return: (str) Graph as string
190+
:since: 1.0.0
191+
"""
192+
193+
if found is None:
194+
found = set()
195+
196+
s = ""
197+
for node in sorted(graph, key=self._node_key):
198+
if node not in found and len(list(graph.predecessors(node))) == 0:
199+
found.add(node)
200+
if len(set(graph.successors(node)) - found) == 0:
201+
s += str(node) + "\n"
202+
else:
203+
s += str(node) + ":\n"
204+
for successor in sorted(
205+
set(graph.successors(node)) - found, key=self._node_key
206+
):
207+
st, fnd = self._treeStr(dfs_tree(graph, successor), found)
208+
found.update(fnd)
209+
s += " " + st.replace("\n", "\n ") + "\n"
210+
# Remove last linebreak as the last line can contain spaces
211+
return "\n".join(s.split("\n")[:-1]), found
212+
213+
@staticmethod
214+
def _dropdown(items) -> str:
215+
"""
216+
Converts the items into a markdown dropwon list if the length is 10 or more
217+
218+
:param items: List of items
219+
220+
:return: (str) List or dropown
221+
:since: 1.0.0
222+
"""
223+
224+
if len(items) <= 10:
225+
return "<br>".join([f"`{item}`" for item in sorted(items)])
226+
else:
227+
for first in sorted(items):
228+
return (
229+
f"<details><summary>{first}...</summary>"
230+
+ "<br>".join([f"`{item}`" for item in sorted(items)])
231+
+ "</details>"
232+
)
233+
return ""
234+
235+
def __str__(self) -> str:
236+
"""
237+
Returns final markdown for the configured reproducibility check
238+
239+
:return: (str) Markdown
240+
:since: 1.0.0
241+
"""
242+
trees = self._generateIntersectionTrees()
243+
244+
result = """# Reproducibility Test Results
245+
246+
{emoji} **{successrate}%** of **{total_count}** tested flavors were reproducible.{problem_count}
247+
248+
## Detailed Result{explanation}
249+
250+
| Affected Files | Flavors | Features Causing the Problem |
251+
|----------------|---------|------------------------------|
252+
{rows}
253+
"""
254+
255+
successrate = round(
256+
100 * (len(self._successful) / len(self._expected_falvors)), 1
257+
)
258+
259+
emoji = (
260+
"✅"
261+
if len(self._expected_falvors) == len(self._successful)
262+
else ("⚠️" if successrate >= 50.0 else "❌")
263+
)
264+
265+
total_count = len(self._expected_falvors)
266+
267+
problem_count = (
268+
""
269+
if len(trees) == 0
270+
else (
271+
"\n**1** Problem detected."
272+
if len(trees) == 1
273+
else f"\n**{len(trees)}** Problems detected."
274+
)
275+
)
276+
277+
explanation = ""
278+
279+
if self._nightly_stats.is_file():
280+
with open(self._nightly_stats, "r") as f:
281+
nightly_a, nightly_b = (
282+
Nightly(*n.split(",")) for n in f.read().split(";")
283+
)
284+
if nightly_a.run_number != "":
285+
explanation += f"\n\nComparison of nightly **[#{nightly_a.run_number}](https://github.com/gardenlinux/gardenlinux/actions/runs/{nightly_a.id})** \
286+
and **[#{nightly_b.run_number}](https://github.com/gardenlinux/gardenlinux/actions/runs/{nightly_b.id})**"
287+
if nightly_a.commit != nightly_b.commit:
288+
explanation += f"\n\n⚠️ The nightlies used different commits: `{nightly_a.commit[:7]}` (#{nightly_a.run_number}) != `{nightly_b.commit[:7]}` (#{nightly_b.run_number})"
289+
if nightly_a.run_number == nightly_b.run_number:
290+
explanation += f"\n\n⚠️ Comparing the nightly **[#{nightly_a.run_number}](https://github.com/gardenlinux/gardenlinux/actions/runs/{nightly_a.id})** to itself can not reveal any issues"
291+
else:
292+
explanation += f"\n\nComparison of the latest nightly **[#{nightly_b.run_number}](https://github.com/gardenlinux/gardenlinux/actions/runs/{nightly_b.id})** \
293+
with a new build"
294+
if nightly_a.commit != nightly_b.commit:
295+
explanation += f"\n\n⚠️ The build used different commits: `{nightly_b.commit[:7]}` (#{nightly_b.run_number}) != `{nightly_a.commit[:7]}` (new build)"
296+
297+
if len(self._whitelist) > 0:
298+
explanation += (
299+
"\n\n<details><summary>📃 These flavors only passed due to the nightly whitelist</summary><pre>"
300+
+ "<br>".join(sorted(self._whitelist))
301+
+ "</pre></details>"
302+
)
303+
304+
if len(self._unexpected_falvors) > 0:
305+
# This should never happen, but print a warning if it somehow does
306+
explanation += (
307+
"\n\n<details><summary>⁉️ These flavors were not expected to appear in the results, please check for errors in the workflow\
308+
</summary><pre>"
309+
+ "<br>".join(sorted(self._unexpected_falvors))
310+
+ "</pre></details>"
311+
)
312+
313+
explanation += (
314+
""
315+
if len(self._expected_falvors) <= len(self._successful)
316+
else "\n\n*The mentioned features are included in every affected flavor and not included in every unaffected flavor.*"
317+
)
318+
319+
rows = ""
320+
321+
if len(self._missing_flavors) > 0:
322+
row = "|❌ Workflow run did not produce any results|"
323+
row += f"**{round(100 * (len(self._missing_flavors) / len(self._expected_falvors)), 1)}%** affected<br>"
324+
row += self._dropdown(self._missing_flavors)
325+
row += "|No analysis available|\n"
326+
rows += row
327+
328+
for files in trees:
329+
flavors, tree = trees[files]
330+
row = "|"
331+
row += self._dropdown(files)
332+
row += "|"
333+
row += f"**{round(100 * (len(flavors) / len(self._expected_falvors)), 1)}%** affected<br>"
334+
row += self._dropdown(flavors)
335+
row += "|"
336+
if len(tree) == 0:
337+
row += "No analysis available"
338+
else:
339+
row += "<pre>" + self._treeStr(tree)[0].replace("\n", "<br>") + "</pre>"
340+
row += "|\n"
341+
rows += row
342+
343+
if len(self._successful) > 0:
344+
# Success row
345+
row = "|"
346+
row += "✅ No problems found"
347+
row += "|"
348+
row += f"**{round(100 * (len(self._successful) / len(self._expected_falvors)), 1)}%**<br>"
349+
row += self._dropdown(self._successful)
350+
row += "|"
351+
row += "-"
352+
row += "|\n"
353+
rows += row
354+
355+
if len(self._successful) < len(self._expected_falvors):
356+
rows += "\n*To add affected files to the whitelist, edit the `whitelist` variable in `.github/workflows/generate_diff.sh`*"
357+
358+
return result.format(
359+
emoji=emoji,
360+
successrate=successrate,
361+
total_count=total_count,
362+
problem_count=problem_count,
363+
explanation=explanation,
364+
rows=rows,
365+
)

0 commit comments

Comments
 (0)