From 6e4397affeb57587b4235ec71d82e82b8b215739 Mon Sep 17 00:00:00 2001 From: Mikhail Dzianishchyts Date: Fri, 3 Oct 2025 15:09:43 +0300 Subject: [PATCH 1/4] Load extensions from extra classpath --- .../org/dflib/jjava/kernel/JavaKernel.java | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/jjava-kernel/src/main/java/org/dflib/jjava/kernel/JavaKernel.java b/jjava-kernel/src/main/java/org/dflib/jjava/kernel/JavaKernel.java index 075929c..7de0210 100644 --- a/jjava-kernel/src/main/java/org/dflib/jjava/kernel/JavaKernel.java +++ b/jjava-kernel/src/main/java/org/dflib/jjava/kernel/JavaKernel.java @@ -28,8 +28,11 @@ import org.dflib.jjava.jupyter.kernel.magic.MagicsRegistry; import org.dflib.jjava.jupyter.kernel.util.CharPredicate; import org.dflib.jjava.jupyter.kernel.util.StringStyler; +import org.dflib.jjava.jupyter.kernel.util.GlobFinder; +import java.io.IOException; import java.nio.charset.Charset; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -370,7 +373,7 @@ public JavaKernel build() { LanguageInfo langInfo = buildLanguageInfo(); MagicTranspiler magicTranspiler = buildMagicTranspiler(); - return new JavaKernel( + JavaKernel kernel = new JavaKernel( name, buildVersion(), langInfo, @@ -387,6 +390,8 @@ public JavaKernel build() { jShell, buildCodeEvaluator(jShell, jShellExecutionControlProvider) ); + loadExtensions(kernel); + return kernel; } protected List buildHelpLinks() { @@ -395,5 +400,26 @@ protected List buildHelpLinks() { new HelpLink("JJava homepage", "https://github.com/dflib/jjava") ); } + + private void loadExtensions(JavaKernel kernel) { + if (this.extraClasspath.isEmpty() || !kernel.extensionsEnabled) { + return; + } + + List resolvedPaths = new ArrayList<>(); + for (String cp : this.extraClasspath) { + if (cp == null || cp.isBlank()) { + continue; + } + try { + Iterable paths = new GlobFinder(cp).computeMatchingPaths(); + paths.forEach(p -> resolvedPaths.add(p.toAbsolutePath().toString())); + } catch (IOException e) { + throw new RuntimeException(String.format("Error computing classpath entries for '%s': %s", cp, e.getMessage()), e); + } + } + + kernel.extensionLoader.loadFromJars(resolvedPaths).forEach(e -> e.install(kernel)); + } } } From cf6d05180c6ea2cf161b47821326f8a69f8c9e81 Mon Sep 17 00:00:00 2001 From: Mikhail Dzianishchyts Date: Fri, 3 Oct 2025 15:16:07 +0300 Subject: [PATCH 2/4] Clean up --- .../src/main/java/org/dflib/jjava/kernel/JavaKernel.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jjava-kernel/src/main/java/org/dflib/jjava/kernel/JavaKernel.java b/jjava-kernel/src/main/java/org/dflib/jjava/kernel/JavaKernel.java index 7de0210..1afc032 100644 --- a/jjava-kernel/src/main/java/org/dflib/jjava/kernel/JavaKernel.java +++ b/jjava-kernel/src/main/java/org/dflib/jjava/kernel/JavaKernel.java @@ -402,12 +402,12 @@ protected List buildHelpLinks() { } private void loadExtensions(JavaKernel kernel) { - if (this.extraClasspath.isEmpty() || !kernel.extensionsEnabled) { + if (extraClasspath.isEmpty() || !kernel.extensionsEnabled) { return; } List resolvedPaths = new ArrayList<>(); - for (String cp : this.extraClasspath) { + for (String cp : extraClasspath) { if (cp == null || cp.isBlank()) { continue; } From 60749cc9af48493962e570b583c020933ac466c2 Mon Sep 17 00:00:00 2001 From: Mikhail Dzianishchyts Date: Fri, 10 Oct 2025 13:48:33 +0300 Subject: [PATCH 3/4] Make JavaKernel constructor require extra classpath --- .../java/org/dflib/jjava/kernel/JavaKernel.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/jjava-kernel/src/main/java/org/dflib/jjava/kernel/JavaKernel.java b/jjava-kernel/src/main/java/org/dflib/jjava/kernel/JavaKernel.java index 1afc032..df06bbf 100644 --- a/jjava-kernel/src/main/java/org/dflib/jjava/kernel/JavaKernel.java +++ b/jjava-kernel/src/main/java/org/dflib/jjava/kernel/JavaKernel.java @@ -78,6 +78,7 @@ protected JavaKernel( MagicParser magicParser, MagicsRegistry magicsRegistry, ExtensionLoader extensionLoader, + List extraClasspath, boolean extensionsEnabled, StringStyler errorStyler, JShell jShell, @@ -103,6 +104,7 @@ protected JavaKernel( if (extensionsEnabled) { this.extensionLoader.loadFromClasspath().forEach(e -> e.install(this)); + this.extensionLoader.loadFromJars(extraClasspath).forEach(e -> e.install(this)); } } @@ -373,7 +375,7 @@ public JavaKernel build() { LanguageInfo langInfo = buildLanguageInfo(); MagicTranspiler magicTranspiler = buildMagicTranspiler(); - JavaKernel kernel = new JavaKernel( + return new JavaKernel( name, buildVersion(), langInfo, @@ -385,13 +387,12 @@ public JavaKernel build() { buildMagicParser(magicTranspiler), buildMagicsRegistry(), buildExtensionLoader(), + buildExtraClasspath(), buildExtensionsEnabled(), buildErrorStyler(), jShell, buildCodeEvaluator(jShell, jShellExecutionControlProvider) ); - loadExtensions(kernel); - return kernel; } protected List buildHelpLinks() { @@ -401,9 +402,9 @@ protected List buildHelpLinks() { ); } - private void loadExtensions(JavaKernel kernel) { - if (extraClasspath.isEmpty() || !kernel.extensionsEnabled) { - return; + private List buildExtraClasspath() { + if (extraClasspath.isEmpty()) { + return List.of(); } List resolvedPaths = new ArrayList<>(); @@ -418,8 +419,7 @@ private void loadExtensions(JavaKernel kernel) { throw new RuntimeException(String.format("Error computing classpath entries for '%s': %s", cp, e.getMessage()), e); } } - - kernel.extensionLoader.loadFromJars(resolvedPaths).forEach(e -> e.install(kernel)); + return resolvedPaths; } } } From 03d382e6f16336de0b846b5f9517f93167c6a6fb Mon Sep 17 00:00:00 2001 From: Mikhail Dzianishchyts Date: Fri, 10 Oct 2025 13:49:51 +0300 Subject: [PATCH 4/4] Add unit tests --- jjava-kernel/pom.xml | 16 ++- .../kernel/ExtensionLoadingBuiltInTest.java | 14 +++ .../ExtensionLoadingExtraClasspathTest.java | 41 ++++++ .../dflib/jjava/kernel/TestJarFactory.java | 119 ++++++++++++++++++ .../org.dflib.jjava.jupyter.Extension | 1 + .../jjava/kernel/test/TestExtension.java | 19 +++ 6 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 jjava-kernel/src/test/java/org/dflib/jjava/kernel/ExtensionLoadingBuiltInTest.java create mode 100644 jjava-kernel/src/test/java/org/dflib/jjava/kernel/ExtensionLoadingExtraClasspathTest.java create mode 100644 jjava-kernel/src/test/java/org/dflib/jjava/kernel/TestJarFactory.java create mode 100644 jjava-kernel/src/test/resources/java/META-INF/services/org.dflib.jjava.jupyter.Extension create mode 100644 jjava-kernel/src/test/resources/java/org/dflib/jjava/kernel/test/TestExtension.java diff --git a/jjava-kernel/pom.xml b/jjava-kernel/pom.xml index 24d10e4..8387de4 100644 --- a/jjava-kernel/pom.xml +++ b/jjava-kernel/pom.xml @@ -1,5 +1,6 @@ - + 4.0.0 @@ -30,4 +31,17 @@ test + + + + + org.apache.maven.plugins + maven-surefire-plugin + + --add-opens jdk.jshell/jdk.jshell=ALL-UNNAMED + false + + + + diff --git a/jjava-kernel/src/test/java/org/dflib/jjava/kernel/ExtensionLoadingBuiltInTest.java b/jjava-kernel/src/test/java/org/dflib/jjava/kernel/ExtensionLoadingBuiltInTest.java new file mode 100644 index 0000000..a30cf45 --- /dev/null +++ b/jjava-kernel/src/test/java/org/dflib/jjava/kernel/ExtensionLoadingBuiltInTest.java @@ -0,0 +1,14 @@ +package org.dflib.jjava.kernel; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class ExtensionLoadingBuiltInTest { + + @Test + void loadsExtensionsFromInitialClasspath() { + JavaKernel.builder().name("TestKernel").build(); + assertNotNull(JavaNotebookStatics.kernel(), "Built-in extension should be installed from initial classpath"); + } +} diff --git a/jjava-kernel/src/test/java/org/dflib/jjava/kernel/ExtensionLoadingExtraClasspathTest.java b/jjava-kernel/src/test/java/org/dflib/jjava/kernel/ExtensionLoadingExtraClasspathTest.java new file mode 100644 index 0000000..9692344 --- /dev/null +++ b/jjava-kernel/src/test/java/org/dflib/jjava/kernel/ExtensionLoadingExtraClasspathTest.java @@ -0,0 +1,41 @@ +package org.dflib.jjava.kernel; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ExtensionLoadingExtraClasspathTest { + + @Test + void loadsExtensionsFromExtraClasspath() throws Exception { + Path jar = TestJarFactory.buildJar( + "java/", + "java/org/dflib/jjava/kernel/test/TestExtension.java", + "java/META-INF/services/org.dflib.jjava.jupyter.Extension" + ); + + List extraClasspath = Stream.of(jar) + .map(Path::toAbsolutePath) + .map(Path::toString) + .collect(Collectors.toList()); + + String extensionClassName = "org.dflib.jjava.kernel.test.TestExtension"; + String installationsProperty = extensionInstallationsProperty(extensionClassName); + System.clearProperty(installationsProperty); + + JavaKernel kernel = JavaKernel.builder().name("TestKernel").extraClasspath(extraClasspath).build(); + assertEquals("1", System.getProperty(installationsProperty)); + + kernel.addToClasspath(extraClasspath); + assertEquals("1", System.getProperty(installationsProperty)); + } + + private static String extensionInstallationsProperty(String className) { + return "ext.installs:" + className; + } +} diff --git a/jjava-kernel/src/test/java/org/dflib/jjava/kernel/TestJarFactory.java b/jjava-kernel/src/test/java/org/dflib/jjava/kernel/TestJarFactory.java new file mode 100644 index 0000000..11fadae --- /dev/null +++ b/jjava-kernel/src/test/java/org/dflib/jjava/kernel/TestJarFactory.java @@ -0,0 +1,119 @@ +package org.dflib.jjava.kernel; + +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.StandardLocation; +import javax.tools.ToolProvider; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +final class TestJarFactory { + + private static final Map, Path> cache = new HashMap<>(); + + private TestJarFactory() { + } + + static synchronized Path buildJar(String prefix, String... resourcePaths) throws Exception { + Set cacheKey = Set.of(resourcePaths); + if (cache.containsKey(cacheKey)) { + Path cached = cache.get(cacheKey); + if (Files.exists(cached)) { + return cached; + } + } + + Path tmpDir = Files.createTempDirectory("jjava-test-jar-"); + Path srcDir = tmpDir.resolve("src"); + Path classesDir = tmpDir.resolve("classes"); + Files.createDirectories(srcDir); + Files.createDirectories(classesDir); + + moveResources(prefix, resourcePaths, srcDir, classesDir); + compileJava(srcDir, classesDir); + Path jar = packageJar(tmpDir.resolve("test.jar"), classesDir); + + cache.put(cacheKey, jar); + return jar; + } + + private static void moveResources(String prefix, String[] resourcePaths, Path srcDir, Path classesDir) throws IOException { + for (String resourcePath : resourcePaths) { + URL url = TestJarFactory.class.getResource("/" + resourcePath); + if (url == null) { + throw new IllegalArgumentException("Resource not found: " + resourcePath); + } + + String targetRelativePath = resourcePath; + if (prefix != null && !prefix.isEmpty() && resourcePath.startsWith(prefix)) { + targetRelativePath = resourcePath.substring(prefix.length()); + } + + Path targetDir = resourcePath.endsWith(".java") ? srcDir : classesDir; + Path targetPath = targetDir.resolve(targetRelativePath); + Files.createDirectories(targetPath.getParent()); + try (InputStream in = url.openStream()) { + Files.copy(in, targetPath, StandardCopyOption.REPLACE_EXISTING); + } + } + } + + private static void compileJava(Path srcDir, Path classesDir) throws IOException { + List srcFiles; + try (Stream stream = Files.walk(srcDir)) { + srcFiles = stream.filter(Files::isRegularFile).map(Path::toFile).collect(Collectors.toList()); + } + if (srcFiles.isEmpty()) { + return; + } + + JavaCompiler compiler = Objects.requireNonNull(ToolProvider.getSystemJavaCompiler()); + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + try (StandardJavaFileManager fm = compiler.getStandardFileManager(diagnostics, null, null)) { + fm.setLocation(StandardLocation.CLASS_OUTPUT, List.of(classesDir.toFile())); + Iterable units = fm.getJavaFileObjectsFromFiles(srcFiles); + Boolean ok = compiler.getTask(null, fm, diagnostics, null, null, units).call(); + if (!Boolean.TRUE.equals(ok)) { + throw new IllegalStateException("Compilation failed: " + diagnostics.getDiagnostics()); + } + } + } + + private static Path packageJar(Path jarPath, Path classesDir) throws IOException { + try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(jarPath))) { + writeDirToJar(jos, classesDir, classesDir); + } + return jarPath; + } + + private static void writeDirToJar(JarOutputStream jos, Path root, Path dir) throws IOException { + try (Stream stream = Files.walk(dir)) { + for (Path path : (Iterable) stream::iterator) { + if (Files.isDirectory(path)) { + continue; + } + String entryName = root.relativize(path).toString().replace('\\', '/'); + JarEntry entry = new JarEntry(entryName); + jos.putNextEntry(entry); + Files.copy(path, jos); + jos.closeEntry(); + } + } + } +} diff --git a/jjava-kernel/src/test/resources/java/META-INF/services/org.dflib.jjava.jupyter.Extension b/jjava-kernel/src/test/resources/java/META-INF/services/org.dflib.jjava.jupyter.Extension new file mode 100644 index 0000000..dc621af --- /dev/null +++ b/jjava-kernel/src/test/resources/java/META-INF/services/org.dflib.jjava.jupyter.Extension @@ -0,0 +1 @@ +org.dflib.jjava.kernel.test.TestExtension diff --git a/jjava-kernel/src/test/resources/java/org/dflib/jjava/kernel/test/TestExtension.java b/jjava-kernel/src/test/resources/java/org/dflib/jjava/kernel/test/TestExtension.java new file mode 100644 index 0000000..9be9422 --- /dev/null +++ b/jjava-kernel/src/test/resources/java/org/dflib/jjava/kernel/test/TestExtension.java @@ -0,0 +1,19 @@ +package org.dflib.jjava.kernel.test; + +import org.dflib.jjava.jupyter.Extension; +import org.dflib.jjava.jupyter.kernel.BaseKernel; + +public class TestExtension implements Extension { + + @Override + public void install(BaseKernel kernel) { + String key = "ext.installs:" + getClass().getName(); + String value = System.getProperty(key, "0"); + try { + int count = Integer.parseInt(value); + System.setProperty(key, String.valueOf(count + 1)); + } catch (NumberFormatException e) { + System.setProperty(key, "1"); + } + } +}