v13 preparations: Standardize command data output

Change-type: patch
Signed-off-by: Scott Lowe <scott@balena.io>
This commit is contained in:
Scott Lowe 2020-12-29 17:41:31 +01:00
parent c125e0b38d
commit f3fb9b6bdf
8 changed files with 540 additions and 39 deletions

View File

@ -21,6 +21,7 @@ import {
NotAvailableInOfflineModeError,
} from './errors';
import { stripIndent } from './utils/lazy';
import * as output from './framework/output';
export default abstract class BalenaCommand extends Command {
/**
@ -167,4 +168,7 @@ export default abstract class BalenaCommand extends Command {
await this.getStdin();
}
}
protected outputMessage = output.outputMessage;
protected outputData = output.outputData;
}

View File

@ -28,8 +28,10 @@ import {
appToFleetCmdMsg,
warnify,
} from '../../utils/messages';
import { isV13 } from '../../utils/version';
import type { DataOutputOptions } from '../../framework';
interface FlagsDef {
interface FlagsDef extends DataOutputOptions {
help: void;
}
@ -56,13 +58,14 @@ export class FleetCmd extends Command {
public static flags: flags.Input<FlagsDef> = {
help: cf.help,
...(isV13() ? cf.dataOutputFlags : {}),
};
public static authenticated = true;
public static primary = true;
public async run(parserOutput?: ParserOutput<FlagsDef, ArgsDef>) {
const { args: params } =
const { args: params, flags: options } =
parserOutput || this.parse<FlagsDef, ArgsDef>(FleetCmd);
const { getApplication } = await import('../../utils/sdk');
@ -82,16 +85,24 @@ export class FleetCmd extends Command {
application.device_type = application.is_for__device_type[0].slug;
application.commit = application.should_be_running__release[0]?.commit;
// Emulate table.vertical title output, but avoid uppercasing and inserting spaces
console.log(`== ${application.app_name}`);
console.log(
getVisuals().table.vertical(application, [
'id',
'device_type',
'slug',
'commit',
]),
);
if (isV13()) {
await this.outputData(
application,
['app_name', 'id', 'device_type', 'slug', 'commit'],
options,
);
} else {
// Emulate table.vertical title output, but avoid uppercasing and inserting spaces
console.log(`== ${application.app_name}`);
console.log(
getVisuals().table.vertical(application, [
'id',
'device_type',
'slug',
'commit',
]),
);
}
}
}

View File

@ -17,18 +17,20 @@
import { flags } from '@oclif/command';
import type { Output as ParserOutput } from '@oclif/parser';
import Command from '../command';
import * as cf from '../utils/common-flags';
import { getBalenaSdk, getVisuals, stripIndent } from '../utils/lazy';
import { appToFleetCmdMsg, warnify } from '../utils/messages';
import { isV13 } from '../utils/version';
import type { DataSetOutputOptions } from '../framework';
interface ExtendedApplication extends ApplicationWithDeviceType {
device_count?: number;
online_devices?: number;
device_count: number;
online_devices: number;
device_type?: string;
}
interface FlagsDef {
interface FlagsDef extends DataSetOutputOptions {
help: void;
verbose?: boolean;
}
@ -48,12 +50,17 @@ export class FleetsCmd extends Command {
public static usage = 'fleets';
public static flags: flags.Input<FlagsDef> = {
...(isV13()
? {}
: {
verbose: flags.boolean({
default: false,
char: 'v',
description: 'No-op since release v12.0.0',
}),
}),
...(isV13() ? cf.dataSetOutputFlags : {}),
help: cf.help,
verbose: flags.boolean({
default: false,
char: 'v',
description: 'No-op since release v12.0.0',
}),
};
public static authenticated = true;
@ -75,28 +82,39 @@ export class FleetsCmd extends Command {
},
})) as ExtendedApplication[];
const _ = await import('lodash');
// Add extended properties
applications.forEach((application) => {
application.device_count = application.owns__device?.length ?? 0;
application.online_devices = _.sumBy(application.owns__device, (d) =>
d.is_online === true ? 1 : 0,
);
// @ts-expect-error
application.online_devices =
application.owns__device?.filter((d) => d.is_online).length || 0;
application.device_type = application.is_for__device_type[0].slug;
});
// Display
console.log(
getVisuals().table.horizontal(applications, [
'id',
this.useAppWord ? 'app_name' : 'app_name => NAME',
'slug',
'device_type',
'online_devices',
'device_count',
]),
);
if (isV13()) {
await this.outputData(
applications,
[
'id',
'app_name',
'slug',
'device_type',
'device_count',
'online_devices',
],
_parserOutput.flags,
);
} else {
console.log(
getVisuals().table.horizontal(applications, [
'id',
this.useAppWord ? 'app_name' : 'app_name => NAME',
'slug',
'device_type',
'online_devices',
'device_count',
]),
);
}
}
}

19
lib/framework/index.ts Normal file
View File

@ -0,0 +1,19 @@
/*
Copyright 2020 Balena
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type { DataOutputOptions, DataSetOutputOptions } from './output';
export { DataOutputOptions, DataSetOutputOptions };

158
lib/framework/output.ts Normal file
View File

@ -0,0 +1,158 @@
/*
Copyright 2020 Balena
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { getCliUx, getChalk } from '../utils/lazy';
export interface DataOutputOptions {
fields?: string;
json?: boolean;
}
export interface DataSetOutputOptions extends DataOutputOptions {
filter?: string;
'no-header'?: boolean;
'no-truncate'?: boolean;
sort?: string;
}
/**
* Output message to STDERR
*/
export function outputMessage(msg: string) {
// Messages go to STDERR
console.error(msg);
}
/**
* Output result data to STDOUT
* Supports:
* - arrays of items (displayed in a tabular way),
* - single items (displayed in a field per row format).
*
* @param data Array of data objects to output
* @param fields Array of fieldnames, specifying the fields and display order
* @param options Output options
*/
export async function outputData(
data: any[] | {},
fields: string[],
options: DataOutputOptions | DataSetOutputOptions,
) {
if (Array.isArray(data)) {
await outputDataSet(data, fields, options as DataSetOutputOptions);
} else {
await outputDataItem(data, fields, options as DataOutputOptions);
}
}
/**
* Wraps the cli.ux table implementation, to output tabular data
*
* @param data Array of data objects to output
* @param fields Array of fieldnames, specifying the fields and display order
* @param options Output options
*/
async function outputDataSet(
data: any[],
fields: string[],
options: DataSetOutputOptions,
) {
// Oclif expects fields to be specified in the format used in table headers (though lowercase)
// By replacing underscores with spaces here, we can support both header format and actual field name
// (e.g. as seen in json output).
options.fields = options.fields?.replace(/_/g, ' ');
options.filter = options.filter?.replace(/_/g, ' ');
options.sort = options.sort?.replace(/_/g, ' ');
getCliUx().table(
data,
// Convert fields array to column object keys
// that cli.ux expects. We can later add support
// for both formats if beneficial
fields.reduce((ac, a) => ({ ...ac, [a]: {} }), {}),
{
...options,
...(options.json
? {
output: 'json',
}
: {}),
columns: options.fields,
printLine,
},
);
}
/**
* Outputs a single data object (like `resin-cli-visuals table.vertical`),
* but supporting a subset of options from `cli-ux table` (--json and --fields)
*
* @param data Array of data objects to output
* @param fields Array of fieldnames, specifying the fields and display order
* @param options Output options
*/
async function outputDataItem(
data: any,
fields: string[],
options: DataOutputOptions,
) {
const outData: typeof data = {};
// Convert comma separated list of fields in `options.fields` to array of correct format.
// Note, user may have specified the true field name (e.g. `some_field`),
// or the format displayed in headers (e.g. `Some field`, case insensitive).
const userSelectedFields = options.fields?.split(',').map((f) => {
return f.toLowerCase().trim().replace(/ /g, '_');
});
// Order and filter the fields based on `fields` parameter and `options.fields`
(userSelectedFields || fields).forEach((fieldName) => {
if (fields.includes(fieldName)) {
outData[fieldName] = data[fieldName];
}
});
if (options.json) {
printLine(JSON.stringify(outData, undefined, 2));
} else {
const chalk = getChalk();
const { capitalize } = await import('lodash');
// Find longest key, so we can align results
const longestKeyLength = getLongestObjectKeyLength(outData);
// Output one field per line
for (const [k, v] of Object.entries(outData)) {
const shim = ' '.repeat(longestKeyLength - k.length);
const kDisplay = capitalize(k.replace(/_/g, ' '));
printLine(`${chalk.bold(kDisplay) + shim} : ${v}`);
}
}
}
function getLongestObjectKeyLength(o: any): number {
return Object.keys(o).length >= 1
? Object.keys(o).reduce((a, b) => {
return a.length > b.length ? a : b;
}).length
: 0;
}
function printLine(s: any) {
// Duplicating oclif cli-ux's default implementation here,
// but using this one explicitly for ease of testing
process.stdout.write(s + '\n');
}

View File

@ -16,11 +16,12 @@
*/
import { flags } from '@oclif/command';
import type { IBooleanFlag } from '@oclif/parser/lib/flags';
import { stripIndent } from './lazy';
import { lowercaseIfSlug } from './normalization';
import { isV13 } from './version';
import type { IBooleanFlag } from '@oclif/parser/lib/flags';
import type { DataOutputOptions, DataSetOutputOptions } from '../framework';
export const v13: IBooleanFlag<boolean> = flags.boolean({
description: stripIndent`\
@ -125,3 +126,37 @@ export const json: IBooleanFlag<boolean> = flags.boolean({
description: 'produce JSON output instead of tabular output',
default: false,
});
export const dataOutputFlags: flags.Input<DataOutputOptions> = {
fields: flags.string({
description: 'only show provided fields (comma-separated)',
}),
json: flags.boolean({
char: 'j',
exclusive: ['no-truncate'],
description: 'output in json format',
default: false,
}),
};
export const dataSetOutputFlags: flags.Input<DataOutputOptions> &
flags.Input<DataSetOutputOptions> = {
...dataOutputFlags,
filter: flags.string({
description:
'filter results by substring matching of a given field, eg: --filter field=foo',
}),
'no-header': flags.boolean({
exclusive: ['json'],
description: 'hide table header from output',
default: false,
}),
'no-truncate': flags.boolean({
exclusive: ['json'],
description: 'do not truncate output to fit screen',
default: false,
}),
sort: flags.string({
description: `field to sort by (prepend '-' for descending order)`,
}),
};

View File

@ -220,6 +220,7 @@
"chalk": "^3.0.0",
"chokidar": "^3.4.3",
"cli-truncate": "^2.1.0",
"cli-ux": "^5.5.1",
"color-hash": "^1.0.3",
"columnify": "^1.5.2",
"common-tags": "^1.7.2",

View File

@ -0,0 +1,255 @@
/**
* @license
* Copyright 2020-2021 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* tslint:disable: prefer-const no-empty */
import rewire = require('rewire');
import sinon = require('sinon');
import { expect } from 'chai';
const dataItem = {
name: 'item1',
id: 1,
thing_color: 'blue',
thing_shape: 'square',
};
const dataSet = [
{
name: 'item1',
id: 1,
thing_color: 'red',
thing_shape: 'square',
},
{
name: 'item2',
id: 2,
thing_color: 'blue',
thing_shape: 'round',
},
];
describe('outputData', function () {
let outputData: any;
let outputDataSetSpy: any;
let outputDataItemSpy: any;
this.beforeEach(() => {
const output = rewire('../../build/framework/output');
outputDataSetSpy = sinon.spy();
outputDataItemSpy = sinon.spy();
output.__set__('outputDataSet', outputDataSetSpy);
output.__set__('outputDataItem', outputDataItemSpy);
outputData = output.__get__('outputData');
});
it('should call outputDataSet function when data param is an array', async () => {
await outputData(dataSet);
expect(outputDataSetSpy.called).to.be.true;
expect(outputDataItemSpy.called).to.be.false;
});
it('should call outputDataItem function when data param is an object', async () => {
await outputData(dataItem);
expect(outputDataSetSpy.called).to.be.false;
expect(outputDataItemSpy.called).to.be.true;
});
});
describe('outputDataSet', function () {
let outputDataSet: any;
let printLineSpy: any;
this.beforeEach(() => {
const output = rewire('../../build/framework/output');
printLineSpy = sinon.spy();
output.__set__('printLine', printLineSpy);
outputDataSet = output.__get__('outputDataSet');
});
it('should only output fields specified in `fields` param, in that order', async () => {
const fields = ['id', 'name', 'thing_color'];
const options = {};
await outputDataSet(dataSet, fields, options);
// check correct number of rows (2 data, 2 header)
expect(printLineSpy.callCount).to.equal(4);
const headerLine = printLineSpy.firstCall.firstArg.toLowerCase();
// check we have fields we specified
fields.forEach((f) => {
expect(headerLine).to.include(f.replace(/_/g, ' '));
});
// check we don't have fields we didn't specify
expect(headerLine).to.not.include('thing_shape');
// check order
// split header using the `name` column as delimiter
const splitHeader = headerLine.split('name');
expect(splitHeader[0]).to.include('id');
expect(splitHeader[1]).to.include('thing');
});
/*
it('should output fields in the order specified in `fields` param', async () => {
const fields = ['thing_color', 'id', 'name'];
const options = {};
await outputDataSet(dataSet, fields, options);
const headerLine = printLineSpy.firstCall.firstArg.toLowerCase();
// split header using the `it` column as delimiter
const splitHeader = headerLine.split('id');
expect(splitHeader[0]).to.include('thing');
expect(splitHeader[1]).to.include('name');
});
*/
it('should only output fields specified in `options.fields` if present', async () => {
const fields = ['name', 'id', 'thing_color', 'thing_shape'];
const options = {
// test all formats
fields: 'Name,thing_color,Thing shape',
};
await outputDataSet(dataSet, fields, options);
const headerLine = printLineSpy.firstCall.firstArg.toLowerCase();
// check we have fields we specified
expect(headerLine).to.include('name');
expect(headerLine).to.include('thing color');
expect(headerLine).to.include('thing shape');
// check we don't have fields we didn't specify
expect(headerLine).to.not.include('id');
});
it('should output records in order specified by `options.sort` if present', async () => {
const fields = ['name', 'id', 'thing_color', 'thing_shape'];
const options = {
sort: 'thing shape',
'no-header': true,
};
await outputDataSet(dataSet, fields, options);
// blue should come before red
expect(printLineSpy.getCall(0).firstArg).to.include('blue');
expect(printLineSpy.getCall(1).firstArg).to.include('red');
});
it('should only output records that match filter specified by `options.filter` if present', async () => {
const fields = ['name', 'id', 'thing_color', 'thing_shape'];
const options = {
filter: 'thing color=red',
'no-header': true,
};
await outputDataSet(dataSet, fields, options);
// check correct number of rows (1 matched data, no-header)
expect(printLineSpy.callCount).to.equal(1);
expect(printLineSpy.getCall(0).firstArg).to.include('red');
});
it('should output data in json format, if `options.json` true', async () => {
const fields = ['name', 'thing_color', 'thing_shape'];
const options = {
json: true,
};
// TODO: I've run into an oclif cli-ux bug, where numbers are output as strings in json
// (this can be seen by including 'id' in the fields list above).
// Issue opened: https://github.com/oclif/cli-ux/issues/309
// For now removing id for this test.
const clonedDataSet = JSON.parse(JSON.stringify(dataSet));
clonedDataSet.forEach((d: any) => {
delete d.id;
});
const expectedJson = JSON.stringify(clonedDataSet, undefined, 2);
await outputDataSet(dataSet, fields, options);
expect(printLineSpy.callCount).to.equal(1);
expect(printLineSpy.getCall(0).firstArg).to.equal(expectedJson);
});
});
describe('outputDataItem', function () {
let outputDataItem: any;
let printLineSpy: any;
this.beforeEach(() => {
const output = rewire('../../build/framework/output');
printLineSpy = sinon.spy();
output.__set__('printLine', printLineSpy);
outputDataItem = output.__get__('outputDataItem');
});
it('should only output fields specified in `fields` param, in that order', async () => {
const fields = ['id', 'name', 'thing_color'];
const options = {};
await outputDataItem(dataItem, fields, options);
// check correct number of rows (3 fields)
expect(printLineSpy.callCount).to.equal(3);
// check we have fields we specified
fields.forEach((f, index) => {
const kvPair = printLineSpy.getCall(index).firstArg.split(':');
expect(kvPair[0].toLowerCase()).to.include(f.replace(/_/g, ' '));
expect(kvPair[1]).to.include((dataItem as any)[f]);
});
});
it('should only output fields specified in `options.fields` if present', async () => {
const fields = ['name', 'id', 'thing_color', 'thing_shape'];
const options = {
// test all formats
fields: 'Name,thing_color,Thing shape',
};
const expectedFields = ['name', 'thing_color', 'thing_shape'];
await outputDataItem(dataItem, fields, options);
// check correct number of rows (3 fields)
expect(printLineSpy.callCount).to.equal(3);
// check we have fields we specified
expectedFields.forEach((f, index) => {
const kvPair = printLineSpy.getCall(index).firstArg.split(':');
expect(kvPair[0].toLowerCase()).to.include(f.replace(/_/g, ' '));
expect(kvPair[1]).to.include((dataItem as any)[f]);
});
});
it('should output data in json format, if `options.json` true', async () => {
const fields = ['name', 'id', 'thing_color', 'thing_shape'];
const options = {
json: true,
};
const expectedJson = JSON.stringify(dataItem, undefined, 2);
await outputDataItem(dataItem, fields, options);
expect(printLineSpy.callCount).to.equal(1);
expect(printLineSpy.getCall(0).firstArg).to.equal(expectedJson);
});
});