Skip to content

Commit 39c6fa5

Browse files
New suite report command with github support (#107)
This adds a new version of the suite report command which generates its summary in github markdown format.
1 parent 4fae647 commit 39c6fa5

2 files changed

Lines changed: 646 additions & 0 deletions

File tree

suite_report_git/suite_data.py

Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
#!/usr/bin/env python3
2+
# *****************************COPYRIGHT*******************************
3+
# (C) Crown copyright Met Office. All rights reserved.
4+
# For further details please refer to the file COPYRIGHT.txt
5+
# which you should have received as part of this distribution.
6+
# *****************************COPYRIGHT*******************************
7+
"""
8+
Class containing helper methods for gathering data needed for a SuiteReport object
9+
"""
10+
11+
import sys
12+
13+
sys.path.append("../")
14+
import subprocess
15+
import sqlite3
16+
import shutil
17+
import yaml
18+
import re
19+
20+
try:
21+
from bdiff.git_bdiff import GitBDiff, GitInfo
22+
except ImportError:
23+
try:
24+
from git_bdiff import GitBDiff, GitInfo
25+
except ImportError as err:
26+
raise ImportError(
27+
"Unable to import from git_bdiff module. This is included in the same "
28+
"repository as this script and included with a relative import. Ensure "
29+
"this script is being called from the correct place."
30+
) from err
31+
from typing import Union, Optional, List, Dict
32+
from pathlib import Path
33+
from collections import defaultdict
34+
35+
36+
class SuiteData:
37+
"""
38+
Class to gather info on a suite
39+
"""
40+
41+
pink_failures = (
42+
"_vs_",
43+
"lrun_crun_atmos",
44+
"proc",
45+
"atmos_omp",
46+
"atmos_nruncrun",
47+
"atmos_thread",
48+
"-v-",
49+
)
50+
51+
def __init__(self) -> None:
52+
self.dependencies = {}
53+
self.rose_data = {}
54+
self.suite_path = None
55+
self.task_states = {}
56+
self.temp_directory = None
57+
58+
def __init__(self) -> None:
59+
pass
60+
61+
def parse_tasks(self) -> Dict[str, List[str]]:
62+
"""
63+
Read through the tasks run, sorting by state
64+
"""
65+
66+
data = defaultdict(list)
67+
68+
for task, state in self.task_states.items():
69+
if state == "failed" and (
70+
task.startswith("rose_ana") or task.startswith("check_")
71+
):
72+
for item in self.pink_failures:
73+
if item in task:
74+
state = "pink failure"
75+
break
76+
data[state].append(task)
77+
return data
78+
79+
def populate_gitbdiff(self) -> None:
80+
"""
81+
Run GitBDiff on each copied source if the source isn't main-like, storing in the
82+
dependencies directory
83+
"""
84+
85+
for dependency, data in self.dependencies.items():
86+
if not data["gitinfo"].is_main():
87+
if dependency.lower() == "simsys_scripts":
88+
parent = "main"
89+
else:
90+
parent = "trunk"
91+
self.dependencies[dependency]["gitbdiff"] = GitBDiff(
92+
repo=self.temp_directory / dependency, parent=parent
93+
).files()
94+
95+
def populate_gitinfo(self) -> None:
96+
"""
97+
Run GitInfo on each copied source, storing in the dependencies directory
98+
"""
99+
100+
for dependency in self.dependencies:
101+
self.dependencies[dependency]["gitinfo"] = GitInfo(
102+
self.temp_directory / dependency
103+
)
104+
105+
def clone_sources(self) -> None:
106+
"""
107+
Clone the sources defined in the dependencies file, to allow reading of files
108+
and creation of diffs.
109+
If the source is not a github url, then copy it using rsync
110+
"""
111+
112+
for dependency, data in self.dependencies.items():
113+
loc = self.temp_directory / dependency
114+
if data["source"].endswith(".git"):
115+
commands = [
116+
f"git clone {data['source']} {loc}",
117+
f"git -C {loc} checkout {data['ref']}",
118+
]
119+
for command in commands:
120+
self.run_command(command)
121+
else:
122+
source = data["source"]
123+
if not source.endswith("/"):
124+
source = source + "/"
125+
command = (
126+
f'rsync -e "ssh -o StrictHostKeyChecking=no" -avl {source} {loc}'
127+
)
128+
self.run_command(command, shell=True)
129+
130+
def determine_primary_source(self) -> str:
131+
"""
132+
Work out what repo launched the suite based on the what sources are available in
133+
the dependencies. Return 'unknown' if unable to determine.
134+
"""
135+
136+
# If just 1 dependency, it must be that
137+
if len(self.dependencies) == 1:
138+
return list(self.dependencies.keys())[0]
139+
140+
# If 2 dependencies, remove simsys_scripts
141+
if len(self.dependencies) == 2:
142+
for item in self.dependencies:
143+
if item.lower() != "simsys_scripts":
144+
return item
145+
146+
# If LFRic Apps in sources, that is the primary source
147+
if "lfric_apps" in self.dependencies.keys():
148+
return "lfric_apps"
149+
150+
if "um" in self.dependencies.keys():
151+
return "um"
152+
153+
return "unknown"
154+
155+
def read_rose_conf(self) -> Dict[str, str]:
156+
"""
157+
Read the suite rose-suite.conf file into a dictionary
158+
"""
159+
160+
path = self.suite_path / "log" / "config"
161+
for item in path.rglob("*-rose-suite.conf"):
162+
conf_file = item
163+
break
164+
else:
165+
raise FileNotFoundError(
166+
"Couldn't find a *-rose-suite.conf file in the cylc-run log directory"
167+
)
168+
169+
rose_conf_text = conf_file.read_text().split("\n")
170+
rose_conf = {}
171+
for line in rose_conf_text:
172+
line = line.strip()
173+
if (
174+
not line
175+
or "=" not in line
176+
or line.startswith("!")
177+
or line.startswith("[")
178+
or line.startswith("#")
179+
):
180+
continue
181+
line = line.split("=")
182+
key = line[0].strip()
183+
value = line[1].strip()
184+
if (
185+
value.startswith("'")
186+
and value.endswith("'")
187+
or value.startswith('"')
188+
and value.endswith('"')
189+
):
190+
value = value[1:-1]
191+
rose_conf[key] = value
192+
return rose_conf
193+
194+
def find_unknown_dependency(self, dependency: str) -> str:
195+
"""
196+
TEMPORARY
197+
The primary dependency may be unset in the dependencies file. In this case find
198+
it from the *_SOURCE variable in the rose-suite.conf.
199+
TODO: Once cylc provides the location of the source code itself, this method
200+
should be changed to use that instead, as then the _SOURCE variable will be
201+
removed
202+
"""
203+
204+
var = f"{dependency.upper()}_SOURCE".replace('"', "")
205+
if var not in self.rose_data:
206+
raise RuntimeError(f"Cant determine source for {dependency}")
207+
rval = self.rose_data[var]
208+
if "$ROSE_ORIG_HOST" in rval:
209+
rval = rval.replace("$ROSE_ORIG_HOST", self.rose_data["ROSE_ORIG_HOST"])
210+
return rval
211+
212+
def read_dependencies(self) -> Dict[str, Dict]:
213+
"""
214+
Read the suite dependencies from the dependencies.yaml file - this is assumed to
215+
have been copied to the suite_path directory
216+
"""
217+
218+
with open(self.suite_path / "dependencies.yaml", "r") as stream:
219+
dependencies = yaml.safe_load(stream)
220+
for dependency, data in dependencies.items():
221+
if data["source"] is None:
222+
dependencies[dependency]["source"] = self.find_unknown_dependency(
223+
dependency
224+
)
225+
return dependencies
226+
227+
def get_workflow_id(self) -> str:
228+
"""
229+
Read cylc install log for workflow id
230+
"""
231+
232+
with open(self.suite_path / "log" / "scheduler" / "log", "r") as f:
233+
for line in f:
234+
match = re.search(r"INFO - Workflow: (\w+\/\w+)", line)
235+
try:
236+
workflow_id = match.group(1)
237+
return workflow_id
238+
except IndexError:
239+
continue
240+
241+
return "unknown_workflow_id"
242+
243+
def get_suite_starttime(self) -> str:
244+
"""
245+
Read the suite starttime from the suite database
246+
"""
247+
248+
starttime = "unknown"
249+
for row in self.query_suite_database(
250+
self.suite_path / "log" / "db", ["start_time"], "workflow_flows"
251+
):
252+
starttime = row[0]
253+
return starttime.split("+")[0]
254+
255+
def read_groups_run(self) -> str:
256+
"""
257+
Read in groups run as part of suite from the cylc database file
258+
"""
259+
260+
groups = None
261+
for row in self.query_suite_database(
262+
self.suite_path / "log" / "db", ["key", "value"], "workflow_template_vars"
263+
):
264+
if row[0] in ("g", "groups"):
265+
groups = row[1].strip("[]'\"").split(",")
266+
break
267+
return groups
268+
269+
def get_task_states(self) -> Dict[str, str]:
270+
"""
271+
Query the database and return a dictionary of states. This is assumed to be in
272+
suite_path/log/db
273+
"""
274+
275+
data = {}
276+
for row in self.query_suite_database(
277+
self.suite_path / "log" / "db", ["name", "status"], "task_states"
278+
):
279+
data[row[0]] = row[1]
280+
return data
281+
282+
def query_suite_database(
283+
self, database: Path, selections: List[str], source: str
284+
) -> List[tuple]:
285+
"""
286+
Create an sql statement and query provided database. Return the result
287+
"""
288+
289+
sql_statement = f"select {', '.join(s for s in selections)} from {source};"
290+
291+
database = sqlite3.connect(database)
292+
cursor = database.cursor()
293+
cursor.execute(sql_statement)
294+
data = []
295+
for row in cursor:
296+
data.append(row)
297+
database.close()
298+
return data
299+
300+
def run_command(
301+
self, command: Union[str, List[str]], shell: bool = False, rval: bool = False
302+
) -> Optional[subprocess.CompletedProcess]:
303+
"""
304+
Run a subprocess command and return the result object
305+
Inputs:
306+
- command, str with command to run
307+
Outputs:
308+
- result object from subprocess.run
309+
"""
310+
if not shell and isinstance(command, str):
311+
command = command.split()
312+
result = subprocess.run(
313+
command,
314+
capture_output=True,
315+
text=True,
316+
timeout=300,
317+
shell=shell,
318+
check=False,
319+
)
320+
if result.returncode:
321+
print(result.stdout, end="\n\n\n")
322+
raise RuntimeError(
323+
f"[FAIL] Issue found running command {command}\n\n{result.stderr}"
324+
)
325+
if rval:
326+
return result
327+
328+
def cleanup(self) -> None:
329+
"""
330+
Remove self.temp_directory
331+
"""
332+
shutil.rmtree(self.temp_directory)

0 commit comments

Comments
 (0)