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 UI support for custom Python operations #640

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ project(":ui") {
testCompile group: 'org.testfx', name: 'testfx-core', version: '4.0.+'
testCompile group: 'org.testfx', name: 'testfx-junit', version: '4.0.+'
testRuntime group: 'org.testfx', name: 'openjfx-monocle', version: '1.8.0_20'
compile group: 'org.fxmisc.richtext', name: 'richtextfx', version: '0.7-M2'
}

evaluationDependsOn(':core')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ public enum Category {
LOGICAL,
OPENCV,
MISCELLANEOUS,
CUSTOM,
}

/**
Expand Down
20 changes: 18 additions & 2 deletions core/src/main/java/edu/wpi/grip/core/Palette.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package edu.wpi.grip.core;

import edu.wpi.grip.core.events.OperationAddedEvent;
import edu.wpi.grip.core.events.OperationRemovedEvent;

import com.google.common.eventbus.EventBus;
import com.google.common.eventbus.Subscribe;
import com.google.inject.Inject;

import java.util.Collection;
import java.util.LinkedHashMap;
Expand All @@ -11,7 +14,6 @@

import javax.inject.Singleton;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;

/**
Expand All @@ -20,6 +22,7 @@
@Singleton
public class Palette {

@Inject private EventBus eventBus;
private final Map<String, OperationMetaData> operations = new LinkedHashMap<>();

@Subscribe
Expand All @@ -39,7 +42,20 @@ public void onOperationAdded(OperationAddedEvent event) {
* @throws IllegalArgumentException if the key is already in the {@link #operations} map.
*/
private void map(String key, OperationMetaData operation) {
checkArgument(!operations.containsKey(key), "Operation name or alias already exists: " + key);
if (operations.containsKey(key)) {
OperationDescription existing = operations.get(key).getDescription();
if (existing.category() == operation.getDescription().category()
&& existing.category() == OperationDescription.Category.CUSTOM) {
// It's a custom operation that can be changed at runtime, allow it
// (But first remove the existing operation)
operations.remove(key);
eventBus.post(new OperationRemovedEvent(existing));
} else {
// Not a custom operation, this should only happen if someone
// adds a new operation and uses an already-taken name
throw new IllegalArgumentException("Operation name or alias already exists: " + key);
}
}
operations.put(key, operation);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package edu.wpi.grip.core.events;

import edu.wpi.grip.core.OperationDescription;

import static com.google.common.base.Preconditions.checkNotNull;

/**
* An event fired when an operation is removed from the palette.
*/
public class OperationRemovedEvent {

private final OperationDescription removedOperation;

public OperationRemovedEvent(OperationDescription removedOperation) {
this.removedOperation = checkNotNull(removedOperation);
}

public OperationDescription getRemovedOperation() {
return removedOperation;
}
}
20 changes: 18 additions & 2 deletions core/src/main/java/edu/wpi/grip/core/operations/Operations.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
import edu.wpi.grip.core.operations.opencv.MinMaxLoc;
import edu.wpi.grip.core.operations.opencv.NewPointOperation;
import edu.wpi.grip.core.operations.opencv.NewSizeOperation;
import edu.wpi.grip.core.operations.python.PythonOperationUtils;
import edu.wpi.grip.core.operations.python.PythonScriptOperation;
import edu.wpi.grip.core.sockets.InputSocket;
import edu.wpi.grip.core.sockets.OutputSocket;

Expand All @@ -53,6 +55,10 @@
import org.bytedeco.javacpp.opencv_core.Point;
import org.bytedeco.javacpp.opencv_core.Size;

import java.util.Arrays;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static com.google.common.base.Preconditions.checkNotNull;

@Singleton
Expand All @@ -75,7 +81,8 @@ public class Operations {
checkNotNull(httpPublishFactory, "httpPublisherFactory cannot be null");
checkNotNull(rosPublishFactory, "rosPublishFactory cannot be null");
checkNotNull(fileManager, "fileManager cannot be null");
this.operations = ImmutableList.of(
PythonOperationUtils.checkDirExists();
this.operations = new ImmutableList.Builder<OperationMetaData>().addAll(Arrays.asList(
// Composite operations
new OperationMetaData(BlurOperation.DESCRIPTION,
() -> new BlurOperation(isf, osf)),
Expand Down Expand Up @@ -186,7 +193,16 @@ public class Operations {
new OperationMetaData(HttpPublishOperation.descriptionFor(Boolean.class),
() -> new HttpPublishOperation<>(isf, Boolean.class, BooleanPublishable.class,
BooleanPublishable::new, httpPublishFactory))
);
)).addAll(
Stream.of(PythonOperationUtils.DIRECTORY.listFiles((dir, name) -> name.endsWith(".py")))
.map(PythonOperationUtils::read)
.filter(code -> code != null) // read() returns null if the file couldn't be read
.map(PythonOperationUtils::tryCreate)
.filter(script -> script != null) // create() returns null if the code has errors
.map(psf -> new OperationMetaData(PythonScriptOperation.descriptionFor(psf),
() -> new PythonScriptOperation(isf, osf, psf)))
.collect(Collectors.toList())
).build();
}

@VisibleForTesting
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package edu.wpi.grip.core.operations.python;

import edu.wpi.grip.core.GripFileManager;

import org.python.core.PyException;

import java.io.File;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
* Utility class handling functionality for custom python operation files on disk.
*/
public final class PythonOperationUtils {

private static final Logger log = Logger.getLogger(PythonOperationUtils.class.getName());

/**
* The directory where custom python operation files are stored.
*/
public static final File DIRECTORY = new File(GripFileManager.GRIP_DIRECTORY, "operations");

private PythonOperationUtils() {
// Utility class, avoid instantiation
}

/**
* Reads the contents of the given file. Assumes it's encoded as UTF-8.
*
* @param file the file to read
* @return the String contents of the file, in UTF-8 encoding
*/
public static String read(File file) {
if (!file.getParentFile().equals(DIRECTORY) || !file.getName().endsWith(".py")) {
throw new IllegalArgumentException(
"Not a custom python operation: " + file.getAbsolutePath());
}
try {
return new String(Files.readAllBytes(file.toPath()), Charset.forName("UTF-8"));
} catch (IOException e) {
log.log(Level.WARNING, "Could not read " + file.getAbsolutePath(), e);
return null;
}
}

/**
* Ensures that {@link #DIRECTORY} exists.
*/
public static void checkDirExists() {
if (DIRECTORY.exists()) {
return;
}
DIRECTORY.mkdirs();
}

/**
* Tries to create a {@code PythonScriptFile} from the given python script. If the script has
* errors (syntax or runtime), the first one encountered will be logged along with the contents
* of the script.
*
* @param code the python script to create a {@code PythonScriptFile} from
* @return a {@code PythonScriptFile} for the given python script
*/
public static PythonScriptFile tryCreate(String code) {
try {
return PythonScriptFile.create(code);
} catch (PyException e) {
log.log(Level.WARNING, "Error in python script", e);
return null;
}
}

}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package edu.wpi.grip.core.operations;
package edu.wpi.grip.core.operations.python;


import edu.wpi.grip.core.OperationMetaData;
Expand All @@ -8,6 +8,7 @@

import com.google.auto.value.AutoValue;

import org.python.core.PyException;
import org.python.core.PyFunction;
import org.python.core.PyObject;
import org.python.core.PyString;
Expand All @@ -18,6 +19,7 @@
import java.net.URL;
import java.util.List;
import java.util.Properties;
import java.util.logging.Logger;

/**
* Converts a string of Python Code or a Python File into something the {@link
Expand All @@ -26,6 +28,32 @@
@AutoValue
public abstract class PythonScriptFile {

private static final Logger logger = Logger.getLogger(PythonScriptFile.class.getName());

/**
* Template for custom python operations. Includes imports for sockets, as well as OpenCV
* core and image processing classes.
*
* <p>The sample operation is a simple arithmetic "add" that hopefully shows how the script
* should be written.</p>
*/
public static final String TEMPLATE =
"import edu.wpi.grip.core.sockets.SocketHints.Inputs as inputs\n"
+ "import edu.wpi.grip.core.sockets.SocketHints.Outputs as outputs\n"
+ "import org.bytedeco.javacpp.opencv_core as opencv_core\n"
+ "import org.bytedeco.javacpp.opencv_imgproc as opencv_imgproc\n\n"
+ "name = \"Addition Sample\"\n"
+ "summary = \"The sample python operation to add two numbers\"\n\n"
+ "inputs = [\n"
+ " inputs.createNumberSpinnerSocketHint(\"a\", 0.0),\n"
+ " inputs.createNumberSpinnerSocketHint(\"b\", 0.0),\n"
+ "]\n"
+ "outputs = [\n"
+ " outputs.createNumberSocketHint(\"sum\", 0.0),\n"
+ "]\n\n\n" // two blank lines
+ "def perform(a, b):\n"
+ " return a + b\n";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe make this a file? What do you think?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes some sense. I'd like to keep the contents in memory though to minimize disk accesses.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put it in a read once file.
Cache the string in memory after its loaded.
It will make it easier to edit in the future.


static {
Properties pythonProperties = new Properties();
pythonProperties.setProperty("python.import.site", "false");
Expand All @@ -34,7 +62,7 @@ public abstract class PythonScriptFile {

/**
* @param url The URL to get the script file from.
* @return The constructed PythonScript file.
* @return The constructed PythonScriptFile.
* @throws IOException If the URL fails to open.
*/
public static PythonScriptFile create(URL url) throws IOException {
Expand All @@ -48,7 +76,8 @@ public static PythonScriptFile create(URL url) throws IOException {

/**
* @param code The code to create the file from.
* @return The constructed PythonScript file.
* @return The constructed PythonScriptFile.
* @throws PyException if the code has syntax or runtime errors
*/
public static PythonScriptFile create(String code) {
final PythonInterpreter interpreter = new PythonInterpreter();
Expand Down Expand Up @@ -88,9 +117,11 @@ private static PythonScriptFile create(PythonInterpreter interpreter, String alt
* @param osf Output Socket Factory
* @return The meta data for a {@link PythonScriptOperation}
*/
public final OperationMetaData toOperationMetaData(InputSocket.Factory isf, OutputSocket
.Factory osf) {
return new OperationMetaData(PythonScriptOperation.descriptionFor(this), () -> new
PythonScriptOperation(isf, osf, this));
public final OperationMetaData toOperationMetaData(InputSocket.Factory isf,
OutputSocket.Factory osf) {
return new OperationMetaData(
PythonScriptOperation.descriptionFor(this),
() -> new PythonScriptOperation(isf, osf, this)
);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package edu.wpi.grip.core.operations;
package edu.wpi.grip.core.operations.python;

import edu.wpi.grip.core.Operation;
import edu.wpi.grip.core.OperationDescription;
Expand All @@ -13,7 +13,6 @@
import org.python.core.PySequence;

import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;

Expand Down Expand Up @@ -81,7 +80,7 @@ public static OperationDescription descriptionFor(PythonScriptFile pythonScriptF
.name(pythonScriptFile.name())
.summary(pythonScriptFile.summary())
.icon(Icon.iconStream("python"))
.category(OperationDescription.Category.MISCELLANEOUS)
.category(OperationDescription.Category.CUSTOM)
.build();
}

Expand Down Expand Up @@ -121,44 +120,31 @@ public void perform() {
pyInputs[i] = Py.java2py(inputSockets.get(i).getValue().get());
}

try {
PyObject pyOutput = this.scriptFile.performFunction().__call__(pyInputs);

if (pyOutput.isSequenceType()) {
/*
* If the Python function returned a sequence type, there must be multiple outputs for
* this step.
* Each element in the sequence is assigned to one output socket.
*/
PySequence pySequence = (PySequence) pyOutput;
Object[] javaOutputs = Py.tojava(pySequence, Object[].class);

if (outputSockets.size() != javaOutputs.length) {
throw new IllegalArgumentException(wrongNumberOfArgumentsMsg(outputSockets.size(),
javaOutputs.length));
}

for (int i = 0; i < javaOutputs.length; i++) {
outputSockets.get(i).setValue(javaOutputs[i]);
}
} else {
/* If the Python script did not return a sequence, there should only be one
output socket. */
if (outputSockets.size() != 1) {
throw new IllegalArgumentException(wrongNumberOfArgumentsMsg(outputSockets.size(), 1));
}

Object javaOutput = Py.tojava(pyOutput, outputSockets.get(0).getSocketHint().getType());
outputSockets.get(0).setValue(javaOutput);
PyObject pyOutput = this.scriptFile.performFunction().__call__(pyInputs);

if (pyOutput.isSequenceType()) {
// If the Python function returned a sequence type,
// there must be multiple outputs for this step.
// Each element in the sequence is assigned to one output socket.
PySequence pySequence = (PySequence) pyOutput;
Object[] javaOutputs = Py.tojava(pySequence, Object[].class);

if (outputSockets.size() != javaOutputs.length) {
throw new IllegalArgumentException(wrongNumberOfArgumentsMsg(outputSockets.size(),
javaOutputs.length));
}

for (int i = 0; i < javaOutputs.length; i++) {
outputSockets.get(i).setValue(javaOutputs[i]);
}
} else {
// If the Python script did not return a sequence, there should only be one output socket.
if (outputSockets.size() != 1) {
throw new IllegalArgumentException(wrongNumberOfArgumentsMsg(outputSockets.size(), 1));
}
} catch (RuntimeException e) {
/* Exceptions can happen if there's a mistake in a Python script, so just print a
stack trace and leave the
* current state of the output sockets alone.
*
* TODO: communicate the error to the GUI.
*/
logger.log(Level.WARNING, e.getMessage(), e);

Object javaOutput = Py.tojava(pyOutput, outputSockets.get(0).getSocketHint().getType());
outputSockets.get(0).setValue(javaOutput);
}
}
}
Loading