PVE::CLIFormatter::print_api_result - use print_api_list to print arrays
[pve-common.git] / src / PVE / CLIFormatter.pm
1 package PVE::CLIFormatter;
2
3 use strict;
4 use warnings;
5 use I18N::Langinfo;
6 use POSIX qw(strftime);
7
8 use PVE::JSONSchema;
9 use PVE::PTY;
10 use JSON;
11 use utf8;
12 use Encode;
13
14 sub render_timestamp {
15     my ($epoch) = @_;
16
17     # ISO 8601 date format
18     return strftime("%F %H:%M:%S", localtime($epoch));
19 }
20
21 PVE::JSONSchema::register_renderer('timestamp', \&render_timestamp);
22
23 sub render_timestamp_gmt {
24     my ($epoch) = @_;
25
26     # ISO 8601 date format, standard Greenwich time zone
27     return strftime("%F %H:%M:%S", gmtime($epoch));
28 }
29
30 PVE::JSONSchema::register_renderer('timestamp_gmt', \&render_timestamp_gmt);
31
32 sub render_duration {
33     my ($duration_in_seconds) = @_;
34
35     my $text = '';
36     my $rest = $duration_in_seconds;
37
38     my $step = sub {
39         my ($unit, $unitlength) = @_;
40
41         if ((my $v = int($rest/$unitlength)) > 0) {
42             $text .= " " if length($text);
43             $text .= "${v}${unit}";
44             $rest -= $v * $unitlength;
45         }
46     };
47
48     $step->('w', 7*24*3600);
49     $step->('d', 24*3600);
50     $step->('h', 3600);
51     $step->('m', 60);
52     $step->('s', 1);
53
54     return $text;
55 }
56
57 PVE::JSONSchema::register_renderer('duration', \&render_duration);
58
59 sub render_fraction_as_percentage {
60     my ($fraction) = @_;
61
62     return sprintf("%.2f%%", $fraction*100);
63 }
64
65 PVE::JSONSchema::register_renderer(
66     'fraction_as_percentage', \&render_fraction_as_percentage);
67
68 sub render_bytes {
69     my ($value) = @_;
70
71     my @units = qw(B KiB MiB GiB TiB PiB);
72
73     my $max_unit = 0;
74     if ($value > 1023) {
75         $max_unit = int(log($value)/log(1024));
76         $value /= 1024**($max_unit);
77     }
78
79     return sprintf "%.2f $units[$max_unit]", $value;
80 }
81
82 PVE::JSONSchema::register_renderer('bytes', \&render_bytes);
83
84 sub query_terminal_options {
85     my ($options) = @_;
86
87     $options //= {};
88
89     if (-t STDOUT) {
90         ($options->{columns}) = PVE::PTY::tcgetsize(*STDOUT);
91     }
92
93     $options->{encoding} = I18N::Langinfo::langinfo(I18N::Langinfo::CODESET());
94
95     $options->{utf8} = 1 if $options->{encoding} eq 'UTF-8';
96
97     return $options;
98 }
99
100 sub data_to_text {
101     my ($data, $propdef, $options) = @_;
102
103     return '' if !defined($data);
104
105     if (defined($propdef)) {
106         if (my $type = $propdef->{type}) {
107             if ($type eq 'boolean') {
108                 return $data ? 1 : 0;
109             }
110         }
111         if (!defined($data) && defined($propdef->{default})) {
112             return "($propdef->{default})";
113         }
114         if (defined(my $renderer = $propdef->{renderer})) {
115             my $code = PVE::JSONSchema::get_renderer($renderer);
116             die "internal error: unknown renderer '$renderer'" if !$code;
117             return $code->($data, $options);
118         }
119     }
120
121     if (my $class = ref($data)) {
122         return to_json($data, { canonical => 1 });
123     } else {
124         return "$data";
125     }
126 }
127
128 # prints a formatted table with a title row.
129 # $data - the data to print (array of objects)
130 # $returnprops -json schema property description
131 # $props_to_print - ordered list of properties to print
132 # $options
133 # - sort_key: can be used to sort after a column, if it isn't set we sort
134 #   after the leftmost column (with no undef value in $data) this can be
135 #   turned off by passing 0 as sort_key
136 # - border: print with/without table header and asciiart border
137 # - columns: limit output width (if > 0)
138 # - utf8: use utf8 characters for table delimiters
139
140 sub print_text_table {
141     my ($data, $returnprops, $props_to_print, $options) = @_;
142
143     my $sort_key = $options->{sort_key};
144     my $border = $options->{border};
145     my $columns = $options->{columns};
146     my $utf8 = $options->{utf8};
147     my $encoding = $options->{encoding} // 'UTF-8';
148
149     if (!defined($sort_key) || $sort_key eq 0) {
150         $sort_key = $props_to_print->[0];
151     }
152
153     if (defined($sort_key)) {
154         my $type = $returnprops->{$sort_key}->{type} // 'string';
155         if ($type eq 'integer' || $type eq 'number') {
156             @$data = sort { $a->{$sort_key} <=> $b->{$sort_key} } @$data;
157         } else {
158             @$data = sort { $a->{$sort_key} cmp $b->{$sort_key} } @$data;
159         }
160     }
161
162     my $colopts = {};
163
164     my $borderstring_m = '';
165     my $borderstring_b = '';
166     my $borderstring_t = '';
167     my $formatstring = '';
168
169     my $column_count = scalar(@$props_to_print);
170
171     my $tabledata = [];
172
173     foreach my $entry (@$data) {
174
175         my $height = 1;
176         my $rowdata = {};
177
178         for (my $i = 0; $i < $column_count; $i++) {
179             my $prop = $props_to_print->[$i];
180             my $propinfo = $returnprops->{$prop} // {};
181
182             my $text = data_to_text($entry->{$prop}, $propinfo, $options);
183             my $lines = [ split(/\n/, $text) ];
184             my $linecount = scalar(@$lines);
185             $height = $linecount if $linecount > $height;
186
187             my $width = 0;
188             foreach my $line (@$lines) {
189                 my $len = length($line);
190                 $width = $len if $len > $width;
191             }
192
193             $rowdata->{$prop} = {
194                 lines => $lines,
195                 width => $width,
196             };
197         }
198
199         push @$tabledata, {
200             height => $height,
201             rowdata => $rowdata,
202         };
203     }
204
205     for (my $i = 0; $i < $column_count; $i++) {
206         my $prop = $props_to_print->[$i];
207         my $propinfo = $returnprops->{$prop} // {};
208
209         my $title = $propinfo->{title} // $prop;
210         my $cutoff = $propinfo->{print_width} // $propinfo->{maxLength};
211
212         # calculate maximal print width and cutoff
213         my $titlelen = length($title);
214
215         my $longest = $titlelen;
216         foreach my $coldata (@$tabledata) {
217             my $rowdata = $coldata->{rowdata}->{$prop};
218             $longest = $rowdata->{width} if $rowdata->{width} > $longest;
219         }
220         $cutoff = $longest if !defined($cutoff) || $cutoff > $longest;
221
222         $colopts->{$prop} = {
223             title => $title,
224             cutoff => $cutoff,
225         };
226
227         if ($border) {
228             if ($i == 0 && ($column_count == 1)) {
229                 if ($utf8) {
230                     $formatstring .= "│ %-${cutoff}s │";
231                     $borderstring_t .= "┌─" . ('─' x $cutoff) . "─┐";
232                     $borderstring_m .= "├─" . ('─' x $cutoff) . "─┤";
233                     $borderstring_b .= "└─" . ('─' x $cutoff) . "─┘";
234                 } else {
235                     $formatstring .= "| %-${cutoff}s |";
236                     $borderstring_m .= "+-" . ('-' x $cutoff) . "-+";
237                 }
238             } elsif ($i == 0) {
239                 if ($utf8) {
240                     $formatstring .= "│ %-${cutoff}s ";
241                     $borderstring_t .= "┌─" . ('─' x $cutoff) . '─';
242                     $borderstring_m .= "├─" . ('─' x $cutoff) . '─';
243                     $borderstring_b .= "└─" . ('─' x $cutoff) . '─';
244                 } else {
245                     $formatstring .= "| %-${cutoff}s ";
246                     $borderstring_m .= "+-" . ('-' x $cutoff) . '-';
247                 }
248             } elsif ($i == ($column_count - 1)) {
249                 if ($utf8) {
250                     $formatstring .= "│ %-${cutoff}s │";
251                     $borderstring_t .= "┬─" . ('─' x $cutoff) . "─┐";
252                     $borderstring_m .= "┼─" . ('─' x $cutoff) . "─┤";
253                     $borderstring_b .= "┴─" . ('─' x $cutoff) . "─┘";
254                 } else {
255                     $formatstring .= "| %-${cutoff}s |";
256                     $borderstring_m .= "+-" . ('-' x $cutoff) . "-+";
257                 }
258             } else {
259                 if ($utf8) {
260                     $formatstring .= "│ %-${cutoff}s ";
261                     $borderstring_t .= "┬─" . ('─' x $cutoff) . '─';
262                     $borderstring_m .= "┼─" . ('─' x $cutoff) . '─';
263                     $borderstring_b .= "┴─" . ('─' x $cutoff) . '─';
264                 } else {
265                     $formatstring .= "| %-${cutoff}s ";
266                     $borderstring_m .= "+-" . ('-' x $cutoff) . '-';
267                 }
268             }
269         } else {
270             # skip alignment and cutoff on last column
271             $formatstring .= ($i == ($column_count - 1)) ? "%s" : "%-${cutoff}s ";
272         }
273     }
274
275     $borderstring_t = $borderstring_m if !length($borderstring_t);
276     $borderstring_b = $borderstring_m if !length($borderstring_b);
277
278     my $writeln = sub {
279         my ($text) = @_;
280
281         if ($columns) {
282             print encode($encoding, substr($text, 0, $columns) . "\n");
283         } else {
284             print encode($encoding, $text) . "\n";
285         }
286     };
287
288     $writeln->($borderstring_t) if $border;
289     my $text = sprintf $formatstring, map { $colopts->{$_}->{title} } @$props_to_print;
290     $writeln->($text);
291
292     foreach my $coldata (@$tabledata) {
293         $writeln->($borderstring_m) if $border;
294
295         for (my $i = 0; $i < $coldata->{height}; $i++) {
296
297             $text = sprintf $formatstring, map {
298                 substr($coldata->{rowdata}->{$_}->{lines}->[$i] // '', 0, $colopts->{$_}->{cutoff});
299             } @$props_to_print;
300
301             $writeln->($text);
302         }
303     }
304
305     $writeln->($borderstring_b) if $border;
306 }
307
308 sub extract_properties_to_print {
309     my ($propdef) = @_;
310
311     my $required = [];
312     my $optional = [];
313
314     foreach my $key (keys %$propdef) {
315         my $prop = $propdef->{$key};
316         if ($prop->{optional}) {
317             push @$optional, $key;
318         } else {
319             push @$required, $key;
320         }
321     }
322
323     return [ sort(@$required), sort(@$optional) ];
324 }
325
326 # prints the result of an API GET call returning an array as a table.
327 # takes formatting information from the results property of the call
328 # if $props_to_print is provided, prints only those columns. otherwise
329 # takes all fields of the results property, with a fallback
330 # to all fields occuring in items of $data.
331 sub print_api_list {
332     my ($data, $result_schema, $props_to_print, $options) = @_;
333
334     die "can only print object lists\n"
335         if !($result_schema->{type} eq 'array' && $result_schema->{items}->{type} eq 'object');
336
337     my $returnprops = $result_schema->{items}->{properties};
338
339     $props_to_print = extract_properties_to_print($returnprops)
340         if !defined($props_to_print);
341
342     if (!scalar(@$props_to_print)) {
343         my $all_props = {};
344         foreach my $obj (@$data) {
345             foreach my $key (keys %$obj) {
346                 $all_props->{$key} = 1;
347             }
348         }
349         $props_to_print = [ sort keys %{$all_props} ];
350     }
351
352     die "unable to detect list properties\n" if !scalar(@$props_to_print);
353
354     print_text_table($data, $returnprops, $props_to_print, $options);
355 }
356
357 sub print_api_result {
358     my ($format, $data, $result_schema, $props_to_print, $options) = @_;
359
360     if (!defined($options)) {
361         $options = query_terminal_options({});
362     } else {
363         $options = { %$options }; # copy
364     }
365
366     return if $result_schema->{type} eq 'null';
367
368     if ($format eq 'json') {
369         # Note: we always use utf8 encoding for json format
370         print to_json($data, {utf8 => 1, allow_nonref => 1, canonical => 1, pretty => 1 });
371     } elsif ($format eq 'text' || $format eq 'plain') {
372         my $encoding = $options->{encoding} // 'UTF-8';
373         my $type = $result_schema->{type};
374         if ($type eq 'object') {
375             $props_to_print = extract_properties_to_print($result_schema->{properties})
376                 if !defined($props_to_print);
377             $props_to_print = [ sort keys %$data ] if !scalar(@$props_to_print);
378             my $kvstore = [];
379             foreach my $key (@$props_to_print) {
380                 push @$kvstore, { key => $key, value => data_to_text($data->{$key}, $result_schema->{properties}->{$key}, $options) };
381             }
382             my $schema = { type => 'array', items => { type => 'object' }};
383             $options->{border} = $format eq 'text';
384             print_api_list($kvstore, $schema, ['key', 'value'], $options);
385         } elsif ($type eq 'array') {
386             return if !scalar(@$data);
387             $options->{border} = $format eq 'text';
388             my $item_type = $result_schema->{items}->{type};
389             if ($item_type eq 'object') {
390                 print_api_list($data, $result_schema, $props_to_print, $options);
391             } else {
392                 my $kvstore = [];
393                 foreach my $value (@$data) {
394                     push @$kvstore, { value => $value };
395                 }
396                 my $schema = { type => 'array', items => { type => 'object', properties => { value => $result_schema->{items} }}};
397                 print_api_list($kvstore, $schema, ['value'], $options);
398             }
399         } else {
400             print encode($encoding, "$data\n");
401         }
402     } else {
403         die "internal error: unknown output format"; # should not happen
404     }
405 }
406
407 1;