Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Command.awaitHook(), Argument.chainArgParserCalls() and Option.chainArgParserCalls() #1902

Closed
wants to merge 22 commits into from
Closed
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4061bff
Add Command.awaitHook() (#1900)
aweebit Jul 6, 2023
4a6e07c
Add support for variadic thenable chains (#1902)
aweebit Jul 6, 2023
4738e3c
Improve error handling and performance (#1902)
aweebit Jul 6, 2023
c0b7e74
Properly handle parseArg rejections (#1902)
aweebit Jul 7, 2023
200b31d
Improve rejection handling (#1902)
aweebit Jul 7, 2023
2114a72
Make parseArg() call chaining optional (#1902)
aweebit Jul 7, 2023
7d1038e
Only await chained arguments and options (#1902)
aweebit Jul 7, 2023
dbca267
Improve option lookup performance (#1902)
aweebit Jul 7, 2023
6f795f7
Fix asynchronous custom processing logic (#1902)
aweebit Jul 7, 2023
e632f84
Add chainArgParserCalls() tests (#1902)
aweebit Jul 7, 2023
b96474c
Add tests for repeated options (#1902)
aweebit Jul 7, 2023
6c44e2b
Improve thenable handling and use in tests (#1902)
aweebit Jul 8, 2023
ca48392
Get rid of linting errors
aweebit Jul 8, 2023
287cdc7
Add global option support for awaitHook() (#1902)
aweebit Jul 23, 2023
1830e14
Get rid of dangling promises (#1902)
aweebit Jul 23, 2023
a451ac5
parseAsync() with awaitHook() by default & postArguments hooks (#1902)
aweebit Jul 23, 2023
3a956b5
Make await hooks last by default (#1902)
aweebit Jul 23, 2023
96b9fc2
Add type tests (#1902)
aweebit Jul 23, 2023
62700a3
Avoid promise chaining when possible (#1902)
aweebit Jul 24, 2023
9a2a2b5
Add thenable function & name method better (#1902)
aweebit Jul 25, 2023
51d460c
Fix bug
aweebit Jul 25, 2023
94cb5ab
Recover old tests that now pass
aweebit Jul 25, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
12 changes: 12 additions & 0 deletions lib/argument.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class Argument {
this.description = description || '';
this.variadic = false;
this.parseArg = undefined;
this.chained = false;
this.defaultValue = undefined;
this.defaultValueDescription = undefined;
this.argChoices = undefined;
Expand Down Expand Up @@ -89,6 +90,17 @@ class Argument {
return this;
}

/**
* When set to true, next call to the function provided via .argParser() will be chained to its return value if it is thenable.
*
* @param {boolean} [chained]
* @return {Argument}
*/
chainArgParserCalls(chained = true) {
this.chained = !!chained;
return this;
}

/**
* Only allow argument value to be one of choices.
*
Expand Down
145 changes: 128 additions & 17 deletions lib/command.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class Command extends EventEmitter {
this._name = name || '';
this._optionValues = {};
this._optionValueSources = {}; // default, env, cli etc
this._overwrittenOptionValues = {};
this._storeOptionsAsProperties = false;
this._actionHandler = null;
this._executableHandler = false;
Expand Down Expand Up @@ -412,6 +413,54 @@ Expecting one of '${allowedValues.join("', '")}'`);
return this;
}

/**
* Add hook to await argument and option values before calling action handlers for this command and its nested subcommands.
* Useful for asynchronous custom processing (parseArg) of arguments and option-arguments.
*/

awaitHook() {
this.hook('preAction', async function awaitHook(thisCommand, actionCommand) {
const toAwait = actionCommand.processedArgs
// .slice(
// 0, actionCommand.args.length
// ) // ignore defaults
.map((value, i) => async() => {
if (typeof value?.then === 'function') {
actionCommand.processedArgs[i] = await value;
}
});

Object.entries(actionCommand.opts())
.forEach(([key, value]) => {
if (typeof value?.then === 'function') {
// const source = actionCommand.getOptionValueSource(key);
// if (source === 'cli' || source === 'env') {
toAwait.push(async() => {
actionCommand.setOptionValueWithSource(
key, await value, actionCommand.getOptionValueSource(key)
);
});
// }
}
});

Object.entries(actionCommand._overwrittenOptionValues)
.forEach(([key, values]) => {
values.forEach(value => {
if (typeof value?.then === 'function') {
toAwait.push(async() => {
await value;
});
}
});
});

return Promise.all(toAwait.map(fn => fn()));
});

return this;
}

/**
* Register callback to use as replacement for calling process.exit.
*
Expand Down Expand Up @@ -526,6 +575,14 @@ Expecting one of '${allowedValues.join("', '")}'`);

// handler for cli and env supplied values
const handleOptionValue = (val, invalidValueMessage, valueSource) => {
const handleError = (err) => {
if (err?.code === 'commander.invalidArgument') {
const message = `${invalidValueMessage} ${err.message}`;
this.error(message, { exitCode: err.exitCode, code: err.code });
}
throw err;
};

// val is null for optional option used without an optional-argument.
// val is undefined for boolean and negated option.
if (val == null && option.presetArg !== undefined) {
Expand All @@ -534,16 +591,17 @@ Expecting one of '${allowedValues.join("', '")}'`);

// custom processing
const oldValue = this.getOptionValue(name);

if (val !== null && option.parseArg) {
try {
val = option.parseArg(val, oldValue);
} catch (err) {
if (err.code === 'commander.invalidArgument') {
const message = `${invalidValueMessage} ${err.message}`;
this.error(message, { exitCode: err.exitCode, code: err.code });
}
throw err;
const optionValues = this._storeOptionsAsProperties
? this
: this._optionValues;
const overwrite = name in optionValues;
if (overwrite) {
this._overwrittenOptionValues[name] ??= [];
this._overwrittenOptionValues[name].push(this.getOptionValue(name));
}
val = this._parseArg(option, val, oldValue, handleError);
} else if (val !== null && option.variadic) {
val = option._concatValue(val, oldValue);
}
Expand Down Expand Up @@ -1126,6 +1184,59 @@ Expecting one of '${allowedValues.join("', '")}'`);
}
}

/**
* @param {Argument|Option} target
* @param {*} value
* @param {Function} handleError
* @api private
*/

_handleChainError(target, value, handleError) {
if (target.chained && ['then', 'catch'].every(
shadowspawn marked this conversation as resolved.
Show resolved Hide resolved
(key) => typeof value?.[key] === 'function'
)) {
value.catch(handleError);
}
}

/**
* Internal implementation shared by ._processArguments() and option and optionEnv event listeners.
*
* @api private
*
* @param {Argument|Option} target
* @param {*} value
* @param {*} previous
* @param {Function} handleError
* @return {*}
* @api private
*/

_parseArg(target, value, previous, handleError) {
let parsedValue;

if (target.chained && typeof previous?.then === 'function') {
// chain thenables
parsedValue = previous.then(
(result) => {
result = target.parseArg(value, result);
// call with result and not parsedValue to not catch handleError exception repeatedly
this._handleChainError(target, result, handleError);
return result;
}
);
} else {
try {
parsedValue = target.parseArg(value, previous);
this._handleChainError(target, parsedValue, handleError);
} catch (err) {
handleError(err);
}
}

return parsedValue;
}

/**
* Process this.args using this._args and save as this.processedArgs!
*
Expand All @@ -1134,18 +1245,18 @@ Expecting one of '${allowedValues.join("', '")}'`);

_processArguments() {
const myParseArg = (argument, value, previous) => {
const handleError = (err) => {
if (err?.code === 'commander.invalidArgument') {
const message = `error: command-argument value '${value}' is invalid for argument '${argument.name()}'. ${err.message}`;
this.error(message, { exitCode: err.exitCode, code: err.code });
}
throw err;
};

// Extra processing for nice error message on parsing failure.
let parsedValue = value;
if (value !== null && argument.parseArg) {
try {
parsedValue = argument.parseArg(value, previous);
} catch (err) {
if (err.code === 'commander.invalidArgument') {
const message = `error: command-argument value '${value}' is invalid for argument '${argument.name()}'. ${err.message}`;
this.error(message, { exitCode: err.exitCode, code: err.code });
}
throw err;
}
parsedValue = this._parseArg(argument, value, previous, handleError);
}
return parsedValue;
};
Expand Down
12 changes: 12 additions & 0 deletions lib/option.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class Option {
this.presetArg = undefined;
this.envVar = undefined;
this.parseArg = undefined;
this.chained = false;
this.hidden = false;
this.argChoices = undefined;
this.conflictsWith = [];
Expand Down Expand Up @@ -135,6 +136,17 @@ class Option {
return this;
}

/**
* When set to true, next call to the function provided via .argParser() will be chained to its return value if it is thenable.
*
* @param {boolean} [chained]
* @return {Argument}
*/
chainArgParserCalls(chained = true) {
this.chained = !!chained;
return this;
}

/**
* Whether the option is mandatory and must have a value after parsing.
*
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions tests/argument.chain.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ describe('Argument methods that should return this for chaining', () => {
expect(result).toBe(argument);
});

test('when call .chainArgParserCalls() then returns this', () => {
const argument = new Argument('<value>');
const result = argument.chainArgParserCalls();
expect(result).toBe(argument);
});

test('when call .choices() then returns this', () => {
const argument = new Argument('<value>');
const result = argument.choices(['a']);
Expand Down
28 changes: 28 additions & 0 deletions tests/argument.custom-processing.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,31 @@ test('when custom processing for argument throws plain error then not CommanderE
expect(caughtErr).toBeInstanceOf(Error);
expect(caughtErr).not.toBeInstanceOf(commander.CommanderError);
});

test('when custom with .chainArgParserCalls() then parsed to chain', async() => {
const args = ['1', '2'];
const resolvedValue = '12';
const coercion = async(value, previousValue) => (
previousValue === undefined ? value : previousValue + value
);
const awaited = coercion(args[0], undefined);
const mockCoercion = jest.fn().mockImplementation(coercion);

const argument = new commander.Argument('<arg...>', 'desc')
.argParser(mockCoercion)
.chainArgParserCalls();

let actionValue;

const program = new commander.Command();
program
.addArgument(argument)
.action((value) => {
actionValue = value;
});

program.parse(args, { from: 'user' });
expect(program.processedArgs[0]).toEqual(awaited);
expect(actionValue).toEqual(awaited);
await expect(actionValue).resolves.toEqual(resolvedValue);
});
18 changes: 12 additions & 6 deletions tests/command.argumentVariations.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ test.each(getSingleArgCases('<explicit-required>'))('when add "<arg>" using %s t
_name: 'explicit-required',
required: true,
variadic: false,
description: ''
description: '',
chained: false
};
expect(argument).toEqual(expectedShape);
});
Expand All @@ -20,7 +21,8 @@ test.each(getSingleArgCases('implicit-required'))('when add "arg" using %s then
_name: 'implicit-required',
required: true,
variadic: false,
description: ''
description: '',
chained: false
};
expect(argument).toEqual(expectedShape);
});
Expand All @@ -31,7 +33,8 @@ test.each(getSingleArgCases('[optional]'))('when add "[arg]" using %s then argum
_name: 'optional',
required: false,
variadic: false,
description: ''
description: '',
chained: false
};
expect(argument).toEqual(expectedShape);
});
Expand All @@ -42,7 +45,8 @@ test.each(getSingleArgCases('<explicit-required...>'))('when add "<arg...>" usin
_name: 'explicit-required',
required: true,
variadic: true,
description: ''
description: '',
chained: false
};
expect(argument).toEqual(expectedShape);
});
Expand All @@ -53,7 +57,8 @@ test.each(getSingleArgCases('implicit-required...'))('when add "arg..." using %s
_name: 'implicit-required',
required: true,
variadic: true,
description: ''
description: '',
chained: false
};
expect(argument).toEqual(expectedShape);
});
Expand All @@ -64,7 +69,8 @@ test.each(getSingleArgCases('[optional...]'))('when add "[arg...]" using %s then
_name: 'optional',
required: false,
variadic: true,
description: ''
description: '',
chained: false
};
expect(argument).toEqual(expectedShape);
});
Expand Down