]> git.proxmox.com Git - pve-common.git/blame - src/PVE/CLIFormatter.pm
extract PVE::Format from PVE::CLIFormatter for reuse
[pve-common.git] / src / PVE / CLIFormatter.pm
CommitLineData
f53ad23a
DM
1package PVE::CLIFormatter;
2
3use strict;
4use warnings;
fb3a1b29 5
4ac85000 6use I18N::Langinfo;
99d02523
FG
7use YAML::XS; # supports Dumping JSON::PP::Boolean
8$YAML::XS::Boolean = "JSON::PP";
4ac85000 9
84142f1d 10use PVE::JSONSchema;
4ac85000 11use PVE::PTY;
57b33852 12use PVE::Format;
fb3a1b29 13
f53ad23a 14use JSON;
eb1c51c2
DM
15use utf8;
16use Encode;
f53ad23a 17
57b33852
SR
18PVE::JSONSchema::register_renderer('timestamp',
19 \&PVE::Format::render_timestamp);
20PVE::JSONSchema::register_renderer('timestamp_gmt',
21 \&PVE::Format::render_timestamp_gmt);
22PVE::JSONSchema::register_renderer('duration',
23 \&PVE::Format::render_duration);
24PVE::JSONSchema::register_renderer('fraction_as_percentage',
25 \&PVE::Format::render_fraction_as_percentage);
26PVE::JSONSchema::register_renderer('bytes',
27 \&PVE::Format::render_bytes);
f495c868 28
ba752c80
DM
29sub render_yaml {
30 my ($value) = @_;
31
99d02523 32 my $data = YAML::XS::Dump($value);
ba752c80
DM
33 $data =~ s/^---[\n\s]//; # remove yaml marker
34
35 return $data;
36}
37
38PVE::JSONSchema::register_renderer('yaml', \&render_yaml);
39
4ac85000
DM
40sub query_terminal_options {
41 my ($options) = @_;
42
43 $options //= {};
44
45 if (-t STDOUT) {
46 ($options->{columns}) = PVE::PTY::tcgetsize(*STDOUT);
47 }
48
49 $options->{encoding} = I18N::Langinfo::langinfo(I18N::Langinfo::CODESET());
50
51 $options->{utf8} = 1 if $options->{encoding} eq 'UTF-8';
52
53 return $options;
54}
55
f53ad23a 56sub data_to_text {
b01a09e7 57 my ($data, $propdef, $options, $terminal_opts) = @_;
f53ad23a 58
3cd6f2f3
DM
59 return '' if !defined($data);
60
b01a09e7
DM
61 $terminal_opts //= {};
62
352b7a14
DM
63 my $human_readable = $options->{'human-readable'} // 1;
64
65 if ($human_readable && defined($propdef)) {
84142f1d
DM
66 if (my $type = $propdef->{type}) {
67 if ($type eq 'boolean') {
68 return $data ? 1 : 0;
69 }
70 }
71 if (!defined($data) && defined($propdef->{default})) {
72 return "($propdef->{default})";
73 }
74 if (defined(my $renderer = $propdef->{renderer})) {
75 my $code = PVE::JSONSchema::get_renderer($renderer);
76 die "internal error: unknown renderer '$renderer'" if !$code;
b01a09e7 77 return $code->($data, $options, $terminal_opts);
84142f1d
DM
78 }
79 }
f53ad23a
DM
80
81 if (my $class = ref($data)) {
b51b930d
DM
82 # JSON::PP::Boolean requires allow_nonref
83 return to_json($data, { allow_nonref => 1, canonical => 1 });
f53ad23a
DM
84 } else {
85 return "$data";
86 }
87}
88
89# prints a formatted table with a title row.
90# $data - the data to print (array of objects)
91# $returnprops -json schema property description
92# $props_to_print - ordered list of properties to print
0c22b00c 93# $options
2e2a4502 94# - sort_key: can be used to sort after a specific column, if it isn't set we sort
890d25d9 95# after the leftmost column. This can be turned off by passing 0 as sort_key
352b7a14
DM
96# - noborder: print without asciiart border
97# - noheader: print without table header
0c22b00c
DM
98# - columns: limit output width (if > 0)
99# - utf8: use utf8 characters for table delimiters
100
f53ad23a 101sub print_text_table {
b01a09e7
DM
102 my ($data, $returnprops, $props_to_print, $options, $terminal_opts) = @_;
103
104 $terminal_opts //= query_terminal_options({});
0c22b00c
DM
105
106 my $sort_key = $options->{sort_key};
352b7a14
DM
107 my $border = !$options->{noborder};
108 my $header = !$options->{noheader};
b01a09e7
DM
109
110 my $columns = $terminal_opts->{columns};
111 my $utf8 = $terminal_opts->{utf8};
112 my $encoding = $terminal_opts->{encoding} // 'UTF-8';
f53ad23a 113
2e2a4502 114 $sort_key //= $props_to_print->[0];
41d554d9 115
2e2a4502 116 if (defined($sort_key) && $sort_key ne 0) {
41d554d9 117 my $type = $returnprops->{$sort_key}->{type} // 'string';
890d25d9 118 my $cmpfn;
41d554d9 119 if ($type eq 'integer' || $type eq 'number') {
890d25d9 120 $cmpfn = sub { $_[0] <=> $_[1] };
41d554d9 121 } else {
890d25d9 122 $cmpfn = sub { $_[0] cmp $_[1] };
41d554d9 123 }
890d25d9
FE
124 @$data = sort {
125 PVE::Tools::safe_compare($a->{$sort_key}, $b->{$sort_key}, $cmpfn)
126 } @$data;
f53ad23a
DM
127 }
128
129 my $colopts = {};
793ad69b 130
eb1c51c2
DM
131 my $borderstring_m = '';
132 my $borderstring_b = '';
133 my $borderstring_t = '';
9e3aaec4 134 my $borderstring_h = '';
f53ad23a
DM
135 my $formatstring = '';
136
137 my $column_count = scalar(@$props_to_print);
138
41d554d9
DM
139 my $tabledata = [];
140
141 foreach my $entry (@$data) {
142
143 my $height = 1;
144 my $rowdata = {};
145
146 for (my $i = 0; $i < $column_count; $i++) {
147 my $prop = $props_to_print->[$i];
148 my $propinfo = $returnprops->{$prop} // {};
149
b01a09e7 150 my $text = data_to_text($entry->{$prop}, $propinfo, $options, $terminal_opts);
41d554d9
DM
151 my $lines = [ split(/\n/, $text) ];
152 my $linecount = scalar(@$lines);
153 $height = $linecount if $linecount > $height;
154
155 my $width = 0;
156 foreach my $line (@$lines) {
157 my $len = length($line);
158 $width = $len if $len > $width;
159 }
160
b9474c96
DM
161 $width = ($width =~ m/^(\d+)$/) ? int($1) : 0; # untaint int
162
41d554d9
DM
163 $rowdata->{$prop} = {
164 lines => $lines,
165 width => $width,
166 };
167 }
168
169 push @$tabledata, {
170 height => $height,
171 rowdata => $rowdata,
172 };
173 }
174
f53ad23a
DM
175 for (my $i = 0; $i < $column_count; $i++) {
176 my $prop = $props_to_print->[$i];
177 my $propinfo = $returnprops->{$prop} // {};
2ec79c0f
DM
178 my $type = $propinfo->{type} // 'string';
179 my $alignstr = ($type eq 'integer' || $type eq 'number') ? '' : '-';
f53ad23a
DM
180
181 my $title = $propinfo->{title} // $prop;
182 my $cutoff = $propinfo->{print_width} // $propinfo->{maxLength};
183
184 # calculate maximal print width and cutoff
185 my $titlelen = length($title);
186
187 my $longest = $titlelen;
41d554d9
DM
188 foreach my $coldata (@$tabledata) {
189 my $rowdata = $coldata->{rowdata}->{$prop};
190 $longest = $rowdata->{width} if $rowdata->{width} > $longest;
f53ad23a
DM
191 }
192 $cutoff = $longest if !defined($cutoff) || $cutoff > $longest;
f53ad23a
DM
193
194 $colopts->{$prop} = {
195 title => $title,
f53ad23a
DM
196 cutoff => $cutoff,
197 };
198
793ad69b 199 if ($border) {
eb1c51c2
DM
200 if ($i == 0 && ($column_count == 1)) {
201 if ($utf8) {
2ec79c0f 202 $formatstring .= "│ %$alignstr${cutoff}s │";
41d554d9 203 $borderstring_t .= "┌─" . ('─' x $cutoff) . "─┐";
9e3aaec4 204 $borderstring_h .= "╞═" . ('═' x $cutoff) . '═╡';
41d554d9
DM
205 $borderstring_m .= "├─" . ('─' x $cutoff) . "─┤";
206 $borderstring_b .= "└─" . ('─' x $cutoff) . "─┘";
eb1c51c2 207 } else {
2ec79c0f 208 $formatstring .= "| %$alignstr${cutoff}s |";
41d554d9 209 $borderstring_m .= "+-" . ('-' x $cutoff) . "-+";
9e3aaec4 210 $borderstring_h .= "+=" . ('=' x $cutoff) . '=';
eb1c51c2
DM
211 }
212 } elsif ($i == 0) {
213 if ($utf8) {
2ec79c0f 214 $formatstring .= "│ %$alignstr${cutoff}s ";
eb1c51c2 215 $borderstring_t .= "┌─" . ('─' x $cutoff) . '─';
9e3aaec4 216 $borderstring_h .= "╞═" . ('═' x $cutoff) . '═';
eb1c51c2
DM
217 $borderstring_m .= "├─" . ('─' x $cutoff) . '─';
218 $borderstring_b .= "└─" . ('─' x $cutoff) . '─';
219 } else {
2ec79c0f 220 $formatstring .= "| %$alignstr${cutoff}s ";
eb1c51c2 221 $borderstring_m .= "+-" . ('-' x $cutoff) . '-';
9e3aaec4 222 $borderstring_h .= "+=" . ('=' x $cutoff) . '=';
eb1c51c2
DM
223 }
224 } elsif ($i == ($column_count - 1)) {
225 if ($utf8) {
2ec79c0f 226 $formatstring .= "│ %$alignstr${cutoff}s │";
41d554d9 227 $borderstring_t .= "┬─" . ('─' x $cutoff) . "─┐";
9e3aaec4 228 $borderstring_h .= "╪═" . ('═' x $cutoff) . '═╡';
41d554d9
DM
229 $borderstring_m .= "┼─" . ('─' x $cutoff) . "─┤";
230 $borderstring_b .= "┴─" . ('─' x $cutoff) . "─┘";
eb1c51c2 231 } else {
2ec79c0f 232 $formatstring .= "| %$alignstr${cutoff}s |";
41d554d9 233 $borderstring_m .= "+-" . ('-' x $cutoff) . "-+";
9e3aaec4 234 $borderstring_h .= "+=" . ('=' x $cutoff) . "=+";
eb1c51c2 235 }
793ad69b 236 } else {
eb1c51c2 237 if ($utf8) {
2ec79c0f 238 $formatstring .= "│ %$alignstr${cutoff}s ";
eb1c51c2 239 $borderstring_t .= "┬─" . ('─' x $cutoff) . '─';
9e3aaec4 240 $borderstring_h .= "╪═" . ('═' x $cutoff) . '═';
eb1c51c2
DM
241 $borderstring_m .= "┼─" . ('─' x $cutoff) . '─';
242 $borderstring_b .= "┴─" . ('─' x $cutoff) . '─';
243 } else {
2ec79c0f 244 $formatstring .= "| %$alignstr${cutoff}s ";
eb1c51c2 245 $borderstring_m .= "+-" . ('-' x $cutoff) . '-';
9e3aaec4 246 $borderstring_h .= "+=" . ('=' x $cutoff) . '=';
eb1c51c2 247 }
793ad69b
DM
248 }
249 } else {
250 # skip alignment and cutoff on last column
2ec79c0f 251 $formatstring .= ($i == ($column_count - 1)) ? "%s" : "%$alignstr${cutoff}s ";
f53ad23a
DM
252 }
253 }
254
eb1c51c2
DM
255 $borderstring_t = $borderstring_m if !length($borderstring_t);
256 $borderstring_b = $borderstring_m if !length($borderstring_b);
257
41d554d9
DM
258 my $writeln = sub {
259 my ($text) = @_;
260
261 if ($columns) {
262 print encode($encoding, substr($text, 0, $columns) . "\n");
263 } else {
264 print encode($encoding, $text) . "\n";
265 }
266 };
267
268 $writeln->($borderstring_t) if $border;
793ad69b 269
9e3aaec4 270 my $borderstring_sep;
352b7a14
DM
271 if ($header) {
272 my $text = sprintf $formatstring, map { $colopts->{$_}->{title} } @$props_to_print;
273 $writeln->($text);
9e3aaec4
WB
274 $borderstring_sep = $borderstring_h;
275 } else {
276 $borderstring_sep = $borderstring_m;
352b7a14
DM
277 }
278
279 for (my $i = 0; $i < scalar(@$tabledata); $i++) {
280 my $coldata = $tabledata->[$i];
281
9e3aaec4
WB
282 if ($border && ($i != 0 || $header)) {
283 $writeln->($borderstring_sep);
284 $borderstring_sep = $borderstring_m;
285 }
41d554d9
DM
286
287 for (my $i = 0; $i < $coldata->{height}; $i++) {
288
352b7a14 289 my $text = sprintf $formatstring, map {
41d554d9
DM
290 substr($coldata->{rowdata}->{$_}->{lines}->[$i] // '', 0, $colopts->{$_}->{cutoff});
291 } @$props_to_print;
292
293 $writeln->($text);
294 }
f53ad23a 295 }
41d554d9
DM
296
297 $writeln->($borderstring_b) if $border;
f53ad23a
DM
298}
299
c5adc5f9
DM
300sub extract_properties_to_print {
301 my ($propdef) = @_;
302
303 my $required = [];
304 my $optional = [];
305
306 foreach my $key (keys %$propdef) {
307 my $prop = $propdef->{$key};
308 if ($prop->{optional}) {
309 push @$optional, $key;
310 } else {
311 push @$required, $key;
312 }
313 }
314
315 return [ sort(@$required), sort(@$optional) ];
316}
317
f53ad23a
DM
318# prints the result of an API GET call returning an array as a table.
319# takes formatting information from the results property of the call
320# if $props_to_print is provided, prints only those columns. otherwise
321# takes all fields of the results property, with a fallback
fb3a1b29 322# to all fields occurring in items of $data.
f53ad23a 323sub print_api_list {
b01a09e7 324 my ($data, $result_schema, $props_to_print, $options, $terminal_opts) = @_;
f53ad23a
DM
325
326 die "can only print object lists\n"
327 if !($result_schema->{type} eq 'array' && $result_schema->{items}->{type} eq 'object');
328
329 my $returnprops = $result_schema->{items}->{properties};
330
c5adc5f9
DM
331 $props_to_print = extract_properties_to_print($returnprops)
332 if !defined($props_to_print);
333
334 if (!scalar(@$props_to_print)) {
335 my $all_props = {};
336 foreach my $obj (@$data) {
337 foreach my $key (keys %$obj) {
338 $all_props->{$key} = 1;
f53ad23a 339 }
f53ad23a 340 }
c5adc5f9 341 $props_to_print = [ sort keys %{$all_props} ];
f53ad23a
DM
342 }
343
c5adc5f9
DM
344 die "unable to detect list properties\n" if !scalar(@$props_to_print);
345
b01a09e7 346 print_text_table($data, $returnprops, $props_to_print, $options, $terminal_opts);
f53ad23a
DM
347}
348
b51b930d
DM
349my $guess_type = sub {
350 my $data = shift;
351
352 return 'null' if !defined($data);
353
354 my $class = ref($data);
355 return 'string' if !$class;
356
357 if ($class eq 'HASH') {
358 return 'object';
359 } elsif ($class eq 'ARRAY') {
360 return 'array';
361 } else {
362 return 'string'; # better than nothing
363 }
364};
365
f53ad23a 366sub print_api_result {
b01a09e7 367 my ($data, $result_schema, $props_to_print, $options, $terminal_opts) = @_;
0c22b00c 368
352b7a14
DM
369 return if $options->{quiet};
370
b01a09e7 371 $terminal_opts //= query_terminal_options({});
f53ad23a 372
4cbcd138 373 my $format = $options->{'output-format'} // 'text';
f0bcf4d4 374
fbd10e04 375 if ($result_schema && defined($result_schema->{type})) {
b51b930d 376 return if $result_schema->{type} eq 'null';
c0b8717c 377 return if $result_schema->{optional} && !defined($data);
b51b930d
DM
378 } else {
379 my $type = $guess_type->($data);
380 $result_schema = { type => $type };
381 $result_schema->{items} = { type => $guess_type->($data->[0]) } if $type eq 'array';
382 }
f53ad23a 383
ac6c61bf 384 if ($format eq 'yaml') {
99d02523 385 print encode('UTF-8', YAML::XS::Dump($data));
ac6c61bf 386 } elsif ($format eq 'json') {
cd6591d3
DM
387 # Note: we always use utf8 encoding for json format
388 print to_json($data, {utf8 => 1, allow_nonref => 1, canonical => 1 }) . "\n";
389 } elsif ($format eq 'json-pretty') {
43b104c4 390 # Note: we always use utf8 encoding for json format
f53ad23a 391 print to_json($data, {utf8 => 1, allow_nonref => 1, canonical => 1, pretty => 1 });
cd6591d3 392 } elsif ($format eq 'text') {
43b104c4 393 my $encoding = $options->{encoding} // 'UTF-8';
f53ad23a
DM
394 my $type = $result_schema->{type};
395 if ($type eq 'object') {
c5adc5f9
DM
396 $props_to_print = extract_properties_to_print($result_schema->{properties})
397 if !defined($props_to_print);
398 $props_to_print = [ sort keys %$data ] if !scalar(@$props_to_print);
2a174d2a 399 my $kvstore = [];
f53ad23a 400 foreach my $key (@$props_to_print) {
a05db8b5 401 next if !defined($data->{$key});
b01a09e7 402 push @$kvstore, { key => $key, value => data_to_text($data->{$key}, $result_schema->{properties}->{$key}, $options, $terminal_opts) };
f53ad23a 403 }
2a174d2a 404 my $schema = { type => 'array', items => { type => 'object' }};
b01a09e7 405 print_api_list($kvstore, $schema, ['key', 'value'], $options, $terminal_opts);
f53ad23a
DM
406 } elsif ($type eq 'array') {
407 return if !scalar(@$data);
408 my $item_type = $result_schema->{items}->{type};
409 if ($item_type eq 'object') {
b01a09e7 410 print_api_list($data, $result_schema, $props_to_print, $options, $terminal_opts);
f53ad23a 411 } else {
c850b998
DM
412 my $kvstore = [];
413 foreach my $value (@$data) {
414 push @$kvstore, { value => $value };
f53ad23a 415 }
c850b998 416 my $schema = { type => 'array', items => { type => 'object', properties => { value => $result_schema->{items} }}};
b01a09e7 417 print_api_list($kvstore, $schema, ['value'], { %$options, noheader => 1 }, $terminal_opts);
f53ad23a
DM
418 }
419 } else {
43b104c4 420 print encode($encoding, "$data\n");
f53ad23a
DM
421 }
422 } else {
423 die "internal error: unknown output format"; # should not happen
424 }
425}
426
6bc09493
DM
427sub print_api_result_plain {
428 my ($data, $result_schema, $props_to_print, $options) = @_;
429
430 # avoid borders and header, ignore terminal width
431 $options = $options ? { %$options } : {}; # copy
432
433 $options->{noheader} //= 1;
434 $options->{noborder} //= 1;
435
436 print_api_result($data, $result_schema, $props_to_print, $options, {});
437}
438
f53ad23a 4391;