View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.job;
17  
18  import static org.codelibs.core.stream.StreamUtil.stream;
19  
20  import java.io.File;
21  import java.util.ArrayList;
22  import java.util.List;
23  import java.util.Map;
24  
25  import org.apache.commons.lang3.RandomStringUtils;
26  import org.apache.logging.log4j.LogManager;
27  import org.apache.logging.log4j.Logger;
28  import org.codelibs.core.lang.StringUtil;
29  import org.codelibs.core.timer.TimeoutTask;
30  import org.codelibs.fess.Constants;
31  import org.codelibs.fess.exception.JobProcessingException;
32  import org.codelibs.fess.helper.ProcessHelper;
33  import org.codelibs.fess.mylasta.direction.FessConfig;
34  import org.codelibs.fess.util.ComponentUtil;
35  import org.codelibs.fess.util.InputStreamThread;
36  import org.codelibs.fess.util.JobProcess;
37  import org.codelibs.fess.util.SystemUtil;
38  
39  import jakarta.servlet.ServletContext;
40  
41  /**
42   * Job for executing Python scripts within the Fess search engine environment.
43   * This job extends ExecJob to provide Python-specific functionality for running
44   * Python scripts with proper environment setup and argument passing.
45   *
46   * <p>Python scripts are executed in the WEB-INF/env/python/resources directory
47   * and have access to the Fess system environment including OpenSearch URL and session ID.</p>
48   */
49  public class PythonJob extends ExecJob {
50      /** Logger instance for this class */
51      static final Logger logger = LogManager.getLogger(PythonJob.class);
52  
53      /**
54       * Default constructor for PythonJob.
55       * Creates a new instance of the Python job with default settings.
56       */
57      public PythonJob() {
58          super();
59      }
60  
61      /** The Python script filename to execute */
62      protected String filename;
63  
64      /** List of command-line arguments to pass to the Python script */
65      protected List<String> argList = new ArrayList<>();
66  
67      /**
68       * Sets the Python script filename to execute.
69       *
70       * @param filename the Python script filename (relative to WEB-INF/env/python/resources)
71       * @return this PythonJob instance for method chaining
72       */
73      public PythonJob filename(final String filename) {
74          this.filename = filename;
75          return this;
76      }
77  
78      /**
79       * Adds a single command-line argument to pass to the Python script.
80       *
81       * @param value the argument value to add
82       * @return this PythonJob instance for method chaining
83       */
84      public PythonJob arg(final String value) {
85          argList.add(value);
86          return this;
87      }
88  
89      /**
90       * Adds multiple command-line arguments to pass to the Python script.
91       *
92       * @param values the argument values to add
93       * @return this PythonJob instance for method chaining
94       */
95      public PythonJob args(final String... values) {
96          stream(values).of(stream -> stream.forEach(argList::add));
97          return this;
98      }
99  
100     /**
101      * Executes the Python script job.
102      * Creates a session ID, sets up the execution environment, and runs the Python script
103      * with the configured filename and arguments.
104      *
105      * @return a string containing the execution result and any error messages
106      */
107     @Override
108     public String execute() {
109         final StringBuilder resultBuf = new StringBuilder();
110 
111         if (sessionId == null) { // create session id
112             sessionId = RandomStringUtils.randomAlphabetic(15);
113         }
114         resultBuf.append("Session Id: ").append(sessionId).append("\n");
115         if (jobExecutor != null) {
116             jobExecutor.addShutdownListener(() -> ComponentUtil.getProcessHelper().destroyProcess(sessionId));
117         }
118 
119         final TimeoutTask timeoutTask = createTimeoutTask();
120         try {
121             executePython();
122         } catch (final Exception e) {
123             logger.warn("Failed to run python command.", e);
124             resultBuf.append(e.getMessage()).append("\n");
125         } finally {
126             if (timeoutTask != null && !timeoutTask.isCanceled()) {
127                 timeoutTask.cancel();
128             }
129         }
130 
131         return resultBuf.toString();
132 
133     }
134 
135     /**
136      * Executes the Python script with the configured parameters.
137      * Sets up the command list, working directory, and environment variables,
138      * then starts the Python process and waits for completion.
139      *
140      * @throws JobProcessingException if the Python script execution fails
141      */
142     protected void executePython() {
143         final List<String> cmdList = new ArrayList<>();
144         final ServletContext servletContext = ComponentUtil.getComponent(ServletContext.class);
145         final ProcessHelper processHelper = ComponentUtil.getProcessHelper();
146         final FessConfig fessConfig = ComponentUtil.getFessConfig();
147 
148         if (StringUtil.isBlank(filename)) {
149             throw new JobProcessingException("Python script is not specified.");
150         }
151 
152         cmdList.add(fessConfig.getPythonCommandPath());
153 
154         cmdList.add(getPyFilePath());
155 
156         cmdList.addAll(argList);
157 
158         try {
159 
160             final File baseDir = new File(servletContext.getRealPath("/WEB-INF")).getParentFile();
161 
162             if (logger.isInfoEnabled()) {
163                 logger.info("Python: \nDirectory={}\nOptions={}", baseDir, cmdList);
164             }
165 
166             final JobProcess jobProcess = processHelper.startProcess(sessionId, cmdList, pb -> {
167                 pb.directory(baseDir);
168                 pb.redirectErrorStream(true);
169                 final Map<String, String> environment = pb.environment();
170                 environment.put("SESSION_ID", sessionId);
171                 environment.put("OPENSEARCH_URL", SystemUtil.getSearchEngineHttpAddress());
172             });
173 
174             final InputStreamThread it = jobProcess.getInputStreamThread();
175             it.start();
176 
177             final Process currentProcess = jobProcess.getProcess();
178             currentProcess.waitFor();
179             it.join(5000);
180 
181             final int exitValue = currentProcess.exitValue();
182 
183             if (logger.isInfoEnabled()) {
184                 logger.info("Python: Exit Code={} - Process Output:\n{}", exitValue, it.getOutput());
185             }
186             if (exitValue != 0) {
187                 final StringBuilder out = new StringBuilder();
188                 if (processTimeout) {
189                     out.append("Process is terminated due to ").append(timeout).append(" second exceeded.\n");
190                 }
191                 out.append("Exit Code: ").append(exitValue).append("\nOutput:\n").append(it.getOutput());
192                 throw new JobProcessingException(out.toString());
193             }
194             ComponentUtil.getPopularWordHelper().clearCache();
195         } catch (final JobProcessingException e) {
196             throw e;
197         } catch (final Exception e) {
198             throw new JobProcessingException("Python Process terminated.", e);
199         } finally {
200             processHelper.destroyProcess(sessionId);
201 
202         }
203     }
204 
205     /**
206      * Constructs the file path for the Python script to execute.
207      * The path is relative to the web application root and follows the pattern:
208      * WEB-INF/env/python/resources/{filename}
209      *
210      * @return the constructed file path for the Python script
211      */
212     protected String getPyFilePath() {
213         final StringBuilder buf = new StringBuilder(100);
214         buf.append("WEB-INF");
215         buf.append(File.separator);
216         buf.append("env");
217         buf.append(File.separator);
218         buf.append(getExecuteType());
219         buf.append(File.separator);
220         buf.append("resources");
221         buf.append(File.separator);
222         buf.append(filename.replaceAll("\\.\\.+", ""));
223         return buf.toString();
224     }
225 
226     /**
227      * Returns the execution type identifier for Python jobs.
228      *
229      * @return the execution type constant for Python jobs
230      */
231     @Override
232     protected String getExecuteType() {
233         return Constants.EXECUTE_TYPE_PYTHON;
234     }
235 }