Skip to content

Commit 2af2c76

Browse files
committed
Add barebones UI support for custom Python operations
1 parent 28d7e0b commit 2af2c76

File tree

13 files changed

+280
-80
lines changed

13 files changed

+280
-80
lines changed

core/src/main/java/edu/wpi/grip/core/OperationDescription.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ public enum Category {
137137
LOGICAL,
138138
OPENCV,
139139
MISCELLANEOUS,
140+
CUSTOM,
140141
}
141142

142143
/**

core/src/main/java/edu/wpi/grip/core/operations/Operations.java

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@
4040
import edu.wpi.grip.core.operations.opencv.MinMaxLoc;
4141
import edu.wpi.grip.core.operations.opencv.NewPointOperation;
4242
import edu.wpi.grip.core.operations.opencv.NewSizeOperation;
43+
import edu.wpi.grip.core.operations.python.PythonOperationUtils;
44+
import edu.wpi.grip.core.operations.python.PythonScriptFile;
45+
import edu.wpi.grip.core.operations.python.PythonScriptOperation;
4346
import edu.wpi.grip.core.sockets.InputSocket;
4447
import edu.wpi.grip.core.sockets.OutputSocket;
4548

@@ -53,6 +56,10 @@
5356
import org.bytedeco.javacpp.opencv_core.Point;
5457
import org.bytedeco.javacpp.opencv_core.Size;
5558

59+
import java.util.Arrays;
60+
import java.util.stream.Collectors;
61+
import java.util.stream.Stream;
62+
5663
import static com.google.common.base.Preconditions.checkNotNull;
5764

5865
@Singleton
@@ -75,7 +82,8 @@ public class Operations {
7582
checkNotNull(httpPublishFactory, "httpPublisherFactory cannot be null");
7683
checkNotNull(rosPublishFactory, "rosPublishFactory cannot be null");
7784
checkNotNull(fileManager, "fileManager cannot be null");
78-
this.operations = ImmutableList.of(
85+
PythonOperationUtils.checkDirExists();
86+
this.operations = new ImmutableList.Builder<OperationMetaData>().addAll(Arrays.asList(
7987
// Composite operations
8088
new OperationMetaData(BlurOperation.DESCRIPTION,
8189
() -> new BlurOperation(isf, osf)),
@@ -186,7 +194,14 @@ public class Operations {
186194
new OperationMetaData(HttpPublishOperation.descriptionFor(Boolean.class),
187195
() -> new HttpPublishOperation<>(isf, Boolean.class, BooleanPublishable.class,
188196
BooleanPublishable::new, httpPublishFactory))
189-
);
197+
)).addAll(
198+
Stream.of(PythonOperationUtils.DIRECTORY.listFiles((dir, name) -> name.endsWith(".py")))
199+
.map(PythonOperationUtils::read)
200+
.map(PythonScriptFile::create)
201+
.map(psf -> new OperationMetaData(PythonScriptOperation.descriptionFor(psf),
202+
() -> new PythonScriptOperation(isf, osf, psf)))
203+
.collect(Collectors.toList())
204+
).build();
190205
}
191206

192207
@VisibleForTesting
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package edu.wpi.grip.core.operations.python;
2+
3+
import edu.wpi.grip.core.GripFileManager;
4+
5+
import java.io.File;
6+
import java.io.IOException;
7+
import java.nio.charset.Charset;
8+
import java.nio.file.Files;
9+
10+
/**
11+
* Utility class handling functionality for custom python operation files on disk.
12+
*/
13+
public final class PythonOperationUtils {
14+
15+
/**
16+
* The directory where custom python operation files are stored.
17+
*/
18+
public static final File DIRECTORY = new File(GripFileManager.GRIP_DIRECTORY, "operations");
19+
20+
private PythonOperationUtils() {
21+
22+
}
23+
24+
/**
25+
* Reads the contents of the given file. Assumes it's encoded as UTF-8.
26+
*
27+
* @param file the file to read
28+
* @return the String contents of the file, in UTF-8 encoding
29+
*/
30+
public static String read(File file) {
31+
if (!file.getParentFile().equals(DIRECTORY) || !file.getName().endsWith(".py")) {
32+
throw new IllegalArgumentException(
33+
"Not a custom python operation: " + file.getAbsolutePath());
34+
}
35+
try {
36+
return new String(Files.readAllBytes(file.toPath()), Charset.forName("UTF-8"));
37+
} catch (IOException e) {
38+
throw new RuntimeException("Could not read " + file.getAbsolutePath(), e);
39+
}
40+
}
41+
42+
/**
43+
* Ensures that {@link #DIRECTORY} exists.
44+
*/
45+
public static void checkDirExists() {
46+
if (DIRECTORY.exists()) {
47+
return;
48+
}
49+
DIRECTORY.mkdirs();
50+
}
51+
52+
}

