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

GROOVY-6675 : Base script support for JCommander annotation-based parameters #371

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
1 change: 1 addition & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
def subprojects = ['groovy-ant',
'groovy-bsf',
'groovy-cli',
'groovy-console',
'groovy-docgenerator',
'groovy-groovydoc',
Original file line number Diff line number Diff line change
@@ -129,22 +129,26 @@ private void changeBaseScriptType(final AnnotatedNode parent, final ClassNode cN
MethodNode runScriptMethod = ClassHelper.findSAM(baseScriptType);

// If they want to use a name other than than "run", then make the change.
if (isSuitableAbstractMethod(runScriptMethod)) {
if (isCustomScriptBodyMethod(runScriptMethod)) {
MethodNode defaultMethod = cNode.getDeclaredMethod("run", Parameter.EMPTY_ARRAY);
cNode.removeMethod(defaultMethod);
MethodNode methodNode = new MethodNode(runScriptMethod.getName(), runScriptMethod.getModifiers() & ~ACC_ABSTRACT
, runScriptMethod.getReturnType(), runScriptMethod.getParameters(), runScriptMethod.getExceptions()
, defaultMethod.getCode());
// The AST node metadata has the flag that indicates that this method is a script body.
// It may also be carrying data for other AST transforms.
methodNode.copyNodeMetaData(defaultMethod);
cNode.addMethod(methodNode);
// GROOVY-6706: Sometimes an NPE is thrown here.
// The reason is that our transform is getting called more than once sometimes.
if (defaultMethod != null) {
cNode.removeMethod(defaultMethod);
MethodNode methodNode = new MethodNode(runScriptMethod.getName(), runScriptMethod.getModifiers() & ~ACC_ABSTRACT
, runScriptMethod.getReturnType(), runScriptMethod.getParameters(), runScriptMethod.getExceptions()
, defaultMethod.getCode());
// The AST node metadata has the flag that indicates that this method is a script body.
// It may also be carrying data for other AST transforms.
methodNode.copyNodeMetaData(defaultMethod);
cNode.addMethod(methodNode);
}
}
}

private boolean isSuitableAbstractMethod(MethodNode node) {
private boolean isCustomScriptBodyMethod(MethodNode node) {
return node != null
&& !(node.getDeclaringClass().equals(ClassHelper.SCRIPT_TYPE)
&& !(node.getDeclaringClass().equals(ClassHelper.SCRIPT_TYPE)
&& "run".equals(node.getName())
&& node.getParameters().length == 0);
}
Original file line number Diff line number Diff line change
@@ -106,6 +106,8 @@ class BaseScriptTransformTest extends CompilableTestSupport {
"""
}

abstract class MyCustomScript extends Script {}

void testBaseScriptFromCompiler(){
CompilerConfiguration config = new CompilerConfiguration()
config.scriptBaseClass = MyCustomScript.name
@@ -259,6 +261,37 @@ class BaseScriptTransformTest extends CompilableTestSupport {
assert result
}

/**
* Test GROOVY-6706. Base script in import (or package) with a SAM.
*/
void testGROOVY_6706() {
assertScript '''
@BaseScript(CustomBase)
import groovy.transform.BaseScript

assert did_before
assert !did_after

42

abstract class CustomBase extends Script {
boolean did_before = false
boolean did_after = false

def run() {
before()
def r = internalRun()
after()
assert r == 42
}

abstract internalRun()

def before() { did_before = true }
def after() { did_after = true }
}'''
}

void testBaseScriptOnPackage() {
def result = new GroovyShell().evaluate('''
@BaseScript(DeclaredBaseScript)
@@ -315,7 +348,4 @@ class BaseScriptTransformTest extends CompilableTestSupport {
println 'ok'
'''
}

}

abstract class MyCustomScript extends Script {}
24 changes: 24 additions & 0 deletions subprojects/groovy-cli/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright 2003-2014 the original author or authors.
*
* 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.
*/

dependencies {
compile rootProject
groovy rootProject
testCompile rootProject.sourceSets.test.runtimeClasspath
testCompile project(':groovy-cli')

compile('com.beust:jcommander:1.35')
}
263 changes: 263 additions & 0 deletions subprojects/groovy-cli/src/main/java/groovy/cli/JCommanderScript.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
/*
* Copyright 2014-2014 the original author or authors.
*
* 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.
*/

package groovy.cli;

import com.beust.jcommander.JCommander;
import com.beust.jcommander.ParameterDescription;
import com.beust.jcommander.ParameterException;

import groovy.lang.MissingPropertyException;
import groovy.lang.Script;

import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.util.List;

import static org.codehaus.groovy.runtime.DefaultGroovyMethods.join;

/**
* Base script that provides JCommander declarative (annotation-based) argument processing for scripts.
*
* @author Jim White
*/

abstract public class JCommanderScript extends Script {
/**
* Name of the property that holds the JCommander for this script (i.e. 'scriptJCommander').
*/
public final static String SCRIPT_JCOMMANDER = "scriptJCommander";

/**
* The script body
* @return The result of the script evaluation.
*/
protected abstract Object runScriptBody();

@Override
public Object run() {
String[] args = getScriptArguments();
JCommander jc = getScriptJCommanderWithInit();
try {
parseScriptArguments(jc, args);
for (ParameterDescription pd : jc.getParameters()) {
if (pd.isHelp() && pd.isAssigned()) return exitCode(printHelpMessage(jc, args));
}
runScriptCommand(jc);
return exitCode(runScriptBody());
} catch (ParameterException pe) {
return exitCode(handleParameterException(jc, args, pe));
}
}

/**
* If the given code is numeric and non-zero, then return that from this process using System.exit.
* Non-numeric values (including null) are taken to be zero and returned as-is.
*
* @param code
* @return the given code
*/
public Object exitCode(Object code) {
if (code instanceof Number) {
int codeValue = ((Number) code).intValue();
if (codeValue != 0) System.exit(codeValue);
}
return code;
}

/**
* Return the script arguments as an array of strings.
* The default implementation is to get the "args" property.
*
* @return the script arguments as an array of strings.
*/
public String[] getScriptArguments() {
return (String[]) getProperty("args");
}

/**
* Return the JCommander for this script.
* If there isn't one already, then create it using createScriptJCommander.
*
* @return the JCommander for this script.
*/
protected JCommander getScriptJCommanderWithInit() {
try {
JCommander jc = (JCommander) getProperty(SCRIPT_JCOMMANDER);
if (jc == null) {
jc = createScriptJCommander();
// The script has a real property (a field or getter) but if we let Script.setProperty handle
// this then it just gets stuffed into a binding that shadows the property.
// This is somewhat related to other bugged behavior in Script wrt properties and bindings.
// See http://jira.codehaus.org/browse/GROOVY-6582 for example.
// The correct behavior for Script.setProperty would be to check whether
// the property has a setter before creating a new script binding.
this.getMetaClass().setProperty(this, SCRIPT_JCOMMANDER, jc);
}
return jc;
} catch (MissingPropertyException mpe) {
JCommander jc = createScriptJCommander();
// Since no property or binding already exists, we can use plain old setProperty here.
setProperty(SCRIPT_JCOMMANDER, jc);
return jc;
}
}

/**
* Create a new (hopefully just once for this script!) JCommander instance.
* The default name for the command name in usage is the script's class simple name.
* This is the time to load it up with command objects, which is done by initializeJCommanderCommands.
*
* @return A JCommander instance with the commands (if any) initialized.
*/
public JCommander createScriptJCommander() {
JCommander jc = new JCommander(this);
jc.setProgramName(this.getClass().getSimpleName());

initializeJCommanderCommands(jc);

return jc;
}

/**
* Add command objects to the given JCommander.
* The default behavior is to look for Subcommand annotations.
*
* @param jc The JCommander instance to add the commands (if any) to.
*/
public void initializeJCommanderCommands(JCommander jc) {
Class cls = this.getClass();
while (cls != null) {
Field[] fields = cls.getDeclaredFields();
for (Field field : fields) {
Annotation annotation = field.getAnnotation(Subcommand.class);
if (annotation != null) {
try {
field.setAccessible(true);
jc.addCommand(field.get(this));
} catch (IllegalAccessException e) {
printErrorMessage("Trying to add JCommander @Subcommand but got error '" + e.getMessage()
+ "' when getting value of field " + field.getName());
}
}
}

cls = cls.getSuperclass();
}
}

/**
* Do JCommander.parse using the given arguments.
* If you want to do any special checking before the Runnable commands get run,
* this is the place to do it by overriding.
*
* @param jc The JCommander instance for this script instance.
* @param args The argument array.
*/
public void parseScriptArguments(JCommander jc, String[] args) {
jc.parse(args);
}

/**
* If there are any objects implementing Runnable that are part of this command script,
* then run them. If there is a parsed command, then run those objects after the main command objects.
* Note that this will not run the main script though, we leave that for run to do (which will happen
* normally since groovy.lang.Script doesn't implement java.lang.Runnable).
*
* @param jc
*/
public void runScriptCommand(JCommander jc) {
List<Object> objects = jc.getObjects();

String parsedCommand = jc.getParsedCommand();
if (parsedCommand != null) {
JCommander commandCommander = jc.getCommands().get(parsedCommand);
objects.addAll(commandCommander.getObjects());
}

for (Object commandObject : objects) {
if (commandObject instanceof Runnable) {
Runnable runnableCommand = (Runnable) commandObject;
if ((Object) runnableCommand != (Object) this) {
runnableCommand.run();
}
}
}
}

/**
* Error messages that arise from command line processing call this.
* The default is to use the Script's println method (which will go to the
* 'out' binding, if any, and System.out otherwise).
* If you want to use System.err, a logger, or something, this is the thing to override.
*
* @param message
*/
public void printErrorMessage(String message) {
println(message);
}

/**
* If a ParameterException occurs during parseScriptArguments, runScriptCommand, or runScriptBody
* then this gets called to report the problem.
* The default behavior is to show the exception message using printErrorMessage, then call printHelpMessage.
* The return value becomes the return value for the Script.run which will be the exit code
* if we've been called from the command line.
*
* @param jc The JCommander instance
* @param args The argument array
* @param pe The ParameterException that occurred
* @return The value that Script.run should return (2 by default).
*/
public Object handleParameterException(JCommander jc, String[] args, ParameterException pe) {
StringBuilder sb = new StringBuilder();

sb.append("args: [");
sb.append(join(args, ", "));
sb.append("]");
sb.append("\n");

sb.append(pe.getMessage());

printErrorMessage(sb.toString());

printHelpMessage(jc, args);

return 3;
}

/**
* If a @Parameter whose help attribute is annotated as true appears in the arguments.
* then the script body is not run and this printHelpMessage method is called instead.
* The default behavior is to show the arguments and the JCommander.usage using printErrorMessage.
* The return value becomes the return value for the Script.run which will be the exit code
* if we've been called from the command line.
*
* @param jc The JCommander instance
* @param args The argument array
* @return The value that Script.run should return (1 by default).
*/
public Object printHelpMessage(JCommander jc, String[] args) {
StringBuilder sb = new StringBuilder();

jc.usage(sb);

printErrorMessage(sb.toString());

return 2;
}

}
37 changes: 37 additions & 0 deletions subprojects/groovy-cli/src/main/java/groovy/cli/Subcommand.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright 2003-2014 the original author or authors.
*
* 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.
*/

package groovy.cli;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Mark a field in a script as a command for automagic initialization.
* Note that this need not be a JCommander-specific value. Instead the
* particular implementation expected depends on what base script you use.
*
* @author Jim White
*/

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Subcommand {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Copyright 2003-2014 the original author or authors.
*
* 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.
*/

package groovy.cli.test

import groovy.transform.SourceURI

/**
* @author Jim White
*/

public class JCommanderScriptTest extends GroovyTestCase {
@SourceURI URI sourceURI

void testParameterizedScript() {
GroovyShell shell = new GroovyShell()
shell.context.setVariable('args',
["--codepath", "/usr/x.jar", "placeholder", "-cp", "/bin/y.jar", "-cp", "z", "another"] as String[])
def result = shell.evaluate '''
@groovy.transform.BaseScript(groovy.cli.JCommanderScript)
import com.beust.jcommander.Parameter
import groovy.transform.Field
@Parameter
@Field List<String> parameters
@Parameter(names = ["-cp", "--codepath"])
@Field List<String> codepath = []
//println parameters
//println codepath
assert parameters == ['placeholder', 'another']
assert codepath == ['/usr/x.jar', '/bin/y.jar', 'z']
[parameters.size(), codepath.size()]
'''
assert result == [2, 3]
}

void testSimpleCommandScript() {
GroovyShell shell = new GroovyShell()
shell.context.setVariable('args',
[ "--codepath", "/usr/x.jar", "placeholder", "-cp", "/bin/y.jar", "another" ] as String[])
def result = shell.evaluate(new File(new File(sourceURI).parentFile, 'SimpleJCommanderScriptTest.groovy'))
assert result == [777]
}

void testMultipleCommandScript() {
GroovyShell shell = new GroovyShell()
def result = shell.evaluate(new File(new File(sourceURI).parentFile, 'MultipleJCommanderScriptTest.groovy'))
assert result == [33]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Test JCommanderScript's multiple command feature in a simple Script.
* More tests are embedded in JCommanderScriptTest strings.
*
* @author: Jim White
*/

import com.beust.jcommander.*
import groovy.cli.*
import groovy.transform.BaseScript
import groovy.transform.Field

@BaseScript JCommanderScript thisScript

// Override the default of using the 'args' binding for our test so we can be run without a special driver.
String[] getScriptArguments() {
[ "add", "-i", "zoos"] as String[]
}

@Parameter(names = ["-log", "-verbose" ], description = "Level of verbosity")
@Field Integer verbose = 1;

@Parameters(commandNames = "commit", commandDescription = "Record changes to the repository")
class CommandCommit implements Runnable {
@Parameter(description = "The list of files to commit")
private List<String> files;

@Parameter(names = "--amend", description = "Amend")
private Boolean amend = false;

@Parameter(names = "--author")
private String author;

@Override
void run() {
println "$author committed $files ${amend ? "using" : "not using"} amend."
}
}

@Parameters(commandNames = "add", separators = "=", commandDescription = "Add file contents to the index")
public class CommandAdd {
@Parameter(description = "File patterns to add to the index")
List<String> patterns;

@Parameter(names = "-i")
Boolean interactive = false;
}

@Subcommand @Field CommandCommit commitCommand = new CommandCommit()
@Subcommand @Field CommandAdd addCommand = new CommandAdd()

println verbose
println scriptJCommander.parsedCommand

switch (scriptJCommander.parsedCommand) {
case "add" :
if (addCommand.interactive) {
println "Adding ${addCommand.patterns} interactively."
} else {
println "Adding ${addCommand.patterns} in batch mode."
}
}

assert scriptJCommander.parsedCommand == "add"
assert addCommand.interactive
assert addCommand.patterns == ["zoos"]

[33]
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Copyright 2014 the original author or authors.
*
* 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.
*/

/**
* @author Jim White
*/

package groovy.cli.test

import groovy.transform.Field

@groovy.transform.BaseScript(groovy.cli.JCommanderScript)
import com.beust.jcommander.Parameter

@Parameter
@Field List<String> parameters

@Parameter(names = ["-cp", "--codepath"])
@Field List<String> codepath = []

//// Override the default of using the 'args' binding for our test.
//String[] getScriptArguments() {
// [ "--codepath", "/usr/x.jar", "placeholder", "-cp", "/bin/y.jar", "another" ] as String[]
//}

println parameters

println codepath

assert parameters == ['placeholder', 'another']
assert codepath == ['/usr/x.jar', '/bin/y.jar']

[777]