From 314131a6249bf9714c74e175a94d6541fc4a6770 Mon Sep 17 00:00:00 2001 From: JMit-dev Date: Tue, 25 Nov 2025 16:26:12 -0500 Subject: [PATCH] feat: python scripting for queue server type handling --- app/queue-server/pom.xml | 6 + .../controller/RePlanEditorController.java | 179 +++++++++-------- .../util/JythonScriptExecutor.java | 154 ++++++++++++++ .../util/PythonParameterConverter.java | 157 +++++++++++++++ .../queueserver/scripts/type_converter.py | 190 ++++++++++++++++++ 5 files changed, 597 insertions(+), 89 deletions(-) create mode 100644 app/queue-server/src/main/java/org/phoebus/applications/queueserver/util/JythonScriptExecutor.java create mode 100644 app/queue-server/src/main/java/org/phoebus/applications/queueserver/util/PythonParameterConverter.java create mode 100644 app/queue-server/src/main/resources/org/phoebus/applications/queueserver/scripts/type_converter.py diff --git a/app/queue-server/pom.xml b/app/queue-server/pom.xml index 5e97e07249..1310dac5f3 100644 --- a/app/queue-server/pom.xml +++ b/app/queue-server/pom.xml @@ -34,6 +34,12 @@ jackson-dataformat-yaml 2.19.1 + + + org.python + jython-standalone + ${jython.version} + org.apache.poi poi diff --git a/app/queue-server/src/main/java/org/phoebus/applications/queueserver/controller/RePlanEditorController.java b/app/queue-server/src/main/java/org/phoebus/applications/queueserver/controller/RePlanEditorController.java index 587a979aa1..79bb119779 100644 --- a/app/queue-server/src/main/java/org/phoebus/applications/queueserver/controller/RePlanEditorController.java +++ b/app/queue-server/src/main/java/org/phoebus/applications/queueserver/controller/RePlanEditorController.java @@ -7,6 +7,7 @@ import org.phoebus.applications.queueserver.view.PlanEditEvent; import org.phoebus.applications.queueserver.view.TabSwitchEvent; import org.phoebus.applications.queueserver.view.ItemUpdateEvent; +import org.phoebus.applications.queueserver.util.PythonParameterConverter; import com.fasterxml.jackson.databind.ObjectMapper; import javafx.application.Platform; import javafx.geometry.Insets; @@ -63,6 +64,8 @@ public class RePlanEditorController implements Initializable { private final ObjectMapper objectMapper = new ObjectMapper(); // Store original parameter values for reset functionality private final Map originalParameterValues = new HashMap<>(); + // Python-based parameter converter + private final PythonParameterConverter pythonConverter = new PythonParameterConverter(); private class EditableTableCell extends TableCell { private TextField textField; @@ -88,9 +91,11 @@ protected void updateItem(String item, boolean empty) { setText(getString()); setGraphic(null); - // Style based on enabled state and add tooltip + // Style based on enabled state and validation if (!row.isEnabled()) { setStyle("-fx-text-fill: grey;"); + } else if (!row.validate(pythonConverter)) { + setStyle("-fx-text-fill: red;"); } else { setStyle(""); } @@ -132,15 +137,19 @@ public void commitEdit(String newValue) { ParameterRow row = getTableRow().getItem(); if (row != null) { row.setValue(newValue); + + // Update cell color based on Python validation + updateValidationColor(row); + switchToEditingMode(); updateButtonStates(); - // Update cell color based on validation - updateValidationColor(row); } } private void updateValidationColor(ParameterRow row) { - if (row.validate()) { + if (!row.isEnabled()) { + setStyle("-fx-text-fill: grey;"); + } else if (row.validate(pythonConverter)) { setStyle(""); } else { setStyle("-fx-text-fill: red;"); @@ -208,69 +217,7 @@ public ParameterRow(String name, boolean enabled, String value, String descripti public boolean isOptional() { return isOptional.get(); } public Object getDefaultValue() { return defaultValue; } - public Object getParsedValue() { - if (!enabled.get()) return defaultValue; - - String valueStr = value.get(); - if (valueStr == null || valueStr.trim().isEmpty()) { - return defaultValue; - } - - try { - return parseLiteralValue(valueStr); - } catch (Exception e) { - return valueStr; - } - } - - private Object parseLiteralValue(String valueStr) throws Exception { - valueStr = valueStr.trim(); - - // Handle None/null - if ("None".equals(valueStr) || "null".equals(valueStr)) { - return null; - } - - // Handle booleans - if ("True".equals(valueStr) || "true".equals(valueStr)) { - return true; - } - if ("False".equals(valueStr) || "false".equals(valueStr)) { - return false; - } - - // Handle strings (quoted) - if ((valueStr.startsWith("'") && valueStr.endsWith("'")) || - (valueStr.startsWith("\"") && valueStr.endsWith("\""))) { - return valueStr.substring(1, valueStr.length() - 1); - } - - // Handle numbers - try { - if (valueStr.contains(".")) { - return Double.parseDouble(valueStr); - } else { - return Long.parseLong(valueStr); - } - } catch (NumberFormatException e) { - // Continue to other parsing attempts - } - - // Handle lists and dicts using JSON parsing (similar to Python's literal_eval) - if (valueStr.startsWith("[") || valueStr.startsWith("{")) { - try { - ObjectMapper mapper = new ObjectMapper(); - return mapper.readValue(valueStr, Object.class); - } catch (Exception e) { - // Fall back to string if JSON parsing fails - } - } - - // Default to string - return valueStr; - } - - public boolean validate() { + public boolean validate(PythonParameterConverter converter) { if (!enabled.get()) { return true; // Disabled parameters are always valid } @@ -280,8 +227,18 @@ public boolean validate() { return isOptional.get(); } + // Validate using Python converter try { - getParsedValue(); + List testParams = List.of( + new PythonParameterConverter.ParameterInfo( + getName(), + valueStr, + true, + isOptional.get(), + getDefaultValue() + ) + ); + converter.convertParameters(testParams); return true; } catch (Exception e) { return false; @@ -545,9 +502,6 @@ private void loadParametersForSelection(String selectedName) { isEnabled = true; } else if (defaultValue != null) { currentValue = String.valueOf(defaultValue); - if (!isEditMode) { - currentValue += " (default)"; - } } ParameterRow row = new ParameterRow(paramName, isEnabled, currentValue, description, isOptional, defaultValue); @@ -642,7 +596,7 @@ private void autoResizeColumns() { private boolean areParametersValid() { boolean allValid = true; for (ParameterRow row : parameterRows) { - boolean rowValid = row.validate(); + boolean rowValid = row.validate(pythonConverter); if (!rowValid) { allValid = false; } @@ -681,8 +635,32 @@ private void updateButtonStates() { cancelBtn.setDisable(!isEditMode); } + /** + * Build kwargs map using Python-based type conversion. + * All type conversion is handled by Python script using ast.literal_eval. + */ + private Map buildKwargsWithPython() { + List paramInfos = new ArrayList<>(); + + for (ParameterRow row : parameterRows) { + PythonParameterConverter.ParameterInfo paramInfo = + new PythonParameterConverter.ParameterInfo( + row.getName(), + row.getValue(), + row.isEnabled(), + row.isOptional(), + row.getDefaultValue() + ); + paramInfos.add(paramInfo); + } + + // Use Python to convert parameters - no Java fallback + return pythonConverter.convertParameters(paramInfos); + } + private void addItemToQueue() { if (!areParametersValid()) { + showValidationError("Some parameters have invalid values. Please check the red fields."); return; } @@ -693,13 +671,9 @@ private void addItemToQueue() { } String itemType = planRadBtn.isSelected() ? "plan" : "instruction"; - Map kwargs = new HashMap<>(); - for (ParameterRow row : parameterRows) { - if (row.isEnabled()) { - kwargs.put(row.getName(), row.getParsedValue()); - } - } + // Use Python-based parameter conversion + Map kwargs = buildKwargsWithPython(); QueueItem item = new QueueItem( itemType, @@ -732,24 +706,45 @@ private void addItemToQueue() { TabSwitchEvent.getInstance().switchToTab("Plan Viewer"); exitEditMode(); showItemPreview(); + } else { + showValidationError("Failed to add item to queue: " + response.msg()); } }); } catch (Exception e) { - e.printStackTrace(); + LOG.log(Level.WARNING, "Failed to add item to queue", e); + Platform.runLater(() -> { + String errorMsg = e.getMessage(); + if (errorMsg == null || errorMsg.isEmpty()) { + errorMsg = e.getClass().getSimpleName(); + } + showValidationError("Failed to add item to queue: " + errorMsg); + }); } }).start(); } catch (Exception e) { - e.printStackTrace(); + LOG.log(Level.SEVERE, "Failed to add item to queue", e); + Platform.runLater(() -> { + showValidationError("Failed to add item: " + e.getMessage()); + }); } } + private void showValidationError(String message) { + Alert alert = new Alert(Alert.AlertType.ERROR); + alert.setTitle("Validation Error"); + alert.setHeaderText(null); + alert.setContentText(message); + alert.showAndWait(); + } + private void saveItem() { if (!isEditMode || currentItem == null) { return; } if (!areParametersValid()) { + showValidationError("Some parameters have invalid values. Please check the red fields."); return; } @@ -760,13 +755,9 @@ private void saveItem() { } String itemType = planRadBtn.isSelected() ? "plan" : "instruction"; - Map kwargs = new HashMap<>(); - for (ParameterRow row : parameterRows) { - if (row.isEnabled()) { - kwargs.put(row.getName(), row.getParsedValue()); - } - } + // Use Python-based parameter conversion + Map kwargs = buildKwargsWithPython(); QueueItem updatedItem = new QueueItem( itemType, @@ -795,16 +786,26 @@ private void saveItem() { exitEditMode(); showItemPreview(); } else { - LOG.log(Level.WARNING, "Save failed: " + response.msg()); + showValidationError("Failed to save item: " + response.msg()); } }); } catch (Exception e) { - LOG.log(Level.WARNING, "Save error", e); + LOG.log(Level.WARNING, "Failed to save item", e); + Platform.runLater(() -> { + String errorMsg = e.getMessage(); + if (errorMsg == null || errorMsg.isEmpty()) { + errorMsg = e.getClass().getSimpleName(); + } + showValidationError("Failed to save item: " + errorMsg); + }); } }).start(); } catch (Exception e) { - e.printStackTrace(); + LOG.log(Level.SEVERE, "Failed to save item", e); + Platform.runLater(() -> { + showValidationError("Failed to save item: " + e.getMessage()); + }); } } diff --git a/app/queue-server/src/main/java/org/phoebus/applications/queueserver/util/JythonScriptExecutor.java b/app/queue-server/src/main/java/org/phoebus/applications/queueserver/util/JythonScriptExecutor.java new file mode 100644 index 0000000000..d6773efa35 --- /dev/null +++ b/app/queue-server/src/main/java/org/phoebus/applications/queueserver/util/JythonScriptExecutor.java @@ -0,0 +1,154 @@ +package org.phoebus.applications.queueserver.util; + +import org.python.core.*; +import org.python.util.PythonInterpreter; + +import java.io.InputStream; +import java.util.concurrent.*; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Utility class for executing Jython scripts in the Queue Server application. + * Based on Phoebus display runtime JythonScriptSupport implementation. + */ +public class JythonScriptExecutor { + + private static final Logger LOG = Logger.getLogger(JythonScriptExecutor.class.getName()); + private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(r -> { + Thread thread = new Thread(r, "QueueServer-Jython"); + thread.setDaemon(true); + return thread; + }); + + private final PythonInterpreter python; + + static { + // Configure Jython options (similar to Phoebus display runtime) + PySystemState.initialize(); + Options.dont_write_bytecode = true; + + // Set console encoding + PySystemState sys = Py.getSystemState(); + sys.setdefaultencoding("utf-8"); + + LOG.log(Level.INFO, "Jython initialized for Queue Server"); + } + + /** + * Create a new Jython script executor with a dedicated interpreter instance. + */ + public JythonScriptExecutor() { + // Synchronized to prevent concurrent initialization issues + synchronized (JythonScriptExecutor.class) { + this.python = new PythonInterpreter(); + } + } + + /** + * Execute a Python script with the given context variables. + * + * @param scriptContent The Python script code to execute + * @param contextVars Variables to pass into the Python context (key -> value pairs) + * @return The result object returned by the script (or null if none) + */ + public Object execute(String scriptContent, java.util.Map contextVars) { + try { + // Set context variables in the Python interpreter + if (contextVars != null) { + for (java.util.Map.Entry entry : contextVars.entrySet()) { + python.set(entry.getKey(), entry.getValue()); + } + } + + // Execute the script + python.exec(scriptContent); + + // Try to get a result variable if one was set + PyObject result = python.get("result"); + + // Clear context to prevent memory leaks + if (contextVars != null) { + for (String key : contextVars.keySet()) { + python.set(key, null); + } + } + + // Convert PyObject to Java object + return result != null ? result.__tojava__(Object.class) : null; + + } catch (Exception e) { + LOG.log(Level.FINE, "Jython script execution failed: " + e.getMessage()); + throw new RuntimeException("Script execution failed: " + e.getMessage(), e); + } + } + + /** + * Execute a Python script asynchronously. + * + * @param scriptContent The Python script code to execute + * @param contextVars Variables to pass into the Python context + * @return Future that will contain the result + */ + public Future executeAsync(String scriptContent, java.util.Map contextVars) { + return EXECUTOR.submit(() -> execute(scriptContent, contextVars)); + } + + /** + * Execute a Python script from a resource file. + * + * @param resourcePath Path to the Python script resource + * @param contextVars Variables to pass into the Python context + * @return The result object returned by the script + */ + public Object executeResource(String resourcePath, java.util.Map contextVars) throws Exception { + try (InputStream stream = getClass().getResourceAsStream(resourcePath)) { + if (stream == null) { + throw new IllegalArgumentException("Resource not found: " + resourcePath); + } + + String scriptContent = new String(stream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + return execute(scriptContent, contextVars); + } + } + + /** + * Execute a simple Python expression and return the result. + * + * @param expression Python expression to evaluate + * @return The evaluated result + */ + public Object eval(String expression) { + try { + PyObject result = python.eval(expression); + return result.__tojava__(Object.class); + } catch (Exception e) { + LOG.log(Level.WARNING, "Jython eval failed for: " + expression, e); + throw new RuntimeException("Evaluation failed: " + e.getMessage(), e); + } + } + + /** + * Close this executor and release resources. + */ + public void close() { + if (python != null) { + python.close(); + } + } + + /** + * Shutdown the shared executor service (call during application shutdown). + */ + public static void shutdown() { + EXECUTOR.shutdown(); + try { + if (!EXECUTOR.awaitTermination(5, TimeUnit.SECONDS)) { + EXECUTOR.shutdownNow(); + } + } catch (InterruptedException e) { + EXECUTOR.shutdownNow(); + Thread.currentThread().interrupt(); + } + } +} diff --git a/app/queue-server/src/main/java/org/phoebus/applications/queueserver/util/PythonParameterConverter.java b/app/queue-server/src/main/java/org/phoebus/applications/queueserver/util/PythonParameterConverter.java new file mode 100644 index 0000000000..4bdc279130 --- /dev/null +++ b/app/queue-server/src/main/java/org/phoebus/applications/queueserver/util/PythonParameterConverter.java @@ -0,0 +1,157 @@ +package org.phoebus.applications.queueserver.util; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Python-based parameter type converter for Queue Server. + * + * This class delegates type conversion to a Python script (using Jython), + * allowing us to use Python's ast.literal_eval for parsing parameter values + * instead of implementing complex type conversions in Java. + */ +public class PythonParameterConverter { + + private static final Logger LOG = Logger.getLogger(PythonParameterConverter.class.getName()); + private static final String SCRIPT_RESOURCE = "/org/phoebus/applications/queueserver/scripts/type_converter.py"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final JythonScriptExecutor executor; + private final String scriptContent; + + /** + * Create a new Python parameter converter. + */ + public PythonParameterConverter() { + this.executor = new JythonScriptExecutor(); + + // Load the Python script from resources + try (var stream = getClass().getResourceAsStream(SCRIPT_RESOURCE)) { + if (stream == null) { + throw new IllegalStateException("Python converter script not found: " + SCRIPT_RESOURCE); + } + this.scriptContent = new String(stream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + LOG.log(Level.INFO, "Python type converter script loaded successfully"); + } catch (Exception e) { + LOG.log(Level.SEVERE, "Failed to load Python converter script", e); + throw new RuntimeException("Failed to initialize Python converter", e); + } + } + + /** + * Parameter information for conversion. + */ + public static class ParameterInfo { + private String name; + private String value; + private boolean enabled; + private boolean isOptional; + private Object defaultValue; + + public ParameterInfo(String name, String value, boolean enabled, boolean isOptional, Object defaultValue) { + this.name = name; + this.value = value; + this.enabled = enabled; + this.isOptional = isOptional; + this.defaultValue = defaultValue; + } + + // Getters and setters + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getValue() { return value; } + public void setValue(String value) { this.value = value; } + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + + public boolean isOptional() { return isOptional; } + public void setOptional(boolean optional) { isOptional = optional; } + + public Object getDefaultValue() { return defaultValue; } + public void setDefaultValue(Object defaultValue) { this.defaultValue = defaultValue; } + } + + /** + * Convert a list of parameters from string values to typed objects using Python. + * + * @param parameters List of parameter information + * @return Map of parameter names to their typed values + */ + public Map convertParameters(List parameters) { + try { + // Serialize parameters to JSON + String parametersJson = objectMapper.writeValueAsString(parameters); + + // Prepare context for Python script + Map context = new HashMap<>(); + context.put("parameters_json", parametersJson); + + // Execute the Python script + Object result = executor.execute(scriptContent, context); + + // Parse result JSON back to Map + if (result != null) { + String resultJson = result.toString(); + + // Check if result contains an error + Map resultMap = objectMapper.readValue(resultJson, + new TypeReference>() {}); + + if (resultMap.containsKey("error")) { + String errorMsg = "Python type conversion failed: " + resultMap.get("error"); + LOG.log(Level.SEVERE, errorMsg); + throw new RuntimeException(errorMsg); + } + + return resultMap; + } + + return new HashMap<>(); + + } catch (Exception e) { + LOG.log(Level.WARNING, "Python parameter conversion failed", e); + throw new RuntimeException("Parameter conversion failed: " + e.getMessage(), e); + } + } + + + /** + * Validate a parameter value using Python. + * + * @param value String value to validate + * @return true if the value can be parsed, false otherwise + */ + public boolean validateValue(String value) { + if (value == null || value.trim().isEmpty()) { + return true; + } + + try { + List testParam = List.of( + new ParameterInfo("test", value, true, true, null) + ); + convertParameters(testParam); + return true; + } catch (Exception e) { + return false; + } + } + + /** + * Close the converter and release resources. + */ + public void close() { + if (executor != null) { + executor.close(); + } + } +} diff --git a/app/queue-server/src/main/resources/org/phoebus/applications/queueserver/scripts/type_converter.py b/app/queue-server/src/main/resources/org/phoebus/applications/queueserver/scripts/type_converter.py new file mode 100644 index 0000000000..6565df103a --- /dev/null +++ b/app/queue-server/src/main/resources/org/phoebus/applications/queueserver/scripts/type_converter.py @@ -0,0 +1,190 @@ +""" +Type converter script for Queue Server parameter parsing. + +This script uses Python's ast.literal_eval to safely parse parameter values +from string representations, matching the behavior of the Python Qt implementation. + +Usage from Java: + - Pass 'parameters_json' as a JSON string containing parameter definitions + - Returns 'result' as a JSON string with parsed values +""" + +import ast +import json +import sys + +# Sentinel value for empty/unset parameters (Jython 2.7 doesn't have inspect.Parameter.empty) +EMPTY = object() + +def parse_literal_value(value_str): + """ + Parse a string representation of a value into its proper Python type. + Mimics ast.literal_eval behavior used in the PyQt implementation. + + Args: + value_str: String representation of the value + + Returns: + Parsed value with appropriate Python type + """ + if value_str is None or value_str == '': + return EMPTY + + value_str = value_str.strip() + + # Handle None/null + if value_str in ('None', 'null'): + return None + + # Handle booleans + if value_str in ('True', 'true'): + return True + if value_str in ('False', 'false'): + return False + + # Handle quoted strings + if ((value_str.startswith("'") and value_str.endswith("'")) or + (value_str.startswith('"') and value_str.endswith('"'))): + return value_str[1:-1] + + # Try numeric parsing + try: + if '.' in value_str: + return float(value_str) + else: + return int(value_str) + except ValueError: + pass + + # Try ast.literal_eval for lists, dicts, tuples, etc. + try: + return ast.literal_eval(value_str) + except (ValueError, SyntaxError): + # If all else fails, return as string + return value_str + + +def convert_parameters(parameters_data): + """ + Convert parameter values from string representations to typed objects. + + Args: + parameters_data: List of parameter dictionaries with 'name', 'value', 'enabled' fields + + Returns: + Dictionary mapping parameter names to their parsed values + """ + result = {} + + for param in parameters_data: + param_name = param.get('name') + param_value = param.get('value') + is_enabled = param.get('enabled', True) + default_value = param.get('defaultValue') + + if not is_enabled: + # Use default value for disabled parameters + if default_value is not None: + result[param_name] = default_value + continue + + if param_value is None or param_value == '': + # Empty value - use default if available + if default_value is not None: + result[param_name] = default_value + continue + + try: + # Parse the value + parsed_value = parse_literal_value(param_value) + + # Only include if it's not empty + if parsed_value != EMPTY: + result[param_name] = parsed_value + + except Exception as e: + # Log error but continue processing other parameters + sys.stderr.write("Warning: Failed to parse parameter '%s' with value '%s': %s\n" % (param_name, param_value, e)) + # Fall back to string value + result[param_name] = param_value + + return result + + +def validate_parameters(parameters_data): + """ + Validate parameter values and return validation results. + + Args: + parameters_data: List of parameter dictionaries + + Returns: + Dictionary with validation results for each parameter + """ + validation_results = {} + + for param in parameters_data: + param_name = param.get('name') + param_value = param.get('value') + is_enabled = param.get('enabled', True) + is_optional = param.get('isOptional', False) + + if not is_enabled: + validation_results[param_name] = {'valid': True, 'message': 'Disabled'} + continue + + if param_value is None or param_value == '': + is_valid = is_optional + validation_results[param_name] = { + 'valid': is_valid, + 'message': 'Required parameter missing' if not is_valid else 'OK' + } + continue + + try: + # Try to parse the value + parse_literal_value(param_value) + validation_results[param_name] = {'valid': True, 'message': 'OK'} + except Exception as e: + validation_results[param_name] = { + 'valid': False, + 'message': 'Parse error: %s' % str(e) + } + + return validation_results + + +# Main execution +if __name__ == '__main__': + # When run directly (for testing) + test_params = [ + {'name': 'detector', 'value': "'det1'", 'enabled': True, 'isOptional': False}, + {'name': 'num_points', 'value': '10', 'enabled': True, 'isOptional': False}, + {'name': 'exposure', 'value': '0.5', 'enabled': True, 'isOptional': False}, + {'name': 'metadata', 'value': "{'key': 'value'}", 'enabled': True, 'isOptional': True}, + ] + + result = convert_parameters(test_params) + # Don't print - just for testing + pass + +# Script entry point for Jython execution +# Expects: parameters_json (input), sets: result (output) +try: + if 'parameters_json' in dir(): + # Parse input JSON + params_data = json.loads(parameters_json) + + # Convert parameters + converted = convert_parameters(params_data) + + # Set result as JSON string + result = json.dumps(converted) + +except Exception as e: + # Return error as result + result = json.dumps({ + 'error': str(e), + 'type': type(e).__name__ + }) + sys.stderr.write("Error in type_converter.py: %s\n" % e)