core/src/main/java/edu/wpi/grip/core/operations/PythonScriptFile.java renamed to core/src/main/java/edu/wpi/grip/core/operations/python/PythonScriptFile.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package edu.wpi.grip.core.operations;
1+
package edu.wpi.grip.core.operations.python;
22

33

44
import edu.wpi.grip.core.OperationMetaData;
@@ -26,6 +26,20 @@
2626
@AutoValue
2727
public abstract class PythonScriptFile {
2828

29+
public static final String TEMPLATE =
30+
"import edu.wpi.grip.core.sockets as grip\n\n"
31+
+ "name = \"Addition Sample\"\n"
32+
+ "summary = \"The sample python operation to add two numbers\"\n\n"
33+
+ "inputs = [\n"
34+
+ " grip.SocketHints.createNumberSocketHint(\"a\", 0.0),\n"
35+
+ " grip.SocketHints.createNumberSocketHint(\"b\", 0.0),\n"
36+
+ "]\n"
37+
+ "outputs = [\n"
38+
+ " grip.SocketHints.createNumberSocketHint(\"sum\", 0.0),\n"
39+
+ "]\n\n"
40+
+ "def perform(a, b):\n"
41+
+ " return a + b\n";
42+
2943
static {
3044
Properties pythonProperties = new Properties();
3145
pythonProperties.setProperty("python.import.site", "false");

core/src/main/java/edu/wpi/grip/core/operations/PythonScriptOperation.java renamed to core/src/main/java/edu/wpi/grip/core/operations/python/PythonScriptOperation.java

Lines changed: 26 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package edu.wpi.grip.core.operations;
1+
package edu.wpi.grip.core.operations.python;
22

33
import edu.wpi.grip.core.Operation;
44
import edu.wpi.grip.core.OperationDescription;
@@ -13,7 +13,6 @@
1313
import org.python.core.PySequence;
1414

1515
import java.util.List;
16-
import java.util.logging.Level;
1716
import java.util.logging.Logger;
1817
import java.util.stream.Collectors;
1918

@@ -81,7 +80,7 @@ public static OperationDescription descriptionFor(PythonScriptFile pythonScriptF
8180
.name(pythonScriptFile.name())
8281
.summary(pythonScriptFile.summary())
8382
.icon(Icon.iconStream("python"))
84-
.category(OperationDescription.Category.MISCELLANEOUS)
83+
.category(OperationDescription.Category.CUSTOM)
8584
.build();
8685
}
8786

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

124-
try {
125-
PyObject pyOutput = this.scriptFile.performFunction().__call__(pyInputs);
126-
127-
if (pyOutput.isSequenceType()) {
128-
/*
129-
* If the Python function returned a sequence type, there must be multiple outputs for
130-
* this step.
131-
* Each element in the sequence is assigned to one output socket.
132-
*/
133-
PySequence pySequence = (PySequence) pyOutput;
134-
Object[] javaOutputs = Py.tojava(pySequence, Object[].class);
135-
136-
if (outputSockets.size() != javaOutputs.length) {
137-
throw new IllegalArgumentException(wrongNumberOfArgumentsMsg(outputSockets.size(),
138-
javaOutputs.length));
139-
}
140-
141-
for (int i = 0; i < javaOutputs.length; i++) {
142-
outputSockets.get(i).setValue(javaOutputs[i]);
143-
}
144-
} else {
145-
/* If the Python script did not return a sequence, there should only be one
146-
output socket. */
147-
if (outputSockets.size() != 1) {
148-
throw new IllegalArgumentException(wrongNumberOfArgumentsMsg(outputSockets.size(), 1));
149-
}
150-
151-
Object javaOutput = Py.tojava(pyOutput, outputSockets.get(0).getSocketHint().getType());
152-
outputSockets.get(0).setValue(javaOutput);
123+
PyObject pyOutput = this.scriptFile.performFunction().__call__(pyInputs);
124+
125+
if (pyOutput.isSequenceType()) {
126+
// If the Python function returned a sequence type,
127+
// there must be multiple outputs for this step.
128+
// Each element in the sequence is assigned to one output socket.
129+
PySequence pySequence = (PySequence) pyOutput;
130+
Object[] javaOutputs = Py.tojava(pySequence, Object[].class);
131+
132+
if (outputSockets.size() != javaOutputs.length) {
133+
throw new IllegalArgumentException(wrongNumberOfArgumentsMsg(outputSockets.size(),
134+
javaOutputs.length));
135+
}
136+
137+
for (int i = 0; i < javaOutputs.length; i++) {
138+
outputSockets.get(i).setValue(javaOutputs[i]);
139+
}
140+
} else {
141+
// If the Python script did not return a sequence, there should only be one output socket.
142+
if (outputSockets.size() != 1) {
143+
throw new IllegalArgumentException(wrongNumberOfArgumentsMsg(outputSockets.size(), 1));
153144
}
154-
} catch (RuntimeException e) {
155-
/* Exceptions can happen if there's a mistake in a Python script, so just print a
156-
stack trace and leave the
157-
* current state of the output sockets alone.
158-
*
159-
* TODO: communicate the error to the GUI.
160-
*/
161-
logger.log(Level.WARNING, e.getMessage(), e);
145+
146+
Object javaOutput = Py.tojava(pyOutput, outputSockets.get(0).getSocketHint().getType());
147+
outputSockets.get(0).setValue(javaOutput);
162148
}
163149
}
164150
}

core/src/test/java/edu/wpi/grip/core/PythonTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package edu.wpi.grip.core;
22

3-
import edu.wpi.grip.core.operations.PythonScriptFile;
3+
import edu.wpi.grip.core.operations.python.PythonScriptFile;
44
import edu.wpi.grip.core.sockets.InputSocket;
55
import edu.wpi.grip.core.sockets.OutputSocket;
66
import edu.wpi.grip.core.sockets.Socket;

core/src/test/java/edu/wpi/grip/core/serialization/ProjectTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import edu.wpi.grip.core.events.OperationAddedEvent;
1313
import edu.wpi.grip.core.events.ProjectSettingsChangedEvent;
1414
import edu.wpi.grip.core.events.SourceAddedEvent;
15-
import edu.wpi.grip.core.operations.PythonScriptFile;
15+
import edu.wpi.grip.core.operations.python.PythonScriptFile;
1616
import edu.wpi.grip.core.settings.ProjectSettings;
1717
import edu.wpi.grip.core.sockets.InputSocket;
1818
import edu.wpi.grip.core.sockets.OutputSocket;
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package edu.wpi.grip.ui;
2+
3+
import edu.wpi.grip.core.GripFileManager;
4+
import edu.wpi.grip.core.OperationMetaData;
5+
import edu.wpi.grip.core.events.OperationAddedEvent;
6+
import edu.wpi.grip.core.operations.python.PythonScriptFile;
7+
import edu.wpi.grip.core.operations.python.PythonScriptOperation;
8+
import edu.wpi.grip.core.sockets.InputSocket;
9+
import edu.wpi.grip.core.sockets.OutputSocket;
10+
import edu.wpi.grip.ui.annotations.ParametrizedController;
11+
12+
import com.google.common.eventbus.EventBus;
13+
import com.google.common.io.Files;
14+
import com.google.inject.Inject;
15+
16+
import java.io.File;
17+
import java.io.IOException;
18+
import java.nio.charset.Charset;
19+
import java.util.Optional;
20+
import java.util.regex.Matcher;
21+
import java.util.regex.Pattern;
22+
23+
import javafx.event.ActionEvent;
24+
import javafx.fxml.FXML;
25+
import javafx.scene.control.Alert;
26+
import javafx.scene.control.ButtonType;
27+
import javafx.scene.control.Dialog;
28+
import javafx.scene.control.TextArea;
29+
import javafx.scene.control.TextInputDialog;
30+
31+
/**
32+
* Controller for the custom operations list.
33+
*/
34+
@ParametrizedController(url = "CustomOperationsList.fxml")
35+
public class CustomOperationsListController extends OperationListController {
36+
37+
@Inject private EventBus eventBus;
38+
@Inject private InputSocket.Factory isf;
39+
@Inject private OutputSocket.Factory osf;
40+
41+
@Override
42+
protected void initialize() {
43+
super.initialize();
44+
}
45+
46+
@FXML
47+
private void createNewPythonOperation(ActionEvent actionEvent) {
48+
Dialog<String> dialog = new TextInputDialog();
49+
dialog.getDialogPane().setContent(new TextArea(PythonScriptFile.TEMPLATE));
50+
dialog.setResultConverter(bt -> {
51+
if (bt == ButtonType.OK) {
52+
return ((TextArea) dialog.getDialogPane().getContent()).getText();
53+
}
54+
return null;
55+
});
56+
Optional<String> result = dialog.showAndWait();
57+
if (result.isPresent()) {
58+
String code = result.get();
59+
String[] lines = code.split("\n");
60+
String name = null;
61+
// Find the name in the user code
62+
final Pattern p = Pattern.compile("name *= *\"(.*)\" *");
63+
for (String line : lines) {
64+
Matcher m = p.matcher(line);
65+
if (m.matches()) {
66+
name = m.group(1);
67+
break;
68+
}
69+
}
70+
if (name == null) {
71+
Alert a = new Alert(Alert.AlertType.ERROR);
72+
a.setContentText("A name must be specified");
73+
a.showAndWait();
74+
return;
75+
} else if (name.isEmpty() || name.matches("[ \t]+")) {
76+
// Empty names are not allowed
77+
Alert a = new Alert(Alert.AlertType.ERROR);
78+
a.setContentText("Name cannot be empty");
79+
a.showAndWait();
80+
return;
81+
} else if (!name.matches("[a-zA-Z0-9_\\- ]+")) {
82+
// Name can only contain (English) letters, numbers, underscores, dashes, and spaces
83+
Alert a = new Alert(Alert.AlertType.ERROR);
84+
a.setContentText("Name contains illegal characters");
85+
a.showAndWait();
86+
return;
87+
}
88+
File file = new File(GripFileManager.GRIP_DIRECTORY + File.separator + "operations",
89+
name.replaceAll("[\\s]", "_") + ".py");
90+
if (file.exists()) {
91+
Alert a = new Alert(Alert.AlertType.ERROR);
92+
a.setContentText("A file for the custom operation \"" + name + "\" already exists");
93+
a.showAndWait();
94+
return;
95+
}
96+
try {
97+
Files.write(code, file, Charset.defaultCharset());
98+
} catch (IOException e) {
99+
Alert a = new Alert(Alert.AlertType.ERROR);
100+
a.setContentText("Could not save custom operation to " + file.getAbsolutePath());
101+
a.showAndWait();
102+
return;
103+
}
104+
PythonScriptFile pcs = PythonScriptFile.create(code);
105+
eventBus.post(new OperationAddedEvent(new OperationMetaData(
106+
PythonScriptOperation.descriptionFor(pcs),
107+
() -> new PythonScriptOperation(isf, osf, pcs)
108+
)));
109+
}
110+
}
111+
112+
}

ui/src/main/java/edu/wpi/grip/ui/OperationListController.java

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import javafx.collections.MapChangeListener;
1818
import javafx.fxml.FXML;
1919
import javafx.scene.Node;
20-
import javafx.scene.control.Tab;
20+
import javafx.scene.control.TitledPane;
2121
import javafx.scene.layout.VBox;
2222

2323
/**
@@ -31,7 +31,7 @@ public class OperationListController {
3131

3232
protected static final String FILTER_TEXT = "filterText";
3333
private final StringProperty filterText = new SimpleStringProperty(this, FILTER_TEXT, "");
34-
@FXML private Tab root;
34+
@FXML private TitledPane root;
3535
@FXML private VBox operations;
3636
@Inject private OperationController.Factory operationControllerFactory;
3737
@SuppressWarnings("PMD.SingularField") private String baseText = null;
@@ -43,7 +43,7 @@ protected void initialize() {
4343
baseText = root.getText();
4444

4545
InvalidationListener filterOperations = observable -> {
46-
if (baseText == null) {
46+
if (baseText == null || baseText.isEmpty()) {
4747
baseText = root.getText();
4848
}
4949

@@ -60,10 +60,8 @@ protected void initialize() {
6060

6161
if (!filter.isEmpty() && numMatches > 0) {
6262
// If we're filtering some operations and there's at least one match, set the title to
63-
// bold and show the
64-
// number of matches. This lets the user quickly see which tabs have matching operations
65-
// when
66-
// searching.
63+
// bold and show the umber of matches.
64+
// This lets the user quickly see which tabs have matching operations when searching.
6765
root.setText(baseText + " (" + numMatches + ")");
6866
root.styleProperty().setValue("-fx-font-weight: bold");
6967
} else {

0 commit comments

Comments
 (0)