Skip to content

Commit 832c8b7

Browse files
authored
Merge branch 'development' into renovate/gradle-8.x
2 parents fac2302 + 45d9a40 commit 832c8b7

File tree

28 files changed

+391
-805
lines changed

28 files changed

+391
-805
lines changed

build.gradle

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ plugins {
88
id "com.gorylenko.gradle-git-properties" version "2.5.0"
99
id 'io.freefair.lombok' version '8.13.1'
1010
id 'java'
11+
id 'application'
1112
id 'jacoco'
1213
}
1314

@@ -75,14 +76,19 @@ dependencies {
7576
implementation "org.javers:javers-spring-boot-starter-sql:${javersVersion}"
7677
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
7778
implementation 'org.apache.commons:commons-collections4:4.4'
79+
implementation 'org.apache.maven:maven-artifact:3.6.3'
7880
implementation 'org.json:json:20250107'
7981
implementation 'com.github.jknack:handlebars:4.4.0'
8082
implementation 'com.google.guava:guava:33.4.8-jre'
8183
implementation 'commons-io:commons-io:2.19.0'
8284
implementation 'javax.validation:validation-api:2.0.1.Final'
83-
implementation 'edu.kit.datamanager:service-base:1.3.3'
85+
implementation ('edu.kit.datamanager:service-base:1.3.3'){
86+
//exclude dependency as spring boot includes
87+
//org.glassfish.jaxb:jaxb-core:4.0.5 which leads to a duplication conflict
88+
exclude group: "com.sun.xml.bind"
89+
}
8490
// apache
85-
implementation "org.apache.tika:tika-core:2.9.3"
91+
implementation "org.apache.tika:tika-core:3.1.0"
8692

8793
testImplementation platform('org.junit:junit-bom')
8894
testImplementation 'org.junit.jupiter:junit-jupiter'

src/main/java/edu/kit/datamanager/mappingservice/configuration/ApplicationProperties.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
*/
1616
package edu.kit.datamanager.mappingservice.configuration;
1717

18-
import edu.kit.datamanager.annotations.ExecutableFileURL;
1918
import edu.kit.datamanager.annotations.LocalFolderURL;
2019
import edu.kit.datamanager.validator.ExecutableFileValidator;
2120
import lombok.Data;
@@ -60,6 +59,14 @@ public class ApplicationProperties {
6059
@Value("${mapping-service.mappingSchemasLocation}")
6160
private URL mappingsLocation;
6261

62+
/**
63+
* The absolute path where mapping plugin code is checked out into, i.e.,
64+
* for Python-based plugins.
65+
*/
66+
@LocalFolderURL
67+
@Value("${mapping-service.codeLocation}")
68+
private URL codeLocation;
69+
6370
/**
6471
* The absolute path where job data is stored.
6572
*/

src/main/java/edu/kit/datamanager/mappingservice/impl/MappingService.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,11 @@ private void init(ApplicationProperties applicationProperties) {
498498
} catch (IOException e) {
499499
throw new MappingServiceException(String.format("Could not initialize job output directory '%s'.", applicationProperties.getJobOutputLocation()), e);
500500
}
501+
try {
502+
Files.createDirectories(new File(applicationProperties.getCodeLocation().getPath()).getAbsoluteFile().toPath());
503+
} catch (IOException e) {
504+
throw new MappingServiceException(String.format("Could not initialize code target directory '%s'.", applicationProperties.getCodeLocation()), e);
505+
}
501506
} else {
502507
throw new MappingServiceException("Cannot configure MappingService due to missing application.properties.");
503508
}
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
/*
2+
* Copyright 2025 Karlsruhe Institute of Technology.
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
package edu.kit.datamanager.mappingservice.plugins;
16+
17+
import edu.kit.datamanager.mappingservice.configuration.ApplicationProperties;
18+
import edu.kit.datamanager.mappingservice.exception.PluginInitializationFailedException;
19+
import edu.kit.datamanager.mappingservice.util.FileUtil;
20+
import edu.kit.datamanager.mappingservice.util.PythonRunnerUtil;
21+
import edu.kit.datamanager.mappingservice.util.ShellRunnerUtil;
22+
import java.io.ByteArrayOutputStream;
23+
import java.io.IOException;
24+
import java.io.InputStream;
25+
import java.net.URISyntaxException;
26+
import java.net.URL;
27+
import java.nio.file.Path;
28+
import java.nio.file.Paths;
29+
import java.util.Arrays;
30+
import java.util.LinkedList;
31+
import java.util.List;
32+
import java.util.Properties;
33+
import org.apache.maven.artifact.versioning.ComparableVersion;
34+
import org.slf4j.Logger;
35+
import org.slf4j.LoggerFactory;
36+
import org.springframework.beans.factory.annotation.Autowired;
37+
import org.springframework.stereotype.Component;
38+
39+
/**
40+
*
41+
* @author jejkal
42+
*/
43+
public abstract class AbstractPythonMappingPlugin implements IMappingPlugin {
44+
45+
private final Logger LOGGER = LoggerFactory.getLogger(AbstractPythonMappingPlugin.class);
46+
47+
/**
48+
* The plugin name.
49+
*/
50+
private String name;
51+
/**
52+
* The URL of the Git repository where the plugin code is located.
53+
*/
54+
private String repositoryUrl;
55+
56+
/**
57+
* The tag which should be used to checkout a specific version from
58+
* repositoryUrl.
59+
*/
60+
private String tag;
61+
62+
/**
63+
* The minimal python version required by the plugin
64+
*/
65+
private String minPython;
66+
67+
/**
68+
* The folder where the code is checked out from repositoryUrl.
69+
*/
70+
private Path dir;
71+
72+
private String pluginVenv = "venv/PluginVenv";
73+
private String venvInterpreter;
74+
75+
/**
76+
* Default constructor for instantiating a Python-based mapping plugin. It
77+
* is assumed, that the code for the plugin is stored in a Git repository
78+
* located at 'repositoryUrl'. Furthermore, it is assumed, that the plugin
79+
* itself is delivered as a single Jar file which contains at least the
80+
* plugin class and a file PLUGIN_NAME.properties, where PLUGIN_NAME must be
81+
* identical to the 'pluginName' argument and the file must be located in
82+
* the root of the Jar file. The properties file must contain one property
83+
* 'version' which represents an existing Git tag matching a released
84+
* version of the Python plugin code, e.g., version=v1.0.0
85+
*
86+
* Furthermore, the properties file may contain a minimal Python version
87+
* that is required by the plugin to work. The minimal Python version is set
88+
* via the 'min.python' property, e.g.., min.python=3.10.0 If the minimal
89+
* Python version is not met, the plugin will be ignored.
90+
*
91+
* @param pluginName The name of the plugin.
92+
* @param repositoryUrl The Git repository where the plugin Python code is
93+
* located.
94+
*/
95+
public AbstractPythonMappingPlugin(String pluginName, String repositoryUrl) {
96+
try {
97+
name = pluginName;
98+
this.repositoryUrl = repositoryUrl;
99+
// Get the context class loader
100+
ClassLoader classLoader = this.getClass().getClassLoader();
101+
// TODO: do we need to make sure that the resource path is somehow related to the current plugin to avoid loading the wrong property file in case of identical property names?
102+
URL resource = classLoader.getResource(pluginName.toLowerCase() + ".properties");
103+
LOGGER.info("Resource file: {}", resource);
104+
if (resource != null) {
105+
// Load the properties file
106+
try (InputStream input = resource.openStream()) {
107+
Properties properties = new Properties();
108+
properties.load(input);
109+
tag = properties.getProperty("version");
110+
minPython = properties.getProperty("min.python");
111+
}
112+
} else {
113+
System.err.println("Properties file not found!");
114+
tag = "unavailable";
115+
}
116+
117+
if (System.getProperty("os.name").startsWith("Windows")) {
118+
venvInterpreter = pluginVenv + "/Scripts/python.exe";
119+
} else {
120+
venvInterpreter = pluginVenv + "/bin/python3";
121+
}
122+
} catch (IOException e) {
123+
throw new PluginInitializationFailedException("Failed to instantiate plugin class.", e);
124+
}
125+
}
126+
127+
/**
128+
* Abstract method that is supposed to be implemented by each Python mapping
129+
* plugin to gather all information required for starting a Python process
130+
* executing the mapping script. The returned array must contain at least
131+
* the following information:
132+
*
133+
* <ul> <li>The absolute path of the main script. It must start
134+
* with the working dir received as argument, where all checked-out code is
135+
* located.</li> <li>Script-specific parameters to provide
136+
* mappingFile, inputFile, and outputFile to the script execution. Depending
137+
* on the script implementation, the number and kind of required arguments
138+
* may differ.</li> </ul>
139+
*
140+
* Example: In standalone mode, a script is called via `plugin_wrapper.py
141+
* sem -m mappingFile -i inputFile -o outputFile -debug`. In that case, the
142+
* resulting array should look as follows: [workingDir +
143+
* "plugin_wrapper.py", "sem", "-m", mappingFile.toString(), "-i",
144+
* inputFile.toString(), "-o", outputFile.toString(), "-debug"].
145+
*
146+
* The Python call itself will be added according to the Venv used for
147+
* plugin execution and must not be included.
148+
*
149+
* @param workingDir The working directory, i.e., where the plugin code was
150+
* checked-out into.
151+
* @param mappingFile The file which contains the mapping rules registered
152+
* at the mapping-service and used by the script.
153+
* @param inputFile The file which was uploaded by the user, i.e., the
154+
* source of the mapping process.
155+
* @param outputFile The destination where mapping results must be written
156+
* to in order to allow the mapping-service to return the result to the
157+
* user.
158+
*
159+
* @return A string array containing the single elements of the command line
160+
* call of the script.
161+
*/
162+
public abstract String[] getCommandArray(Path workingDir, Path mappingFile, Path inputFile, Path outputFile);
163+
164+
@Override
165+
public String name() {
166+
return this.name;
167+
}
168+
169+
@Override
170+
public String version() {
171+
return this.tag;
172+
}
173+
174+
@Override
175+
public String description() {
176+
return "Plugin " + name() + ", Version " + version() + ", Implementation: " + uri();
177+
}
178+
179+
@Override
180+
public String uri() {
181+
return this.repositoryUrl;
182+
}
183+
184+
@Override
185+
public void setup(ApplicationProperties applicationProperties) {
186+
LOGGER.trace("Setting up mapping plugin {} {}", name(), version());
187+
188+
//testing minimal Python version
189+
if (minPython != null) {
190+
if (!hasMinimalPythonVersion(minPython)) {
191+
throw new PluginInitializationFailedException("Minimal Python version '" + minPython + "' required by plugin not met.");
192+
}
193+
}
194+
195+
//checkout and install plugin
196+
try {
197+
LOGGER.info("Cloning git repository {}, tag {}", repositoryUrl, tag);
198+
Path path = Paths.get(applicationProperties.getCodeLocation().toURI());
199+
path = path.resolve(repositoryUrl.trim().replace("https://", "").replace("http://", "").replace(".git", "") + "_" + version());
200+
LOGGER.info("Target path: {}", path);
201+
dir = FileUtil.cloneGitRepository(repositoryUrl, tag, path.toAbsolutePath().toString());
202+
// Install Python dependencies
203+
MappingPluginState venvState = PythonRunnerUtil.runPythonScript("-m", "venv", "--system-site-packages", dir + "/" + pluginVenv);
204+
if (MappingPluginState.SUCCESS().getState().equals(venvState.getState())) {
205+
LOGGER.info("Venv for plugin installed successfully. Installing requirements.");
206+
207+
Path requirementsFile = Paths.get(dir + "/" + "requirements.dist.txt");
208+
if (requirementsFile.toFile().exists()) {
209+
MappingPluginState requirementsInstallState = ShellRunnerUtil.run(dir + "/" + venvInterpreter, "-m", "pip", "install", "-r", dir + "/" + "requirements.dist.txt");
210+
if (MappingPluginState.SUCCESS().getState().equals(requirementsInstallState.getState())) {
211+
LOGGER.info("Requirements for plugin installed successfully. Setup complete.");
212+
} else {
213+
throw new PluginInitializationFailedException("Failed to install plugin requirements. Status: " + venvState.getState());
214+
}
215+
} else {
216+
LOGGER.info("No requirements file found. Skipping dependency installation.");
217+
}
218+
} else {
219+
throw new PluginInitializationFailedException("Venv installation has failed. Status: " + venvState.getState());
220+
}
221+
} catch (URISyntaxException e) {
222+
throw new PluginInitializationFailedException("Invalid codeLocation configured in application.properties.", e);
223+
} catch (MappingPluginException e) {
224+
throw new PluginInitializationFailedException("Unexpected error during plugin setup.", e);
225+
}
226+
}
227+
228+
@Override
229+
public MappingPluginState mapFile(Path mappingFile, Path inputFile, Path outputFile) throws MappingPluginException {
230+
long startTime = System.currentTimeMillis();
231+
LOGGER.trace("Run mapping plugin {} {} on '{}' with mapping '{}' -> '{}'", name(), version(), mappingFile, inputFile, outputFile);
232+
String[] commandArray = getCommandArray(dir, mappingFile, inputFile, outputFile);
233+
List<String> command = new LinkedList<>();
234+
command.add(dir + "/" + venvInterpreter);
235+
command.addAll(Arrays.asList(commandArray));
236+
MappingPluginState result = ShellRunnerUtil.run(command.toArray(String[]::new));
237+
long endTime = System.currentTimeMillis();
238+
long totalTime = endTime - startTime;
239+
LOGGER.info("Execution time of mapFile: {} milliseconds", totalTime);
240+
return result;
241+
}
242+
243+
/**
244+
* This method checks if the local Python installation version is larger or
245+
* equal the provided version number. The version should be provided as
246+
* semantic version number, i.e., 3.13.2
247+
*
248+
* The method will return TRUE if the minimal requirements are met and false
249+
* otherwise. False is also returned if obtaining/parsing the local python
250+
* version fails. for any reason.
251+
*
252+
* @param versionString The semantic version string to compare the local
253+
* Python version against.
254+
*
255+
* @return True if versionString is smaller or equal the local Python
256+
* version, false otherwise.
257+
*/
258+
private boolean hasMinimalPythonVersion(String versionString) {
259+
boolean result = false;
260+
try {
261+
LOGGER.trace("Checking for minimal Python version {}.", versionString);
262+
ByteArrayOutputStream bout = new ByteArrayOutputStream();
263+
MappingPluginState state = PythonRunnerUtil.runPythonScript("--version", bout, System.err);
264+
265+
if (!MappingPluginState.StateEnum.SUCCESS.equals(state.getState())) {
266+
LOGGER.error("Failed to obtain Python version. python --version returned with status {}.", state.getState());
267+
} else {
268+
269+
LOGGER.trace("Version command output: {}", bout.toString());
270+
271+
String[] split = bout.toString().split(" ");
272+
273+
if (split.length == 2) {
274+
String localPythonVersion = bout.toString().split(" ")[1].trim();
275+
LOGGER.trace("Obtained local Python version: {}", localPythonVersion);
276+
ComparableVersion localVersion = new ComparableVersion(localPythonVersion);
277+
ComparableVersion minimalVersion = new ComparableVersion(versionString);
278+
result = minimalVersion.compareTo(localVersion) <= 0;
279+
} else {
280+
LOGGER.info("Unexpected Python version output. Unable to check for minimal version.");
281+
}
282+
}
283+
} catch (MappingPluginException e) {
284+
LOGGER.error("Failed to obtain Python version.", e);
285+
}
286+
return result;
287+
}
288+
}

0 commit comments

Comments
 (0)