tools/fiograph: accommodate job files not ending in .fio
[fio.git] / tools / fiograph / fiograph.py
1 #!/usr/bin/env python3
2 import uuid
3 import time
4 import errno
5 from graphviz import Digraph
6 import argparse
7 import configparser
8 import os
9
10 config_file = None
11 fio_file = None
12
13
14 def get_section_option(section_name, option_name, default=None):
15     global fio_file
16     if fio_file.has_option(section_name, option_name):
17         return fio_file[section_name][option_name]
18     return default
19
20
21 def get_config_option(section_name, option_name, default=None):
22     global config_file
23     if config_file.has_option(section_name, option_name):
24         return config_file[section_name][option_name]
25     return default
26
27
28 def get_header_color(keyword='fio_jobs', default_color='black'):
29     return get_config_option(keyword, 'header_color', default_color)
30
31
32 def get_shape_color(keyword='fio_jobs', default_color='black'):
33     return get_config_option(keyword, 'shape_color', default_color)
34
35
36 def get_text_color(keyword='fio_jobs', default_color='black'):
37     return get_config_option(keyword, 'text_color', default_color)
38
39
40 def get_cluster_color(keyword='fio_jobs', default_color='gray92'):
41     return get_config_option(keyword, 'cluster_color', default_color)
42
43
44 def get_header(keyword='fio_jobs'):
45     return get_config_option(keyword, 'header')
46
47
48 def get_shape(keyword='fio_jobs'):
49     return get_config_option(keyword, 'shape', 'box')
50
51
52 def get_style(keyword='fio_jobs'):
53     return get_config_option(keyword, 'style', 'rounded')
54
55
56 def get_cluster_style(keyword='fio_jobs'):
57     return get_config_option(keyword, 'cluster_style', 'filled')
58
59
60 def get_specific_options(engine):
61     if not engine:
62         return ''
63     return get_config_option('ioengine_{}'.format(engine), 'specific_options', '').split(' ')
64
65
66 def render_option(section, label, display, option, color_override=None):
67     # These options are already shown with graphical helpers, no need to report them directly
68     skip_list = ['size', 'stonewall', 'runtime', 'time_based',
69                  'numjobs', 'wait_for', 'wait_for_previous']
70     # If the option doesn't exist or if a special handling is already done
71     # don't render it, just return the current state
72     if option in skip_list or option not in section:
73         return label, display
74     display = option
75     if section[option]:
76         display = '{} = {}'.format(display, section[option])
77
78     # Adding jobs's options into the box, darkgreen is the default color
79     if color_override:
80         color = color_override
81     else:
82         color = get_text_color(option, get_text_color('fio_jobs', 'darkgreen'))
83     label += get_config_option('fio_jobs',
84                                'item_style').format(color, display)
85     return label, display
86
87
88 def render_options(fio_file, section_name):
89     """Render all options of a section."""
90     display = section_name
91     section = fio_file[section_name]
92
93     # Add a multiplier to the section_name if numjobs is set
94     numjobs = int(get_section_option(section_name, 'numjobs', '1'))
95     if numjobs > 1:
96         display = display + \
97             get_style('numjobs').format(
98                 get_text_color('numjobs'), numjobs)
99
100     # Header of the box
101     label = get_config_option('fio_jobs', 'title_style').format(display)
102
103     # Let's parse all the options of the current fio thread
104     # Some needs to be printed on top or bottom of the job to ease the read
105     to_early_print = ['exec_prerun', 'ioengine']
106     to_late_print = ['exec_postrun']
107
108     # Let's print the options on top of the box
109     for early_print in to_early_print:
110         label, display = render_option(
111             section, label, display, early_print)
112
113     current_io_engine = get_section_option(
114         section_name, 'ioengine', None)
115     if current_io_engine:
116         # Let's print all specifics options for this engine
117         for specific_option in sorted(get_specific_options(current_io_engine)):
118             label, display = render_option(
119                 section, label, display, specific_option, get_config_option('ioengine', 'specific_options_color'))
120
121     # Let's print generic options sorted by name
122     for option in sorted(section):
123         if option in to_early_print or option in to_late_print or option in get_specific_options(current_io_engine):
124             continue
125         label, display = render_option(section, label, display, option)
126
127     # let's print options on the bottom of the box
128     for late_print in to_late_print:
129         label, display = render_option(
130             section, label, display, late_print)
131
132     # End of the box content
133     label += '</table>>'
134     return label
135
136
137 def render_section(current_graph, fio_file, section_name, label):
138     """Render the section."""
139     attr = None
140     section = fio_file[section_name]
141
142     # Let's render the box associated to a job
143     current_graph.node(section_name, label,
144                        shape=get_shape(),
145                        color=get_shape_color(),
146                        style=get_style())
147
148     # Let's report the duration of the jobs with a self-loop arrow
149     if 'runtime' in section and 'time_based' in section:
150         attr = 'runtime={}'.format(section['runtime'])
151     elif 'size' in section:
152         attr = 'size={}'.format(section['size'])
153     if attr:
154         current_graph.edge(section_name, section_name, attr)
155
156
157 def create_sub_graph(name):
158     """Return a new graph."""
159     # We need to put 'cluster' in the name to ensure graphviz consider it as a cluster
160     cluster_name = 'cluster_' + name
161     # Unset the main graph labels to avoid a recopy in each subgraph
162     attr = {}
163     attr['label'] = ''
164     new_graph = Digraph(name=cluster_name, graph_attr=attr)
165     new_graph.attr(style=get_cluster_style(),
166                    color=get_cluster_color())
167     return new_graph
168
169
170 def create_legend():
171     """Return a legend."""
172     html_table = "<<table border='0' cellborder='1' cellspacing='0' cellpadding='4'>"
173     html_table += '<tr><td COLSPAN="2"><b>Legend</b></td></tr>'
174     legend_item = '<tr> <td>{}</td> <td><font color="{}">{}</font></td></tr>"'
175     legend_bgcolor_item = '<tr><td>{}</td><td BGCOLOR="{}"></td></tr>'
176     html_table += legend_item.format('numjobs',
177                                      get_text_color('numjobs'), 'x numjobs')
178     html_table += legend_item.format('generic option',
179                                      get_text_color(), 'generic option')
180     html_table += legend_item.format('ioengine option',
181                                      get_text_color('ioengine'), 'ioengine option')
182     html_table += legend_bgcolor_item.format('job', get_shape_color())
183     html_table += legend_bgcolor_item.format(
184         'execution group', get_cluster_color())
185     html_table += '</table>>'
186     legend = Digraph('html_table')
187     legend.node('legend', shape='none', label=html_table)
188     return legend
189
190
191 def fio_to_graphviz(filename, format):
192     """Compute the graphviz graph from the fio file."""
193
194     # Let's read the fio file
195     global fio_file
196     fio_file = configparser.RawConfigParser(
197         allow_no_value=True,
198         default_section="global",
199         inline_comment_prefixes="'#', ';'")
200     fio_file.read(filename)
201
202     # Prepare the main graph object
203     # Let's define the header of the document
204     attrs = {}
205     attrs['labelloc'] = 't'
206     attrs['label'] = get_header().format(
207         get_header_color(), os.path.basename(filename))
208     main_graph = Digraph(engine='dot', graph_attr=attrs, format=format)
209
210     # Let's add a legend
211     main_graph.subgraph(create_legend())
212
213     # By default all jobs are run in parallel and depends on "global"
214     depends_on = fio_file.default_section
215
216     # The previous section is by default the global section
217     previous_section = fio_file.default_section
218
219     current_graph = main_graph
220
221     # The first job will be a new execution group
222     new_execution_group = True
223
224     # Let's iterate on all sections to create links between them
225     for section_name in fio_file.sections():
226         # The current section
227         section = fio_file[section_name]
228
229         # If the current section is waiting the previous job
230         if ('stonewall' or 'wait_for_previous') in section:
231             # let's remember what was the previous job we depend on
232             depends_on = previous_section
233             new_execution_group = True
234         elif 'wait_for' in section:
235             # This sections depends on a named section pointed by wait_for
236             depends_on = section['wait_for']
237             new_execution_group = True
238
239         if new_execution_group:
240             # Let's link the current graph with the main one
241             main_graph.subgraph(current_graph)
242             # Let's create a new graph to represent all the incoming jobs running at the same time
243             current_graph = create_sub_graph(section_name)
244
245         # Let's render the current section in its execution group
246         render_section(current_graph, fio_file, section_name,
247                        render_options(fio_file, section_name))
248
249         # Let's trace the link between this job and the one it depends on
250         # If we depend on 'global', we can avoid doing adding an arrow as we don't want to see 'global'
251         if depends_on != fio_file.default_section:
252             current_graph.edge(depends_on, section_name)
253
254         # The current section become the parent of the next one
255         previous_section = section_name
256
257         # We are by default in the same execution group
258         new_execution_group = False
259
260     # The last subgraph isn't rendered yet
261     main_graph.subgraph(current_graph)
262
263     # Let's return the main graphviz object
264     return main_graph
265
266
267 def setup_commandline():
268     "Prepare the command line."
269     parser = argparse.ArgumentParser()
270     parser.add_argument('--file', action='store',
271                         type=str,
272                         required=True,
273                         help='the fio file to graph')
274     parser.add_argument('--output', action='store',
275                         type=str,
276                         help='the output filename')
277     parser.add_argument('--format', action='store',
278                         type=str,
279                         default='png',
280                         help='the output format (see https://graphviz.org/docs/outputs/)')
281     parser.add_argument('--view', action='store_true',
282                         default=False,
283                         help='view the graph')
284     parser.add_argument('--keep', action='store_true',
285                         default=False,
286                         help='keep the graphviz script file')
287     parser.add_argument('--config', action='store',
288                         type=str,
289                         help='the configuration filename')
290     args = parser.parse_args()
291     return args
292
293
294 def main():
295     global config_file
296     args = setup_commandline()
297
298     if args.config is None:
299         if os.path.exists('fiograph.conf'):
300             config_filename = 'fiograph.conf'
301         else:
302             config_filename = os.path.join(os.path.dirname(__file__), 'fiograph.conf')
303             if not os.path.exists(config_filename):
304                 raise FileNotFoundError("Cannot locate configuration file")
305     else:
306         config_filename = args.config
307     config_file = configparser.RawConfigParser(allow_no_value=True)
308     config_file.read(config_filename)
309
310     temp_filename = uuid.uuid4().hex
311     image_filename = fio_to_graphviz(args.file, args.format).render(temp_filename, view=args.view)
312
313     output_filename_stub = args.file
314     if args.output:
315         output_filename = args.output
316     else:
317         if output_filename_stub.endswith('.fio'):
318             output_filename_stub = output_filename_stub[:-4]
319         output_filename = image_filename.replace(temp_filename, output_filename_stub)
320     if args.view:
321         time.sleep(1)
322         # allow time for the file to be opened before renaming it
323     os.rename(image_filename, output_filename)
324
325     if not args.keep:
326         os.remove(temp_filename)
327     else:
328         os.rename(temp_filename, output_filename_stub + '.gv')
329
330
331 main()