From 4c48a8bb0f7b51e0e7b37408f9f397a574434424 Mon Sep 17 00:00:00 2001 From: Sameeul Samee Date: Mon, 3 Feb 2025 10:22:05 -0500 Subject: [PATCH 001/119] Relax jpype1 version constrain --- dev-environment.yml | 2 +- environment.yml | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dev-environment.yml b/dev-environment.yml index 2243e1c..d8d2568 100644 --- a/dev-environment.yml +++ b/dev-environment.yml @@ -20,7 +20,7 @@ channels: dependencies: - python >= 3.8 # Project dependencies - - jpype1 >= 1.3.0, <= 1.5.0 + - jpype1 >= 1.3.0 - jgo - openjdk >= 8, < 12 # Test dependencies diff --git a/environment.yml b/environment.yml index b5bfa73..c1038c5 100644 --- a/environment.yml +++ b/environment.yml @@ -21,7 +21,7 @@ channels: dependencies: - python >= 3.8 # Project dependencies - - jpype1 >= 1.3.0, <= 1.5.0 + - jpype1 >= 1.3.0 - jgo - openjdk >= 8 # Project from source diff --git a/pyproject.toml b/pyproject.toml index d0b7197..93f10f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ classifiers = [ # NB: Keep this in sync with environment.yml AND dev-environment.yml! requires-python = ">=3.8" dependencies = [ - "jpype1 >= 1.3.0, <= 1.5.0", + "jpype1 >= 1.3.0", "jgo", ] From ed767c4fb5b1914fb382270ec233bee5e4cab5b0 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Mon, 3 Feb 2025 20:08:19 -0600 Subject: [PATCH 002/119] Release version 1.10.2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 93f10f3..1a89482 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "scyjava" -version = "1.10.2.dev0" +version = "1.10.2" description = "Supercharged Java access from Python" license = {text = "The Unlicense"} authors = [{name = "SciJava developers", email = "ctrueden@wisc.edu"}] From b66fc06cd592cc21c14f94ea49c950622a912dc7 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Mon, 3 Feb 2025 20:11:39 -0600 Subject: [PATCH 003/119] Bump to next development cycle --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1a89482..bbcc8b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "scyjava" -version = "1.10.2" +version = "1.10.3.dev0" description = "Supercharged Java access from Python" license = {text = "The Unlicense"} authors = [{name = "SciJava developers", email = "ctrueden@wisc.edu"}] From 5b6195aa77900cad183b2d606b6e4c956700456c Mon Sep 17 00:00:00 2001 From: hinerm Date: Thu, 13 Feb 2025 10:48:41 -0600 Subject: [PATCH 004/119] Combine output lines when checking version Suggested by @gselzer to work around failure to detect Java version noted in https://forum.image.sc/t/pyimagej-on-windows-10/107934/11 when JAVA_TOOL_OPTIONS is set --- src/scyjava/_jvm.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index 2e2350a..d150ddc 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -98,6 +98,7 @@ def jvm_version() -> str: except subprocess.CalledProcessError as e: raise RuntimeError("System call to java failed") from e + output = output.replace('\n', ' ').replace('\r', '') m = re.match('.*version "(([0-9]+\\.)+[0-9]+)', output) if not m: raise RuntimeError(f"Inscrutable java command output:\n{output}") From d1583a1bb4b1fc795829bdb877eb2e8ba46af49c Mon Sep 17 00:00:00 2001 From: hinerm Date: Thu, 13 Feb 2025 10:53:49 -0600 Subject: [PATCH 005/119] Formatting fix --- src/scyjava/_jvm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index d150ddc..1a6c5ca 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -98,7 +98,7 @@ def jvm_version() -> str: except subprocess.CalledProcessError as e: raise RuntimeError("System call to java failed") from e - output = output.replace('\n', ' ').replace('\r', '') + output = output.replace("\n", " ").replace("\r", "") m = re.match('.*version "(([0-9]+\\.)+[0-9]+)', output) if not m: raise RuntimeError(f"Inscrutable java command output:\n{output}") From f55d40f55c694bd610948990e5f3c208715b498d Mon Sep 17 00:00:00 2001 From: ian-coccimiglio Date: Thu, 20 Mar 2025 16:00:40 -0700 Subject: [PATCH 006/119] Fix python scripting to allow global imports and added testfile --- src/scyjava/_script.py | 3 +- tests/it/script_scope.py | 61 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 tests/it/script_scope.py diff --git a/src/scyjava/_script.py b/src/scyjava/_script.py index ec73f90..7ce10d6 100644 --- a/src/scyjava/_script.py +++ b/src/scyjava/_script.py @@ -91,7 +91,8 @@ def apply(self, arg): # Last statement looks like an expression. Evaluate! last = ast.Expression(block.body.pop().value) - _globals = {} + _globals = {name: module for name, module in sys.modules.items() if name != '__main__'} + exec( compile(block, "", mode="exec"), _globals, script_locals ) diff --git a/tests/it/script_scope.py b/tests/it/script_scope.py new file mode 100644 index 0000000..9965d7f --- /dev/null +++ b/tests/it/script_scope.py @@ -0,0 +1,61 @@ +""" +Test the enable_python_scripting function, but here explictly testing import scope for declared functions. +""" + +import sys + +import scyjava + +scyjava.config.endpoints.extend( + ["org.scijava:scijava-common:2.94.2", "org.scijava:scripting-python:MANAGED"] +) + +# Create minimal SciJava context with a ScriptService. +Context = scyjava.jimport("org.scijava.Context") +ScriptService = scyjava.jimport("org.scijava.script.ScriptService") +# HACK: Avoid "[ERROR] Cannot create plugin" spam. +WidgetService = scyjava.jimport("org.scijava.widget.WidgetService") +ctx = Context(ScriptService, WidgetService) + +# Enable the Python script language. +scyjava.enable_python_scripting(ctx) + +# Assert that the Python script language is available. +ss = ctx.service("org.scijava.script.ScriptService") +lang = ss.getLanguageByName("Python") +assert lang is not None and "Python" in lang.getNames() + +# Construct a script. +script = """ +#@ int age +#@output String cbrt_age +import math + +def calculate_cbrt(age): + return round(math.cbrt(age)) + +cbrt_age = calculate_cbrt(age) +# cbrt_age = round(math.cbrt(age)) +f"The rounded cube root of my age is {cbrt_age}" +""" +StringReader = scyjava.jimport("java.io.StringReader") +ScriptInfo = scyjava.jimport("org.scijava.script.ScriptInfo") +info = ScriptInfo(ctx, "script.py", StringReader(script)) +info.setLanguage(lang) + +# Run the script. +future = ss.run(info, True, "age", 13) +try: + module = future.get() + outputs = module.getOutputs() + statement = outputs["cbrt_age"] + return_value = module.getReturnValue() +except Exception as e: + sys.stderr.write("-- SCRIPT EXECUTION FAILED --\n") + trace = scyjava.jstacktrace(e) + if trace: + sys.stderr.write(f"{trace}\n") + raise e + +assert return_value == "The rounded cube root of my age is 2" +assert statement == "2" From e260032af6f47fa2e440628e66f9d54413d8f6f6 Mon Sep 17 00:00:00 2001 From: ian-coccimiglio Date: Fri, 21 Mar 2025 00:26:07 -0700 Subject: [PATCH 007/119] Updated tests, fixed scoping issues --- src/scyjava/_script.py | 12 ++++++++---- tests/it/script_scope.py | 3 ++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/scyjava/_script.py b/src/scyjava/_script.py index 7ce10d6..0ba1b28 100644 --- a/src/scyjava/_script.py +++ b/src/scyjava/_script.py @@ -91,15 +91,19 @@ def apply(self, arg): # Last statement looks like an expression. Evaluate! last = ast.Expression(block.body.pop().value) - _globals = {name: module for name, module in sys.modules.items() if name != '__main__'} - + # _globals = {name: module for name, module in sys.modules.items() if name != '__main__'} + # _globals = {__builtins__: builtins, '__name__': '__main__','__file__': '', '__package__': None,} + # _globals.update(globals()) + # _globals = None + # _globals = locals() + script_globals = script_locals exec( - compile(block, "", mode="exec"), _globals, script_locals + compile(block, "", mode="exec"), script_globals, script_locals ) if last is not None: return_value = eval( compile(last, "", mode="eval"), - _globals, + script_globals, script_locals, ) except Exception: diff --git a/tests/it/script_scope.py b/tests/it/script_scope.py index 9965d7f..2374fb8 100644 --- a/tests/it/script_scope.py +++ b/tests/it/script_scope.py @@ -29,6 +29,7 @@ script = """ #@ int age #@output String cbrt_age +import numpy as np import math def calculate_cbrt(age): @@ -57,5 +58,5 @@ def calculate_cbrt(age): sys.stderr.write(f"{trace}\n") raise e -assert return_value == "The rounded cube root of my age is 2" assert statement == "2" +assert return_value == "The rounded cube root of my age is 2" From e02e167e8ca59b2889e69fbe12edf4496ffb2332 Mon Sep 17 00:00:00 2001 From: ian-coccimiglio Date: Fri, 21 Mar 2025 00:32:55 -0700 Subject: [PATCH 008/119] Added documentation --- src/scyjava/_script.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/scyjava/_script.py b/src/scyjava/_script.py index 0ba1b28..2d86cd8 100644 --- a/src/scyjava/_script.py +++ b/src/scyjava/_script.py @@ -90,20 +90,18 @@ def apply(self, arg): ): # Last statement looks like an expression. Evaluate! last = ast.Expression(block.body.pop().value) - - # _globals = {name: module for name, module in sys.modules.items() if name != '__main__'} - # _globals = {__builtins__: builtins, '__name__': '__main__','__file__': '', '__package__': None,} - # _globals.update(globals()) - # _globals = None - # _globals = locals() - script_globals = script_locals + # See here for why this implementation: https://docs.python.org/3/library/functions.html#exec + # When `exec` gets two separate objects as *globals* and *locals*, the code will be executed as if it were embedded in a class definition. + # This means functions and classes defined in the executed code will not be able to access variables assigned at the top level + # (as the “top level” variables are treated as class variables in a class definition). + _globals = script_locals exec( - compile(block, "", mode="exec"), script_globals, script_locals + compile(block, "", mode="exec"), _globals, script_locals ) if last is not None: return_value = eval( compile(last, "", mode="eval"), - script_globals, + _globals, script_locals, ) except Exception: From cb577c07542464a1baeaa3f2666ba5e52a3e5f58 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Fri, 21 Mar 2025 11:00:34 -0500 Subject: [PATCH 009/119] Fix up exec comment to please ruff --- src/scyjava/_script.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/scyjava/_script.py b/src/scyjava/_script.py index 2d86cd8..fef0a0e 100644 --- a/src/scyjava/_script.py +++ b/src/scyjava/_script.py @@ -90,11 +90,16 @@ def apply(self, arg): ): # Last statement looks like an expression. Evaluate! last = ast.Expression(block.body.pop().value) - # See here for why this implementation: https://docs.python.org/3/library/functions.html#exec - # When `exec` gets two separate objects as *globals* and *locals*, the code will be executed as if it were embedded in a class definition. - # This means functions and classes defined in the executed code will not be able to access variables assigned at the top level - # (as the “top level” variables are treated as class variables in a class definition). + + # NB: When `exec` gets two separate objects as *globals* and + # *locals*, the code will be executed as if it were embedded in + # a class definition. This means functions and classes defined + # in the executed code will not be able to access variables + # assigned at the top level, because the "top level" variables + # are treated as class variables in a class definition. + # See: https://docs.python.org/3/library/functions.html#exec _globals = script_locals + exec( compile(block, "", mode="exec"), _globals, script_locals ) From 835ead7838a20069abd0c821e7de0f3b7a52b2a2 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Fri, 21 Mar 2025 12:49:22 -0500 Subject: [PATCH 010/119] Use assertpy in integration tests So that when assertions fail, we get more information about how. --- tests/it/awt.py | 9 ++++++--- tests/it/headless.py | 15 +++++++-------- tests/it/java_heap.py | 9 +++++---- tests/it/jvm_version.py | 18 ++++++++++-------- tests/it/script_scope.py | 9 ++++++--- tests/it/scripting.py | 11 ++++++++--- 6 files changed, 42 insertions(+), 29 deletions(-) diff --git a/tests/it/awt.py b/tests/it/awt.py index 452cc30..9e74671 100644 --- a/tests/it/awt.py +++ b/tests/it/awt.py @@ -7,11 +7,14 @@ import scyjava +from assertpy import assert_that + if platform.system() == "Darwin": # NB: This test would hang on macOS, due to AWT threading issues. sys.exit(0) -assert not scyjava.jvm_started() +assert_that(scyjava.jvm_started()).is_false() + scyjava.start_jvm() if scyjava.is_jvm_headless(): @@ -21,9 +24,9 @@ # In that case, we are not able to perform this test. sys.exit(0) -assert not scyjava.is_awt_initialized() +assert_that(scyjava.is_awt_initialized()).is_false() Frame = scyjava.jimport("java.awt.Frame") f = Frame() -assert scyjava.is_awt_initialized() +assert_that(scyjava.is_awt_initialized()).is_true() diff --git a/tests/it/headless.py b/tests/it/headless.py index 97ee385..6f21f37 100644 --- a/tests/it/headless.py +++ b/tests/it/headless.py @@ -4,16 +4,15 @@ import scyjava +from assertpy import assert_that + scyjava.config.enable_headless_mode() -assert not scyjava.jvm_started() +assert_that(scyjava.jvm_started()).is_false() scyjava.start_jvm() - -assert scyjava.is_jvm_headless() +assert_that(scyjava.is_jvm_headless()).is_true() Frame = scyjava.jimport("java.awt.Frame") -try: - f = Frame() - assert False, "HeadlessException should have occurred" -except Exception as e: - assert "java.awt.HeadlessException" == str(e) +assert_that(Frame).raises(Exception).when_called_with().is_equal_to( + "java.awt.HeadlessException" +) diff --git a/tests/it/java_heap.py b/tests/it/java_heap.py index 0d0461a..77267ae 100644 --- a/tests/it/java_heap.py +++ b/tests/it/java_heap.py @@ -2,20 +2,21 @@ Test scyjava JVM memory-related functions. """ -from assertpy import assert_that - import scyjava +from assertpy import assert_that + mb_initial = 50 # initial MB of memory to snarf up mb_tolerance = 10 # ceiling of expected MB in use scyjava.config.set_heap_min(mb=mb_initial) scyjava.config.set_heap_max(gb=1) -assert not scyjava.jvm_started() +assert_that(scyjava.jvm_started()).is_false() + scyjava.start_jvm() -assert scyjava.available_processors() >= 1 +assert_that(scyjava.available_processors()).is_greater_than_or_equal_to(1) mb_max = scyjava.memory_max() // 1024 // 1024 mb_total = scyjava.memory_total() // 1024 // 1024 diff --git a/tests/it/jvm_version.py b/tests/it/jvm_version.py index 669875b..a79d20b 100644 --- a/tests/it/jvm_version.py +++ b/tests/it/jvm_version.py @@ -4,19 +4,21 @@ import scyjava -assert not scyjava.jvm_started() +from assertpy import assert_that + +assert_that(scyjava.jvm_started()).is_false() before_version = scyjava.jvm_version() -assert before_version is not None -assert len(before_version) >= 3 -assert before_version[0] > 0 +assert_that(before_version).is_not_none() +assert_that(len(before_version)).is_greater_than_or_equal_to(3) +assert_that(before_version[0]).is_greater_than(0) scyjava.config.enable_headless_mode() scyjava.start_jvm() after_version = scyjava.jvm_version() -assert after_version is not None -assert len(after_version) >= 3 -assert after_version[0] > 0 +assert_that(after_version).is_not_none() +assert_that(len(after_version)).is_greater_than_or_equal_to(3) +assert_that(after_version[0]).is_greater_than(0) -assert before_version == after_version +assert_that(before_version).is_equal_to(after_version) diff --git a/tests/it/script_scope.py b/tests/it/script_scope.py index 2374fb8..45dbedc 100644 --- a/tests/it/script_scope.py +++ b/tests/it/script_scope.py @@ -6,6 +6,8 @@ import scyjava +from assertpy import assert_that + scyjava.config.endpoints.extend( ["org.scijava:scijava-common:2.94.2", "org.scijava:scripting-python:MANAGED"] ) @@ -23,7 +25,8 @@ # Assert that the Python script language is available. ss = ctx.service("org.scijava.script.ScriptService") lang = ss.getLanguageByName("Python") -assert lang is not None and "Python" in lang.getNames() +assert_that(lang).is_not_none() +assert_that(lang.getNames()).contains("Python") # Construct a script. script = """ @@ -58,5 +61,5 @@ def calculate_cbrt(age): sys.stderr.write(f"{trace}\n") raise e -assert statement == "2" -assert return_value == "The rounded cube root of my age is 2" +assert_that(statement).is_equal_to("2") +assert_that(return_value).is_equal_to("The rounded cube root of my age is 2") diff --git a/tests/it/scripting.py b/tests/it/scripting.py index 0a8bf68..48d24b5 100644 --- a/tests/it/scripting.py +++ b/tests/it/scripting.py @@ -9,6 +9,8 @@ import scyjava +from assertpy import assert_that + scyjava.config.endpoints.extend( ["org.scijava:scijava-common:2.94.2", "org.scijava:scripting-python:MANAGED"] ) @@ -26,7 +28,8 @@ # Assert that the Python script language is available. ss = ctx.service("org.scijava.script.ScriptService") lang = ss.getLanguageByName("Python") -assert lang is not None and "Python" in lang.getNames() +assert_that(lang).is_not_none() +assert_that(lang.getNames()).contains("Python") # Construct a script. script = """ @@ -55,5 +58,7 @@ sys.stderr.write(f"{trace}\n") raise e -assert statement == "Hello, Chuckles! In one year you will be 14 years old." -assert return_value == "A wild return value appears!" +assert_that(statement).is_equal_to( + "Hello, Chuckles! In one year you will be 14 years old." +) +assert_that(return_value).is_equal_to("A wild return value appears!") From e4aeebafd3bd481134002407e8b0cef7f7d247e1 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Fri, 21 Mar 2025 14:02:19 -0500 Subject: [PATCH 011/119] Print script errors even when no error writer --- src/scyjava/_script.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/scyjava/_script.py b/src/scyjava/_script.py index fef0a0e..a371b2b 100644 --- a/src/scyjava/_script.py +++ b/src/scyjava/_script.py @@ -110,9 +110,15 @@ def apply(self, arg): script_locals, ) except Exception: + error_message = traceback.format_exc() error_writer = arg.scriptContext.getErrorWriter() - if error_writer is not None: - error_writer.write(to_java(traceback.format_exc())) + if error_writer is None: + # Emit error message to stderr stream. + error_writer = sys.stderr + else: + # Emit error message to designated error writer. + error_message = to_java(error_message) + error_writer.write(error_message) stdoutContextWriter.removeScriptContext(threading.currentThread()) @@ -122,9 +128,6 @@ def apply(self, arg): arg.vars[key] = to_java(script_locals[key]) except Exception: arg.vars[key] = PythonObjectSupplier(script_locals[key]) - # error_writer = arg.scriptContext.getErrorWriter() - # if error_writer is not None: - # error_writer.write(to_java(traceback.format_exc())) return to_java(return_value) From d6b118a3b0da494a23ba2306120e83ff33c08557 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Fri, 21 Mar 2025 14:04:42 -0500 Subject: [PATCH 012/119] Make cube root computation work in Python <3.11 --- tests/it/script_scope.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/it/script_scope.py b/tests/it/script_scope.py index 45dbedc..923bdaf 100644 --- a/tests/it/script_scope.py +++ b/tests/it/script_scope.py @@ -33,10 +33,9 @@ #@ int age #@output String cbrt_age import numpy as np -import math def calculate_cbrt(age): - return round(math.cbrt(age)) + return round(age ** (1. / 3)) cbrt_age = calculate_cbrt(age) # cbrt_age = round(math.cbrt(age)) From 124e3a4a630bb148c545f62271ee83cc87344706 Mon Sep 17 00:00:00 2001 From: ian-coccimiglio Date: Fri, 21 Mar 2025 15:11:33 -0700 Subject: [PATCH 013/119] Explicit test to check if function can retrieve module into local namespace --- tests/it/script_scope.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/it/script_scope.py b/tests/it/script_scope.py index 923bdaf..fc751fd 100644 --- a/tests/it/script_scope.py +++ b/tests/it/script_scope.py @@ -35,10 +35,11 @@ import numpy as np def calculate_cbrt(age): - return round(age ** (1. / 3)) + # check whether defined function can import module from global namespace + if round(age ** (1. / 3)) == round(np.cbrt(age)): + return round(age ** (1. /3)) cbrt_age = calculate_cbrt(age) -# cbrt_age = round(math.cbrt(age)) f"The rounded cube root of my age is {cbrt_age}" """ StringReader = scyjava.jimport("java.io.StringReader") From 9ec3bb4ebd35c7503749fc385e318f1581f484a0 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 26 Mar 2025 13:07:26 -0500 Subject: [PATCH 014/119] Add missing type helper function exports --- src/scyjava/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/scyjava/__init__.py b/src/scyjava/__init__.py index ce20173..f622685 100644 --- a/src/scyjava/__init__.py +++ b/src/scyjava/__init__.py @@ -111,6 +111,14 @@ from ._types import ( JavaClasses, is_jarray, + is_jboolean, + is_jbyte, + is_jcharacter, + is_jdouble, + is_jfloat, + is_jinteger, + is_jlong, + is_jshort, isjava, jarray, jclass, From 141a3edde27200192b1b0f27c1375abacc35d023 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 26 Mar 2025 13:00:24 -0500 Subject: [PATCH 015/119] Guard against invalid mode in jarray function All the other functions already guard with an assertion. --- src/scyjava/_types.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/scyjava/_types.py b/src/scyjava/_types.py index 34c2caf..ef0318a 100644 --- a/src/scyjava/_types.py +++ b/src/scyjava/_types.py @@ -275,6 +275,9 @@ def jarray(kind, lengths: Sequence): # instantiate the n-dimensional array arr = arraytype(lengths[0]) + else: + raise RuntimeError(f"Invalid mode: {mode}") + if len(lengths) > 1: for i in range(len(arr)): arr[i] = jarray(kind, lengths[1:]) From 1e54c4b8de0c98213bd18e1e457eaa2b9e80edd4 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 26 Mar 2025 13:25:56 -0500 Subject: [PATCH 016/119] Add down-the-middle testing of the jclass function --- tests/test_types.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/test_types.py b/tests/test_types.py index cc6adc4..7ccf23b 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,4 +1,5 @@ -from scyjava import numeric_bounds, to_java +from scyjava import jclass, jimport, numeric_bounds, to_java +from scyjava.config import Mode, mode class TestTypes(object): @@ -30,3 +31,22 @@ def test_numeric_bounds(self): type(v_double) ) assert (None, None) == numeric_bounds(type(v_bigdec)) + + def test_jclass(self): + if mode == Mode.JEP: + # JEP does not support the jclass function. + return + + # A. Name of a class to look up -- e.g. "java.lang.String" -> String.class + a_cls = jclass("java.lang.String") + assert a_cls.getName() == "java.lang.String" + + # B. A static-style class reference -- String -> String.class + String = jimport("java.lang.String") + b_cls = jclass(String) + assert b_cls.getName() == "java.lang.String" + + # C. A Java object -- String("hello") -> "hello".getClass() + v_str = to_java("gubernatorial") + c_cls = jclass(v_str) + assert c_cls.getName() == "java.lang.String" From 0a8141cd2c25c30e5a3a1aa023a4576e5ee76d93 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 26 Mar 2025 13:27:11 -0500 Subject: [PATCH 017/119] Add some functions for Java object introspection --- src/scyjava/__init__.py | 1 + src/scyjava/_types.py | 49 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/scyjava/__init__.py b/src/scyjava/__init__.py index f622685..4ba3a41 100644 --- a/src/scyjava/__init__.py +++ b/src/scyjava/__init__.py @@ -124,6 +124,7 @@ jclass, jinstance, jstacktrace, + methods, numeric_bounds, ) from ._versions import compare_version, get_version, is_version_at_least diff --git a/src/scyjava/_types.py b/src/scyjava/_types.py index ef0318a..fa0ae97 100644 --- a/src/scyjava/_types.py +++ b/src/scyjava/_types.py @@ -324,6 +324,55 @@ def numeric_bounds( return None, None +def methods(data) -> list[dict[str, Any]]: + """ + Use Java reflection to introspect the given Java object, + returning a table of its available methods. + + :param data: The object or class to inspect. + :return: List of table rows with columns "name", "arguments", and "returns". + """ + + if not isjava(data): + raise ValueError("Not a Java object") + + cls = data if jinstance(data, "java.lang.Class") else jclass(data) + + methods = cls.getMethods() + + # NB: Methods are returned in inconsistent order. + # Arrays.sort(methods, (m1, m2) -> { + # final int nameComp = m1.getName().compareTo(m2.getName()) + # if (nameComp != 0) return nameComp + # final int pCount1 = m1.getParameterCount() + # final int pCount2 = m2.getParameterCount() + # if (pCount1 != pCount2) return pCount1 - pCount2 + # final Class[] pTypes1 = m1.getParameterTypes() + # final Class[] pTypes2 = m2.getParameterTypes() + # for (int i = 0; i < pTypes1.length; i++) { + # final int typeComp = ClassUtils.compare(pTypes1[i], pTypes2[i]) + # if (typeComp != 0) return typeComp + # } + # return ClassUtils.compare(m1.getReturnType(), m2.getReturnType()) + # }) + + table = [] + + for m in methods: + name = m.getName() + args = [c.getName() for c in m.getParameterTypes()] + returns = m.getReturnType().getName() + table.append( + { + "name": name, + "arguments": args, + "returns": returns, + } + ) + + return table + + def _is_jtype(the_type: type, class_name: str) -> bool: """ Test if the given type object is *exactly* the specified Java type. From 7b8a6c4b08f6786bc7123948716516cc039b6646 Mon Sep 17 00:00:00 2001 From: ian-coccimiglio Date: Wed, 26 Mar 2025 22:15:19 -0700 Subject: [PATCH 018/119] Update methods() functionality --- src/scyjava/__init__.py | 1 + src/scyjava/_types.py | 55 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/scyjava/__init__.py b/src/scyjava/__init__.py index 4ba3a41..139e591 100644 --- a/src/scyjava/__init__.py +++ b/src/scyjava/__init__.py @@ -125,6 +125,7 @@ jinstance, jstacktrace, methods, + find_java_methods, numeric_bounds, ) from ._versions import compare_version, get_version, is_version_at_least diff --git a/src/scyjava/_types.py b/src/scyjava/_types.py index fa0ae97..f69c6fa 100644 --- a/src/scyjava/_types.py +++ b/src/scyjava/_types.py @@ -324,7 +324,7 @@ def numeric_bounds( return None, None -def methods(data) -> list[dict[str, Any]]: +def find_java_methods(data) -> list[dict[str, Any]]: """ Use Java reflection to introspect the given Java object, returning a table of its available methods. @@ -369,8 +369,59 @@ def methods(data) -> list[dict[str, Any]]: "returns": returns, } ) + sorted_table = sorted(table, key=lambda d: d["name"]) - return table + return sorted_table + + +def map_syntax(base_type): + """ + Maps a java BaseType annotation (see link below) in an Java array + to a specific type with an Python interpretable syntax. + https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.3 + """ + basetype_mapping = { + "[B": "byte[]", + "[C": "char[]", + "[D": "double[]", + "[F": "float[]", + "[C": "int[]", + "[J": "long[]", + "[L": "[]", # array + "[S": "short[]", + "[Z": "boolean[]", + } + + if base_type in basetype_mapping: + return basetype_mapping[base_type] + elif base_type.__str__().startswith("[L"): + return base_type.__str__()[2:-1] + "[]" + else: + return base_type + + +def methods(data) -> str: + table = find_java_methods(data) + + offset = max(list(map(lambda l: len(l["returns"]), table))) + all_methods = "" + + for entry in table: + entry["returns"] = map_syntax(entry["returns"]) + entry["arguments"] = [map_syntax(e) for e in entry["arguments"]] + + if not entry["arguments"]: + all_methods = ( + all_methods + + f'{entry["returns"].__str__():<{offset}} = {entry["name"]}()\n' + ) + else: + arg_string = ", ".join([r.__str__() for r in entry["arguments"]]) + all_methods = ( + all_methods + + f'{entry["returns"].__str__():<{offset}} = {entry["name"]}({arg_string})\n' + ) + print(all_methods) def _is_jtype(the_type: type, class_name: str) -> bool: From fe0bfe391d24a7b4b37f2b38e2ebb138c8c9d41b Mon Sep 17 00:00:00 2001 From: ian-coccimiglio Date: Thu, 27 Mar 2025 00:29:02 -0700 Subject: [PATCH 019/119] Make progress on introspection methods --- src/scyjava/_types.py | 105 +++++++++++++++++++++++++++++++++++------- 1 file changed, 88 insertions(+), 17 deletions(-) diff --git a/src/scyjava/_types.py b/src/scyjava/_types.py index f69c6fa..fa53eae 100644 --- a/src/scyjava/_types.py +++ b/src/scyjava/_types.py @@ -330,7 +330,7 @@ def find_java_methods(data) -> list[dict[str, Any]]: returning a table of its available methods. :param data: The object or class to inspect. - :return: List of table rows with columns "name", "arguments", and "returns". + :return: List of table rows with columns "name", "static", "arguments", and "returns". """ if not isjava(data): @@ -357,14 +357,17 @@ def find_java_methods(data) -> list[dict[str, Any]]: # }) table = [] + Modifier = jimport("java.lang.reflect.Modifier") for m in methods: name = m.getName() args = [c.getName() for c in m.getParameterTypes()] + mods = Modifier.isStatic(m.getModifiers()) returns = m.getReturnType().getName() table.append( { "name": name, + "static": mods, "arguments": args, "returns": returns, } @@ -374,7 +377,31 @@ def find_java_methods(data) -> list[dict[str, Any]]: return sorted_table -def map_syntax(base_type): +# TODO +def find_java_fields(data) -> list[dict[str, Any]]: + """ + Use Java reflection to introspect the given Java object, + returning a table of its available fields. + + :param data: The object or class to inspect. + :return: List of table rows with columns "name", "arguments", and "returns". + """ + if not isjava(data): + raise ValueError("Not a Java object") + + cls = data if jinstance(data, "java.lang.Class") else jclass(data) + + fields = cls.getFields() + table = [] + + for f in fields: + name = f.getName() + table.append(name) + + return table + + +def _map_syntax(base_type): """ Maps a java BaseType annotation (see link below) in an Java array to a specific type with an Python interpretable syntax. @@ -385,7 +412,7 @@ def map_syntax(base_type): "[C": "char[]", "[D": "double[]", "[F": "float[]", - "[C": "int[]", + "[I": "int[]", "[J": "long[]", "[L": "[]", # array "[S": "short[]", @@ -394,33 +421,77 @@ def map_syntax(base_type): if base_type in basetype_mapping: return basetype_mapping[base_type] + # Handle the case of a returned array of an object elif base_type.__str__().startswith("[L"): return base_type.__str__()[2:-1] + "[]" else: return base_type +def _make_pretty_string(entry, offset): + """ + Prints the entry with a specific formatting and aligned style + :param entry: Dictionary of class names, modifiers, arguments, and return values. + :param offset: Offset between the return value and the method. + """ + + # A star implies that the method is a static method + return_val = f'{entry["returns"].__str__():<{offset}}' + # Handle whether to print static/instance modifiers + obj_name = f'{entry["name"]}' + modifier = f'{"*":>4}' if entry["static"] else f'{"":>4}' + + # Handle methods with no arguments + if not entry["arguments"]: + return f"{return_val} {modifier} = {obj_name}()\n" + else: + arg_string = ", ".join([r.__str__() for r in entry["arguments"]]) + return f"{return_val} {modifier} = {obj_name}({arg_string})\n" + + +# TODO +def fields(data) -> str: + """ + Writes data to a printed field names with the field value. + :param data: The object or class to inspect. + """ + table = find_java_fields(data) + + all_fields = "" + ################ + # FILL THIS IN # + ################ + + print(all_fields) + + +# TODO +def attrs(data): + """ + Writes data to a printed field names with the field value. Alias for `fields(data)`. + :param data: The object or class to inspect. + """ + fields(data) + + def methods(data) -> str: + """ + Writes data to a printed string of class methods with inputs, static modifier, arguments, and return values. + + :param data: The object or class to inspect. + """ table = find_java_methods(data) offset = max(list(map(lambda l: len(l["returns"]), table))) all_methods = "" - for entry in table: - entry["returns"] = map_syntax(entry["returns"]) - entry["arguments"] = [map_syntax(e) for e in entry["arguments"]] + entry["returns"] = _map_syntax(entry["returns"]) + entry["arguments"] = [_map_syntax(e) for e in entry["arguments"]] + entry_string = _make_pretty_string(entry, offset) + all_methods += entry_string - if not entry["arguments"]: - all_methods = ( - all_methods - + f'{entry["returns"].__str__():<{offset}} = {entry["name"]}()\n' - ) - else: - arg_string = ", ".join([r.__str__() for r in entry["arguments"]]) - all_methods = ( - all_methods - + f'{entry["returns"].__str__():<{offset}} = {entry["name"]}({arg_string})\n' - ) + # 4 added to align the asterisk with output. + print(f'{"":<{offset+4}}* indicates a static method') print(all_methods) From 25d769e11c0c394121b3f520d853432ab09fe017 Mon Sep 17 00:00:00 2001 From: ian-coccimiglio Date: Thu, 27 Mar 2025 00:34:05 -0700 Subject: [PATCH 020/119] Make linter happy --- src/scyjava/_types.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/scyjava/_types.py b/src/scyjava/_types.py index fa53eae..c797ae0 100644 --- a/src/scyjava/_types.py +++ b/src/scyjava/_types.py @@ -436,10 +436,10 @@ def _make_pretty_string(entry, offset): """ # A star implies that the method is a static method - return_val = f'{entry["returns"].__str__():<{offset}}' + return_val = f"{entry['returns'].__str__():<{offset}}" # Handle whether to print static/instance modifiers - obj_name = f'{entry["name"]}' - modifier = f'{"*":>4}' if entry["static"] else f'{"":>4}' + obj_name = f"{entry['name']}" + modifier = f"{'*':>4}" if entry["static"] else f"{'':>4}" # Handle methods with no arguments if not entry["arguments"]: @@ -455,7 +455,7 @@ def fields(data) -> str: Writes data to a printed field names with the field value. :param data: The object or class to inspect. """ - table = find_java_fields(data) + # table = find_java_fields(data) all_fields = "" ################ @@ -482,7 +482,7 @@ def methods(data) -> str: """ table = find_java_methods(data) - offset = max(list(map(lambda l: len(l["returns"]), table))) + offset = max(list(map(lambda entry: len(entry["returns"]), table))) all_methods = "" for entry in table: entry["returns"] = _map_syntax(entry["returns"]) @@ -491,7 +491,7 @@ def methods(data) -> str: all_methods += entry_string # 4 added to align the asterisk with output. - print(f'{"":<{offset+4}}* indicates a static method') + print(f"{'':<{offset + 4}}* indicates a static method") print(all_methods) From 1a0fd7123c965f67f3b91c6b8783cf8786bf0f70 Mon Sep 17 00:00:00 2001 From: ian-coccimiglio Date: Thu, 27 Mar 2025 11:49:54 -0700 Subject: [PATCH 021/119] Add source code reporting to methods() function --- src/scyjava/_types.py | 57 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/src/scyjava/_types.py b/src/scyjava/_types.py index c797ae0..479e4e5 100644 --- a/src/scyjava/_types.py +++ b/src/scyjava/_types.py @@ -336,9 +336,9 @@ def find_java_methods(data) -> list[dict[str, Any]]: if not isjava(data): raise ValueError("Not a Java object") - cls = data if jinstance(data, "java.lang.Class") else jclass(data) + jcls = data if jinstance(data, "java.lang.Class") else jclass(data) - methods = cls.getMethods() + methods = jcls.getMethods() # NB: Methods are returned in inconsistent order. # Arrays.sort(methods, (m1, m2) -> { @@ -389,9 +389,9 @@ def find_java_fields(data) -> list[dict[str, Any]]: if not isjava(data): raise ValueError("Not a Java object") - cls = data if jinstance(data, "java.lang.Class") else jclass(data) + jcls = data if jinstance(data, "java.lang.Class") else jclass(data) - fields = cls.getFields() + fields = jcls.getFields() table = [] for f in fields: @@ -474,21 +474,64 @@ def attrs(data): fields(data) -def methods(data) -> str: +def get_source_code(data): + """ + Tries to find the source code using Scijava's SourceFinder' + :param data: The object or class to check for source code. + """ + types = jimport("org.scijava.util.Types") + sf = jimport("org.scijava.search.SourceFinder") + jstring = jimport("java.lang.String") + try: + jcls = data if jinstance(data, "java.lang.Class") else jclass(data) + if types.location(jcls).toString().startsWith(jstring("jrt")): + # Handles Java RunTime (jrt) exceptions. + return "GitHub source code not available" + url = sf.sourceLocation(jcls, None) + urlstring = url.toString() + return urlstring + except jimport("java.lang.IllegalArgumentException") as err: + return f"Illegal argument provided {err=}, {type(err)=}" + except Exception as err: + return f"Unexpected {err=}, {type(err)=}" + + +def methods(data, static: bool | None = None, source: bool = True) -> str: """ Writes data to a printed string of class methods with inputs, static modifier, arguments, and return values. :param data: The object or class to inspect. + :param static: Which methods to print. Can be set as boolean to filter the class methods based on + static vs. instance methods. Optional, default is None (prints all methods). + :param source: Whether to print any available source code. Default True. """ table = find_java_methods(data) + # Print source code offset = max(list(map(lambda entry: len(entry["returns"]), table))) all_methods = "" + if source: + urlstring = get_source_code(data) + print(f"URL: {urlstring}") + else: + pass + + # Print methods for entry in table: entry["returns"] = _map_syntax(entry["returns"]) entry["arguments"] = [_map_syntax(e) for e in entry["arguments"]] - entry_string = _make_pretty_string(entry, offset) - all_methods += entry_string + if static is None: + entry_string = _make_pretty_string(entry, offset) + all_methods += entry_string + + elif static and entry["static"]: + entry_string = _make_pretty_string(entry, offset) + all_methods += entry_string + elif not static and not entry["static"]: + entry_string = _make_pretty_string(entry, offset) + all_methods += entry_string + else: + continue # 4 added to align the asterisk with output. print(f"{'':<{offset + 4}}* indicates a static method") From 640d57deb7393407fe6edcced2d728186133e3bb Mon Sep 17 00:00:00 2001 From: ian-coccimiglio Date: Thu, 27 Mar 2025 12:47:48 -0700 Subject: [PATCH 022/119] Implement fields introspection function --- src/scyjava/__init__.py | 5 ++- src/scyjava/_types.py | 67 ++++++++++++++++++++++++----------------- 2 files changed, 43 insertions(+), 29 deletions(-) diff --git a/src/scyjava/__init__.py b/src/scyjava/__init__.py index 139e591..be38678 100644 --- a/src/scyjava/__init__.py +++ b/src/scyjava/__init__.py @@ -124,8 +124,11 @@ jclass, jinstance, jstacktrace, - methods, find_java_methods, + find_java_fields, + methods, + fields, + attrs, numeric_bounds, ) from ._versions import compare_version, get_version, is_version_at_least diff --git a/src/scyjava/_types.py b/src/scyjava/_types.py index 479e4e5..24e9387 100644 --- a/src/scyjava/_types.py +++ b/src/scyjava/_types.py @@ -396,9 +396,11 @@ def find_java_fields(data) -> list[dict[str, Any]]: for f in fields: name = f.getName() - table.append(name) + ftype = f.getType().getName() + table.append({"name": name, "type": ftype}) + sorted_table = sorted(table, key=lambda d: d["name"]) - return table + return sorted_table def _map_syntax(base_type): @@ -441,39 +443,18 @@ def _make_pretty_string(entry, offset): obj_name = f"{entry['name']}" modifier = f"{'*':>4}" if entry["static"] else f"{'':>4}" + # Handle fields + if entry["arguments"] is None: + return f"{return_val} = {obj_name}\n" + # Handle methods with no arguments - if not entry["arguments"]: + if len(entry["arguments"]) == 0: return f"{return_val} {modifier} = {obj_name}()\n" else: arg_string = ", ".join([r.__str__() for r in entry["arguments"]]) return f"{return_val} {modifier} = {obj_name}({arg_string})\n" -# TODO -def fields(data) -> str: - """ - Writes data to a printed field names with the field value. - :param data: The object or class to inspect. - """ - # table = find_java_fields(data) - - all_fields = "" - ################ - # FILL THIS IN # - ################ - - print(all_fields) - - -# TODO -def attrs(data): - """ - Writes data to a printed field names with the field value. Alias for `fields(data)`. - :param data: The object or class to inspect. - """ - fields(data) - - def get_source_code(data): """ Tries to find the source code using Scijava's SourceFinder' @@ -496,6 +477,36 @@ def get_source_code(data): return f"Unexpected {err=}, {type(err)=}" +def fields(data) -> str: + """ + Writes data to a printed field names with the field value. + :param data: The object or class to inspect. + """ + table = find_java_fields(data) + if len(table) == 0: + print("No fields found") + return + + all_fields = "" + offset = max(list(map(lambda entry: len(entry["type"]), table))) + for entry in table: + entry["returns"] = _map_syntax(entry["type"]) + entry["static"] = False + entry["arguments"] = None + entry_string = _make_pretty_string(entry, offset) + all_fields += entry_string + + print(all_fields) + + +def attrs(data): + """ + Writes data to a printed field names with the field value. Alias for `fields(data)`. + :param data: The object or class to inspect. + """ + fields(data) + + def methods(data, static: bool | None = None, source: bool = True) -> str: """ Writes data to a printed string of class methods with inputs, static modifier, arguments, and return values. From aa3996c22b2d3e76932969a314725a92847a128d Mon Sep 17 00:00:00 2001 From: ian-coccimiglio Date: Fri, 28 Mar 2025 13:49:01 -0700 Subject: [PATCH 023/119] Add partials, refactor, add java_source function --- src/scyjava/__init__.py | 6 +- src/scyjava/_types.py | 162 ++++++++++++++++------------------------ 2 files changed, 70 insertions(+), 98 deletions(-) diff --git a/src/scyjava/__init__.py b/src/scyjava/__init__.py index be38678..043374f 100644 --- a/src/scyjava/__init__.py +++ b/src/scyjava/__init__.py @@ -124,11 +124,13 @@ jclass, jinstance, jstacktrace, - find_java_methods, - find_java_fields, + find_java, + java_source, methods, fields, attrs, + src, + java_source, numeric_bounds, ) from ._versions import compare_version, get_version, is_version_at_least diff --git a/src/scyjava/_types.py b/src/scyjava/_types.py index 24e9387..6fdced3 100644 --- a/src/scyjava/_types.py +++ b/src/scyjava/_types.py @@ -5,6 +5,7 @@ from typing import Any, Callable, Sequence, Tuple, Union import jpype +from functools import partial from scyjava._jvm import jimport, jvm_started, start_jvm from scyjava.config import Mode, mode @@ -324,46 +325,43 @@ def numeric_bounds( return None, None -def find_java_methods(data) -> list[dict[str, Any]]: +def find_java(data, aspect: str) -> list[dict[str, Any]]: """ Use Java reflection to introspect the given Java object, returning a table of its available methods. - :param data: The object or class to inspect. - :return: List of table rows with columns "name", "static", "arguments", and "returns". + :param data: The object or class or fully qualified class name to inspect. + :param aspect: Either 'methods' or 'fields' + :return: List of dicts with keys: "name", "static", "arguments", and "returns". """ - if not isjava(data): - raise ValueError("Not a Java object") + if not isjava(data) and isinstance(data, str): + try: + data = jimport(data) + except: + raise ValueError("Not a Java object") + Modifier = jimport("java.lang.reflect.Modifier") jcls = data if jinstance(data, "java.lang.Class") else jclass(data) - methods = jcls.getMethods() - - # NB: Methods are returned in inconsistent order. - # Arrays.sort(methods, (m1, m2) -> { - # final int nameComp = m1.getName().compareTo(m2.getName()) - # if (nameComp != 0) return nameComp - # final int pCount1 = m1.getParameterCount() - # final int pCount2 = m2.getParameterCount() - # if (pCount1 != pCount2) return pCount1 - pCount2 - # final Class[] pTypes1 = m1.getParameterTypes() - # final Class[] pTypes2 = m2.getParameterTypes() - # for (int i = 0; i < pTypes1.length; i++) { - # final int typeComp = ClassUtils.compare(pTypes1[i], pTypes2[i]) - # if (typeComp != 0) return typeComp - # } - # return ClassUtils.compare(m1.getReturnType(), m2.getReturnType()) - # }) + if aspect == "methods": + cls_aspects = jcls.getMethods() + elif aspect == "fields": + cls_aspects = jcls.getFields() + else: + return "`aspect` must be either 'fields' or 'methods'" table = [] - Modifier = jimport("java.lang.reflect.Modifier") - for m in methods: + for m in cls_aspects: name = m.getName() - args = [c.getName() for c in m.getParameterTypes()] + if aspect == "methods": + args = [c.getName() for c in m.getParameterTypes()] + returns = m.getReturnType().getName() + elif aspect == "fields": + args = None + returns = m.getType().getName() mods = Modifier.isStatic(m.getModifiers()) - returns = m.getReturnType().getName() table.append( { "name": name, @@ -377,32 +375,6 @@ def find_java_methods(data) -> list[dict[str, Any]]: return sorted_table -# TODO -def find_java_fields(data) -> list[dict[str, Any]]: - """ - Use Java reflection to introspect the given Java object, - returning a table of its available fields. - - :param data: The object or class to inspect. - :return: List of table rows with columns "name", "arguments", and "returns". - """ - if not isjava(data): - raise ValueError("Not a Java object") - - jcls = data if jinstance(data, "java.lang.Class") else jclass(data) - - fields = jcls.getFields() - table = [] - - for f in fields: - name = f.getName() - ftype = f.getType().getName() - table.append({"name": name, "type": ftype}) - sorted_table = sorted(table, key=lambda d: d["name"]) - - return sorted_table - - def _map_syntax(base_type): """ Maps a java BaseType annotation (see link below) in an Java array @@ -445,7 +417,7 @@ def _make_pretty_string(entry, offset): # Handle fields if entry["arguments"] is None: - return f"{return_val} = {obj_name}\n" + return f"{return_val} {modifier} = {obj_name}\n" # Handle methods with no arguments if len(entry["arguments"]) == 0: @@ -455,82 +427,65 @@ def _make_pretty_string(entry, offset): return f"{return_val} {modifier} = {obj_name}({arg_string})\n" -def get_source_code(data): +def java_source(data): """ Tries to find the source code using Scijava's SourceFinder' - :param data: The object or class to check for source code. + :param data: The object or class or fully qualified class name to check for source code. + :return: The URL of the java class """ types = jimport("org.scijava.util.Types") sf = jimport("org.scijava.search.SourceFinder") jstring = jimport("java.lang.String") try: + if not isjava(data) and isinstance(data, str): + try: + data = jimport(data) # check if data can be imported + except: + raise ValueError("Not a Java object") jcls = data if jinstance(data, "java.lang.Class") else jclass(data) if types.location(jcls).toString().startsWith(jstring("jrt")): # Handles Java RunTime (jrt) exceptions. - return "GitHub source code not available" + raise ValueError("Java Builtin: GitHub source code not available") url = sf.sourceLocation(jcls, None) urlstring = url.toString() return urlstring except jimport("java.lang.IllegalArgumentException") as err: return f"Illegal argument provided {err=}, {type(err)=}" + except ValueError as err: + return f"{err}" + except TypeError: + return f"Not a Java class {str(type(data))}" except Exception as err: return f"Unexpected {err=}, {type(err)=}" -def fields(data) -> str: - """ - Writes data to a printed field names with the field value. - :param data: The object or class to inspect. - """ - table = find_java_fields(data) - if len(table) == 0: - print("No fields found") - return - - all_fields = "" - offset = max(list(map(lambda entry: len(entry["type"]), table))) - for entry in table: - entry["returns"] = _map_syntax(entry["type"]) - entry["static"] = False - entry["arguments"] = None - entry_string = _make_pretty_string(entry, offset) - all_fields += entry_string - - print(all_fields) - - -def attrs(data): - """ - Writes data to a printed field names with the field value. Alias for `fields(data)`. - :param data: The object or class to inspect. - """ - fields(data) - - -def methods(data, static: bool | None = None, source: bool = True) -> str: +def _print_data(data, aspect, static: bool | None = None, source: bool = True): """ Writes data to a printed string of class methods with inputs, static modifier, arguments, and return values. :param data: The object or class to inspect. - :param static: Which methods to print. Can be set as boolean to filter the class methods based on - static vs. instance methods. Optional, default is None (prints all methods). + :param aspect: Whether to print class fields or methods. + :param static: Filter on Static/Instance. Can be set as boolean to filter the class methods based on + static vs. instance methods. Optional, default is None (prints all). :param source: Whether to print any available source code. Default True. """ - table = find_java_methods(data) + table = find_java(data, aspect) + if len(table) == 0: + print(f"No {aspect} found") + return # Print source code offset = max(list(map(lambda entry: len(entry["returns"]), table))) all_methods = "" if source: - urlstring = get_source_code(data) - print(f"URL: {urlstring}") - else: - pass + urlstring = java_source(data) + print(f"Source code URL: {urlstring}") # Print methods for entry in table: entry["returns"] = _map_syntax(entry["returns"]) - entry["arguments"] = [_map_syntax(e) for e in entry["arguments"]] + if entry["arguments"]: + entry["arguments"] = [_map_syntax(e) for e in entry["arguments"]] if static is None: entry_string = _make_pretty_string(entry, offset) all_methods += entry_string @@ -545,10 +500,25 @@ def methods(data, static: bool | None = None, source: bool = True) -> str: continue # 4 added to align the asterisk with output. - print(f"{'':<{offset + 4}}* indicates a static method") + print(f"{'':<{offset + 4}}* indicates static modifier") print(all_methods) +methods = partial(_print_data, aspect="methods") +fields = partial(_print_data, aspect="fields") +attrs = partial(_print_data, aspect="fields") + + +def src(data): + """ + Prints the source code URL for a Java class, object, or class name. + + :param data: The Java class, object, or fully qualified class name as string + """ + source_url = java_source(data) + print(f"Source code URL: {source_url}") + + def _is_jtype(the_type: type, class_name: str) -> bool: """ Test if the given type object is *exactly* the specified Java type. From 9352ff6a98620882e503d4ccb60b55ac210f4d6c Mon Sep 17 00:00:00 2001 From: ian-coccimiglio Date: Fri, 28 Mar 2025 13:55:53 -0700 Subject: [PATCH 024/119] Refactor introspection code --- src/scyjava/__init__.py | 4 +- src/scyjava/_introspection.py | 203 ++++++++++++++++++++++++++++++++++ src/scyjava/_types.py | 195 -------------------------------- 3 files changed, 206 insertions(+), 196 deletions(-) create mode 100644 src/scyjava/_introspection.py diff --git a/src/scyjava/__init__.py b/src/scyjava/__init__.py index 043374f..b30f909 100644 --- a/src/scyjava/__init__.py +++ b/src/scyjava/__init__.py @@ -124,6 +124,9 @@ jclass, jinstance, jstacktrace, + numeric_bounds, +) +from ._introspection import ( find_java, java_source, methods, @@ -131,7 +134,6 @@ attrs, src, java_source, - numeric_bounds, ) from ._versions import compare_version, get_version, is_version_at_least diff --git a/src/scyjava/_introspection.py b/src/scyjava/_introspection.py new file mode 100644 index 0000000..f136eaa --- /dev/null +++ b/src/scyjava/_introspection.py @@ -0,0 +1,203 @@ +""" +Introspection functions for reporting java classes and URL +""" + +from functools import partial +from typing import Any + +from scyjava._jvm import jimport +from scyjava._types import isjava, jinstance, jclass + + +def find_java(data, aspect: str) -> list[dict[str, Any]]: + """ + Use Java reflection to introspect the given Java object, + returning a table of its available methods. + + :param data: The object or class or fully qualified class name to inspect. + :param aspect: Either 'methods' or 'fields' + :return: List of dicts with keys: "name", "static", "arguments", and "returns". + """ + + if not isjava(data) and isinstance(data, str): + try: + data = jimport(data) + except: + raise ValueError("Not a Java object") + + Modifier = jimport("java.lang.reflect.Modifier") + jcls = data if jinstance(data, "java.lang.Class") else jclass(data) + + if aspect == "methods": + cls_aspects = jcls.getMethods() + elif aspect == "fields": + cls_aspects = jcls.getFields() + else: + return "`aspect` must be either 'fields' or 'methods'" + + table = [] + + for m in cls_aspects: + name = m.getName() + if aspect == "methods": + args = [c.getName() for c in m.getParameterTypes()] + returns = m.getReturnType().getName() + elif aspect == "fields": + args = None + returns = m.getType().getName() + mods = Modifier.isStatic(m.getModifiers()) + table.append( + { + "name": name, + "static": mods, + "arguments": args, + "returns": returns, + } + ) + sorted_table = sorted(table, key=lambda d: d["name"]) + + return sorted_table + + +def _map_syntax(base_type): + """ + Maps a java BaseType annotation (see link below) in an Java array + to a specific type with an Python interpretable syntax. + https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.3 + """ + basetype_mapping = { + "[B": "byte[]", + "[C": "char[]", + "[D": "double[]", + "[F": "float[]", + "[I": "int[]", + "[J": "long[]", + "[L": "[]", # array + "[S": "short[]", + "[Z": "boolean[]", + } + + if base_type in basetype_mapping: + return basetype_mapping[base_type] + # Handle the case of a returned array of an object + elif base_type.__str__().startswith("[L"): + return base_type.__str__()[2:-1] + "[]" + else: + return base_type + + +def _make_pretty_string(entry, offset): + """ + Prints the entry with a specific formatting and aligned style + :param entry: Dictionary of class names, modifiers, arguments, and return values. + :param offset: Offset between the return value and the method. + """ + + # A star implies that the method is a static method + return_val = f"{entry['returns'].__str__():<{offset}}" + # Handle whether to print static/instance modifiers + obj_name = f"{entry['name']}" + modifier = f"{'*':>4}" if entry["static"] else f"{'':>4}" + + # Handle fields + if entry["arguments"] is None: + return f"{return_val} {modifier} = {obj_name}\n" + + # Handle methods with no arguments + if len(entry["arguments"]) == 0: + return f"{return_val} {modifier} = {obj_name}()\n" + else: + arg_string = ", ".join([r.__str__() for r in entry["arguments"]]) + return f"{return_val} {modifier} = {obj_name}({arg_string})\n" + + +def java_source(data): + """ + Tries to find the source code using Scijava's SourceFinder' + :param data: The object or class or fully qualified class name to check for source code. + :return: The URL of the java class + """ + types = jimport("org.scijava.util.Types") + sf = jimport("org.scijava.search.SourceFinder") + jstring = jimport("java.lang.String") + try: + if not isjava(data) and isinstance(data, str): + try: + data = jimport(data) # check if data can be imported + except: + raise ValueError("Not a Java object") + jcls = data if jinstance(data, "java.lang.Class") else jclass(data) + if types.location(jcls).toString().startsWith(jstring("jrt")): + # Handles Java RunTime (jrt) exceptions. + raise ValueError("Java Builtin: GitHub source code not available") + url = sf.sourceLocation(jcls, None) + urlstring = url.toString() + return urlstring + except jimport("java.lang.IllegalArgumentException") as err: + return f"Illegal argument provided {err=}, {type(err)=}" + except ValueError as err: + return f"{err}" + except TypeError: + return f"Not a Java class {str(type(data))}" + except Exception as err: + return f"Unexpected {err=}, {type(err)=}" + + +def _print_data(data, aspect, static: bool | None = None, source: bool = True): + """ + Writes data to a printed string of class methods with inputs, static modifier, arguments, and return values. + + :param data: The object or class to inspect. + :param aspect: Whether to print class fields or methods. + :param static: Filter on Static/Instance. Can be set as boolean to filter the class methods based on + static vs. instance methods. Optional, default is None (prints all). + :param source: Whether to print any available source code. Default True. + """ + table = find_java(data, aspect) + if len(table) == 0: + print(f"No {aspect} found") + return + + # Print source code + offset = max(list(map(lambda entry: len(entry["returns"]), table))) + all_methods = "" + if source: + urlstring = java_source(data) + print(f"Source code URL: {urlstring}") + + # Print methods + for entry in table: + entry["returns"] = _map_syntax(entry["returns"]) + if entry["arguments"]: + entry["arguments"] = [_map_syntax(e) for e in entry["arguments"]] + if static is None: + entry_string = _make_pretty_string(entry, offset) + all_methods += entry_string + + elif static and entry["static"]: + entry_string = _make_pretty_string(entry, offset) + all_methods += entry_string + elif not static and not entry["static"]: + entry_string = _make_pretty_string(entry, offset) + all_methods += entry_string + else: + continue + + # 4 added to align the asterisk with output. + print(f"{'':<{offset + 4}}* indicates static modifier") + print(all_methods) + + +methods = partial(_print_data, aspect="methods") +fields = partial(_print_data, aspect="fields") +attrs = partial(_print_data, aspect="fields") + + +def src(data): + """ + Prints the source code URL for a Java class, object, or class name. + + :param data: The Java class, object, or fully qualified class name as string + """ + source_url = java_source(data) + print(f"Source code URL: {source_url}") diff --git a/src/scyjava/_types.py b/src/scyjava/_types.py index 6fdced3..ef0318a 100644 --- a/src/scyjava/_types.py +++ b/src/scyjava/_types.py @@ -5,7 +5,6 @@ from typing import Any, Callable, Sequence, Tuple, Union import jpype -from functools import partial from scyjava._jvm import jimport, jvm_started, start_jvm from scyjava.config import Mode, mode @@ -325,200 +324,6 @@ def numeric_bounds( return None, None -def find_java(data, aspect: str) -> list[dict[str, Any]]: - """ - Use Java reflection to introspect the given Java object, - returning a table of its available methods. - - :param data: The object or class or fully qualified class name to inspect. - :param aspect: Either 'methods' or 'fields' - :return: List of dicts with keys: "name", "static", "arguments", and "returns". - """ - - if not isjava(data) and isinstance(data, str): - try: - data = jimport(data) - except: - raise ValueError("Not a Java object") - - Modifier = jimport("java.lang.reflect.Modifier") - jcls = data if jinstance(data, "java.lang.Class") else jclass(data) - - if aspect == "methods": - cls_aspects = jcls.getMethods() - elif aspect == "fields": - cls_aspects = jcls.getFields() - else: - return "`aspect` must be either 'fields' or 'methods'" - - table = [] - - for m in cls_aspects: - name = m.getName() - if aspect == "methods": - args = [c.getName() for c in m.getParameterTypes()] - returns = m.getReturnType().getName() - elif aspect == "fields": - args = None - returns = m.getType().getName() - mods = Modifier.isStatic(m.getModifiers()) - table.append( - { - "name": name, - "static": mods, - "arguments": args, - "returns": returns, - } - ) - sorted_table = sorted(table, key=lambda d: d["name"]) - - return sorted_table - - -def _map_syntax(base_type): - """ - Maps a java BaseType annotation (see link below) in an Java array - to a specific type with an Python interpretable syntax. - https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.3 - """ - basetype_mapping = { - "[B": "byte[]", - "[C": "char[]", - "[D": "double[]", - "[F": "float[]", - "[I": "int[]", - "[J": "long[]", - "[L": "[]", # array - "[S": "short[]", - "[Z": "boolean[]", - } - - if base_type in basetype_mapping: - return basetype_mapping[base_type] - # Handle the case of a returned array of an object - elif base_type.__str__().startswith("[L"): - return base_type.__str__()[2:-1] + "[]" - else: - return base_type - - -def _make_pretty_string(entry, offset): - """ - Prints the entry with a specific formatting and aligned style - :param entry: Dictionary of class names, modifiers, arguments, and return values. - :param offset: Offset between the return value and the method. - """ - - # A star implies that the method is a static method - return_val = f"{entry['returns'].__str__():<{offset}}" - # Handle whether to print static/instance modifiers - obj_name = f"{entry['name']}" - modifier = f"{'*':>4}" if entry["static"] else f"{'':>4}" - - # Handle fields - if entry["arguments"] is None: - return f"{return_val} {modifier} = {obj_name}\n" - - # Handle methods with no arguments - if len(entry["arguments"]) == 0: - return f"{return_val} {modifier} = {obj_name}()\n" - else: - arg_string = ", ".join([r.__str__() for r in entry["arguments"]]) - return f"{return_val} {modifier} = {obj_name}({arg_string})\n" - - -def java_source(data): - """ - Tries to find the source code using Scijava's SourceFinder' - :param data: The object or class or fully qualified class name to check for source code. - :return: The URL of the java class - """ - types = jimport("org.scijava.util.Types") - sf = jimport("org.scijava.search.SourceFinder") - jstring = jimport("java.lang.String") - try: - if not isjava(data) and isinstance(data, str): - try: - data = jimport(data) # check if data can be imported - except: - raise ValueError("Not a Java object") - jcls = data if jinstance(data, "java.lang.Class") else jclass(data) - if types.location(jcls).toString().startsWith(jstring("jrt")): - # Handles Java RunTime (jrt) exceptions. - raise ValueError("Java Builtin: GitHub source code not available") - url = sf.sourceLocation(jcls, None) - urlstring = url.toString() - return urlstring - except jimport("java.lang.IllegalArgumentException") as err: - return f"Illegal argument provided {err=}, {type(err)=}" - except ValueError as err: - return f"{err}" - except TypeError: - return f"Not a Java class {str(type(data))}" - except Exception as err: - return f"Unexpected {err=}, {type(err)=}" - - -def _print_data(data, aspect, static: bool | None = None, source: bool = True): - """ - Writes data to a printed string of class methods with inputs, static modifier, arguments, and return values. - - :param data: The object or class to inspect. - :param aspect: Whether to print class fields or methods. - :param static: Filter on Static/Instance. Can be set as boolean to filter the class methods based on - static vs. instance methods. Optional, default is None (prints all). - :param source: Whether to print any available source code. Default True. - """ - table = find_java(data, aspect) - if len(table) == 0: - print(f"No {aspect} found") - return - - # Print source code - offset = max(list(map(lambda entry: len(entry["returns"]), table))) - all_methods = "" - if source: - urlstring = java_source(data) - print(f"Source code URL: {urlstring}") - - # Print methods - for entry in table: - entry["returns"] = _map_syntax(entry["returns"]) - if entry["arguments"]: - entry["arguments"] = [_map_syntax(e) for e in entry["arguments"]] - if static is None: - entry_string = _make_pretty_string(entry, offset) - all_methods += entry_string - - elif static and entry["static"]: - entry_string = _make_pretty_string(entry, offset) - all_methods += entry_string - elif not static and not entry["static"]: - entry_string = _make_pretty_string(entry, offset) - all_methods += entry_string - else: - continue - - # 4 added to align the asterisk with output. - print(f"{'':<{offset + 4}}* indicates static modifier") - print(all_methods) - - -methods = partial(_print_data, aspect="methods") -fields = partial(_print_data, aspect="fields") -attrs = partial(_print_data, aspect="fields") - - -def src(data): - """ - Prints the source code URL for a Java class, object, or class name. - - :param data: The Java class, object, or fully qualified class name as string - """ - source_url = java_source(data) - print(f"Source code URL: {source_url}") - - def _is_jtype(the_type: type, class_name: str) -> bool: """ Test if the given type object is *exactly* the specified Java type. From fe602176b9505bffaf1db0ba4eb9ebf0d989b0f7 Mon Sep 17 00:00:00 2001 From: ian-coccimiglio Date: Fri, 28 Mar 2025 15:16:13 -0700 Subject: [PATCH 025/119] Add test cases for introspection functions --- tests/test_introspection.py | 68 +++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tests/test_introspection.py diff --git a/tests/test_introspection.py b/tests/test_introspection.py new file mode 100644 index 0000000..e8405f3 --- /dev/null +++ b/tests/test_introspection.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Created on Fri Mar 28 13:58:54 2025 + +@author: ian +""" + +import scyjava +from scyjava.config import Mode, mode + +scyjava.config.endpoints.append("net.imagej:imagej") +scyjava.config.endpoints.append("net.imagej:imagej-legacy:MANAGED") + + +class TestIntrospection(object): + """ + Test introspection functionality. + """ + + def test_find_java_methods(self): + if mode == Mode.JEP: + # JEP does not support the jclass function. + return + str_String = "java.lang.String" + String = scyjava.jimport(str_String) + str_Obj = scyjava.find_java(str_String, "methods") + jimport_Obj = scyjava.find_java(String, "methods") + assert len(str_Obj) > 0 + assert len(jimport_Obj) > 0 + assert jimport_Obj is not None + assert jimport_Obj == str_Obj + + def test_find_java_fields(self): + if mode == Mode.JEP: + # JEP does not support the jclass function. + return + str_BitSet = "java.util.BitSet" + BitSet = scyjava.jimport(str_BitSet) + str_Obj = scyjava.find_java(str_BitSet, "fields") + bitset_Obj = scyjava.find_java(BitSet, "fields") + assert len(str_Obj) == 0 + assert len(bitset_Obj) == 0 + assert bitset_Obj is not None + assert bitset_Obj == str_Obj + + def test_find_source(self): + if mode == Mode.JEP: + # JEP does not support the jclass function. + return + str_SF = "org.scijava.search.SourceFinder" + SF = scyjava.jimport(str_SF) + source_strSF = scyjava.java_source(str_SF) + source_SF = scyjava.java_source(SF) + github_home = "https://github.com/" + assert source_strSF.startsWith(github_home) + assert source_SF.startsWith(github_home) + assert source_strSF == source_SF + + def test_imagej_legacy(self): + if mode == Mode.JEP: + # JEP does not support the jclass function. + return + str_RE = "ij.plugin.RoiEnlarger" + table = scyjava.find_java(str_RE, aspect="methods") + assert len([entry for entry in table if entry["static"]]) == 3 + github_home = "https://github.com/" + assert scyjava.java_source(str_RE).startsWith(github_home) From 7293d2343f4d8c6575f5461489719012127df0b8 Mon Sep 17 00:00:00 2001 From: ian-coccimiglio Date: Fri, 28 Mar 2025 15:26:45 -0700 Subject: [PATCH 026/119] Lint code --- src/scyjava/__init__.py | 1 - src/scyjava/_introspection.py | 8 ++++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/scyjava/__init__.py b/src/scyjava/__init__.py index b30f909..c922cd0 100644 --- a/src/scyjava/__init__.py +++ b/src/scyjava/__init__.py @@ -133,7 +133,6 @@ fields, attrs, src, - java_source, ) from ._versions import compare_version, get_version, is_version_at_least diff --git a/src/scyjava/_introspection.py b/src/scyjava/_introspection.py index f136eaa..9cbf199 100644 --- a/src/scyjava/_introspection.py +++ b/src/scyjava/_introspection.py @@ -22,8 +22,8 @@ def find_java(data, aspect: str) -> list[dict[str, Any]]: if not isjava(data) and isinstance(data, str): try: data = jimport(data) - except: - raise ValueError("Not a Java object") + except Exception as err: + raise ValueError(f"Not a Java object {err}") Modifier = jimport("java.lang.reflect.Modifier") jcls = data if jinstance(data, "java.lang.Class") else jclass(data) @@ -124,8 +124,8 @@ def java_source(data): if not isjava(data) and isinstance(data, str): try: data = jimport(data) # check if data can be imported - except: - raise ValueError("Not a Java object") + except Exception as err: + raise ValueError(f"Not a Java object {err}") jcls = data if jinstance(data, "java.lang.Class") else jclass(data) if types.location(jcls).toString().startsWith(jstring("jrt")): # Handles Java RunTime (jrt) exceptions. From 41230787cc12512a7bcbe8e17f42207437312fdc Mon Sep 17 00:00:00 2001 From: ian-coccimiglio Date: Sun, 30 Mar 2025 23:37:27 -0700 Subject: [PATCH 027/119] Improve introspection function documentation --- src/scyjava/_introspection.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/scyjava/_introspection.py b/src/scyjava/_introspection.py index 9cbf199..18e097e 100644 --- a/src/scyjava/_introspection.py +++ b/src/scyjava/_introspection.py @@ -1,5 +1,5 @@ """ -Introspection functions for reporting java classes and URL +Introspection functions for reporting java class 'methods', 'fields', and source code URL. """ from functools import partial @@ -61,7 +61,7 @@ def find_java(data, aspect: str) -> list[dict[str, Any]]: def _map_syntax(base_type): """ - Maps a java BaseType annotation (see link below) in an Java array + Maps a Java BaseType annotation (see link below) in an Java array to a specific type with an Python interpretable syntax. https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.3 """ @@ -113,7 +113,7 @@ def _make_pretty_string(entry, offset): def java_source(data): """ - Tries to find the source code using Scijava's SourceFinder' + Tries to find the source code using Scijava's SourceFinder :param data: The object or class or fully qualified class name to check for source code. :return: The URL of the java class """ @@ -147,10 +147,9 @@ def _print_data(data, aspect, static: bool | None = None, source: bool = True): """ Writes data to a printed string of class methods with inputs, static modifier, arguments, and return values. - :param data: The object or class to inspect. - :param aspect: Whether to print class fields or methods. - :param static: Filter on Static/Instance. Can be set as boolean to filter the class methods based on - static vs. instance methods. Optional, default is None (prints all). + :param data: The object or class to inspect or fully qualified class name. + :param aspect: Whether to print class 'fields' or 'methods'. + :param static: Boolean filter on Static or Instance methods. Optional, default is None (prints all). :param source: Whether to print any available source code. Default True. """ table = find_java(data, aspect) @@ -188,6 +187,7 @@ def _print_data(data, aspect, static: bool | None = None, source: bool = True): print(all_methods) +# The functions with short names for quick usage. methods = partial(_print_data, aspect="methods") fields = partial(_print_data, aspect="fields") attrs = partial(_print_data, aspect="fields") From 3533446337f8c43e57df729872b4ef6387c19d65 Mon Sep 17 00:00:00 2001 From: ian-coccimiglio Date: Sun, 30 Mar 2025 23:40:37 -0700 Subject: [PATCH 028/119] Add docstring to test_introspection.py --- tests/test_introspection.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_introspection.py b/tests/test_introspection.py index e8405f3..98cdaf4 100644 --- a/tests/test_introspection.py +++ b/tests/test_introspection.py @@ -1,9 +1,7 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ -Created on Fri Mar 28 13:58:54 2025 +Tests for introspection of java classes (fields and methods), as well as the GitHub source code URLs. Created on Fri Mar 28 13:58:54 2025 -@author: ian +@author: ian-coccimiglio """ import scyjava From 9516a72eaed28c6fd52424a682738a56b00c5e9a Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 2 Apr 2025 12:25:12 -0500 Subject: [PATCH 029/119] Wrap long line --- tests/test_introspection.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_introspection.py b/tests/test_introspection.py index 98cdaf4..37504ec 100644 --- a/tests/test_introspection.py +++ b/tests/test_introspection.py @@ -1,5 +1,6 @@ """ -Tests for introspection of java classes (fields and methods), as well as the GitHub source code URLs. Created on Fri Mar 28 13:58:54 2025 +Tests for introspection of java classes (fields and methods), as well +as the GitHub source code URLs. Created on Fri Mar 28 13:58:54 2025 @author: ian-coccimiglio """ From 652022322727a62ffcf38a5c2771e11b469df631 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 2 Apr 2025 12:26:16 -0500 Subject: [PATCH 030/119] Increment minor version digit The introspection functions are new API. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bbcc8b8..3a559b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "scyjava" -version = "1.10.3.dev0" +version = "1.11.0.dev0" description = "Supercharged Java access from Python" license = {text = "The Unlicense"} authors = [{name = "SciJava developers", email = "ctrueden@wisc.edu"}] From 363e7bc6cd459282a88a037c5e368131b53aba3c Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 2 Apr 2025 12:29:35 -0500 Subject: [PATCH 031/119] Alphabetize introspection imports --- src/scyjava/__init__.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/scyjava/__init__.py b/src/scyjava/__init__.py index c922cd0..555effd 100644 --- a/src/scyjava/__init__.py +++ b/src/scyjava/__init__.py @@ -91,6 +91,14 @@ to_java, to_python, ) +from ._introspection import ( + attrs, + fields, + find_java, + java_source, + methods, + src, +) from ._jvm import ( # noqa: F401 available_processors, gc, @@ -126,14 +134,6 @@ jstacktrace, numeric_bounds, ) -from ._introspection import ( - find_java, - java_source, - methods, - fields, - attrs, - src, -) from ._versions import compare_version, get_version, is_version_at_least __version__ = get_version("scyjava") From c8afba44be7595c909da35d2934829c7c2e2ae44 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 2 Apr 2025 12:30:20 -0500 Subject: [PATCH 032/119] Shorten introspection to introspect For conversion functions, we use the name `convert`. So let's be consistent with introspection functions. It's also more concise. --- src/scyjava/__init__.py | 2 +- src/scyjava/{_introspection.py => _introspect.py} | 0 tests/{test_introspection.py => test_introspect.py} | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename src/scyjava/{_introspection.py => _introspect.py} (100%) rename tests/{test_introspection.py => test_introspect.py} (100%) diff --git a/src/scyjava/__init__.py b/src/scyjava/__init__.py index 555effd..e7a8e61 100644 --- a/src/scyjava/__init__.py +++ b/src/scyjava/__init__.py @@ -91,7 +91,7 @@ to_java, to_python, ) -from ._introspection import ( +from ._introspect import ( attrs, fields, find_java, diff --git a/src/scyjava/_introspection.py b/src/scyjava/_introspect.py similarity index 100% rename from src/scyjava/_introspection.py rename to src/scyjava/_introspect.py diff --git a/tests/test_introspection.py b/tests/test_introspect.py similarity index 100% rename from tests/test_introspection.py rename to tests/test_introspect.py From 6bfec82e7eab7e70f6fe6a73c900717a48445aa5 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 2 Apr 2025 12:43:02 -0500 Subject: [PATCH 033/119] Add toplevel docstrings to test files --- tests/test_arrays.py | 4 ++++ tests/test_basics.py | 4 ++++ tests/test_convert.py | 4 ++++ tests/test_introspect.py | 4 ++-- tests/test_pandas.py | 4 ++++ tests/test_types.py | 4 ++++ tests/test_version.py | 4 ++++ 7 files changed, 26 insertions(+), 2 deletions(-) diff --git a/tests/test_arrays.py b/tests/test_arrays.py index fca796d..80f1891 100644 --- a/tests/test_arrays.py +++ b/tests/test_arrays.py @@ -1,3 +1,7 @@ +""" +Tests for array-related functions in _types submodule. +""" + import numpy as np from scyjava import is_jarray, jarray, to_python diff --git a/tests/test_basics.py b/tests/test_basics.py index 042b436..76e2229 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -1,3 +1,7 @@ +""" +Tests for key functions across all scyjava submodules. +""" + import re import pytest diff --git a/tests/test_convert.py b/tests/test_convert.py index e9f0489..fcfabe1 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -1,3 +1,7 @@ +""" +Tests for functions in _convert submodule. +""" + import math from os import getcwd from pathlib import Path diff --git a/tests/test_introspect.py b/tests/test_introspect.py index 37504ec..a0d5872 100644 --- a/tests/test_introspect.py +++ b/tests/test_introspect.py @@ -1,6 +1,6 @@ """ -Tests for introspection of java classes (fields and methods), as well -as the GitHub source code URLs. Created on Fri Mar 28 13:58:54 2025 +Tests for functions in _introspect submodule. +Created on Fri Mar 28 13:58:54 2025 @author: ian-coccimiglio """ diff --git a/tests/test_pandas.py b/tests/test_pandas.py index 8fc4bc3..c18d243 100644 --- a/tests/test_pandas.py +++ b/tests/test_pandas.py @@ -1,3 +1,7 @@ +""" +Tests for functions in _pandas submodule. +""" + import numpy as np import numpy.testing as npt import pandas as pd diff --git a/tests/test_types.py b/tests/test_types.py index 7ccf23b..e4bdbc9 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,3 +1,7 @@ +""" +Tests for functions in _types submodule. +""" + from scyjava import jclass, jimport, numeric_bounds, to_java from scyjava.config import Mode, mode diff --git a/tests/test_version.py b/tests/test_version.py index 5411387..3b5fafc 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,3 +1,7 @@ +""" +Tests for functions in _versions submodule. +""" + from pathlib import Path import toml From 37bd92e2b25f2ba87268767971b6c130bee90d48 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 2 Apr 2025 12:45:13 -0500 Subject: [PATCH 034/119] Fix naming of versions test file The submodule is called versions; let's name the test file consistently. --- tests/{test_version.py => test_versions.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_version.py => test_versions.py} (100%) diff --git a/tests/test_version.py b/tests/test_versions.py similarity index 100% rename from tests/test_version.py rename to tests/test_versions.py From 5f883f1119f9b0e05a371b0dbe69faa848ba0aab Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 2 Apr 2025 13:45:19 -0500 Subject: [PATCH 035/119] Fix type hints to work with Python 3.8 --- src/scyjava/_introspect.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/scyjava/_introspect.py b/src/scyjava/_introspect.py index 18e097e..50d54bd 100644 --- a/src/scyjava/_introspect.py +++ b/src/scyjava/_introspect.py @@ -3,13 +3,13 @@ """ from functools import partial -from typing import Any +from typing import Any, Dict, List, Optional from scyjava._jvm import jimport from scyjava._types import isjava, jinstance, jclass -def find_java(data, aspect: str) -> list[dict[str, Any]]: +def find_java(data, aspect: str) -> List[Dict[str, Any]]: """ Use Java reflection to introspect the given Java object, returning a table of its available methods. @@ -143,7 +143,7 @@ def java_source(data): return f"Unexpected {err=}, {type(err)=}" -def _print_data(data, aspect, static: bool | None = None, source: bool = True): +def _print_data(data, aspect, static: Optional[bool] = None, source: bool = True): """ Writes data to a printed string of class methods with inputs, static modifier, arguments, and return values. From 6282d8c399ed6381f6c2f85d09c8daaad40e1b08 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 2 Apr 2025 13:45:31 -0500 Subject: [PATCH 036/119] CI: test Python 3.13 support --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8b246a0..09a2f3d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,7 @@ jobs: python-version: [ '3.8', '3.10', - '3.12' + '3.13' ] steps: From bded14f5e267eca69f71c376b11aed0b419980d7 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 2 Apr 2025 14:11:32 -0500 Subject: [PATCH 037/119] Rename find_java function to jreflect To me, the name `find_java` suggests we will be locating a JVM installation, rather than "finding" information about Java objects. The information doesn't need to be "found" or "located", but rather only introspected or interrogated. Technically, I suppose "introspection" implies read/access while "reflection" implies write/mutation, but `jintrospect` is rather clunky, whereas the term "reflection" is widely known in both Java and Python circles. --- src/scyjava/__init__.py | 2 +- src/scyjava/_introspect.py | 6 +++--- tests/test_introspect.py | 14 +++++++------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/scyjava/__init__.py b/src/scyjava/__init__.py index e7a8e61..e42b51a 100644 --- a/src/scyjava/__init__.py +++ b/src/scyjava/__init__.py @@ -94,8 +94,8 @@ from ._introspect import ( attrs, fields, - find_java, java_source, + jreflect, methods, src, ) diff --git a/src/scyjava/_introspect.py b/src/scyjava/_introspect.py index 50d54bd..643969a 100644 --- a/src/scyjava/_introspect.py +++ b/src/scyjava/_introspect.py @@ -9,10 +9,10 @@ from scyjava._types import isjava, jinstance, jclass -def find_java(data, aspect: str) -> List[Dict[str, Any]]: +def jreflect(data, aspect: str) -> List[Dict[str, Any]]: """ Use Java reflection to introspect the given Java object, - returning a table of its available methods. + returning a table of its available methods or fields. :param data: The object or class or fully qualified class name to inspect. :param aspect: Either 'methods' or 'fields' @@ -152,7 +152,7 @@ def _print_data(data, aspect, static: Optional[bool] = None, source: bool = True :param static: Boolean filter on Static or Instance methods. Optional, default is None (prints all). :param source: Whether to print any available source code. Default True. """ - table = find_java(data, aspect) + table = jreflect(data, aspect) if len(table) == 0: print(f"No {aspect} found") return diff --git a/tests/test_introspect.py b/tests/test_introspect.py index a0d5872..a12c051 100644 --- a/tests/test_introspect.py +++ b/tests/test_introspect.py @@ -17,27 +17,27 @@ class TestIntrospection(object): Test introspection functionality. """ - def test_find_java_methods(self): + def test_jreflect_methods(self): if mode == Mode.JEP: # JEP does not support the jclass function. return str_String = "java.lang.String" String = scyjava.jimport(str_String) - str_Obj = scyjava.find_java(str_String, "methods") - jimport_Obj = scyjava.find_java(String, "methods") + str_Obj = scyjava.jreflect(str_String, "methods") + jimport_Obj = scyjava.jreflect(String, "methods") assert len(str_Obj) > 0 assert len(jimport_Obj) > 0 assert jimport_Obj is not None assert jimport_Obj == str_Obj - def test_find_java_fields(self): + def test_jreflect_fields(self): if mode == Mode.JEP: # JEP does not support the jclass function. return str_BitSet = "java.util.BitSet" BitSet = scyjava.jimport(str_BitSet) - str_Obj = scyjava.find_java(str_BitSet, "fields") - bitset_Obj = scyjava.find_java(BitSet, "fields") + str_Obj = scyjava.jreflect(str_BitSet, "fields") + bitset_Obj = scyjava.jreflect(BitSet, "fields") assert len(str_Obj) == 0 assert len(bitset_Obj) == 0 assert bitset_Obj is not None @@ -61,7 +61,7 @@ def test_imagej_legacy(self): # JEP does not support the jclass function. return str_RE = "ij.plugin.RoiEnlarger" - table = scyjava.find_java(str_RE, aspect="methods") + table = scyjava.jreflect(str_RE, aspect="methods") assert len([entry for entry in table if entry["static"]]) == 3 github_home = "https://github.com/" assert scyjava.java_source(str_RE).startsWith(github_home) From 0778a893f11809643d3f6f1f6785bb79d761386a Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 2 Apr 2025 14:37:54 -0500 Subject: [PATCH 038/119] Add missing is_j* type methods to README --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index eb22bbb..5a2cff1 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,22 @@ FUNCTIONS is_jarray(data: Any) -> bool Return whether the given data object is a Java array. + is_jboolean(the_type: type) -> bool + + is_jbyte(the_type: type) -> bool + + is_jcharacter(the_type: type) -> bool + + is_jdouble(the_type: type) -> bool + + is_jfloat(the_type: type) -> bool + + is_jinteger(the_type: type) -> bool + + is_jlong(the_type: type) -> bool + + is_jshort(the_type: type) -> bool + is_jvm_headless() -> bool Return true iff Java is running in headless mode. From 21ffae9015b182e38c7ffb228cbf62b8db64b4cf Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 2 Apr 2025 14:45:40 -0500 Subject: [PATCH 039/119] Use imperative tense for function docstrings --- src/scyjava/_introspect.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/scyjava/_introspect.py b/src/scyjava/_introspect.py index 643969a..bc5aa4c 100644 --- a/src/scyjava/_introspect.py +++ b/src/scyjava/_introspect.py @@ -61,7 +61,7 @@ def jreflect(data, aspect: str) -> List[Dict[str, Any]]: def _map_syntax(base_type): """ - Maps a Java BaseType annotation (see link below) in an Java array + Map a Java BaseType annotation (see link below) in an Java array to a specific type with an Python interpretable syntax. https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.3 """ @@ -88,7 +88,7 @@ def _map_syntax(base_type): def _make_pretty_string(entry, offset): """ - Prints the entry with a specific formatting and aligned style + Print the entry with a specific formatting and aligned style. :param entry: Dictionary of class names, modifiers, arguments, and return values. :param offset: Offset between the return value and the method. """ @@ -113,7 +113,7 @@ def _make_pretty_string(entry, offset): def java_source(data): """ - Tries to find the source code using Scijava's SourceFinder + Try to find the source code using SciJava's SourceFinder. :param data: The object or class or fully qualified class name to check for source code. :return: The URL of the java class """ @@ -145,7 +145,8 @@ def java_source(data): def _print_data(data, aspect, static: Optional[bool] = None, source: bool = True): """ - Writes data to a printed string of class methods with inputs, static modifier, arguments, and return values. + Write data to a printed string of class methods with inputs, static modifier, + arguments, and return values. :param data: The object or class to inspect or fully qualified class name. :param aspect: Whether to print class 'fields' or 'methods'. @@ -195,7 +196,7 @@ def _print_data(data, aspect, static: Optional[bool] = None, source: bool = True def src(data): """ - Prints the source code URL for a Java class, object, or class name. + Print the source code URL for a Java class, object, or class name. :param data: The Java class, object, or fully qualified class name as string """ From bb06ed1f1999542fb3eba0dde63fd47232aeedfc Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 2 Apr 2025 14:46:14 -0500 Subject: [PATCH 040/119] Wrap >88 lines, and make quoting more consistent --- src/scyjava/_introspect.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/scyjava/_introspect.py b/src/scyjava/_introspect.py index bc5aa4c..17b844f 100644 --- a/src/scyjava/_introspect.py +++ b/src/scyjava/_introspect.py @@ -1,5 +1,6 @@ """ -Introspection functions for reporting java class 'methods', 'fields', and source code URL. +Introspection functions for reporting Java +class methods, fields, and source code URL. """ from functools import partial @@ -15,7 +16,7 @@ def jreflect(data, aspect: str) -> List[Dict[str, Any]]: returning a table of its available methods or fields. :param data: The object or class or fully qualified class name to inspect. - :param aspect: Either 'methods' or 'fields' + :param aspect: Either "methods" or "fields" :return: List of dicts with keys: "name", "static", "arguments", and "returns". """ @@ -33,7 +34,7 @@ def jreflect(data, aspect: str) -> List[Dict[str, Any]]: elif aspect == "fields": cls_aspects = jcls.getFields() else: - return "`aspect` must be either 'fields' or 'methods'" + return '`aspect` must be either "fields" or "methods"' table = [] @@ -114,7 +115,8 @@ def _make_pretty_string(entry, offset): def java_source(data): """ Try to find the source code using SciJava's SourceFinder. - :param data: The object or class or fully qualified class name to check for source code. + :param data: + The object or class or fully qualified class name to check for source code. :return: The URL of the java class """ types = jimport("org.scijava.util.Types") @@ -149,8 +151,10 @@ def _print_data(data, aspect, static: Optional[bool] = None, source: bool = True arguments, and return values. :param data: The object or class to inspect or fully qualified class name. - :param aspect: Whether to print class 'fields' or 'methods'. - :param static: Boolean filter on Static or Instance methods. Optional, default is None (prints all). + :param aspect: Whether to print class "fields" or "methods". + :param static: + Boolean filter on Static or Instance methods. + Optional, default is None (prints all). :param source: Whether to print any available source code. Default True. """ table = jreflect(data, aspect) From 553d552a4316ac0229fa3ea1f01cf014f54ad477 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 2 Apr 2025 14:46:49 -0500 Subject: [PATCH 041/119] Add introspection functions to the README --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index 5a2cff1..8891917 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,12 @@ FUNCTIONS You can pass a single integer to make a 1-dimensional array of that length. :return: The newly allocated array + java_source(data) + Try to find the source code using SciJava's SourceFinder. + :param data: + The object or class or fully qualified class name to check for source code. + :return: The URL of the java class + jclass(data) Obtain a Java class object. @@ -319,6 +325,14 @@ FUNCTIONS :param jtype: The Java type, as either a jimported class or as a string. :return: True iff the object is an instance of that Java type. + jreflect(data, aspect: str) -> List[Dict[str, Any]] + Use Java reflection to introspect the given Java object, + returning a table of its available methods or fields. + + :param data: The object or class or fully qualified class name to inspect. + :param aspect: Either "methods" or "fields" + :return: List of dicts with keys: "name", "static", "arguments", and "returns". + jstacktrace(exc) -> str Extract the Java-side stack trace from a Java exception. @@ -427,6 +441,11 @@ FUNCTIONS :raise RuntimeError: if this method is called while in Jep mode. + src(data) + Print the source code URL for a Java class, object, or class name. + + :param data: The Java class, object, or fully qualified class name as string + start_jvm(options=None) -> None Explicitly connect to the Java virtual machine (JVM). Only one JVM can be active; does nothing if the JVM has already been started. Calling From e6826879ac8d6c885bbaf99b24f690ed3316aeae Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sat, 19 Apr 2025 08:04:01 -0500 Subject: [PATCH 042/119] Update license metadata format to new standard --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bbcc8b8..301f283 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "scyjava" version = "1.10.3.dev0" description = "Supercharged Java access from Python" -license = {text = "The Unlicense"} +license = "Unlicense" authors = [{name = "SciJava developers", email = "ctrueden@wisc.edu"}] readme = "README.md" keywords = ["java", "maven", "cross-language"] @@ -21,7 +21,6 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", - "License :: OSI Approved :: The Unlicense (Unlicense)", "Operating System :: Microsoft :: Windows", "Operating System :: Unix", "Operating System :: MacOS", From aebbfd963edc0893207b84f7d08b10091ebc4dc8 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 20 Apr 2025 18:24:10 -0500 Subject: [PATCH 043/119] Fix syntax of license declaration --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 301f283..1332f1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "scyjava" version = "1.10.3.dev0" description = "Supercharged Java access from Python" -license = "Unlicense" +license = {text = "Unlicense"} authors = [{name = "SciJava developers", email = "ctrueden@wisc.edu"}] readme = "README.md" keywords = ["java", "maven", "cross-language"] From 28af43b7073fd3bf31b9d8d62921d2b2fc654c7e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 20 Apr 2025 15:58:04 -0400 Subject: [PATCH 044/119] feat: add auto-fetch with cjdk --- .github/workflows/build.yml | 10 +++- dev-environment.yml | 1 + pyproject.toml | 2 + src/scyjava/_cjdk_fetch.py | 99 +++++++++++++++++++++++++++++++++++++ src/scyjava/_jvm.py | 39 ++++++++++++++- 5 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 src/scyjava/_cjdk_fetch.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8b246a0..92cd367 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,6 +26,13 @@ jobs: '3.10', '3.12' ] + java-version: ['11'] + include: + # one test without java to test cjdk fallback + - os: ubuntu-latest + python-version: '3.12' + java-version: '' + steps: - uses: actions/checkout@v2 @@ -35,8 +42,9 @@ jobs: python-version: ${{matrix.python-version}} - uses: actions/setup-java@v3 + if: matrix.java-version != '' with: - java-version: '11' + java-version: ${{matrix.java-version}} distribution: 'zulu' cache: 'maven' diff --git a/dev-environment.yml b/dev-environment.yml index d8d2568..2e0f6ee 100644 --- a/dev-environment.yml +++ b/dev-environment.yml @@ -37,5 +37,6 @@ dependencies: # Project from source - pip - pip: + - cjdk - git+https://github.com/ninia/jep.git@cfca63f8b3398daa6d2685428660dc4b2bfab67d - -e . diff --git a/pyproject.toml b/pyproject.toml index 1332f1a..9db6da7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,9 @@ dependencies = [ [project.optional-dependencies] # NB: Keep this in sync with dev-environment.yml! +cjdk = ["cjdk"] dev = [ + "scyjava[cjdk]", "assertpy", "build", "jep", diff --git a/src/scyjava/_cjdk_fetch.py b/src/scyjava/_cjdk_fetch.py new file mode 100644 index 0000000..a620aad --- /dev/null +++ b/src/scyjava/_cjdk_fetch.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import logging +import os +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + +_logger = logging.getLogger(__name__) +_DEFAULT_MAVEN_URL = "tgz+https://dlcdn.apache.org/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.tar.gz" # noqa: E501 +_DEFAULT_MAVEN_SHA = "a555254d6b53d267965a3404ecb14e53c3827c09c3b94b5678835887ab404556bfaf78dcfe03ba76fa2508649dca8531c74bca4d5846513522404d48e8c4ac8b" # noqa: E501 +_DEFAULT_JAVA_VENDOR = "zulu-jre" +_DEFAULT_JAVA_VERSION = "11" + + +def cjdk_fetch_java( + vendor: str = "", version: str = "", raise_on_error: bool = True +) -> None: + """Fetch java using cjdk and add it to the PATH.""" + try: + import cjdk + except ImportError as e: + if raise_on_error is True: + raise ImportError( + "No JVM found. Please install `cjdk` to use the fetch_java feature." + ) from e + _logger.info("cjdk is not installed. Skipping automatic fetching of java.") + return + + if not vendor: + vendor = os.getenv("JAVA_VENDOR", _DEFAULT_JAVA_VENDOR) + version = os.getenv("JAVA_VERSION", _DEFAULT_JAVA_VERSION) + + _logger.info(f"No JVM found, fetching {vendor}:{version} using cjdk...") + home = cjdk.java_home(vendor=vendor, version=version) + _add_to_path(str(home / "bin")) + os.environ["JAVA_HOME"] = str(home) + + +def cjdk_fetch_maven(url: str = "", sha: str = "", raise_on_error: bool = True) -> None: + """Fetch Maven using cjdk and add it to the PATH.""" + try: + import cjdk + except ImportError as e: + if raise_on_error is True: + raise ImportError( + "Please install `cjdk` to use the fetch_java feature." + ) from e + _logger.info("cjdk is not installed. Skipping automatic fetching of Maven.") + return + + # if url was passed as an argument, or env_var, use it with provided sha + # otherwise, use default values for both + if url := url or os.getenv("MAVEN_URL", ""): + sha = sha or os.getenv("MAVEN_SHA", "") + else: + url = _DEFAULT_MAVEN_URL + sha = _DEFAULT_MAVEN_SHA + + # fix urls to have proper prefix for cjdk + if url.startswith("http"): + if url.endswith(".tar.gz"): + url = url.replace("http", "tgz+http") + elif url.endswith(".zip"): + url = url.replace("http", "zip+http") + + # determine sha type based on length (cjdk requires specifying sha type) + # assuming hex-encoded SHA, length should be 40, 64, or 128 + kwargs = {} + if sha_len := len(sha): # empty sha is fine... we just don't pass it + sha_lengths = {40: "sha1", 64: "sha256", 128: "sha512"} + if sha_len not in sha_lengths: + raise ValueError( + "MAVEN_SHA be a valid sha1, sha256, or sha512 hash." + f"Got invalid SHA length: {sha_len}. " + ) + kwargs = {sha_lengths[sha_len]: sha} + + maven_dir = cjdk.cache_package("Maven", url, **kwargs) + if maven_bin := next(maven_dir.rglob("apache-maven-*/**/mvn"), None): + _add_to_path(maven_bin.parent, front=True) + else: + raise RuntimeError("Failed to find Maven executable in the downloaded package.") + + +def _add_to_path(path: Path | str, front: bool = False) -> None: + """Add a path to the PATH environment variable. + + If front is True, the path is added to the front of the PATH. + By default, the path is added to the end of the PATH. + If the path is already in the PATH, it is not added again. + """ + + current_path = os.environ.get("PATH", "") + if (path := str(path)) in current_path: + return + new_path = [path, current_path] if front else [current_path, path] + os.environ["PATH"] = os.pathsep.join(new_path) diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index 1a6c5ca..0b8f215 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -6,6 +6,7 @@ import logging import os import re +import shutil import subprocess import sys from functools import lru_cache @@ -16,6 +17,7 @@ import jpype.config from jgo import jgo +from scyjava._cjdk_fetch import cjdk_fetch_java, cjdk_fetch_maven import scyjava.config from scyjava.config import Mode, mode @@ -106,7 +108,7 @@ def jvm_version() -> str: return tuple(map(int, m.group(1).split("."))) -def start_jvm(options=None) -> None: +def start_jvm(options=None, *, fetch_java: bool | None = None) -> None: """ Explicitly connect to the Java virtual machine (JVM). Only one JVM can be active; does nothing if the JVM has already been started. Calling @@ -117,6 +119,13 @@ def start_jvm(options=None) -> None: :param options: List of options to pass to the JVM. For example: ['-Dfoo=bar', '-XX:+UnlockExperimentalVMOptions'] + :param fetch_java: + Whether to automatically fetch a JRE (and/or maven) using + [`cjdk`](https://github.com/cachedjdk/cjdk) if java and maven executables are + not found. Requires `cjdk` to be installed. See README for details. + - If `None` (default), then fetching will only occur if `cjdk` is available. + - If `True`, an exception will be raised if `cjdk` is not available. + - If `False`, no attempt to import `cjdk` is be made. """ # if JVM is already running -- break if jvm_started(): @@ -132,8 +141,14 @@ def start_jvm(options=None) -> None: # use the logger to notify user that endpoints are being added _logger.debug("Adding jars from endpoints {0}".format(endpoints)) + if fetch_java is not False and not is_jvm_available(): + cjdk_fetch_java(raise_on_error=fetch_java is True) + # get endpoints and add to JPype class path if len(endpoints) > 0: + if not shutil.which("mvn") and fetch_java is not False: + cjdk_fetch_maven(raise_on_error=fetch_java is True) + endpoints = endpoints[:1] + sorted(endpoints[1:]) _logger.debug("Using endpoints %s", endpoints) _, workspace = jgo.resolve_dependencies( @@ -340,6 +355,28 @@ def is_jvm_headless() -> bool: return bool(GraphicsEnvironment.isHeadless()) +def is_jvm_available() -> bool: + """ + Return True if the JVM is available, suppressing stderr on macos. + """ + from unittest.mock import patch + + subprocess_check_output = subprocess.check_output + + def _silent_check_output(*args, **kwargs): + # also suppress stderr on calls to subprocess.check_output + kwargs.setdefault("stderr", subprocess.DEVNULL) + return subprocess_check_output(*args, **kwargs) + + try: + with patch.object(subprocess, "check_output", new=_silent_check_output): + jpype.getDefaultJVMPath() + # on Darwin, may raise a CalledProcessError when invoking `/user/libexec/java_home` + except (jpype.JVMNotFoundException, subprocess.CalledProcessError): + return False + return True + + def is_awt_initialized() -> bool: """ Return true iff the AWT subsystem has been initialized. From aa913dd2bb2132bf36a59c2fe5da06b89429f194 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 20 Apr 2025 16:06:32 -0400 Subject: [PATCH 045/119] reorg --- src/scyjava/_cjdk_fetch.py | 38 ++++++++++++++++++++++++++++++++++++-- src/scyjava/_jvm.py | 38 +++++++------------------------------- 2 files changed, 43 insertions(+), 33 deletions(-) diff --git a/src/scyjava/_cjdk_fetch.py b/src/scyjava/_cjdk_fetch.py index a620aad..3cdcfba 100644 --- a/src/scyjava/_cjdk_fetch.py +++ b/src/scyjava/_cjdk_fetch.py @@ -2,8 +2,12 @@ import logging import os +import shutil +import subprocess from typing import TYPE_CHECKING +import jpype + if TYPE_CHECKING: from pathlib import Path @@ -14,6 +18,34 @@ _DEFAULT_JAVA_VERSION = "11" +def ensure_jvm_available(raise_on_error: bool = True) -> None: + """Ensure that the JVM is available, or raise if `raise_on_error` is True.""" + if not is_jvm_available(): + cjdk_fetch_java(raise_on_error=raise_on_error) + if not shutil.which("mvn"): + cjdk_fetch_maven(raise_on_error=raise_on_error) + + +def is_jvm_available() -> bool: + """Return True if the JVM is available, suppressing stderr on macos.""" + from unittest.mock import patch + + subprocess_check_output = subprocess.check_output + + def _silent_check_output(*args, **kwargs): + # also suppress stderr on calls to subprocess.check_output + kwargs.setdefault("stderr", subprocess.DEVNULL) + return subprocess_check_output(*args, **kwargs) + + try: + with patch.object(subprocess, "check_output", new=_silent_check_output): + jpype.getDefaultJVMPath() + # on Darwin, may raise a CalledProcessError when invoking `/user/libexec/java_home` + except (jpype.JVMNotFoundException, subprocess.CalledProcessError): + return False + return True + + def cjdk_fetch_java( vendor: str = "", version: str = "", raise_on_error: bool = True ) -> None: @@ -25,7 +57,7 @@ def cjdk_fetch_java( raise ImportError( "No JVM found. Please install `cjdk` to use the fetch_java feature." ) from e - _logger.info("cjdk is not installed. Skipping automatic fetching of java.") + _logger.info("JVM not found. Please install `cjdk` fetch java automatically.") return if not vendor: @@ -47,7 +79,9 @@ def cjdk_fetch_maven(url: str = "", sha: str = "", raise_on_error: bool = True) raise ImportError( "Please install `cjdk` to use the fetch_java feature." ) from e - _logger.info("cjdk is not installed. Skipping automatic fetching of Maven.") + _logger.info( + "Maven not found. Please install `cjdk` fetch maven automatically." + ) return # if url was passed as an argument, or env_var, use it with provided sha diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index 0b8f215..a69a737 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -6,7 +6,6 @@ import logging import os import re -import shutil import subprocess import sys from functools import lru_cache @@ -17,7 +16,7 @@ import jpype.config from jgo import jgo -from scyjava._cjdk_fetch import cjdk_fetch_java, cjdk_fetch_maven +from scyjava._cjdk_fetch import ensure_jvm_available import scyjava.config from scyjava.config import Mode, mode @@ -120,10 +119,12 @@ def start_jvm(options=None, *, fetch_java: bool | None = None) -> None: List of options to pass to the JVM. For example: ['-Dfoo=bar', '-XX:+UnlockExperimentalVMOptions'] :param fetch_java: - Whether to automatically fetch a JRE (and/or maven) using + Whether to automatically fetch a JRE (and maven) using [`cjdk`](https://github.com/cachedjdk/cjdk) if java and maven executables are - not found. Requires `cjdk` to be installed. See README for details. + not found. Requires `cjdk` to be installed, either manually, or via the + `scyjava[cjdk]` extra. - If `None` (default), then fetching will only occur if `cjdk` is available. + (A log info will be issued if `cjdk` is not available.) - If `True`, an exception will be raised if `cjdk` is not available. - If `False`, no attempt to import `cjdk` is be made. """ @@ -141,14 +142,11 @@ def start_jvm(options=None, *, fetch_java: bool | None = None) -> None: # use the logger to notify user that endpoints are being added _logger.debug("Adding jars from endpoints {0}".format(endpoints)) - if fetch_java is not False and not is_jvm_available(): - cjdk_fetch_java(raise_on_error=fetch_java is True) + if fetch_java is not False: + ensure_jvm_available(raise_on_error=fetch_java is True) # get endpoints and add to JPype class path if len(endpoints) > 0: - if not shutil.which("mvn") and fetch_java is not False: - cjdk_fetch_maven(raise_on_error=fetch_java is True) - endpoints = endpoints[:1] + sorted(endpoints[1:]) _logger.debug("Using endpoints %s", endpoints) _, workspace = jgo.resolve_dependencies( @@ -355,28 +353,6 @@ def is_jvm_headless() -> bool: return bool(GraphicsEnvironment.isHeadless()) -def is_jvm_available() -> bool: - """ - Return True if the JVM is available, suppressing stderr on macos. - """ - from unittest.mock import patch - - subprocess_check_output = subprocess.check_output - - def _silent_check_output(*args, **kwargs): - # also suppress stderr on calls to subprocess.check_output - kwargs.setdefault("stderr", subprocess.DEVNULL) - return subprocess_check_output(*args, **kwargs) - - try: - with patch.object(subprocess, "check_output", new=_silent_check_output): - jpype.getDefaultJVMPath() - # on Darwin, may raise a CalledProcessError when invoking `/user/libexec/java_home` - except (jpype.JVMNotFoundException, subprocess.CalledProcessError): - return False - return True - - def is_awt_initialized() -> bool: """ Return true iff the AWT subsystem has been initialized. From c9cf3cae8a0bb5503a64d4c14243c8957a0a2aed Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 20 Apr 2025 16:09:07 -0400 Subject: [PATCH 046/119] move import --- src/scyjava/_jvm.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index a69a737..13fe866 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -16,7 +16,6 @@ import jpype.config from jgo import jgo -from scyjava._cjdk_fetch import ensure_jvm_available import scyjava.config from scyjava.config import Mode, mode @@ -143,6 +142,8 @@ def start_jvm(options=None, *, fetch_java: bool | None = None) -> None: _logger.debug("Adding jars from endpoints {0}".format(endpoints)) if fetch_java is not False: + from scyjava._cjdk_fetch import ensure_jvm_available + ensure_jvm_available(raise_on_error=fetch_java is True) # get endpoints and add to JPype class path From ace4df58d2d4ec62c46220b7cf8e312e7f002439 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 20 Apr 2025 18:19:20 -0500 Subject: [PATCH 047/119] Avoid usage of pipe symbol in type hints --- src/scyjava/_cjdk_fetch.py | 4 ++-- src/scyjava/_jvm.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/scyjava/_cjdk_fetch.py b/src/scyjava/_cjdk_fetch.py index 3cdcfba..8199335 100644 --- a/src/scyjava/_cjdk_fetch.py +++ b/src/scyjava/_cjdk_fetch.py @@ -4,7 +4,7 @@ import os import shutil import subprocess -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Union import jpype @@ -118,7 +118,7 @@ def cjdk_fetch_maven(url: str = "", sha: str = "", raise_on_error: bool = True) raise RuntimeError("Failed to find Maven executable in the downloaded package.") -def _add_to_path(path: Path | str, front: bool = False) -> None: +def _add_to_path(path: Union[Path, str], front: bool = False) -> None: """Add a path to the PATH environment variable. If front is True, the path is added to the front of the PATH. diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index 13fe866..90f42d0 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -11,6 +11,7 @@ from functools import lru_cache from importlib import import_module from pathlib import Path +from typing import Optional import jpype import jpype.config @@ -106,7 +107,7 @@ def jvm_version() -> str: return tuple(map(int, m.group(1).split("."))) -def start_jvm(options=None, *, fetch_java: bool | None = None) -> None: +def start_jvm(options=None, *, fetch_java: Optional[bool] = None) -> None: """ Explicitly connect to the Java virtual machine (JVM). Only one JVM can be active; does nothing if the JVM has already been started. Calling From 64ffbeebbe9ff314fb9256046ffdf26ab5097f6b Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 20 Apr 2025 18:38:09 -0500 Subject: [PATCH 048/119] CI: skip jep tests when running the javaless job * Include java version in job names, to disambiguate them. * As a hack for now, just fail if Java >= v17 or <8. --- .github/workflows/build.yml | 3 +-- bin/test.sh | 7 ++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 92cd367..29b8797 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ on: jobs: build-cross-platform: - name: test ${{matrix.os}} - ${{matrix.python-version}} + name: test ${{matrix.os}} - ${{matrix.python-version}} - ${{matrix.java-version}} runs-on: ${{ matrix.os }} strategy: matrix: @@ -33,7 +33,6 @@ jobs: python-version: '3.12' java-version: '' - steps: - uses: actions/checkout@v2 diff --git a/bin/test.sh b/bin/test.sh index da27567..db00333 100755 --- a/bin/test.sh +++ b/bin/test.sh @@ -73,7 +73,12 @@ then else argString="" fi -if [ "$(uname -s)" = "Darwin" ] +if ! java -version 2>&1 | grep -q '^openjdk version "\(1\.8\|9\|10\|11\|12\|13\|14\|15\|16\)\.' +then + echo "Skipping jep tests due to unsupported Java version:" + java -version || true + jepCode=0 +elif [ "$(uname -s)" = "Darwin" ] then echo "Skipping jep tests on macOS due to flakiness" jepCode=0 From ccb2271cf1e8056d8425a55c5d513080354101f0 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 20 Apr 2025 21:12:54 -0400 Subject: [PATCH 049/119] include cjdk by default --- pyproject.toml | 3 +-- src/scyjava/_cjdk_fetch.py | 46 +++++++++++--------------------------- src/scyjava/_jvm.py | 26 ++++++++++++--------- 3 files changed, 29 insertions(+), 46 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9db6da7..89b4200 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,13 +35,12 @@ requires-python = ">=3.8" dependencies = [ "jpype1 >= 1.3.0", "jgo", + "cjdk", ] [project.optional-dependencies] # NB: Keep this in sync with dev-environment.yml! -cjdk = ["cjdk"] dev = [ - "scyjava[cjdk]", "assertpy", "build", "jep", diff --git a/src/scyjava/_cjdk_fetch.py b/src/scyjava/_cjdk_fetch.py index 8199335..27c8035 100644 --- a/src/scyjava/_cjdk_fetch.py +++ b/src/scyjava/_cjdk_fetch.py @@ -6,6 +6,7 @@ import subprocess from typing import TYPE_CHECKING, Union +import cjdk import jpype if TYPE_CHECKING: @@ -18,12 +19,12 @@ _DEFAULT_JAVA_VERSION = "11" -def ensure_jvm_available(raise_on_error: bool = True) -> None: - """Ensure that the JVM is available, or raise if `raise_on_error` is True.""" +def ensure_jvm_available() -> None: + """Ensure that the JVM is available and Maven is installed.""" if not is_jvm_available(): - cjdk_fetch_java(raise_on_error=raise_on_error) + cjdk_fetch_java() if not shutil.which("mvn"): - cjdk_fetch_maven(raise_on_error=raise_on_error) + cjdk_fetch_maven() def is_jvm_available() -> bool: @@ -46,20 +47,8 @@ def _silent_check_output(*args, **kwargs): return True -def cjdk_fetch_java( - vendor: str = "", version: str = "", raise_on_error: bool = True -) -> None: +def cjdk_fetch_java(vendor: str = "", version: str = "") -> None: """Fetch java using cjdk and add it to the PATH.""" - try: - import cjdk - except ImportError as e: - if raise_on_error is True: - raise ImportError( - "No JVM found. Please install `cjdk` to use the fetch_java feature." - ) from e - _logger.info("JVM not found. Please install `cjdk` fetch java automatically.") - return - if not vendor: vendor = os.getenv("JAVA_VENDOR", _DEFAULT_JAVA_VENDOR) version = os.getenv("JAVA_VERSION", _DEFAULT_JAVA_VERSION) @@ -70,20 +59,8 @@ def cjdk_fetch_java( os.environ["JAVA_HOME"] = str(home) -def cjdk_fetch_maven(url: str = "", sha: str = "", raise_on_error: bool = True) -> None: +def cjdk_fetch_maven(url: str = "", sha: str = "") -> None: """Fetch Maven using cjdk and add it to the PATH.""" - try: - import cjdk - except ImportError as e: - if raise_on_error is True: - raise ImportError( - "Please install `cjdk` to use the fetch_java feature." - ) from e - _logger.info( - "Maven not found. Please install `cjdk` fetch maven automatically." - ) - return - # if url was passed as an argument, or env_var, use it with provided sha # otherwise, use default values for both if url := url or os.getenv("MAVEN_URL", ""): @@ -104,7 +81,7 @@ def cjdk_fetch_maven(url: str = "", sha: str = "", raise_on_error: bool = True) kwargs = {} if sha_len := len(sha): # empty sha is fine... we just don't pass it sha_lengths = {40: "sha1", 64: "sha256", 128: "sha512"} - if sha_len not in sha_lengths: + if sha_len not in sha_lengths: # pragma: no cover raise ValueError( "MAVEN_SHA be a valid sha1, sha256, or sha512 hash." f"Got invalid SHA length: {sha_len}. " @@ -114,8 +91,11 @@ def cjdk_fetch_maven(url: str = "", sha: str = "", raise_on_error: bool = True) maven_dir = cjdk.cache_package("Maven", url, **kwargs) if maven_bin := next(maven_dir.rglob("apache-maven-*/**/mvn"), None): _add_to_path(maven_bin.parent, front=True) - else: - raise RuntimeError("Failed to find Maven executable in the downloaded package.") + else: # pragma: no cover + raise RuntimeError( + "Failed to find Maven executable on system " + "PATH, and download via cjdk failed." + ) def _add_to_path(path: Union[Path, str], front: bool = False) -> None: diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index 90f42d0..617b9d6 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -107,7 +107,7 @@ def jvm_version() -> str: return tuple(map(int, m.group(1).split("."))) -def start_jvm(options=None, *, fetch_java: Optional[bool] = None) -> None: +def start_jvm(options=None, *, fetch_java: bool = True) -> None: """ Explicitly connect to the Java virtual machine (JVM). Only one JVM can be active; does nothing if the JVM has already been started. Calling @@ -119,14 +119,18 @@ def start_jvm(options=None, *, fetch_java: Optional[bool] = None) -> None: List of options to pass to the JVM. For example: ['-Dfoo=bar', '-XX:+UnlockExperimentalVMOptions'] :param fetch_java: - Whether to automatically fetch a JRE (and maven) using - [`cjdk`](https://github.com/cachedjdk/cjdk) if java and maven executables are - not found. Requires `cjdk` to be installed, either manually, or via the - `scyjava[cjdk]` extra. - - If `None` (default), then fetching will only occur if `cjdk` is available. - (A log info will be issued if `cjdk` is not available.) - - If `True`, an exception will be raised if `cjdk` is not available. - - If `False`, no attempt to import `cjdk` is be made. + If True (default), when a JVM/or maven cannot be located on the system, + [`cjdk`](https://github.com/cachedjdk/cjdk) will be used to download + a JRE distribution and set up the JVM. The following environment variables + may be used to configure the JRE and Maven distributions to download: + * `JAVA_VENDOR`: The vendor of the JRE distribution to download. + Defaults to "zulu-jre". + * `JAVA_VERSION`: The version of the JRE distribution to download. + Defaults to "11". + * `MAVEN_URL`: The URL of the Maven distribution to download. + Defaults to https://dlcdn.apache.org/maven/maven-3/3.9.9/ + * `MAVEN_SHA`: The SHA512 hash of the Maven distribution to download, if + providing a custom MAVEN_URL. """ # if JVM is already running -- break if jvm_started(): @@ -142,10 +146,10 @@ def start_jvm(options=None, *, fetch_java: Optional[bool] = None) -> None: # use the logger to notify user that endpoints are being added _logger.debug("Adding jars from endpoints {0}".format(endpoints)) - if fetch_java is not False: + if fetch_java: from scyjava._cjdk_fetch import ensure_jvm_available - ensure_jvm_available(raise_on_error=fetch_java is True) + ensure_jvm_available() # get endpoints and add to JPype class path if len(endpoints) > 0: From 0af3f650c310499a34c64654671ddef90267801d Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 20 Apr 2025 20:17:20 -0500 Subject: [PATCH 050/119] Bump to setuptools 77.0.0 to use new license field And bump the minimum Python version to 3.9, since that version of setuptools requires it. And raise the test ceiling from Python 3.12 to 3.13. --- .github/workflows/build.yml | 7 +++---- dev-environment.yml | 2 +- environment.yml | 2 +- pyproject.toml | 10 +++++----- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 29b8797..a6dbb29 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,15 +22,14 @@ jobs: macos-latest ] python-version: [ - '3.8', - '3.10', - '3.12' + '3.9', + '3.13' ] java-version: ['11'] include: # one test without java to test cjdk fallback - os: ubuntu-latest - python-version: '3.12' + python-version: '3.9' java-version: '' steps: diff --git a/dev-environment.yml b/dev-environment.yml index 2e0f6ee..bbacab4 100644 --- a/dev-environment.yml +++ b/dev-environment.yml @@ -18,7 +18,7 @@ name: scyjava-dev channels: - conda-forge dependencies: - - python >= 3.8 + - python >= 3.9 # Project dependencies - jpype1 >= 1.3.0 - jgo diff --git a/environment.yml b/environment.yml index c1038c5..d3e3af9 100644 --- a/environment.yml +++ b/environment.yml @@ -19,7 +19,7 @@ name: scyjava channels: - conda-forge dependencies: - - python >= 3.8 + - python >= 3.9 # Project dependencies - jpype1 >= 1.3.0 - jgo diff --git a/pyproject.toml b/pyproject.toml index 89b4200..8da9830 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [build-system] -requires = ["setuptools>=61.2"] +requires = ["setuptools>=77.0.0"] build-backend = "setuptools.build_meta" [project] name = "scyjava" -version = "1.10.3.dev0" +version = "1.11.0.dev0" description = "Supercharged Java access from Python" -license = {text = "Unlicense"} +license = "Unlicense" authors = [{name = "SciJava developers", email = "ctrueden@wisc.edu"}] readme = "README.md" keywords = ["java", "maven", "cross-language"] @@ -16,11 +16,11 @@ classifiers = [ "Intended Audience :: Education", "Intended Audience :: Science/Research", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Operating System :: Microsoft :: Windows", "Operating System :: Unix", "Operating System :: MacOS", @@ -31,7 +31,7 @@ classifiers = [ ] # NB: Keep this in sync with environment.yml AND dev-environment.yml! -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ "jpype1 >= 1.3.0", "jgo", From 3af5c6e0cdff27b25ff28bc56c7f14274af0baa5 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 20 Apr 2025 20:20:50 -0500 Subject: [PATCH 051/119] Make ruff happy --- src/scyjava/_jvm.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index 617b9d6..2035e34 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -11,7 +11,6 @@ from functools import lru_cache from importlib import import_module from pathlib import Path -from typing import Optional import jpype import jpype.config From ec51083975e281b9f5ceaf4e1bb2f25f5693d9e3 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 22 Apr 2025 22:47:44 -0400 Subject: [PATCH 052/119] chore: update env.yml files for cjdk --- dev-environment.yml | 4 ++-- environment.yml | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/dev-environment.yml b/dev-environment.yml index bbacab4..9fa75a7 100644 --- a/dev-environment.yml +++ b/dev-environment.yml @@ -12,7 +12,7 @@ # # In addition to the dependencies needed for using scyjava, it # includes tools for developer-related actions like running -# automated tests (pytest) and linting the code (black). If you +# automated tests (pytest) and linting the code (ruff). If you # want an environment without these tools, use environment.yml. name: scyjava-dev channels: @@ -22,6 +22,7 @@ dependencies: # Project dependencies - jpype1 >= 1.3.0 - jgo + - cjdk - openjdk >= 8, < 12 # Test dependencies - numpy @@ -37,6 +38,5 @@ dependencies: # Project from source - pip - pip: - - cjdk - git+https://github.com/ninia/jep.git@cfca63f8b3398daa6d2685428660dc4b2bfab67d - -e . diff --git a/environment.yml b/environment.yml index d3e3af9..e222104 100644 --- a/environment.yml +++ b/environment.yml @@ -12,7 +12,7 @@ # # It includes the dependencies needed for using scyjava, but not tools # for developer-related actions like running automated tests (pytest), -# linting the code (black), and generating the API documentation (sphinx). +# linting the code (ruff), and generating the API documentation (sphinx). # If you want an environment including these tools, use dev-environment.yml. name: scyjava @@ -23,6 +23,7 @@ dependencies: # Project dependencies - jpype1 >= 1.3.0 - jgo + - cjdk - openjdk >= 8 # Project from source - pip From 5753a593c80fcff17a5fdb66b7b7234bae0a7a7e Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Tue, 22 Apr 2025 16:37:42 -0500 Subject: [PATCH 053/119] Remove openjdk conda dependency cjdk is lighter weight and offers more control over the JDK/JRE used. People can still use the openjdk package with scyjava, of course. --- dev-environment.yml | 1 - environment.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/dev-environment.yml b/dev-environment.yml index 9fa75a7..7bbcf28 100644 --- a/dev-environment.yml +++ b/dev-environment.yml @@ -23,7 +23,6 @@ dependencies: - jpype1 >= 1.3.0 - jgo - cjdk - - openjdk >= 8, < 12 # Test dependencies - numpy - pandas diff --git a/environment.yml b/environment.yml index e222104..bb4bd19 100644 --- a/environment.yml +++ b/environment.yml @@ -24,7 +24,6 @@ dependencies: - jpype1 >= 1.3.0 - jgo - cjdk - - openjdk >= 8 # Project from source - pip - pip: From bf2ee8235dbfbd93c63e0135c3a75d19b83c0ea1 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 24 Apr 2025 16:09:05 -0500 Subject: [PATCH 054/119] Improve get_version method --- src/scyjava/_versions.py | 16 ++++++++++++---- tests/test_versions.py | 19 +++++++++++++++---- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/scyjava/_versions.py b/src/scyjava/_versions.py index c1695db..f163219 100644 --- a/src/scyjava/_versions.py +++ b/src/scyjava/_versions.py @@ -15,8 +15,8 @@ def get_version(java_class_or_python_package) -> str: """ Return the version of a Java class or Python package. - For Python package, uses importlib.metadata.version if available - (Python 3.8+), with pkg_resources.get_distribution as a fallback. + For Python packages, invokes importlib.metadata.version on the given + object's base __module__ or __package__ (before the first dot symbol). For Java classes, requires org.scijava:scijava-common on the classpath. @@ -32,8 +32,16 @@ def get_version(java_class_or_python_package) -> str: VersionUtils = jimport("org.scijava.util.VersionUtils") return str(VersionUtils.getVersion(java_class_or_python_package)) - # Assume we were given a Python package name. - return version(java_class_or_python_package) + # Assume we were given a Python package name or module. + package_name = None + if hasattr(java_class_or_python_package, "__module__"): + package_name = java_class_or_python_package.__module__ + elif hasattr(java_class_or_python_package, "__package__"): + package_name = java_class_or_python_package.__package__ + else: + package_name = str(java_class_or_python_package) + + return version(package_name.split(".")[0]) def is_version_at_least(actual_version: str, minimum_version: str) -> bool: diff --git a/tests/test_versions.py b/tests/test_versions.py index 3b5fafc..d588a0b 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -2,6 +2,7 @@ Tests for functions in _versions submodule. """ +from importlib.metadata import version from pathlib import Path import toml @@ -18,8 +19,18 @@ def _expected_version(): def test_version(): - # First, ensure that the version is correct - assert _expected_version() == scyjava.__version__ + sjver = _expected_version() - # Then, ensure that we get the correct version via get_version - assert _expected_version() == scyjava.get_version("scyjava") + # First, ensure that the version is correct. + assert sjver == scyjava.__version__ + + # Then, ensure that we get the correct version via get_version. + assert sjver == scyjava.get_version("scyjava") + assert sjver == scyjava.get_version(scyjava) + assert sjver == scyjava.get_version("scyjava.config") + assert sjver == scyjava.get_version(scyjava.config) + assert sjver == scyjava.get_version(scyjava.config.mode) + assert sjver == scyjava.get_version(scyjava.config.Mode) + + # And that we get the correct version of other things, too. + assert version("toml") == scyjava.get_version(toml) From 2d44fed2690032bf86191c8daa4218c8da92ea3b Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 24 Apr 2025 16:10:55 -0500 Subject: [PATCH 055/119] Test a little further into the GitHub source paths --- tests/test_introspect.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_introspect.py b/tests/test_introspect.py index a12c051..f4bb485 100644 --- a/tests/test_introspect.py +++ b/tests/test_introspect.py @@ -51,9 +51,9 @@ def test_find_source(self): SF = scyjava.jimport(str_SF) source_strSF = scyjava.java_source(str_SF) source_SF = scyjava.java_source(SF) - github_home = "https://github.com/" - assert source_strSF.startsWith(github_home) - assert source_SF.startsWith(github_home) + repo_path = "https://github.com/scijava/scijava-search/" + assert source_strSF.startsWith(repo_path) + assert source_SF.startsWith(repo_path) assert source_strSF == source_SF def test_imagej_legacy(self): @@ -63,5 +63,5 @@ def test_imagej_legacy(self): str_RE = "ij.plugin.RoiEnlarger" table = scyjava.jreflect(str_RE, aspect="methods") assert len([entry for entry in table if entry["static"]]) == 3 - github_home = "https://github.com/" - assert scyjava.java_source(str_RE).startsWith(github_home) + repo_path = "https://github.com/imagej/ImageJ/" + assert scyjava.java_source(str_RE).startsWith(repo_path) From 8846ce5429474e98e5941ff4425ca21553962631 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 24 Apr 2025 16:15:42 -0500 Subject: [PATCH 056/119] Tweak management of multiple endpoints For consistency with scripting integration tests. --- tests/test_introspect.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_introspect.py b/tests/test_introspect.py index f4bb485..3b7d659 100644 --- a/tests/test_introspect.py +++ b/tests/test_introspect.py @@ -8,8 +8,9 @@ import scyjava from scyjava.config import Mode, mode -scyjava.config.endpoints.append("net.imagej:imagej") -scyjava.config.endpoints.append("net.imagej:imagej-legacy:MANAGED") +scyjava.config.endpoints.extend( + ["net.imagej:imagej", "net.imagej:imagej-legacy:MANAGED"] +) class TestIntrospection(object): From a31701b331ae09bfbc2518591411dcd3b2cee869 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 24 Apr 2025 16:27:05 -0500 Subject: [PATCH 057/119] Rename java_source method to jsource More consistent with the rest of the library. --- README.md | 2 +- src/scyjava/__init__.py | 2 +- src/scyjava/_introspect.py | 6 +++--- tests/test_introspect.py | 8 ++++---- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8891917..e78a7c6 100644 --- a/README.md +++ b/README.md @@ -283,7 +283,7 @@ FUNCTIONS You can pass a single integer to make a 1-dimensional array of that length. :return: The newly allocated array - java_source(data) + jsource(data) Try to find the source code using SciJava's SourceFinder. :param data: The object or class or fully qualified class name to check for source code. diff --git a/src/scyjava/__init__.py b/src/scyjava/__init__.py index e42b51a..166099a 100644 --- a/src/scyjava/__init__.py +++ b/src/scyjava/__init__.py @@ -94,8 +94,8 @@ from ._introspect import ( attrs, fields, - java_source, jreflect, + jsource, methods, src, ) diff --git a/src/scyjava/_introspect.py b/src/scyjava/_introspect.py index 17b844f..dc5bb67 100644 --- a/src/scyjava/_introspect.py +++ b/src/scyjava/_introspect.py @@ -112,7 +112,7 @@ def _make_pretty_string(entry, offset): return f"{return_val} {modifier} = {obj_name}({arg_string})\n" -def java_source(data): +def jsource(data): """ Try to find the source code using SciJava's SourceFinder. :param data: @@ -166,7 +166,7 @@ def _print_data(data, aspect, static: Optional[bool] = None, source: bool = True offset = max(list(map(lambda entry: len(entry["returns"]), table))) all_methods = "" if source: - urlstring = java_source(data) + urlstring = jsource(data) print(f"Source code URL: {urlstring}") # Print methods @@ -204,5 +204,5 @@ def src(data): :param data: The Java class, object, or fully qualified class name as string """ - source_url = java_source(data) + source_url = jsource(data) print(f"Source code URL: {source_url}") diff --git a/tests/test_introspect.py b/tests/test_introspect.py index 3b7d659..3108c20 100644 --- a/tests/test_introspect.py +++ b/tests/test_introspect.py @@ -44,14 +44,14 @@ def test_jreflect_fields(self): assert bitset_Obj is not None assert bitset_Obj == str_Obj - def test_find_source(self): + def test_jsource(self): if mode == Mode.JEP: # JEP does not support the jclass function. return str_SF = "org.scijava.search.SourceFinder" SF = scyjava.jimport(str_SF) - source_strSF = scyjava.java_source(str_SF) - source_SF = scyjava.java_source(SF) + source_strSF = scyjava.jsource(str_SF) + source_SF = scyjava.jsource(SF) repo_path = "https://github.com/scijava/scijava-search/" assert source_strSF.startsWith(repo_path) assert source_SF.startsWith(repo_path) @@ -65,4 +65,4 @@ def test_imagej_legacy(self): table = scyjava.jreflect(str_RE, aspect="methods") assert len([entry for entry in table if entry["static"]]) == 3 repo_path = "https://github.com/imagej/ImageJ/" - assert scyjava.java_source(str_RE).startsWith(repo_path) + assert scyjava.jsource(str_RE).startsWith(repo_path) From 2713b6cc44ddf5ba0c9a261033a04f331ea26c02 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 24 Apr 2025 17:18:02 -0500 Subject: [PATCH 058/119] Make jreflect function more powerful --- README.md | 8 ++--- src/scyjava/_introspect.py | 69 ++++++++++++++++++++++++-------------- tests/test_introspect.py | 2 +- 3 files changed, 49 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index e78a7c6..81337fc 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ AttributeError: 'list' object has no attribute 'stream' Traceback (most recent call last): File "", line 1, in TypeError: No matching overloads found for java.util.Set.addAll(set), options are: - public abstract boolean java.util.Set.addAll(java.util.Collection) + public abstract boolean java.util.Set.addAll(java.util.Collection) >>> from scyjava import to_java as p2j >>> jset.addAll(p2j(pset)) True @@ -325,13 +325,13 @@ FUNCTIONS :param jtype: The Java type, as either a jimported class or as a string. :return: True iff the object is an instance of that Java type. - jreflect(data, aspect: str) -> List[Dict[str, Any]] + jreflect(data, aspect: str = "all") -> List[Dict[str, Any]] Use Java reflection to introspect the given Java object, returning a table of its available methods or fields. :param data: The object or class or fully qualified class name to inspect. - :param aspect: Either "methods" or "fields" - :return: List of dicts with keys: "name", "static", "arguments", and "returns". + :param aspect: One of: "all", "constructors", "fields", or "methods". + :return: List of dicts with keys: "name", "mods", "arguments", and "returns". jstacktrace(exc) -> str Extract the Java-side stack trace from a Java exception. diff --git a/src/scyjava/_introspect.py b/src/scyjava/_introspect.py index dc5bb67..413aa0d 100644 --- a/src/scyjava/_introspect.py +++ b/src/scyjava/_introspect.py @@ -10,54 +10,73 @@ class methods, fields, and source code URL. from scyjava._types import isjava, jinstance, jclass -def jreflect(data, aspect: str) -> List[Dict[str, Any]]: +def jreflect(data, aspect: str = "all") -> List[Dict[str, Any]]: """ Use Java reflection to introspect the given Java object, returning a table of its available methods or fields. :param data: The object or class or fully qualified class name to inspect. - :param aspect: Either "methods" or "fields" - :return: List of dicts with keys: "name", "static", "arguments", and "returns". + :param aspect: One of: "all", "constructors", "fields", or "methods". + :return: List of dicts with keys: "name", "mods", "arguments", and "returns". """ + aspects = ["all", "constructors", "fields", "methods"] + if aspect not in aspects: + raise ValueError("aspect must be one of {aspects}") + if not isjava(data) and isinstance(data, str): try: data = jimport(data) - except Exception as err: - raise ValueError(f"Not a Java object {err}") + except Exception as e: + raise ValueError( + f"Object of type '{type(data).__name__}' is not a Java object" + ) from e - Modifier = jimport("java.lang.reflect.Modifier") jcls = data if jinstance(data, "java.lang.Class") else jclass(data) - if aspect == "methods": - cls_aspects = jcls.getMethods() - elif aspect == "fields": - cls_aspects = jcls.getFields() - else: - return '`aspect` must be either "fields" or "methods"' + Modifier = jimport("java.lang.reflect.Modifier") + modifiers = { + attr[2:].lower(): getattr(Modifier, attr) + for attr in dir(Modifier) + if attr.startswith("is") + } + + members = [] + if aspect in ["all", "constructors"]: + members.extend(jcls.getConstructors()) + if aspect in ["all", "fields"]: + members.extend(jcls.getFields()) + if aspect in ["all", "methods"]: + members.extend(jcls.getMethods()) table = [] - for m in cls_aspects: - name = m.getName() - if aspect == "methods": - args = [c.getName() for c in m.getParameterTypes()] - returns = m.getReturnType().getName() - elif aspect == "fields": - args = None - returns = m.getType().getName() - mods = Modifier.isStatic(m.getModifiers()) + for member in members: + mtype = str(member.getClass().getName()).split(".")[-1].lower() + name = member.getName() + modflags = member.getModifiers() + mods = [name for name, hasmod in modifiers.items() if hasmod(modflags)] + args = ( + [ptype.getName() for ptype in member.getParameterTypes()] + if hasattr(member, "getParameterTypes") + else None + ) + returns = ( + member.getReturnType().getName() + if hasattr(member, "getReturnType") + else (member.getType().getName() if hasattr(member, "getType") else None) + ) table.append( { + "type": mtype, "name": name, - "static": mods, + "mods": mods, "arguments": args, "returns": returns, } ) - sorted_table = sorted(table, key=lambda d: d["name"]) - return sorted_table + return table def _map_syntax(base_type): @@ -98,7 +117,7 @@ def _make_pretty_string(entry, offset): return_val = f"{entry['returns'].__str__():<{offset}}" # Handle whether to print static/instance modifiers obj_name = f"{entry['name']}" - modifier = f"{'*':>4}" if entry["static"] else f"{'':>4}" + modifier = f"{'*':>4}" if "static" in entry["mods"] else f"{'':>4}" # Handle fields if entry["arguments"] is None: diff --git a/tests/test_introspect.py b/tests/test_introspect.py index 3108c20..09eb692 100644 --- a/tests/test_introspect.py +++ b/tests/test_introspect.py @@ -63,6 +63,6 @@ def test_imagej_legacy(self): return str_RE = "ij.plugin.RoiEnlarger" table = scyjava.jreflect(str_RE, aspect="methods") - assert len([entry for entry in table if entry["static"]]) == 3 + assert sum(1 for entry in table if "static" in entry["mods"]) == 3 repo_path = "https://github.com/imagej/ImageJ/" assert scyjava.jsource(str_RE).startsWith(repo_path) From 92d7fb154e89df78a6f6c81d5f018680efaa54be Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 24 Apr 2025 19:59:01 -0500 Subject: [PATCH 059/119] Split pretty-print functions to own subpackage * scyjava.fields -> scyjava.inspect.fields * scyjava.methods -> scyjava.inspect.methods * scyjava.src -> scyjava.inspect.src And add new `constructors` and `members` convenience functions. --- README.md | 5 -- src/scyjava/__init__.py | 4 - src/scyjava/_introspect.py | 120 +--------------------------- src/scyjava/inspect.py | 155 +++++++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 127 deletions(-) create mode 100644 src/scyjava/inspect.py diff --git a/README.md b/README.md index 81337fc..cedcfcb 100644 --- a/README.md +++ b/README.md @@ -441,11 +441,6 @@ FUNCTIONS :raise RuntimeError: if this method is called while in Jep mode. - src(data) - Print the source code URL for a Java class, object, or class name. - - :param data: The Java class, object, or fully qualified class name as string - start_jvm(options=None) -> None Explicitly connect to the Java virtual machine (JVM). Only one JVM can be active; does nothing if the JVM has already been started. Calling diff --git a/src/scyjava/__init__.py b/src/scyjava/__init__.py index 166099a..4f7c6a2 100644 --- a/src/scyjava/__init__.py +++ b/src/scyjava/__init__.py @@ -92,12 +92,8 @@ to_python, ) from ._introspect import ( - attrs, - fields, jreflect, jsource, - methods, - src, ) from ._jvm import ( # noqa: F401 available_processors, diff --git a/src/scyjava/_introspect.py b/src/scyjava/_introspect.py index 413aa0d..067abe7 100644 --- a/src/scyjava/_introspect.py +++ b/src/scyjava/_introspect.py @@ -3,8 +3,7 @@ class methods, fields, and source code URL. """ -from functools import partial -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List from scyjava._jvm import jimport from scyjava._types import isjava, jinstance, jclass @@ -64,7 +63,7 @@ def jreflect(data, aspect: str = "all") -> List[Dict[str, Any]]: returns = ( member.getReturnType().getName() if hasattr(member, "getReturnType") - else (member.getType().getName() if hasattr(member, "getType") else None) + else (member.getType().getName() if hasattr(member, "getType") else name) ) table.append( { @@ -79,58 +78,6 @@ def jreflect(data, aspect: str = "all") -> List[Dict[str, Any]]: return table -def _map_syntax(base_type): - """ - Map a Java BaseType annotation (see link below) in an Java array - to a specific type with an Python interpretable syntax. - https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.3 - """ - basetype_mapping = { - "[B": "byte[]", - "[C": "char[]", - "[D": "double[]", - "[F": "float[]", - "[I": "int[]", - "[J": "long[]", - "[L": "[]", # array - "[S": "short[]", - "[Z": "boolean[]", - } - - if base_type in basetype_mapping: - return basetype_mapping[base_type] - # Handle the case of a returned array of an object - elif base_type.__str__().startswith("[L"): - return base_type.__str__()[2:-1] + "[]" - else: - return base_type - - -def _make_pretty_string(entry, offset): - """ - Print the entry with a specific formatting and aligned style. - :param entry: Dictionary of class names, modifiers, arguments, and return values. - :param offset: Offset between the return value and the method. - """ - - # A star implies that the method is a static method - return_val = f"{entry['returns'].__str__():<{offset}}" - # Handle whether to print static/instance modifiers - obj_name = f"{entry['name']}" - modifier = f"{'*':>4}" if "static" in entry["mods"] else f"{'':>4}" - - # Handle fields - if entry["arguments"] is None: - return f"{return_val} {modifier} = {obj_name}\n" - - # Handle methods with no arguments - if len(entry["arguments"]) == 0: - return f"{return_val} {modifier} = {obj_name}()\n" - else: - arg_string = ", ".join([r.__str__() for r in entry["arguments"]]) - return f"{return_val} {modifier} = {obj_name}({arg_string})\n" - - def jsource(data): """ Try to find the source code using SciJava's SourceFinder. @@ -162,66 +109,3 @@ def jsource(data): return f"Not a Java class {str(type(data))}" except Exception as err: return f"Unexpected {err=}, {type(err)=}" - - -def _print_data(data, aspect, static: Optional[bool] = None, source: bool = True): - """ - Write data to a printed string of class methods with inputs, static modifier, - arguments, and return values. - - :param data: The object or class to inspect or fully qualified class name. - :param aspect: Whether to print class "fields" or "methods". - :param static: - Boolean filter on Static or Instance methods. - Optional, default is None (prints all). - :param source: Whether to print any available source code. Default True. - """ - table = jreflect(data, aspect) - if len(table) == 0: - print(f"No {aspect} found") - return - - # Print source code - offset = max(list(map(lambda entry: len(entry["returns"]), table))) - all_methods = "" - if source: - urlstring = jsource(data) - print(f"Source code URL: {urlstring}") - - # Print methods - for entry in table: - entry["returns"] = _map_syntax(entry["returns"]) - if entry["arguments"]: - entry["arguments"] = [_map_syntax(e) for e in entry["arguments"]] - if static is None: - entry_string = _make_pretty_string(entry, offset) - all_methods += entry_string - - elif static and entry["static"]: - entry_string = _make_pretty_string(entry, offset) - all_methods += entry_string - elif not static and not entry["static"]: - entry_string = _make_pretty_string(entry, offset) - all_methods += entry_string - else: - continue - - # 4 added to align the asterisk with output. - print(f"{'':<{offset + 4}}* indicates static modifier") - print(all_methods) - - -# The functions with short names for quick usage. -methods = partial(_print_data, aspect="methods") -fields = partial(_print_data, aspect="fields") -attrs = partial(_print_data, aspect="fields") - - -def src(data): - """ - Print the source code URL for a Java class, object, or class name. - - :param data: The Java class, object, or fully qualified class name as string - """ - source_url = jsource(data) - print(f"Source code URL: {source_url}") diff --git a/src/scyjava/inspect.py b/src/scyjava/inspect.py new file mode 100644 index 0000000..882cd61 --- /dev/null +++ b/src/scyjava/inspect.py @@ -0,0 +1,155 @@ +""" +High-level convenience functions for inspecting Java objects. +""" + +from typing import Optional + +from scyjava._introspect import jreflect, jsource + + +def members(data): + """ + Print all the members (constructors, fields, and methods) + for a Java class, object, or class name. + + :param data: The Java class, object, or fully qualified class name as string. + """ + _print_data(data, aspect="all") + + +def constructors(data): + """ + Print the constructors for a Java class, object, or class name. + + :param data: The Java class, object, or fully qualified class name as string. + """ + _print_data(data, aspect="constructors") + + +def fields(data): + """ + Print the fields for a Java class, object, or class name. + + :param data: The Java class, object, or fully qualified class name as string. + """ + _print_data(data, aspect="fields") + + +def methods(data): + """ + Print the methods for a Java class, object, or class name. + + :param data: The Java class, object, or fully qualified class name as string. + """ + _print_data(data, aspect="methods") + + +def src(data): + """ + Print the source code URL for a Java class, object, or class name. + + :param data: The Java class, object, or fully qualified class name as string. + """ + source_url = jsource(data) + print(f"Source code URL: {source_url}") + + +def _map_syntax(base_type): + """ + Map a Java BaseType annotation (see link below) in an Java array + to a specific type with an Python interpretable syntax. + https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.3 + """ + basetype_mapping = { + "[B": "byte[]", + "[C": "char[]", + "[D": "double[]", + "[F": "float[]", + "[I": "int[]", + "[J": "long[]", + "[L": "[]", # array + "[S": "short[]", + "[Z": "boolean[]", + } + + if base_type in basetype_mapping: + return basetype_mapping[base_type] + # Handle the case of a returned array of an object + elif base_type.__str__().startswith("[L"): + return base_type.__str__()[2:-1] + "[]" + else: + return base_type + + +def _pretty_string(entry, offset): + """ + Print the entry with a specific formatting and aligned style. + + :param entry: Dictionary of class names, modifiers, arguments, and return values. + :param offset: Offset between the return value and the method. + """ + + # A star implies that the method is a static method + return_type = entry["returns"] or "void" + return_val = f"{return_type.__str__():<{offset}}" + # Handle whether to print static/instance modifiers + obj_name = f"{entry['name']}" + modifier = f"{'*':>4}" if "static" in entry["mods"] else f"{'':>4}" + + # Handle fields + if entry["arguments"] is None: + return f"{return_val} {modifier} = {obj_name}\n" + + # Handle methods with no arguments + if len(entry["arguments"]) == 0: + return f"{return_val} {modifier} = {obj_name}()\n" + else: + arg_string = ", ".join([r.__str__() for r in entry["arguments"]]) + return f"{return_val} {modifier} = {obj_name}({arg_string})\n" + + +def _print_data(data, aspect, static: Optional[bool] = None, source: bool = True): + """ + Write data to a printed table with inputs, static modifier, + arguments, and return values. + + :param data: The object or class to inspect or fully qualified class name. + :param static: + Boolean filter on Static or Instance methods. + Optional, default is None (prints all). + :param source: Whether to print any available source code. Default True. + """ + table = jreflect(data, aspect) + if len(table) == 0: + print(f"No {aspect} found") + return + + # Print source code + offset = max(list(map(lambda entry: len(entry["returns"] or "void"), table))) + all_methods = "" + if source: + urlstring = jsource(data) + print(f"Source code URL: {urlstring}") + + # Print methods + for entry in table: + if entry["returns"]: + entry["returns"] = _map_syntax(entry["returns"]) + if entry["arguments"]: + entry["arguments"] = [_map_syntax(e) for e in entry["arguments"]] + if static is None: + entry_string = _pretty_string(entry, offset) + all_methods += entry_string + + elif static and "static" in entry["mods"]: + entry_string = _pretty_string(entry, offset) + all_methods += entry_string + elif not static and "static" not in entry["mods"]: + entry_string = _pretty_string(entry, offset) + all_methods += entry_string + else: + continue + + # 4 added to align the asterisk with output. + print(f"{'':<{offset + 4}}* indicates static modifier") + print(all_methods) From 30bd4d3a9b5e1535a4124d6310e3bde983823afe Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 24 Apr 2025 20:48:13 -0500 Subject: [PATCH 060/119] Hide non-public scijava.config attrs --- src/scyjava/config.py | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/scyjava/config.py b/src/scyjava/config.py index e2cc007..712b2ab 100644 --- a/src/scyjava/config.py +++ b/src/scyjava/config.py @@ -1,24 +1,26 @@ -import enum -import logging -import os -import pathlib +import enum as _enum +import logging as _logging +import os as _os +import pathlib as _pathlib -import jpype -from jgo import maven_scijava_repository +import jpype as _jpype +from jgo import maven_scijava_repository as _scijava_public -_logger = logging.getLogger(__name__) + +_logger = _logging.getLogger(__name__) endpoints = [] -_repositories = {"scijava.public": maven_scijava_repository()} + +_repositories = {"scijava.public": _scijava_public()} _verbose = 0 _manage_deps = True -_cache_dir = pathlib.Path.home() / ".jgo" -_m2_repo = pathlib.Path.home() / ".m2" / "repository" +_cache_dir = _pathlib.Path.home() / ".jgo" +_m2_repo = _pathlib.Path.home() / ".m2" / "repository" _options = [] _shortcuts = {} -class Mode(enum.Enum): +class Mode(_enum.Enum): JEP = "jep" JPYPE = "jpype" @@ -143,7 +145,7 @@ def add_classpath(*path): foo.bar.Fubar. """ for p in path: - jpype.addClassPath(p) + _jpype.addClassPath(p) def find_jars(directory): @@ -154,16 +156,16 @@ def find_jars(directory): :return: a list of JAR files """ jars = [] - for root, _, files in os.walk(directory): + for root, _, files in _os.walk(directory): for f in files: if f.lower().endswith(".jar"): - path = os.path.join(root, f) + path = _os.path.join(root, f) jars.append(path) return jars def get_classpath(): - return jpype.getClassPath() + return _jpype.getClassPath() def set_heap_min(mb: int = None, gb: int = None): From a537c00f3e9cf04770fff11b02bd69d3b9f5035b Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 24 Apr 2025 20:58:24 -0500 Subject: [PATCH 061/119] Hide non-public scyjava.inspect attrs --- src/scyjava/inspect.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/scyjava/inspect.py b/src/scyjava/inspect.py index 882cd61..2ea01f1 100644 --- a/src/scyjava/inspect.py +++ b/src/scyjava/inspect.py @@ -2,9 +2,7 @@ High-level convenience functions for inspecting Java objects. """ -from typing import Optional - -from scyjava._introspect import jreflect, jsource +from scyjava import _introspect def members(data): @@ -50,7 +48,7 @@ def src(data): :param data: The Java class, object, or fully qualified class name as string. """ - source_url = jsource(data) + source_url = _introspect.jsource(data) print(f"Source code URL: {source_url}") @@ -108,7 +106,7 @@ def _pretty_string(entry, offset): return f"{return_val} {modifier} = {obj_name}({arg_string})\n" -def _print_data(data, aspect, static: Optional[bool] = None, source: bool = True): +def _print_data(data, aspect, static: bool | None = None, source: bool = True): """ Write data to a printed table with inputs, static modifier, arguments, and return values. @@ -119,7 +117,7 @@ def _print_data(data, aspect, static: Optional[bool] = None, source: bool = True Optional, default is None (prints all). :param source: Whether to print any available source code. Default True. """ - table = jreflect(data, aspect) + table = _introspect.jreflect(data, aspect) if len(table) == 0: print(f"No {aspect} found") return @@ -128,7 +126,7 @@ def _print_data(data, aspect, static: Optional[bool] = None, source: bool = True offset = max(list(map(lambda entry: len(entry["returns"] or "void"), table))) all_methods = "" if source: - urlstring = jsource(data) + urlstring = _introspect.jsource(data) print(f"Source code URL: {urlstring}") # Print methods From cea10cd4322796f09ef885b2dca6fccea996ac86 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 24 Apr 2025 21:02:12 -0500 Subject: [PATCH 062/119] Use jimport naming convention for Java types --- src/scyjava/_introspect.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/scyjava/_introspect.py b/src/scyjava/_introspect.py index 067abe7..a00b5b8 100644 --- a/src/scyjava/_introspect.py +++ b/src/scyjava/_introspect.py @@ -85,9 +85,9 @@ def jsource(data): The object or class or fully qualified class name to check for source code. :return: The URL of the java class """ - types = jimport("org.scijava.util.Types") - sf = jimport("org.scijava.search.SourceFinder") - jstring = jimport("java.lang.String") + Types = jimport("org.scijava.util.Types") + SourceFinder = jimport("org.scijava.search.SourceFinder") + String = jimport("java.lang.String") try: if not isjava(data) and isinstance(data, str): try: @@ -95,10 +95,10 @@ def jsource(data): except Exception as err: raise ValueError(f"Not a Java object {err}") jcls = data if jinstance(data, "java.lang.Class") else jclass(data) - if types.location(jcls).toString().startsWith(jstring("jrt")): + if Types.location(jcls).toString().startsWith(String("jrt")): # Handles Java RunTime (jrt) exceptions. raise ValueError("Java Builtin: GitHub source code not available") - url = sf.sourceLocation(jcls, None) + url = SourceFinder.sourceLocation(jcls, None) urlstring = url.toString() return urlstring except jimport("java.lang.IllegalArgumentException") as err: From 3a46f1bde40d39e3bcb535032dd7aca8de9b1fd1 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 24 Apr 2025 21:51:46 -0500 Subject: [PATCH 063/119] Make output writer configurable --- src/scyjava/inspect.py | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/src/scyjava/inspect.py b/src/scyjava/inspect.py index 2ea01f1..13b5f03 100644 --- a/src/scyjava/inspect.py +++ b/src/scyjava/inspect.py @@ -2,54 +2,62 @@ High-level convenience functions for inspecting Java objects. """ +from sys import stdout as _stdout + from scyjava import _introspect -def members(data): +def members(data, writer=None): """ Print all the members (constructors, fields, and methods) for a Java class, object, or class name. :param data: The Java class, object, or fully qualified class name as string. + :param writer: Function to which output will be sent, sys.stdout.write by default. """ - _print_data(data, aspect="all") + _print_data(data, aspect="all", writer=writer) -def constructors(data): +def constructors(data, writer=None): """ Print the constructors for a Java class, object, or class name. :param data: The Java class, object, or fully qualified class name as string. + :param writer: Function to which output will be sent, sys.stdout.write by default. """ - _print_data(data, aspect="constructors") + _print_data(data, aspect="constructors", writer=writer) -def fields(data): +def fields(data, writer=None): """ Print the fields for a Java class, object, or class name. :param data: The Java class, object, or fully qualified class name as string. + :param writer: Function to which output will be sent, sys.stdout.write by default. """ - _print_data(data, aspect="fields") + _print_data(data, aspect="fields", writer=writer) -def methods(data): +def methods(data, writer=None): """ Print the methods for a Java class, object, or class name. :param data: The Java class, object, or fully qualified class name as string. + :param writer: Function to which output will be sent, sys.stdout.write by default. """ _print_data(data, aspect="methods") -def src(data): +def src(data, writer=None): """ Print the source code URL for a Java class, object, or class name. :param data: The Java class, object, or fully qualified class name as string. + :param writer: Function to which output will be sent, sys.stdout.write by default. """ + writer = writer or _stdout.write source_url = _introspect.jsource(data) - print(f"Source code URL: {source_url}") + writer(f"Source code URL: {source_url}\n") def _map_syntax(base_type): @@ -106,7 +114,9 @@ def _pretty_string(entry, offset): return f"{return_val} {modifier} = {obj_name}({arg_string})\n" -def _print_data(data, aspect, static: bool | None = None, source: bool = True): +def _print_data( + data, aspect, static: bool | None = None, source: bool = True, writer=None +): """ Write data to a printed table with inputs, static modifier, arguments, and return values. @@ -117,9 +127,10 @@ def _print_data(data, aspect, static: bool | None = None, source: bool = True): Optional, default is None (prints all). :param source: Whether to print any available source code. Default True. """ + writer = writer or _stdout.write table = _introspect.jreflect(data, aspect) if len(table) == 0: - print(f"No {aspect} found") + writer(f"No {aspect} found\n") return # Print source code @@ -127,7 +138,7 @@ def _print_data(data, aspect, static: bool | None = None, source: bool = True): all_methods = "" if source: urlstring = _introspect.jsource(data) - print(f"Source code URL: {urlstring}") + writer(f"Source code URL: {urlstring}\n") # Print methods for entry in table: @@ -147,7 +158,8 @@ def _print_data(data, aspect, static: bool | None = None, source: bool = True): all_methods += entry_string else: continue + all_methods += "\n" # 4 added to align the asterisk with output. - print(f"{'':<{offset + 4}}* indicates static modifier") - print(all_methods) + writer(f"{'':<{offset + 4}}* indicates static modifier\n") + writer(all_methods) From 5fe146166d52ae4082e1f22491deac6603d023af Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 24 Apr 2025 21:53:46 -0500 Subject: [PATCH 064/119] Replace print statements with logger calls --- src/scyjava/_jvm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index 1a6c5ca..742b6eb 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -226,7 +226,7 @@ def shutdown_jvm() -> None: try: callback() except Exception as e: - print(f"Exception during shutdown callback: {e}") + _logger.error(f"Exception during shutdown callback: {e}") # dispose AWT resources if applicable if is_awt_initialized(): @@ -238,7 +238,7 @@ def shutdown_jvm() -> None: try: jpype.shutdownJVM() except Exception as e: - print(f"Exception during JVM shutdown: {e}") + _logger.error(f"Exception during JVM shutdown: {e}") def jvm_started() -> bool: From 2f11f0d8c5efb7719448c58cfe42b6ee372b89f0 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 24 Apr 2025 22:35:31 -0500 Subject: [PATCH 065/119] Add unit test for inspect.members function --- tests/test_inspect.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/test_inspect.py diff --git a/tests/test_inspect.py b/tests/test_inspect.py new file mode 100644 index 0000000..c99076d --- /dev/null +++ b/tests/test_inspect.py @@ -0,0 +1,29 @@ +""" +Tests for functions in inspect submodule. +""" + +from scyjava import inspect +from scyjava.config import mode, Mode + + +class TestInspect(object): + """ + Test scyjava.inspect convenience functions. + """ + + def test_inspect_members(self): + if mode == Mode.JEP: + # JEP does not support the jclass function. + return + members = [] + inspect.members("java.lang.Iterable", writer=members.append) + expected = [ + "Source code URL: java.lang.NullPointerException", + " * indicates static modifier", + "java.util.Iterator = iterator()", + "java.util.Spliterator = spliterator()", + "void = forEach(java.util.function.Consumer)", + "", + "", + ] + assert expected == "".join(members).split("\n") From f4266c40fa2c3dc8940b185fd72776bc39ab6734 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 24 Apr 2025 22:45:36 -0500 Subject: [PATCH 066/119] Ensure submodules are directly available This code worked: import scyjava print(scyjava.config) But this code didn't: import scyjava print(scyjava.inspect) Because scyjava.config was being imported in another file further down the chain. Better to be explicit about wanting both of these submodules available at the top level. --- src/scyjava/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/scyjava/__init__.py b/src/scyjava/__init__.py index 4f7c6a2..e19cc79 100644 --- a/src/scyjava/__init__.py +++ b/src/scyjava/__init__.py @@ -71,6 +71,7 @@ from functools import lru_cache from typing import Any, Callable, Dict +from . import config, inspect from ._arrays import is_arraylike, is_memoryarraylike, is_xarraylike from ._convert import ( Converter, From 1d9b507d0b51932a21af44d6e869411e8efb2923 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Fri, 25 Apr 2025 18:07:37 -0500 Subject: [PATCH 067/119] Let jsource also find Java library source code And do not try so hard with exception handling; it should be up to higher level functions like scyjava.inspect.src to catch such failures. --- src/scyjava/_introspect.py | 71 +++++++++++++++++++++++--------------- tests/test_inspect.py | 6 ++-- tests/test_introspect.py | 11 ++++++ 3 files changed, 58 insertions(+), 30 deletions(-) diff --git a/src/scyjava/_introspect.py b/src/scyjava/_introspect.py index a00b5b8..9449fc8 100644 --- a/src/scyjava/_introspect.py +++ b/src/scyjava/_introspect.py @@ -5,7 +5,7 @@ class methods, fields, and source code URL. from typing import Any, Dict, List -from scyjava._jvm import jimport +from scyjava._jvm import jimport, jvm_version from scyjava._types import isjava, jinstance, jclass @@ -78,34 +78,49 @@ def jreflect(data, aspect: str = "all") -> List[Dict[str, Any]]: return table -def jsource(data): +def jsource(data) -> str: """ - Try to find the source code using SciJava's SourceFinder. + Try to find the source code URL for the given Java object, class, or class name. + Requires org.scijava:scijava-search on the classpath. :param data: - The object or class or fully qualified class name to check for source code. - :return: The URL of the java class + Object, class, or fully qualified class name for which to discern the source code location. + :return: URL of the class's source code. """ - Types = jimport("org.scijava.util.Types") + + if not isjava(data) and isinstance(data, str): + try: + data = jimport(data) # check if data can be imported + except Exception as err: + raise ValueError(f"Not a Java object {err}") + jcls = data if jinstance(data, "java.lang.Class") else jclass(data) + + if jcls.getClassLoader() is None: + # Class is from the Java standard library. + cls_path = str(jcls.getName()).replace(".", "/") + + # Discern the Java version. + java_version = jvm_version()[0] + + # Note: some classes (e.g. corba and jaxp) will not be located correctly before + # Java 10, because they fall under a different subtree than `jdk`. But Java 11+ + # dispenses with such subtrees in favor of using only the module designations. + if java_version <= 7: + return f"https://github.com/openjdk/jdk/blob/jdk7-b147/jdk/src/share/classes/{cls_path}.java" + elif java_version == 8: + return f"https://github.com/openjdk/jdk/blob/jdk8-b120/jdk/src/share/classes/{cls_path}.java" + else: # java_version >= 9 + module_name = jcls.getModule().getName() + # if module_name is null, it's in the unnamed module + if java_version == 9: + suffix = "%2B181/jdk" + elif java_version == 10: + suffix = "%2B46" + else: + suffix = "-ga" + return f"https://github.com/openjdk/jdk/blob/jdk-{java_version}{suffix}/src/{module_name}/share/classes/{cls_path}.java" + + # Ask scijava-search for the source location. SourceFinder = jimport("org.scijava.search.SourceFinder") - String = jimport("java.lang.String") - try: - if not isjava(data) and isinstance(data, str): - try: - data = jimport(data) # check if data can be imported - except Exception as err: - raise ValueError(f"Not a Java object {err}") - jcls = data if jinstance(data, "java.lang.Class") else jclass(data) - if Types.location(jcls).toString().startsWith(String("jrt")): - # Handles Java RunTime (jrt) exceptions. - raise ValueError("Java Builtin: GitHub source code not available") - url = SourceFinder.sourceLocation(jcls, None) - urlstring = url.toString() - return urlstring - except jimport("java.lang.IllegalArgumentException") as err: - return f"Illegal argument provided {err=}, {type(err)=}" - except ValueError as err: - return f"{err}" - except TypeError: - return f"Not a Java class {str(type(data))}" - except Exception as err: - return f"Unexpected {err=}, {type(err)=}" + url = SourceFinder.sourceLocation(jcls, None) + urlstring = url.toString() + return urlstring diff --git a/tests/test_inspect.py b/tests/test_inspect.py index c99076d..4eca8d9 100644 --- a/tests/test_inspect.py +++ b/tests/test_inspect.py @@ -18,7 +18,9 @@ def test_inspect_members(self): members = [] inspect.members("java.lang.Iterable", writer=members.append) expected = [ - "Source code URL: java.lang.NullPointerException", + "Source code URL: " + "https://github.com/openjdk/jdk/blob/jdk-11-ga/" + "src/java.base/share/classes/java/lang/Iterable.java", " * indicates static modifier", "java.util.Iterator = iterator()", "java.util.Spliterator = spliterator()", @@ -26,4 +28,4 @@ def test_inspect_members(self): "", "", ] - assert expected == "".join(members).split("\n") + assert "".join(members).split("\n") == expected diff --git a/tests/test_introspect.py b/tests/test_introspect.py index 09eb692..bbfa93b 100644 --- a/tests/test_introspect.py +++ b/tests/test_introspect.py @@ -57,6 +57,17 @@ def test_jsource(self): assert source_SF.startsWith(repo_path) assert source_strSF == source_SF + def test_jsource_jdk_class(self): + if mode == Mode.JEP: + # JEP does not support the jclass function. + return + jv = scyjava.jvm_version()[0] + source = scyjava.jsource("java.util.List") + assert ( + source == f"https://github.com/openjdk/jdk/blob/jdk-{jv}-ga/" + "src/java.base/share/classes/java/util/List.java" + ) + def test_imagej_legacy(self): if mode == Mode.JEP: # JEP does not support the jclass function. From 5c067473145d6cc49ba5255b3738a07c2b3d8bb3 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Fri, 25 Apr 2025 18:09:38 -0500 Subject: [PATCH 068/119] Be less aggressive with source code detection When invoking scyjava.inspect functions, they can optionally report the source URL at the top, before printing the members. But this only works if scijava-search is on the classpath. Let's let the source flag default to None, in which case it swallows source code URL detection failures gracefully, to make the common case of scijava-search not being available work without hassle. And let's have the various inspect functions accept static and source boolean flags, which get passed along to the internal _print_data routine, as was previously the case when they were partial functions. --- src/scyjava/inspect.py | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/scyjava/inspect.py b/src/scyjava/inspect.py index 13b5f03..6b0d9e8 100644 --- a/src/scyjava/inspect.py +++ b/src/scyjava/inspect.py @@ -7,7 +7,7 @@ from scyjava import _introspect -def members(data, writer=None): +def members(data, static: bool | None = None, source: bool | None = None, writer=None): """ Print all the members (constructors, fields, and methods) for a Java class, object, or class name. @@ -15,30 +15,34 @@ def members(data, writer=None): :param data: The Java class, object, or fully qualified class name as string. :param writer: Function to which output will be sent, sys.stdout.write by default. """ - _print_data(data, aspect="all", writer=writer) + _print_data(data, aspect="all", static=static, source=source, writer=writer) -def constructors(data, writer=None): +def constructors( + data, static: bool | None = None, source: bool | None = None, writer=None +): """ Print the constructors for a Java class, object, or class name. :param data: The Java class, object, or fully qualified class name as string. :param writer: Function to which output will be sent, sys.stdout.write by default. """ - _print_data(data, aspect="constructors", writer=writer) + _print_data( + data, aspect="constructors", static=static, source=source, writer=writer + ) -def fields(data, writer=None): +def fields(data, static: bool | None = None, source: bool | None = None, writer=None): """ Print the fields for a Java class, object, or class name. :param data: The Java class, object, or fully qualified class name as string. :param writer: Function to which output will be sent, sys.stdout.write by default. """ - _print_data(data, aspect="fields", writer=writer) + _print_data(data, aspect="fields", static=static, source=source, writer=writer) -def methods(data, writer=None): +def methods(data, static: bool | None = None, source: bool | None = None, writer=None): """ Print the methods for a Java class, object, or class name. @@ -115,7 +119,7 @@ def _pretty_string(entry, offset): def _print_data( - data, aspect, static: bool | None = None, source: bool = True, writer=None + data, aspect, static: bool | None = None, source: bool | None = None, writer=None ): """ Write data to a printed table with inputs, static modifier, @@ -125,7 +129,11 @@ def _print_data( :param static: Boolean filter on Static or Instance methods. Optional, default is None (prints all). - :param source: Whether to print any available source code. Default True. + :param source: + Whether to discern and report a URL to the relevant source code. + Requires org.scijava:scijava-search to be on the classpath. + When set to None (the default), autodetects whether scijava-search + is available, reporting source URL if so, or leaving it out if not. """ writer = writer or _stdout.write table = _introspect.jreflect(data, aspect) @@ -136,9 +144,15 @@ def _print_data( # Print source code offset = max(list(map(lambda entry: len(entry["returns"] or "void"), table))) all_methods = "" - if source: - urlstring = _introspect.jsource(data) - writer(f"Source code URL: {urlstring}\n") + if source or source is None: + try: + urlstring = _introspect.jsource(data) + writer(f"Source code URL: {urlstring}\n") + except TypeError: + if source: + writer( + "Classpath lacks scijava-search; no source code URL detection is available.\n" + ) # Print methods for entry in table: From e5e9cab0184ccfb09b96c30469bbca6b78fc8295 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Tue, 29 Apr 2025 13:25:33 -0500 Subject: [PATCH 069/119] Add test for jreflect constructors --- tests/test_introspect.py | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/tests/test_introspect.py b/tests/test_introspect.py index bbfa93b..7c7fe99 100644 --- a/tests/test_introspect.py +++ b/tests/test_introspect.py @@ -39,11 +39,46 @@ def test_jreflect_fields(self): BitSet = scyjava.jimport(str_BitSet) str_Obj = scyjava.jreflect(str_BitSet, "fields") bitset_Obj = scyjava.jreflect(BitSet, "fields") - assert len(str_Obj) == 0 - assert len(bitset_Obj) == 0 + assert len(str_Obj) == len(bitset_Obj) == 0 assert bitset_Obj is not None assert bitset_Obj == str_Obj + def test_jreflect_ctors(self): + if mode == Mode.JEP: + # JEP does not support the jclass function. + return + str_ArrayList = "java.util.ArrayList" + ArrayList = scyjava.jimport(str_ArrayList) + str_Obj = scyjava.jreflect(str_ArrayList, "constructors") + arraylist_Obj = scyjava.jreflect(ArrayList, "constructors") + assert len(str_Obj) == len(arraylist_Obj) == 3 + arraylist_Obj.sort( + key=lambda row: f"{row['type']}:{row['name']}:{','.join(str(row['arguments']))}" + ) + assert arraylist_Obj == [ + { + "arguments": ["int"], + "mods": ["public"], + "name": "java.util.ArrayList", + "returns": "java.util.ArrayList", + "type": "constructor", + }, + { + "arguments": ["java.util.Collection"], + "mods": ["public"], + "name": "java.util.ArrayList", + "returns": "java.util.ArrayList", + "type": "constructor", + }, + { + "arguments": [], + "mods": ["public"], + "name": "java.util.ArrayList", + "returns": "java.util.ArrayList", + "type": "constructor", + }, + ] + def test_jsource(self): if mode == Mode.JEP: # JEP does not support the jclass function. From b083173f8618d01c919f306b62c7a1b1bebd21e5 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 30 Apr 2025 12:19:54 -0500 Subject: [PATCH 070/119] Let type annotation syntax to work with Python 3.9 And fix the development Python to 3.9, so that local testing catches problems like this sooner. --- dev-environment.yml | 2 +- src/scyjava/config.py | 2 ++ src/scyjava/inspect.py | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/dev-environment.yml b/dev-environment.yml index 7bbcf28..a2fb77e 100644 --- a/dev-environment.yml +++ b/dev-environment.yml @@ -18,7 +18,7 @@ name: scyjava-dev channels: - conda-forge dependencies: - - python >= 3.9 + - python = 3.9 # Project dependencies - jpype1 >= 1.3.0 - jgo diff --git a/src/scyjava/config.py b/src/scyjava/config.py index 712b2ab..3609305 100644 --- a/src/scyjava/config.py +++ b/src/scyjava/config.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import enum as _enum import logging as _logging import os as _os diff --git a/src/scyjava/inspect.py b/src/scyjava/inspect.py index 6b0d9e8..3058822 100644 --- a/src/scyjava/inspect.py +++ b/src/scyjava/inspect.py @@ -2,6 +2,8 @@ High-level convenience functions for inspecting Java objects. """ +from __future__ import annotations + from sys import stdout as _stdout from scyjava import _introspect From 64dd34cf97d1d0d2bc8207dedf0affb79b10b6fa Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Tue, 29 Apr 2025 14:28:15 -0500 Subject: [PATCH 071/119] Fix jvm_version return type declaration --- src/scyjava/_jvm.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index acdd3f5..c9117d0 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -25,16 +25,16 @@ _shutdown_callbacks = [] -def jvm_version() -> str: +def jvm_version() -> tuple[int, ...]: """ Gets the version of the JVM as a tuple, with each dot-separated digit as one element. Characters in the version string beyond only numbers and dots are ignored, in line with the java.version system property. Examples: - * OpenJDK 17.0.1 -> [17, 0, 1] - * OpenJDK 11.0.9.1-internal -> [11, 0, 9, 1] - * OpenJDK 1.8.0_312 -> [1, 8, 0] + * OpenJDK 17.0.1 -> (17, 0, 1) + * OpenJDK 11.0.9.1-internal -> (11, 0, 9, 1) + * OpenJDK 1.8.0_312 -> (1, 8, 0) If the JVM is already started, this function returns the equivalent of: jimport('java.lang.System') From 51f96a4fd6b56d31378c79cdebfbda058800f870 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 30 Apr 2025 11:51:29 -0500 Subject: [PATCH 072/119] Generalize JVM source code expectations The tests won't necessarily run with Java 11. And if the Java version is 1.8 or earlier, use the second digit. --- src/scyjava/_introspect.py | 4 +++- tests/test_inspect.py | 14 ++++++++++---- tests/test_introspect.py | 10 +++++----- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/scyjava/_introspect.py b/src/scyjava/_introspect.py index 9449fc8..4268038 100644 --- a/src/scyjava/_introspect.py +++ b/src/scyjava/_introspect.py @@ -99,7 +99,9 @@ def jsource(data) -> str: cls_path = str(jcls.getName()).replace(".", "/") # Discern the Java version. - java_version = jvm_version()[0] + jv_digits = jvm_version() + assert jv_digits is not None and len(jv_digits) > 1 + java_version = jv_digits[1] if jv_digits[0] == 1 else jv_digits[0] # Note: some classes (e.g. corba and jaxp) will not be located correctly before # Java 10, because they fall under a different subtree than `jdk`. But Java 11+ diff --git a/tests/test_inspect.py b/tests/test_inspect.py index 4eca8d9..d308307 100644 --- a/tests/test_inspect.py +++ b/tests/test_inspect.py @@ -2,6 +2,8 @@ Tests for functions in inspect submodule. """ +import re + from scyjava import inspect from scyjava.config import mode, Mode @@ -18,9 +20,8 @@ def test_inspect_members(self): members = [] inspect.members("java.lang.Iterable", writer=members.append) expected = [ - "Source code URL: " - "https://github.com/openjdk/jdk/blob/jdk-11-ga/" - "src/java.base/share/classes/java/lang/Iterable.java", + "Source code URL: https://github.com/openjdk/jdk/blob/" + ".../share/classes/java/lang/Iterable.java", " * indicates static modifier", "java.util.Iterator = iterator()", "java.util.Spliterator = spliterator()", @@ -28,4 +29,9 @@ def test_inspect_members(self): "", "", ] - assert "".join(members).split("\n") == expected + pattern = ( + r"(https://github.com/openjdk/jdk/blob/)" + "[^ ]*(/share/classes/java/lang/Iterable\.java)" + ) + members_string = re.sub(pattern, r"\1...\2", "".join(members)) + assert members_string.split("\n") == expected diff --git a/tests/test_introspect.py b/tests/test_introspect.py index 7c7fe99..e986dff 100644 --- a/tests/test_introspect.py +++ b/tests/test_introspect.py @@ -96,12 +96,12 @@ def test_jsource_jdk_class(self): if mode == Mode.JEP: # JEP does not support the jclass function. return - jv = scyjava.jvm_version()[0] + jv_digits = scyjava.jvm_version() + jv = jv_digits[1] if jv_digits[0] == 1 else jv_digits[0] source = scyjava.jsource("java.util.List") - assert ( - source == f"https://github.com/openjdk/jdk/blob/jdk-{jv}-ga/" - "src/java.base/share/classes/java/util/List.java" - ) + assert source.startswith("https://github.com/openjdk/jdk/blob/") + assert source.endswith("/share/classes/java/util/List.java") + assert str(jv) in source def test_imagej_legacy(self): if mode == Mode.JEP: From e36a950c484ba52cca5085a71e73a00443649502 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Tue, 29 Apr 2025 14:13:03 -0500 Subject: [PATCH 073/119] Add type hints and docstrings to scyjava.config --- src/scyjava/config.py | 129 ++++++++++++++++++++++++++++++++---------- 1 file changed, 100 insertions(+), 29 deletions(-) diff --git a/src/scyjava/config.py b/src/scyjava/config.py index 3609305..1707597 100644 --- a/src/scyjava/config.py +++ b/src/scyjava/config.py @@ -3,7 +3,8 @@ import enum as _enum import logging as _logging import os as _os -import pathlib as _pathlib +from pathlib import Path +from typing import Sequence import jpype as _jpype from jgo import maven_scijava_repository as _scijava_public @@ -11,13 +12,13 @@ _logger = _logging.getLogger(__name__) -endpoints = [] +endpoints: list[str] = [] _repositories = {"scijava.public": _scijava_public()} _verbose = 0 _manage_deps = True -_cache_dir = _pathlib.Path.home() / ".jgo" -_m2_repo = _pathlib.Path.home() / ".m2" / "repository" +_cache_dir = Path.home() / ".jgo" +_m2_repo = Path.home() / ".m2" / "repository" _options = [] _shortcuts = {} @@ -62,7 +63,11 @@ def get_endpoints(): return endpoints -def add_repositories(*args, **kwargs): +def add_repositories(*args, **kwargs) -> None: + """ + Add one or more Maven repositories to be used by jgo for downloading dependencies. + See the jgo documentation for details. + """ global _repositories for arg in args: _logger.debug("Adding repositories %s to %s", arg, _repositories) @@ -71,57 +76,92 @@ def add_repositories(*args, **kwargs): _repositories.update(kwargs) -def get_repositories(): +def get_repositories() -> dict[str, str]: + """ + Gets the Maven repositories jgo will use for downloading dependencies. + See the jgo documentation for details. + """ global _repositories return _repositories -def set_verbose(level): +def set_verbose(level: int) -> None: + """ + Set the level of verbosity for logging environment construction details. + + :param level: + 0 for quiet (default), 1 for verbose, 2 for extra verbose. + """ global _verbose _logger.debug("Setting verbose level to %d (was %d)", level, _verbose) _verbose = level -def get_verbose(): +def get_verbose() -> int: + """ + Get the level of verbosity for logging environment construction details. + """ global _verbose _logger.debug("Getting verbose level: %d", _verbose) return _verbose -def set_manage_deps(manage): +def set_manage_deps(manage: bool) -> None: + """ + Set whether jgo will resolve dependencies in managed mode. + See the jgo documentation for details. + """ global _manage_deps _logger.debug("Setting manage deps to %d (was %d)", manage, _manage_deps) _manage_deps = manage -def get_manage_deps(): +def get_manage_deps() -> bool: + """ + Get whether jgo will resolve dependencies in managed mode. + See the jgo documentation for details. + """ global _manage_deps return _manage_deps -def set_cache_dir(dir): +def set_cache_dir(cache_dir: Path | str) -> None: + """ + Set the location to use for the jgo environment cache. + See the jgo documentation for details. + """ global _cache_dir - _logger.debug("Setting cache dir to %s (was %s)", dir, _cache_dir) - _cache_dir = dir + _logger.debug("Setting cache dir to %s (was %s)", cache_dir, _cache_dir) + _cache_dir = cache_dir -def get_cache_dir(): +def get_cache_dir() -> Path: + """ + Get the location to use for the jgo environment cache. + See the jgo documentation for details. + """ global _cache_dir return _cache_dir -def set_m2_repo(dir): +def set_m2_repo(repo_dir : Path | str) -> None: + """ + Set the location to use for the local Maven repository cache. + """ global _m2_repo - _logger.debug("Setting m2 repo dir to %s (was %s)", dir, _m2_repo) - _m2_repo = dir + _logger.debug("Setting m2 repo dir to %s (was %s)", repo_dir, _m2_repo) + _m2_repo = repo_dir -def get_m2_repo(): +def get_m2_repo() -> Path: + """ + Get the location to use for the local Maven repository cache. + """ global _m2_repo return _m2_repo -def add_classpath(*path): +def add_classpath(*path) -> None: """ Add elements to the Java class path. @@ -150,7 +190,7 @@ def add_classpath(*path): _jpype.addClassPath(p) -def find_jars(directory): +def find_jars(directory: Path | str) -> list[str]: """ Find .jar files beneath a given directory. @@ -166,11 +206,14 @@ def find_jars(directory): return jars -def get_classpath(): +def get_classpath() -> str: + """ + Get the classpath to be passed to the JVM at startup. + """ return _jpype.getClassPath() -def set_heap_min(mb: int = None, gb: int = None): +def set_heap_min(mb: int = None, gb: int = None) -> None: """ Set the initial amount of memory to allocate to the Java heap. @@ -187,7 +230,7 @@ def set_heap_min(mb: int = None, gb: int = None): add_option(f"-Xms{_mem_value(mb, gb)}") -def set_heap_max(mb: int = None, gb: int = None): +def set_heap_max(mb: int = None, gb: int = None) -> None: """ Shortcut for passing -Xmx###m or -Xmx###g to Java. @@ -210,7 +253,7 @@ def _mem_value(mb: int = None, gb: int = None) -> str: raise ValueError("Exactly one of mb or gb must be given.") -def enable_headless_mode(): +def enable_headless_mode() -> None: """ Enable headless mode, for running Java without a display. This mode prevents any graphical elements from popping up. @@ -239,12 +282,29 @@ def enable_remote_debugging(port: int = 8000, suspend: bool = False): add_option(f"-agentlib:jdwp={arg_string}") -def add_option(option): +def add_option(option: str) -> None: + """ + Add an option to pass at JVM startup. Examples: + + -Djava.awt.headless=true + -Xmx10g + --add-opens=java.base/java.lang=ALL-UNNAMED + -XX:+UnlockExperimentalVMOptions + + :param option: + The option to add. + """ global _options _options.append(option) -def add_options(options): +def add_options(options: str | Sequence) -> None: + """ + Add one or more options to pass at JVM startup. + + :param options: + Sequence of options to add, or single string to pass as an individual option. + """ global _options if isinstance(options, str): _options.append(options) @@ -252,16 +312,27 @@ def add_options(options): _options.extend(options) -def get_options(): +def get_options() -> list[str]: + """ + Get the list of options to be passed at JVM startup. + """ global _options return _options -def add_shortcut(k, v): +def add_shortcut(k: str, v: str): + """ + Add a shortcut key/value to be used by jgo for evaluating endpoints. + See the jgo documentation for details. + """ global _shortcuts _shortcuts[k] = v -def get_shortcuts(): +def get_shortcuts() -> dict[str, str]: + """ + Get the dictionary of shorts that jgo will use for evaluating endpoints. + See the jgo documentation for details. + """ global _shortcuts return _shortcuts From 02913c5e902bfc9e656bc0a540749b12aceceace Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Tue, 29 Apr 2025 14:20:36 -0500 Subject: [PATCH 074/119] Let scyjava.config accept JPype keyword arguments Closes #79. --- src/scyjava/_jvm.py | 3 ++- src/scyjava/config.py | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index c9117d0..a7e6889 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -198,7 +198,8 @@ def start_jvm(options=None, *, fetch_java: bool = True) -> None: _logger.debug("Starting JVM") if options is None: options = scyjava.config.get_options() - jpype.startJVM(*options, interrupt=True) + kwargs = scyjava.config.get_kwargs() + jpype.startJVM(*options, **kwargs) # replace JPype/JVM shutdown handling with our own jpype.config.onexit = False diff --git a/src/scyjava/config.py b/src/scyjava/config.py index 1707597..dda4e70 100644 --- a/src/scyjava/config.py +++ b/src/scyjava/config.py @@ -20,6 +20,7 @@ _cache_dir = Path.home() / ".jgo" _m2_repo = Path.home() / ".m2" / "repository" _options = [] +_kwargs = {"interrupt": True} _shortcuts = {} @@ -320,6 +321,27 @@ def get_options() -> list[str]: return _options +def add_kwargs(**kwargs) -> None: + """ + Add keyword arguments to be passed to JPype at JVM startup. Examples: + + jvmpath = "/path/to/my_jvm" + ignoreUnrecognized = True + convertStrings = True + interrupt = True + """ + global _kwargs + _kwargs.update(kwargs) + + +def get_kwargs() -> dict[str, str]: + """ + Get the keyword arguments to be passed to JPype at JVM startup. + """ + global _kwargs + return _kwargs + + def add_shortcut(k: str, v: str): """ Add a shortcut key/value to be used by jgo for evaluating endpoints. From 6f2d4f171563b00e7404d74d118b79b78a3d6327 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Tue, 29 Apr 2025 14:28:37 -0500 Subject: [PATCH 075/119] Avoid variable name shadowing --- src/scyjava/_jvm.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index a7e6889..3c21b2d 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -55,12 +55,12 @@ def jvm_version() -> tuple[int, ...]: assert mode == Mode.JPYPE - jvm_version = jpype.getJVMVersion() - if jvm_version and jvm_version[0]: + jvm_ver = jpype.getJVMVersion() + if jvm_ver and jvm_ver[0]: # JPype already knew the version. # JVM is probably already started. # Or JPype got smarter since 1.3.0. - return jvm_version + return jvm_ver # JPype was clueless, which means the JVM has probably not started yet. # Let's look for a java executable, and ask via 'java -version'. From fdbda7e913f3d0af3f64a8f088fb3116a9001db1 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Tue, 29 Apr 2025 15:15:08 -0500 Subject: [PATCH 076/119] Add a "see also" for start_jvm options --- src/scyjava/_jvm.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index 3c21b2d..c4c5e57 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -117,6 +117,7 @@ def start_jvm(options=None, *, fetch_java: bool = True) -> None: :param options: List of options to pass to the JVM. For example: ['-Dfoo=bar', '-XX:+UnlockExperimentalVMOptions'] + See also scyjava.config.add_options. :param fetch_java: If True (default), when a JVM/or maven cannot be located on the system, [`cjdk`](https://github.com/cachedjdk/cjdk) will be used to download From dad2b2a5ac449eb3ca2aa2c147e7fc0b654b22ac Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Tue, 29 Apr 2025 15:15:23 -0500 Subject: [PATCH 077/119] Soften annoying start_jvm debug warning Now it only shows up when options were attempted to be passed. --- src/scyjava/_jvm.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index c4c5e57..484439f 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -134,7 +134,8 @@ def start_jvm(options=None, *, fetch_java: bool = True) -> None: """ # if JVM is already running -- break if jvm_started(): - _logger.debug("The JVM is already running.") + if options is not None and len(options) > 0: + _logger.debug(f"Options ignored due to already running JVM: {options}") return assert mode == Mode.JPYPE From 69ff2c39cb49eaa6e1ccf7ae900cb137e362bfcc Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 30 Apr 2025 09:55:03 -0500 Subject: [PATCH 078/119] Move deprecated functions to bottom of source file --- src/scyjava/config.py | 54 +++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/src/scyjava/config.py b/src/scyjava/config.py index dda4e70..2a4c692 100644 --- a/src/scyjava/config.py +++ b/src/scyjava/config.py @@ -37,33 +37,6 @@ class Mode(_enum.Enum): mode = Mode.JPYPE -def add_endpoints(*new_endpoints): - """ - DEPRECATED since v1.2.1 - Please modify the endpoints field directly instead. - """ - _logger.warning( - "Deprecated method call: scyjava.config.add_endpoints(). " - "Please modify scyjava.config.endpoints directly instead." - ) - global endpoints - _logger.debug("Adding endpoints %s to %s", new_endpoints, endpoints) - endpoints.extend(new_endpoints) - - -def get_endpoints(): - """ - DEPRECATED since v1.2.1 - Please access the endpoints field directly instead. - """ - _logger.warning( - "Deprecated method call: scyjava.config.get_endpoints(). " - "Please access scyjava.config.endpoints directly instead." - ) - global endpoints - return endpoints - - def add_repositories(*args, **kwargs) -> None: """ Add one or more Maven repositories to be used by jgo for downloading dependencies. @@ -358,3 +331,30 @@ def get_shortcuts() -> dict[str, str]: """ global _shortcuts return _shortcuts + + +def add_endpoints(*new_endpoints): + """ + DEPRECATED since v1.2.1 + Please modify the endpoints field directly instead. + """ + _logger.warning( + "Deprecated method call: scyjava.config.add_endpoints(). " + "Please modify scyjava.config.endpoints directly instead." + ) + global endpoints + _logger.debug("Adding endpoints %s to %s", new_endpoints, endpoints) + endpoints.extend(new_endpoints) + + +def get_endpoints(): + """ + DEPRECATED since v1.2.1 + Please access the endpoints field directly instead. + """ + _logger.warning( + "Deprecated method call: scyjava.config.get_endpoints(). " + "Please access scyjava.config.endpoints directly instead." + ) + global endpoints + return endpoints From 0b2e964b65ee771caf2a27b35c0fa7bfa96c4aef Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 30 Apr 2025 09:55:38 -0500 Subject: [PATCH 079/119] Fix tiny issues in scyjava.config source --- src/scyjava/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/scyjava/config.py b/src/scyjava/config.py index 2a4c692..17a1bf3 100644 --- a/src/scyjava/config.py +++ b/src/scyjava/config.py @@ -52,7 +52,7 @@ def add_repositories(*args, **kwargs) -> None: def get_repositories() -> dict[str, str]: """ - Gets the Maven repositories jgo will use for downloading dependencies. + Get the Maven repositories jgo will use for downloading dependencies. See the jgo documentation for details. """ global _repositories @@ -118,7 +118,7 @@ def get_cache_dir() -> Path: return _cache_dir -def set_m2_repo(repo_dir : Path | str) -> None: +def set_m2_repo(repo_dir: Path | str) -> None: """ Set the location to use for the local Maven repository cache. """ From a29e6740390a7c43f3d9b213e40d11842469d493 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 30 Apr 2025 10:55:55 -0500 Subject: [PATCH 080/119] Move cjdk fetch settings into scyjava.config --- src/scyjava/_cjdk_fetch.py | 48 +++++++++------- src/scyjava/_jvm.py | 23 ++------ src/scyjava/config.py | 110 +++++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 38 deletions(-) diff --git a/src/scyjava/_cjdk_fetch.py b/src/scyjava/_cjdk_fetch.py index 27c8035..378a373 100644 --- a/src/scyjava/_cjdk_fetch.py +++ b/src/scyjava/_cjdk_fetch.py @@ -1,3 +1,7 @@ +""" +Utility functions for fetching JDK/JRE and Maven. +""" + from __future__ import annotations import logging @@ -9,21 +13,23 @@ import cjdk import jpype +import scyjava.config + if TYPE_CHECKING: from pathlib import Path _logger = logging.getLogger(__name__) -_DEFAULT_MAVEN_URL = "tgz+https://dlcdn.apache.org/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.tar.gz" # noqa: E501 -_DEFAULT_MAVEN_SHA = "a555254d6b53d267965a3404ecb14e53c3827c09c3b94b5678835887ab404556bfaf78dcfe03ba76fa2508649dca8531c74bca4d5846513522404d48e8c4ac8b" # noqa: E501 -_DEFAULT_JAVA_VENDOR = "zulu-jre" -_DEFAULT_JAVA_VERSION = "11" def ensure_jvm_available() -> None: """Ensure that the JVM is available and Maven is installed.""" - if not is_jvm_available(): + fetch = scyjava.config.get_fetch_java() + if fetch == "never": + # Not allowed to use cjdk. + return + if fetch == "always" or not is_jvm_available(): cjdk_fetch_java() - if not shutil.which("mvn"): + if fetch == "always" or not shutil.which("mvn"): cjdk_fetch_maven() @@ -47,27 +53,27 @@ def _silent_check_output(*args, **kwargs): return True -def cjdk_fetch_java(vendor: str = "", version: str = "") -> None: +def cjdk_fetch_java(vendor: str | None = None, version: str | None = None) -> None: """Fetch java using cjdk and add it to the PATH.""" - if not vendor: - vendor = os.getenv("JAVA_VENDOR", _DEFAULT_JAVA_VENDOR) - version = os.getenv("JAVA_VERSION", _DEFAULT_JAVA_VERSION) + if vendor is None: + vendor = scyjava.config.get_java_vendor() + if version is None: + version = scyjava.config.get_java_version() - _logger.info(f"No JVM found, fetching {vendor}:{version} using cjdk...") - home = cjdk.java_home(vendor=vendor, version=version) - _add_to_path(str(home / "bin")) - os.environ["JAVA_HOME"] = str(home) + _logger.info(f"Fetching {vendor}:{version} using cjdk...") + java_home = cjdk.java_home(vendor=vendor, version=version) + _logger.debug(f"java_home -> {java_home}") + _add_to_path(str(java_home / "bin"), front=True) + os.environ["JAVA_HOME"] = str(java_home) def cjdk_fetch_maven(url: str = "", sha: str = "") -> None: """Fetch Maven using cjdk and add it to the PATH.""" - # if url was passed as an argument, or env_var, use it with provided sha + # if url was passed as an argument, use it with provided sha # otherwise, use default values for both - if url := url or os.getenv("MAVEN_URL", ""): - sha = sha or os.getenv("MAVEN_SHA", "") - else: - url = _DEFAULT_MAVEN_URL - sha = _DEFAULT_MAVEN_SHA + if not url: + url = scyjava.config.get_maven_url() + sha = scyjava.config.get_maven_sha() # fix urls to have proper prefix for cjdk if url.startswith("http"): @@ -88,7 +94,9 @@ def cjdk_fetch_maven(url: str = "", sha: str = "") -> None: ) kwargs = {sha_lengths[sha_len]: sha} + _logger.info("Fetching Maven using cjdk...") maven_dir = cjdk.cache_package("Maven", url, **kwargs) + _logger.debug(f"maven_dir -> {maven_dir}") if maven_bin := next(maven_dir.rglob("apache-maven-*/**/mvn"), None): _add_to_path(maven_bin.parent, front=True) else: # pragma: no cover diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index 484439f..224ac61 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -11,6 +11,7 @@ from functools import lru_cache from importlib import import_module from pathlib import Path +from typing import Sequence import jpype import jpype.config @@ -18,6 +19,7 @@ import scyjava.config from scyjava.config import Mode, mode +from scyjava._cjdk_fetch import ensure_jvm_available _logger = logging.getLogger(__name__) @@ -106,7 +108,7 @@ def jvm_version() -> tuple[int, ...]: return tuple(map(int, m.group(1).split("."))) -def start_jvm(options=None, *, fetch_java: bool = True) -> None: +def start_jvm(options: Sequence[str] = None) -> None: """ Explicitly connect to the Java virtual machine (JVM). Only one JVM can be active; does nothing if the JVM has already been started. Calling @@ -118,19 +120,6 @@ def start_jvm(options=None, *, fetch_java: bool = True) -> None: List of options to pass to the JVM. For example: ['-Dfoo=bar', '-XX:+UnlockExperimentalVMOptions'] See also scyjava.config.add_options. - :param fetch_java: - If True (default), when a JVM/or maven cannot be located on the system, - [`cjdk`](https://github.com/cachedjdk/cjdk) will be used to download - a JRE distribution and set up the JVM. The following environment variables - may be used to configure the JRE and Maven distributions to download: - * `JAVA_VENDOR`: The vendor of the JRE distribution to download. - Defaults to "zulu-jre". - * `JAVA_VERSION`: The version of the JRE distribution to download. - Defaults to "11". - * `MAVEN_URL`: The URL of the Maven distribution to download. - Defaults to https://dlcdn.apache.org/maven/maven-3/3.9.9/ - * `MAVEN_SHA`: The SHA512 hash of the Maven distribution to download, if - providing a custom MAVEN_URL. """ # if JVM is already running -- break if jvm_started(): @@ -147,10 +136,8 @@ def start_jvm(options=None, *, fetch_java: bool = True) -> None: # use the logger to notify user that endpoints are being added _logger.debug("Adding jars from endpoints {0}".format(endpoints)) - if fetch_java: - from scyjava._cjdk_fetch import ensure_jvm_available - - ensure_jvm_available() + # download JDK/JRE and Maven as appropriate + ensure_jvm_available() # get endpoints and add to JPype class path if len(endpoints) > 0: diff --git a/src/scyjava/config.py b/src/scyjava/config.py index 17a1bf3..e284fc6 100644 --- a/src/scyjava/config.py +++ b/src/scyjava/config.py @@ -12,6 +12,13 @@ _logger = _logging.getLogger(__name__) +# Constraints on the Java installation to be used. +_fetch_java: str = "auto" +_java_vendor: str = "zulu-jre" +_java_version: str = "11" +_maven_url: str = "tgz+https://dlcdn.apache.org/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.tar.gz" # noqa: E501 +_maven_sha: str = "a555254d6b53d267965a3404ecb14e53c3827c09c3b94b5678835887ab404556bfaf78dcfe03ba76fa2508649dca8531c74bca4d5846513522404d48e8c4ac8b" # noqa: E501 + endpoints: list[str] = [] _repositories = {"scijava.public": _scijava_public()} @@ -37,6 +44,109 @@ class Mode(_enum.Enum): mode = Mode.JPYPE +def set_java_constraints( + fetch: str | bool | None = None, + vendor: str | None = None, + version: str | None = None, + maven_url: str | None = None, + maven_sha: str | None = None, +) -> None: + """ + Set constraints on the version of Java to be used. + + :param fetch: + If "auto" (default), when a JVM/or maven cannot be located on the system, + [`cjdk`](https://github.com/cachedjdk/cjdk) will be used to download + a JDK/JRE distribution and set up the JVM. + If "always", cjdk will always be used; if "never", cjdk will never be used. + :param vendor: + The vendor of the JDK/JRE distribution for cjdk to download and cache. + Defaults to "zulu-jre". See the cjdk documentation for details. + :param version: + Expression defining the Java version for cjdk to download and cache. + Defaults to "11". See the cjdk documentation for details. + :param maven_url: + URL of the Maven distribution for cjdk to download and cache. + Defaults to the Maven 3.9.9 binary distribution from dlcdn.apache.org. + :param maven_sha: + The SHA512 (or SHA256 or SHA1) hash of the Maven distribution to download, + if providing a custom maven_url. + """ + global _fetch_java, _java_vendor, _java_version, _maven_url, _maven_sha + if fetch is not None: + if isinstance(fetch, bool): + # Be nice and allow boolean values as a convenience. + fetch = "always" if fetch else "never" + expected = ["auto", "always", "never"] + if fetch not in expected: + raise ValueError(f"Fetch mode {fetch} is not one of {expected}") + _fetch_java = fetch + if vendor is not None: + _java_vendor = vendor + if version is not None: + _java_version = version + if maven_url is not None: + _maven_url = maven_url + _maven_sha = "" + if maven_sha is not None: + _maven_sha = maven_sha + + +def get_fetch_java() -> str: + """ + Get whether [`cjdk`](https://github.com/cachedjdk/cjdk) + will be used to download a JDK/JRE distribution and set up the JVM. + To set this value, see set_java_constraints. + + :return: + "always" for cjdk to obtain the JDK/JRE; + "never" for cjdk *not* to obtain a JDK/JRE; + "auto" for cjdk to be used only when a JVM/or Maven is not on the system path. + """ + return _fetch_java + + +def get_java_vendor() -> str: + """ + Get the vendor of the JDK/JRE distribution to download. + Vendor of the Java installation for cjdk to download and cache. + To set this value, see set_java_constraints. + + :return: String defining the desired JDK/JRE vendor for downloaded JDK/JREs. + """ + return _java_vendor + + +def get_java_version() -> str: + """ + Expression defining the Java version for cjdk to download and cache. + To set this value, see set_java_constraints. + + :return: String defining the desired JDK/JRE version for downloaded JDK/JREs. + """ + return _java_version + + +def get_maven_url() -> str: + """ + The URL of the Maven distribution to download. + To set this value, see set_java_constraints. + + :return: URL pointing to the Maven distribution. + """ + return _maven_url + + +def get_maven_sha() -> str: + """ + The SHA512 (or SHA256 or SHA1) hash of the Maven distribution to download, + if providing a custom maven_url. To set this value, see set_java_constraints. + + :return: Hash value of the Maven distribution, or empty string to skip hash check. + """ + return _maven_sha + + def add_repositories(*args, **kwargs) -> None: """ Add one or more Maven repositories to be used by jgo for downloading dependencies. From 630ff9db34113017857d02a5fa1a4dcab53fd3d5 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 30 Apr 2025 12:00:49 -0500 Subject: [PATCH 081/119] Add Java bootstrapping example to documentation --- src/scyjava/__init__.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/scyjava/__init__.py b/src/scyjava/__init__.py index e19cc79..6d23d09 100644 --- a/src/scyjava/__init__.py +++ b/src/scyjava/__init__.py @@ -40,6 +40,22 @@ +++oo*OO######O**oo+++++oo*OO######O**oo+++++oo*OO######O**oo+++ +++oo*OO######OO*oo+++++oo*OO######OO*oo+++++oo*OO######OO*oo+++ +Bootstrap a Java installation: + + >>> from scyjava import config, jimport + >>> config.set_java_constraints(fetch=True, vendor='zulu', version='17') + >>> System = jimport('java.lang.System') + cjdk: Installing JDK zulu:17.0.15 to /home/chuckles/.cache/cjdk + Download 100% of 189.4 MiB |##########| Elapsed Time: 0:00:02 Time: 0:00:02 + Extract | | # | 714 Elapsed Time: 0:00:01 + cjdk: Installing Maven to /home/chuckles/.cache/cjdk + Download 100% of 8.7 MiB |##########| Elapsed Time: 0:00:00 Time: 0:00:00 + Extract | |# | 102 Elapsed Time: 0:00:00 + >>> System.getProperty('java.vendor') + 'Azul Systems, Inc.' + >>> System.getProperty('java.version') + '17.0.15' + Convert Java collections to Python: >>> from scyjava import jimport From a8a318458c60c6ba4cc62fcb064d7f77bbce4b68 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 30 Apr 2025 12:47:20 -0500 Subject: [PATCH 082/119] Release version 1.12.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8da9830..cbe04dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "scyjava" -version = "1.11.0.dev0" +version = "1.12.0" description = "Supercharged Java access from Python" license = "Unlicense" authors = [{name = "SciJava developers", email = "ctrueden@wisc.edu"}] From 859a78779554a5819ad6ca190c6f6bc70f613979 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 30 Apr 2025 12:48:05 -0500 Subject: [PATCH 083/119] Bump to next development cycle --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cbe04dc..cd124a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "scyjava" -version = "1.12.0" +version = "1.12.1.dev0" description = "Supercharged Java access from Python" license = "Unlicense" authors = [{name = "SciJava developers", email = "ctrueden@wisc.edu"}] From 89f043abb73895e96fd221fb009ee991bd9533d4 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 30 Apr 2025 12:49:12 -0500 Subject: [PATCH 084/119] Add Java installation bootstrap example to README --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index cedcfcb..ea8f48d 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,24 @@ u'1.8.0_152-release' See the [jgo documentation](https://github.com/scijava/jgo) for more about Maven endpoints. +## Bootstrap a Java installation + +```python +>>> from scyjava import config, jimport +>>> config.set_java_constraints(fetch=True, vendor='zulu', version='17') +>>> System = jimport('java.lang.System') +cjdk: Installing JDK zulu:17.0.15 to /home/chuckles/.cache/cjdk +Download 100% of 189.4 MiB |##########| Elapsed Time: 0:00:02 Time: 0:00:02 +Extract | | # | 714 Elapsed Time: 0:00:01 +cjdk: Installing Maven to /home/chuckles/.cache/cjdk +Download 100% of 8.7 MiB |##########| Elapsed Time: 0:00:00 Time: 0:00:00 +Extract | |# | 102 Elapsed Time: 0:00:00 +>>> System.getProperty('java.vendor') +'Azul Systems, Inc.' +>>> System.getProperty('java.version') +'17.0.15' +``` + ## Convert between Python and Java data structures ### Convert Java collections to Python From fe835a19923fc96edd01ac76c42fb8435d899513 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Mon, 16 Jun 2025 21:45:16 -0500 Subject: [PATCH 085/119] Fix link to Maven 3.9.9 download Unfortunately, it appears that whenever a new Maven version is released, the previously working link breaks. But old versions are available from a hopefully more permanent link in the release archive, so we'll stick with 3.9.9 in its less volatile home. --- src/scyjava/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scyjava/config.py b/src/scyjava/config.py index e284fc6..0b85bc8 100644 --- a/src/scyjava/config.py +++ b/src/scyjava/config.py @@ -16,7 +16,7 @@ _fetch_java: str = "auto" _java_vendor: str = "zulu-jre" _java_version: str = "11" -_maven_url: str = "tgz+https://dlcdn.apache.org/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.tar.gz" # noqa: E501 +_maven_url: str = "tgz+https://archive.apache.org/dist/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.tar.gz" # noqa: E501 _maven_sha: str = "a555254d6b53d267965a3404ecb14e53c3827c09c3b94b5678835887ab404556bfaf78dcfe03ba76fa2508649dca8531c74bca4d5846513522404d48e8c4ac8b" # noqa: E501 endpoints: list[str] = [] From d6cf1f929b2e7778bd60016212d8a8918965722c Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Mon, 16 Jun 2025 21:56:57 -0500 Subject: [PATCH 086/119] Stop installing and testing jep * The jep tests do not pass reliably on macOS. * Installing jep can trigger a build from source. * The support for scyjava via jep is incomplete anyway, due to jep's lack of support for class reflection (i.e. the jclass function). And now, on Windows CI with Python 3.13: LINK : fatal error LNK1104: cannot open file 'python313t.lib' error: command 'C:\\Program Files\\Microsoft Visual Studio\\2022\\Enterprise\\VC\\Tools\\MSVC\\14.43.34808\\bin\\HostX86\\x64\\link.exe' failed with exit code 1104 [end of output] note: This error originates from a subprocess, and is likely not a problem with pip. ERROR: Failed building wheel for jep ERROR: Failed to build installable wheels for some pyproject.toml based projects (jep) Successfully built scyjava assertpy Failed to build jep The jep mode is probably used by no one. We'll leave it in as-is, but no more worrying about whether it still works, at least for now. :-( --- bin/test.sh | 70 ++++----------------------------------------- dev-environment.yml | 1 - pyproject.toml | 1 - 3 files changed, 6 insertions(+), 66 deletions(-) diff --git a/bin/test.sh b/bin/test.sh index db00333..17b18d5 100755 --- a/bin/test.sh +++ b/bin/test.sh @@ -13,9 +13,9 @@ dir=$(dirname "$0") cd "$dir/.." echo -echo "-------------------------------------------" -echo "| Testing JPype mode (Java inside Python) |" -echo "-------------------------------------------" +echo "----------------------" +echo "| Running unit tests |" +echo "----------------------" if [ $# -gt 0 ] then @@ -26,9 +26,9 @@ fi jpypeCode=$? echo -echo "-------------------------------------------" -echo "| Running integration tests (JPype only) |" -echo "-------------------------------------------" +echo "-----------------------------" +echo "| Running integration tests |" +echo "-----------------------------" itCode=0 for t in tests/it/*.py do @@ -44,64 +44,6 @@ do fi done -echo -echo "-------------------------------------------" -echo "| Testing Jep mode (Python inside Java) |" -echo "-------------------------------------------" - -# Discern the Jep installation. -site_packages=$(python -c 'import sys; print(next(p for p in sys.path if p.endswith("site-packages")))') -test -d "$site_packages/jep" || { - echo "[ERROR] Failed to detect Jep installation in current environment!" 1>&2 - exit 1 -} - -# We execute the pytest framework through Jep via jgo, so that -# the surrounding JVM includes scijava-table on the classpath. -# -# Arguments to the shell script are translated into an argument -# list to the pytest.main function. A weak attempt at handling -# special characters, e.g. single quotation marks and backslashes, -# is made, but there are surely other non-working cases. - -if [ $# -gt 0 ] -then - a=$(echo "$@" | sed 's/\\/\\\\/g') # escape backslashes - a=$(echo "$a" | sed 's/'\''/\\'\''/g') # escape single quotes - a=$(echo "$a" | sed 's/ /'\'','\''/g') # replace space with ',' - argString="['-v', '$a']" -else - argString="" -fi -if ! java -version 2>&1 | grep -q '^openjdk version "\(1\.8\|9\|10\|11\|12\|13\|14\|15\|16\)\.' -then - echo "Skipping jep tests due to unsupported Java version:" - java -version || true - jepCode=0 -elif [ "$(uname -s)" = "Darwin" ] -then - echo "Skipping jep tests on macOS due to flakiness" - jepCode=0 -else - echo "# AUTOGENERATED test file for jep; safe to delete. -import logging, sys, pytest, scyjava -scyjava._logger.addHandler(logging.StreamHandler(sys.stderr)) -scyjava._logger.setLevel(logging.INFO) -scyjava.config.set_verbose(2) -result = pytest.main($argString) -if result: - sys.exit(result) -" > jep_test.py - jgo -vv \ - -r scijava.public=https://maven.scijava.org/content/groups/public \ - -Djava.library.path="$site_packages/jep" \ - black.ninia:jep:jep.Run+org.scijava:scijava-table \ - jep_test.py - jepCode=$? - rm -f jep_test.py -fi - test "$jpypeCode" -ne 0 && exit "$jpypeCode" test "$itCode" -ne 0 && exit "$itCode" -test "$jepCode" -ne 0 && exit "$jepCode" exit 0 diff --git a/dev-environment.yml b/dev-environment.yml index a2fb77e..bd00941 100644 --- a/dev-environment.yml +++ b/dev-environment.yml @@ -37,5 +37,4 @@ dependencies: # Project from source - pip - pip: - - git+https://github.com/ninia/jep.git@cfca63f8b3398daa6d2685428660dc4b2bfab67d - -e . diff --git a/pyproject.toml b/pyproject.toml index cd124a4..cfc1e63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,6 @@ dependencies = [ dev = [ "assertpy", "build", - "jep", "pytest", "pytest-cov", "numpy", From ebbceeb00b7923258a8eda43b3d202eb7f51f4fa Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Mon, 16 Jun 2025 22:07:37 -0500 Subject: [PATCH 087/119] Release version 1.12.1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cfc1e63..43f1e4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "scyjava" -version = "1.12.1.dev0" +version = "1.12.1" description = "Supercharged Java access from Python" license = "Unlicense" authors = [{name = "SciJava developers", email = "ctrueden@wisc.edu"}] From b71c8b369a3ba0b2c693c96e37f030595d4266e8 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Mon, 16 Jun 2025 22:12:16 -0500 Subject: [PATCH 088/119] Bump to next development cycle --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 43f1e4a..393d3c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "scyjava" -version = "1.12.1" +version = "1.12.2.dev0" description = "Supercharged Java access from Python" license = "Unlicense" authors = [{name = "SciJava developers", email = "ctrueden@wisc.edu"}] From ccceaa7000668cd446adb3b48d0755b531608803 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 20 Jul 2025 11:40:21 -0500 Subject: [PATCH 089/119] Switch build system to uv No more parallel dependency lists! \^_^/ --- .github/workflows/build.yml | 74 ++++++------------------------------- .gitignore | 4 ++ Makefile | 14 +------ bin/check.sh | 12 ++---- bin/{setup.sh => dist.sh} | 2 +- bin/fmt.sh | 11 ------ bin/lint.sh | 6 +-- bin/test.sh | 8 ++-- dev-environment.yml | 40 -------------------- environment.yml | 30 --------------- pyproject.toml | 4 +- 11 files changed, 30 insertions(+), 175 deletions(-) rename bin/{setup.sh => dist.sh} (52%) delete mode 100755 bin/fmt.sh delete mode 100644 dev-environment.yml delete mode 100644 environment.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a6dbb29..e2f19e5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,7 +11,7 @@ on: - main jobs: - build-cross-platform: + build: name: test ${{matrix.os}} - ${{matrix.python-version}} - ${{matrix.java-version}} runs-on: ${{ matrix.os }} strategy: @@ -33,79 +33,27 @@ jobs: java-version: '' steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - uses: actions/setup-python@v3 + - uses: actions/setup-python@v5 with: python-version: ${{matrix.python-version}} - - uses: actions/setup-java@v3 + - uses: actions/setup-java@v4 if: matrix.java-version != '' with: java-version: ${{matrix.java-version}} distribution: 'zulu' cache: 'maven' - - name: Install ScyJava + - name: Run tests run: | - python -m pip install --upgrade pip - python -m pip install -e '.[dev]' + bin/test.sh - - name: Test ScyJava + - name: Lint code run: | - bin/test.sh --color=yes + bin/lint.sh - ensure-clean-code: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v3 - - - name: Lint code - run: | - python -m pip install ruff - ruff check - ruff format --check - - - name: Validate pyproject.toml - run: | - python -m pip install validate-pyproject[all] - python -m validate_pyproject pyproject.toml - - conda-dev-test: - name: Conda Setup & Code Coverage - runs-on: ubuntu-latest - defaults: - # Steps that rely on the activated environment must be run with this shell setup. - # See https://github.com/marketplace/actions/setup-miniconda#important - run: - shell: bash -l {0} - steps: - - uses: actions/checkout@v2 - - name: Cache conda - uses: actions/cache@v4 - env: - # Increase this value to reset cache if dev-environment.yml has not changed - CACHE_NUMBER: 0 - with: - path: ~/conda_pkgs_dir - key: - ${{ runner.os }}-conda-${{ env.CACHE_NUMBER }}-${{ hashFiles('dev-environment.yml') }} - - uses: conda-incubator/setup-miniconda@v3 - with: - # Create env with dev packages - auto-update-conda: true - python-version: 3.9 - miniforge-version: latest - environment-file: dev-environment.yml - # Activate scyjava-dev environment - activate-environment: scyjava-dev - auto-activate-base: false - # Use mamba for faster setup - use-mamba: true - - name: Test scyjava - run: | - bin/test.sh --cov-report=xml --cov=. - # We could do this in its own action, but we'd have to setup the environment again. - - name: Upload Coverage to Codecov - uses: codecov/codecov-action@v2 + - name: Upload coverage + if: matrix.platform == 'ubuntu-latest' && matrix.python-version == '3.13' && matrix.java-version == '11' + uses: codecov/codecov-action@v4 diff --git a/.gitignore b/.gitignore index 7fdf6cb..56372b8 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ coverage.xml # IDEA .idea/ *.iml + +# uv +/.venv/ +/uv.lock diff --git a/Makefile b/Makefile index 5827c14..ed19ec6 100644 --- a/Makefile +++ b/Makefile @@ -1,32 +1,22 @@ help: @echo "Available targets:\n\ clean - remove build files and directories\n\ - setup - create mamba developer environment\n\ lint - run code formatters and linters\n\ test - run automated test suite\n\ dist - generate release archives\n\ - \n\ - Remember to 'mamba activate scyjava-dev' first!" + " clean: bin/clean.sh -setup: - bin/setup.sh - check: @bin/check.sh lint: check bin/lint.sh -fmt: check - bin/fmt.sh - test: check bin/test.sh dist: check clean - python -m build - -.PHONY: test + bin/dist.sh diff --git a/bin/check.sh b/bin/check.sh index 4a9db61..7ef142c 100755 --- a/bin/check.sh +++ b/bin/check.sh @@ -1,10 +1,6 @@ #!/bin/sh -case "$CONDA_PREFIX" in - */scyjava-dev) - ;; - *) - echo "Please run 'make setup' and then 'mamba activate scyjava-dev' first." - exit 1 - ;; -esac +if ! command -v uv >/dev/null 2>&1; then + echo "Please install uv (https://docs.astral.sh/uv/getting-started/installation/)." + exit 1 +fi diff --git a/bin/setup.sh b/bin/dist.sh similarity index 52% rename from bin/setup.sh rename to bin/dist.sh index 3c711c7..f21fbf4 100755 --- a/bin/setup.sh +++ b/bin/dist.sh @@ -3,4 +3,4 @@ dir=$(dirname "$0") cd "$dir/.." -mamba env create -f dev-environment.yml +uv run python -m build diff --git a/bin/fmt.sh b/bin/fmt.sh deleted file mode 100755 index cd04d02..0000000 --- a/bin/fmt.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh - -dir=$(dirname "$0") -cd "$dir/.." - -exitCode=0 -ruff check --fix -code=$?; test $code -eq 0 || exitCode=$code -ruff format -code=$?; test $code -eq 0 || exitCode=$code -exit $exitCode diff --git a/bin/lint.sh b/bin/lint.sh index 1cf8682..74871c9 100755 --- a/bin/lint.sh +++ b/bin/lint.sh @@ -4,10 +4,10 @@ dir=$(dirname "$0") cd "$dir/.." exitCode=0 -ruff check +uv run validate-pyproject pyproject.toml code=$?; test $code -eq 0 || exitCode=$code -ruff format --check +uv run ruff check --fix code=$?; test $code -eq 0 || exitCode=$code -validate-pyproject pyproject.toml +uv run ruff format code=$?; test $code -eq 0 || exitCode=$code exit $exitCode diff --git a/bin/test.sh b/bin/test.sh index 17b18d5..fc17237 100755 --- a/bin/test.sh +++ b/bin/test.sh @@ -1,6 +1,6 @@ #!/bin/sh -# Executes the pytest framework in both JPype and Jep modes. +# Runs the unit tests. # # Usage examples: # bin/test.sh @@ -19,9 +19,9 @@ echo "----------------------" if [ $# -gt 0 ] then - python -m pytest -p no:faulthandler $@ + uv run python -m pytest -v -p no:faulthandler $@ else - python -m pytest -p no:faulthandler tests/ + uv run python -m pytest -v -p no:faulthandler tests/ fi jpypeCode=$? @@ -32,7 +32,7 @@ echo "-----------------------------" itCode=0 for t in tests/it/*.py do - python "$t" + uv run python "$t" code=$? printf -- "--> %s " "$t" if [ "$code" -eq 0 ] diff --git a/dev-environment.yml b/dev-environment.yml deleted file mode 100644 index bd00941..0000000 --- a/dev-environment.yml +++ /dev/null @@ -1,40 +0,0 @@ -# Use this file to construct an environment -# for developing scyjava from source. -# -# First, install mambaforge: -# -# https://github.com/conda-forge/miniforge#mambaforge -# -# Then run: -# -# mamba env create -f dev-environment.yml -# conda activate scyjava-dev -# -# In addition to the dependencies needed for using scyjava, it -# includes tools for developer-related actions like running -# automated tests (pytest) and linting the code (ruff). If you -# want an environment without these tools, use environment.yml. -name: scyjava-dev -channels: - - conda-forge -dependencies: - - python = 3.9 - # Project dependencies - - jpype1 >= 1.3.0 - - jgo - - cjdk - # Test dependencies - - numpy - - pandas - # Developer tools - - assertpy - - pytest - - pytest-cov - - python-build - - ruff - - toml - - validate-pyproject - # Project from source - - pip - - pip: - - -e . diff --git a/environment.yml b/environment.yml deleted file mode 100644 index bb4bd19..0000000 --- a/environment.yml +++ /dev/null @@ -1,30 +0,0 @@ -# Use this file to construct an environment for -# working with scyjava in a runtime setting. -# -# First, install mambaforge: -# -# https://github.com/conda-forge/miniforge#mambaforge -# -# Then run: -# -# mamba env create -# mamba activate scyjava -# -# It includes the dependencies needed for using scyjava, but not tools -# for developer-related actions like running automated tests (pytest), -# linting the code (ruff), and generating the API documentation (sphinx). -# If you want an environment including these tools, use dev-environment.yml. - -name: scyjava -channels: - - conda-forge -dependencies: - - python >= 3.9 - # Project dependencies - - jpype1 >= 1.3.0 - - jgo - - cjdk - # Project from source - - pip - - pip: - - -e . diff --git a/pyproject.toml b/pyproject.toml index 393d3c4..fa03571 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,6 @@ classifiers = [ "Topic :: Utilities", ] -# NB: Keep this in sync with environment.yml AND dev-environment.yml! requires-python = ">=3.9" dependencies = [ "jpype1 >= 1.3.0", @@ -38,8 +37,7 @@ dependencies = [ "cjdk", ] -[project.optional-dependencies] -# NB: Keep this in sync with dev-environment.yml! +[dependency-groups] dev = [ "assertpy", "build", From c566a65e1e415741392370d11feece69b85b061f Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 20 Jul 2025 12:01:56 -0500 Subject: [PATCH 090/119] Make lint script return non-zero upon any change So that the CI fails when reformatting needed to occur. --- bin/lint.sh | 9 +++++++++ pyproject.toml | 3 +-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/bin/lint.sh b/bin/lint.sh index 74871c9..978c1aa 100755 --- a/bin/lint.sh +++ b/bin/lint.sh @@ -4,10 +4,19 @@ dir=$(dirname "$0") cd "$dir/.." exitCode=0 + +# Check for errors and capture non-zero exit codes. uv run validate-pyproject pyproject.toml code=$?; test $code -eq 0 || exitCode=$code +uv run ruff check >/dev/null 2>&1 +code=$?; test $code -eq 0 || exitCode=$code +uv run ruff format --check >/dev/null 2>&1 +code=$?; test $code -eq 0 || exitCode=$code + +# Do actual code reformatting. uv run ruff check --fix code=$?; test $code -eq 0 || exitCode=$code uv run ruff format code=$?; test $code -eq 0 || exitCode=$code + exit $exitCode diff --git a/pyproject.toml b/pyproject.toml index fa03571..ccaa9d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dev = [ "pandas", "ruff", "toml", - "validate-pyproject[all]" + "validate-pyproject[all]", ] [project.urls] @@ -65,7 +65,6 @@ include-package-data = false where = ["src"] namespaces = false -# ruff configuration [tool.ruff] line-length = 88 src = ["src", "tests"] From e9f49427a3c7e3d4243fe68e3baeb0740382ff7b Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 20 Jul 2025 12:08:17 -0500 Subject: [PATCH 091/119] CI: add missing uv installation step --- .github/workflows/build.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e2f19e5..bbaf58e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -46,6 +46,11 @@ jobs: distribution: 'zulu' cache: 'maven' + - name: Set up uv + run: | + python -m pip install --upgrade pip + python -m pip install uv + - name: Run tests run: | bin/test.sh From 8a2ec3b624a4003aa33faa2c7ad6c4f5d4806028 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 20 Jul 2025 12:11:16 -0500 Subject: [PATCH 092/119] CI: attempt to fix code coverage upload --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bbaf58e..eabde90 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -60,5 +60,5 @@ jobs: bin/lint.sh - name: Upload coverage - if: matrix.platform == 'ubuntu-latest' && matrix.python-version == '3.13' && matrix.java-version == '11' + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && matrix.java-version == '11' uses: codecov/codecov-action@v4 From bca21e607384c7c9be5a275f20fb3153e09d80eb Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 20 Jul 2025 12:14:19 -0500 Subject: [PATCH 093/119] CI: remove code coverage and fix Windows build I'm sick of dealing with Codecov. Latest error message was: Token required because branch is protected It's just not worth the hassle. --- .github/workflows/build.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index eabde90..a9f51a6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -52,13 +52,11 @@ jobs: python -m pip install uv - name: Run tests + shell: bash run: | bin/test.sh - name: Lint code + shell: bash run: | bin/lint.sh - - - name: Upload coverage - if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && matrix.java-version == '11' - uses: codecov/codecov-action@v4 From afe97d37627beddc4d008767446c4414925e8235 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 20 Jul 2025 12:27:21 -0500 Subject: [PATCH 094/119] Remove obsolete codecov.yml file --- codecov.yml | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index aa84c67..0000000 --- a/codecov.yml +++ /dev/null @@ -1,2 +0,0 @@ -ignore: - - "*/tests/*" From 9e33f2ed350973b4e657634290e7d75824d635e4 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 23 Aug 2025 09:07:03 -0400 Subject: [PATCH 095/119] refactor: delay import of pandas until needed --- src/scyjava/_convert.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/scyjava/_convert.py b/src/scyjava/_convert.py index af1583b..c73f8b7 100644 --- a/src/scyjava/_convert.py +++ b/src/scyjava/_convert.py @@ -7,8 +7,9 @@ import logging import math from bisect import insort +from importlib.util import find_spec from pathlib import Path -from typing import Any, Callable, Dict, List, NamedTuple +from typing import Any, Callable, Dict, List, NamedTuple, reveal_type from jpype import JBoolean, JByte, JChar, JDouble, JFloat, JInt, JLong, JShort @@ -677,7 +678,7 @@ def _stock_py_converters() -> List: priority=Priority.VERY_LOW, ), ] - if _import_pandas(required=False): + if find_spec("pandas"): converters.append( Converter( name="org.scijava.table.Table -> pandas.DataFrame", @@ -716,7 +717,7 @@ def _stock_py_converters() -> List: ), ] ) - if _import_numpy(required=False): + if find_spec("numpy"): converters.append( Converter( name="primitive array -> numpy.ndarray", @@ -803,16 +804,15 @@ def _jarray_shape(jarr): return shape -def _import_numpy(required=True): +def _import_numpy(): try: import numpy as np return np except ImportError as e: - if required: - msg = "The NumPy library is missing (https://numpy.org/). " - msg += "Please install it before using this function." - raise RuntimeError(msg) from e + msg = "The NumPy library is missing (https://numpy.org/). " + msg += "Please install it before using this function." + raise RuntimeError(msg) from e ###################################### @@ -838,16 +838,15 @@ def _convert_table(obj: Any): return None -def _import_pandas(required=True): +def _import_pandas(): try: import pandas as pd return pd except ImportError as e: - if required: - msg = "The Pandas library is missing (http://pandas.pydata.org/). " - msg += "Please install it before using this function." - raise RuntimeError(msg) from e + msg = "The Pandas library is missing (http://pandas.pydata.org/). " + msg += "Please install it before using this function." + raise RuntimeError(msg) from e def _table_to_pandas(table): From 6f89935091be21f25af75214d6d28de1efcd68b2 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 23 Aug 2025 09:10:47 -0400 Subject: [PATCH 096/119] remove reveal type --- src/scyjava/_convert.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scyjava/_convert.py b/src/scyjava/_convert.py index c73f8b7..4a04f27 100644 --- a/src/scyjava/_convert.py +++ b/src/scyjava/_convert.py @@ -9,7 +9,7 @@ from bisect import insort from importlib.util import find_spec from pathlib import Path -from typing import Any, Callable, Dict, List, NamedTuple, reveal_type +from typing import Any, Callable, Dict, List, NamedTuple from jpype import JBoolean, JByte, JChar, JDouble, JFloat, JInt, JLong, JShort From 785e75a23085164dfeba91e49afa102b2800f35d Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Mon, 22 Sep 2025 13:43:35 -0500 Subject: [PATCH 097/119] Fix JVM version detection for no-dot versions OpenJDK 25 was released very recently, and its version is simply "25" without any period symbols. The regex here was assuming at least one, and so the jvm_version() function was failing with this sort of version string. This commit generalizes the regex to work in such cases. --- src/scyjava/_jvm.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index 224ac61..3a5685f 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -93,6 +93,7 @@ def jvm_version() -> tuple[int, ...]: if java is None: raise RuntimeError(f"No java executable found inside: {p}") + _logger.debug(f"Invoking `{java} -version`...") try: output = subprocess.check_output( [str(java), "-version"], stderr=subprocess.STDOUT @@ -101,11 +102,21 @@ def jvm_version() -> tuple[int, ...]: raise RuntimeError("System call to java failed") from e output = output.replace("\n", " ").replace("\r", "") - m = re.match('.*version "(([0-9]+\\.)+[0-9]+)', output) + m = re.match('.* version "([^"]*)"', output) if not m: - raise RuntimeError(f"Inscrutable java command output:\n{output}") + raise RuntimeError( + "Inscrutable java command output:\n" + + f"$ {java} -version\n" + + output + ) + + v = m.group(1) + _logger.debug(f"Got Java version: {v}") - return tuple(map(int, m.group(1).split("."))) + try: + return tuple(map(int, v.split("."))) + except ValueError: + raise RuntimeError(f"Inscrutable java version: {v}") def start_jvm(options: Sequence[str] = None) -> None: From f4d5784a128ad99cdf6ee3807eeebb3e1e55eed0 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Mon, 22 Sep 2025 16:17:16 -0500 Subject: [PATCH 098/119] Make code lint-congruent --- src/scyjava/_jvm.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index 3a5685f..c702623 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -105,9 +105,7 @@ def jvm_version() -> tuple[int, ...]: m = re.match('.* version "([^"]*)"', output) if not m: raise RuntimeError( - "Inscrutable java command output:\n" + - f"$ {java} -version\n" + - output + f"Inscrutable java command output:\n$ {java} -version\n{output}" ) v = m.group(1) From 6eebe950dbf7f96256d690993f3e1115d18fd261 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 24 Sep 2025 14:30:02 -0500 Subject: [PATCH 099/119] Fix jsource function for no-dot Java versions For the recently released OpenJDK 25, the version is simply "25". --- src/scyjava/_introspect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scyjava/_introspect.py b/src/scyjava/_introspect.py index 4268038..a9ab98a 100644 --- a/src/scyjava/_introspect.py +++ b/src/scyjava/_introspect.py @@ -100,7 +100,7 @@ def jsource(data) -> str: # Discern the Java version. jv_digits = jvm_version() - assert jv_digits is not None and len(jv_digits) > 1 + assert jv_digits is not None and len(jv_digits) > 0 java_version = jv_digits[1] if jv_digits[0] == 1 else jv_digits[0] # Note: some classes (e.g. corba and jaxp) will not be located correctly before From 3b30d08fa702dfdeac1d63bbeb3e9a9383bb2f00 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 24 Sep 2025 14:51:08 -0500 Subject: [PATCH 100/119] Fix remaining OpenJDK-25-related issues * Relax version digit length checks. * Handle wonky JVM paths on macOS. * Fix typo. --- src/scyjava/_cjdk_fetch.py | 2 +- src/scyjava/_jvm.py | 8 +++++++- tests/it/jvm_version.py | 4 ++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/scyjava/_cjdk_fetch.py b/src/scyjava/_cjdk_fetch.py index 378a373..b8fea09 100644 --- a/src/scyjava/_cjdk_fetch.py +++ b/src/scyjava/_cjdk_fetch.py @@ -47,7 +47,7 @@ def _silent_check_output(*args, **kwargs): try: with patch.object(subprocess, "check_output", new=_silent_check_output): jpype.getDefaultJVMPath() - # on Darwin, may raise a CalledProcessError when invoking `/user/libexec/java_home` + # on Darwin, may raise a CalledProcessError when invoking `/usr/libexec/java_home` except (jpype.JVMNotFoundException, subprocess.CalledProcessError): return False return True diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index c702623..29b499b 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -70,8 +70,14 @@ def jvm_version() -> tuple[int, ...]: default_jvm_path = jpype.getDefaultJVMPath() if not default_jvm_path: raise RuntimeError("Cannot glean the default JVM path") + print(f"Default JVM path from JPype: {default_jvm_path}") - p = Path(default_jvm_path) + # Good ol' macOS! Nothing beats macOS. + jvm_path = default_jvm_path.replace( + "/Contents/MacOS/libjli.dylib", "/Contents/Home/lib/libjli.dylib" + ) + + p = Path(jvm_path) if not p.exists(): raise RuntimeError(f"Invalid default JVM path: {p}") diff --git a/tests/it/jvm_version.py b/tests/it/jvm_version.py index a79d20b..98103bc 100644 --- a/tests/it/jvm_version.py +++ b/tests/it/jvm_version.py @@ -10,7 +10,7 @@ before_version = scyjava.jvm_version() assert_that(before_version).is_not_none() -assert_that(len(before_version)).is_greater_than_or_equal_to(3) +assert_that(len(before_version)).is_greater_than_or_equal_to(1) assert_that(before_version[0]).is_greater_than(0) scyjava.config.enable_headless_mode() @@ -18,7 +18,7 @@ after_version = scyjava.jvm_version() assert_that(after_version).is_not_none() -assert_that(len(after_version)).is_greater_than_or_equal_to(3) +assert_that(len(after_version)).is_greater_than_or_equal_to(1) assert_that(after_version[0]).is_greater_than(0) assert_that(before_version).is_equal_to(after_version) From a2b8bed0a07a87d4c9b715a6dddaad308080f440 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 12 Nov 2025 15:53:11 -0600 Subject: [PATCH 101/119] Fix incorrect type hint in README function list --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ea8f48d..3542410 100644 --- a/README.md +++ b/README.md @@ -370,7 +370,7 @@ FUNCTIONS jvm_started() -> bool Return true iff a Java virtual machine (JVM) has been started. - jvm_version() -> str + jvm_version() -> tuple[int, ...] Gets the version of the JVM as a tuple, with each dot-separated digit as one element. Characters in the version string beyond only numbers and dots are ignored, in line with the java.version system property. From 11deca3291d5b02dd08cc423378a621ffaae35f8 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 8 Feb 2026 20:59:25 -0600 Subject: [PATCH 102/119] Change JDK fetch default from "auto" to "always" The "auto" mode can still be used for on-demand fetching, but it is more robust to fetch a JDK with known characteristics rather than relying on whatever the user happens to have installed, regardless of Java version. See tlambert03/bffile#16. --- src/scyjava/_jvm.py | 2 +- src/scyjava/config.py | 10 +++++----- tests/it/jvm_version.py | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index 29b499b..1bd8017 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -70,7 +70,7 @@ def jvm_version() -> tuple[int, ...]: default_jvm_path = jpype.getDefaultJVMPath() if not default_jvm_path: raise RuntimeError("Cannot glean the default JVM path") - print(f"Default JVM path from JPype: {default_jvm_path}") + _logger.debug(f"Default JVM path from JPype: {default_jvm_path}") # Good ol' macOS! Nothing beats macOS. jvm_path = default_jvm_path.replace( diff --git a/src/scyjava/config.py b/src/scyjava/config.py index 0b85bc8..cfab9d8 100644 --- a/src/scyjava/config.py +++ b/src/scyjava/config.py @@ -13,7 +13,7 @@ _logger = _logging.getLogger(__name__) # Constraints on the Java installation to be used. -_fetch_java: str = "auto" +_fetch_java: str = "always" _java_vendor: str = "zulu-jre" _java_version: str = "11" _maven_url: str = "tgz+https://archive.apache.org/dist/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.tar.gz" # noqa: E501 @@ -55,10 +55,10 @@ def set_java_constraints( Set constraints on the version of Java to be used. :param fetch: - If "auto" (default), when a JVM/or maven cannot be located on the system, - [`cjdk`](https://github.com/cachedjdk/cjdk) will be used to download - a JDK/JRE distribution and set up the JVM. - If "always", cjdk will always be used; if "never", cjdk will never be used. + If "always" (default), cjdk will always be used; if "never", cjdk will + never be used. If "auto", when a JVM/or maven cannot be located on the system, + [`cjdk`](https://github.com/cachedjdk/cjdk) will be used to download a + JDK/JRE distribution and set up the JVM. :param vendor: The vendor of the JDK/JRE distribution for cjdk to download and cache. Defaults to "zulu-jre". See the cjdk documentation for details. diff --git a/tests/it/jvm_version.py b/tests/it/jvm_version.py index 98103bc..0833167 100644 --- a/tests/it/jvm_version.py +++ b/tests/it/jvm_version.py @@ -13,6 +13,7 @@ assert_that(len(before_version)).is_greater_than_or_equal_to(1) assert_that(before_version[0]).is_greater_than(0) +scyjava.config.set_java_constraints(fetch="never") scyjava.config.enable_headless_mode() scyjava.start_jvm() From 280b622662c67993da8a8441609f40e8041dee70 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 8 Feb 2026 21:14:01 -0600 Subject: [PATCH 103/119] Fix pandas API usages Co-authored-by: Claude Sonnet 4.5 --- src/scyjava/_convert.py | 78 ++++++++++++++++++++++++---------------- tests/test_introspect.py | 4 ++- tests/test_pandas.py | 12 +++---- 3 files changed, 57 insertions(+), 37 deletions(-) diff --git a/src/scyjava/_convert.py b/src/scyjava/_convert.py index 4a04f27..6cda756 100644 --- a/src/scyjava/_convert.py +++ b/src/scyjava/_convert.py @@ -237,72 +237,90 @@ def _stock_java_converters() -> List[Converter]: ), Converter( name="int -> java.lang.Byte", - predicate=lambda obj, **hints: isinstance(obj, int) - and ("type" in hints and hints["type"] in ("b", "byte", "Byte")) - and _jc.Byte.MIN_VALUE <= obj <= _jc.Byte.MAX_VALUE, + predicate=lambda obj, **hints: ( + isinstance(obj, int) + and ("type" in hints and hints["type"] in ("b", "byte", "Byte")) + and _jc.Byte.MIN_VALUE <= obj <= _jc.Byte.MAX_VALUE + ), converter=_jc.Byte, priority=Priority.HIGH, ), Converter( name="int -> java.lang.Short", - predicate=lambda obj, **hints: isinstance(obj, int) - and ("type" in hints and hints["type"] in ("s", "short", "Short")) - and _jc.Short.MIN_VALUE <= obj <= _jc.Short.MAX_VALUE, + predicate=lambda obj, **hints: ( + isinstance(obj, int) + and ("type" in hints and hints["type"] in ("s", "short", "Short")) + and _jc.Short.MIN_VALUE <= obj <= _jc.Short.MAX_VALUE + ), converter=_jc.Short, priority=Priority.HIGH, ), Converter( name="int -> java.lang.Integer", - predicate=lambda obj, **hints: isinstance(obj, int) - and ("type" not in hints or hints["type"] in ("i", "int", "Integer")) - and _jc.Integer.MIN_VALUE <= obj <= _jc.Integer.MAX_VALUE, + predicate=lambda obj, **hints: ( + isinstance(obj, int) + and ("type" not in hints or hints["type"] in ("i", "int", "Integer")) + and _jc.Integer.MIN_VALUE <= obj <= _jc.Integer.MAX_VALUE + ), converter=_jc.Integer, ), Converter( name="int -> java.lang.Long", - predicate=lambda obj, **hints: isinstance(obj, int) - and ("type" not in hints or hints["type"] in ("j", "l", "long", "Long")) - and _jc.Long.MIN_VALUE <= obj <= _jc.Long.MAX_VALUE, + predicate=lambda obj, **hints: ( + isinstance(obj, int) + and ("type" not in hints or hints["type"] in ("j", "l", "long", "Long")) + and _jc.Long.MIN_VALUE <= obj <= _jc.Long.MAX_VALUE + ), converter=_jc.Long, priority=Priority.NORMAL - 1, ), Converter( name="int -> java.math.BigInteger", - predicate=lambda obj, **hints: isinstance(obj, int) - and ( - "type" not in hints or hints["type"] in ("bi", "bigint", "BigInteger") + predicate=lambda obj, **hints: ( + isinstance(obj, int) + and ( + "type" not in hints + or hints["type"] in ("bi", "bigint", "BigInteger") + ) ), converter=lambda obj: _jc.BigInteger(str(obj)), priority=Priority.NORMAL - 2, ), Converter( name="float -> java.lang.Float", - predicate=lambda obj, **hints: isinstance(obj, float) - and ("type" not in hints or hints["type"] in ("f", "float", "Float")) - and ( - math.isinf(obj) - or math.isnan(obj) - or -_jc.Float.MAX_VALUE <= obj <= _jc.Float.MAX_VALUE + predicate=lambda obj, **hints: ( + isinstance(obj, float) + and ("type" not in hints or hints["type"] in ("f", "float", "Float")) + and ( + math.isinf(obj) + or math.isnan(obj) + or -_jc.Float.MAX_VALUE <= obj <= _jc.Float.MAX_VALUE + ) ), converter=_jc.Float, ), Converter( name="float -> java.lang.Double", - predicate=lambda obj, **hints: isinstance(obj, float) - and ("type" not in hints or hints["type"] in ("d", "double", "Double")) - and ( - math.isinf(obj) - or math.isnan(obj) - or -_jc.Double.MAX_VALUE <= obj <= _jc.Double.MAX_VALUE + predicate=lambda obj, **hints: ( + isinstance(obj, float) + and ("type" not in hints or hints["type"] in ("d", "double", "Double")) + and ( + math.isinf(obj) + or math.isnan(obj) + or -_jc.Double.MAX_VALUE <= obj <= _jc.Double.MAX_VALUE + ) ), converter=_jc.Double, priority=Priority.NORMAL - 1, ), Converter( name="float -> java.math.BigDecimal", - predicate=lambda obj, **hints: isinstance(obj, float) - and ( - "type" not in hints or hints["type"] in ("bd", "bigdec", "BigDecimal") + predicate=lambda obj, **hints: ( + isinstance(obj, float) + and ( + "type" not in hints + or hints["type"] in ("bd", "bigdec", "BigDecimal") + ) ), converter=lambda obj: _jc.BigDecimal(str(obj)), priority=Priority.NORMAL - 2, diff --git a/tests/test_introspect.py b/tests/test_introspect.py index e986dff..a438ede 100644 --- a/tests/test_introspect.py +++ b/tests/test_introspect.py @@ -53,7 +53,9 @@ def test_jreflect_ctors(self): arraylist_Obj = scyjava.jreflect(ArrayList, "constructors") assert len(str_Obj) == len(arraylist_Obj) == 3 arraylist_Obj.sort( - key=lambda row: f"{row['type']}:{row['name']}:{','.join(str(row['arguments']))}" + key=lambda row: ( + f"{row['type']}:{row['name']}:{','.join(str(row['arguments']))}" + ) ) assert arraylist_Obj == [ { diff --git a/tests/test_pandas.py b/tests/test_pandas.py index c18d243..1baa5dd 100644 --- a/tests/test_pandas.py +++ b/tests/test_pandas.py @@ -60,11 +60,11 @@ def testPandasToTable(self): df = pd.DataFrame(array, columns=columns) # Convert column 0 to integer - df.iloc[:, 0] = (df.iloc[:, 0] * 100).astype("int") + df[columns[0]] = (df[columns[0]] * 100).astype("int") # Convert column 1 to bool - df.iloc[:, 1] = df.iloc[:, 1] > 0.5 + df[columns[1]] = df[columns[1]] > 0.5 # Convert column 2 to string - df.iloc[:, 2] = df.iloc[:, 2].to_string(index=False).split("\n") + df[columns[2]] = df[columns[2]].to_string(index=False).split("\n") table = to_java(df) @@ -137,11 +137,11 @@ def testTabletoPandas(self): # fill mixed table for i in range(table.getRowCount()): - table.set(0, i, Float(float(array_float[i]))) + table.set(0, i, Float(float(array_float[i].item()))) table.set(1, i, Integer(int(array_int[i].item()))) - table.set(2, i, Boolean(bool(array_bool[i]))) + table.set(2, i, Boolean(bool(array_bool[i].item()))) table.set(3, i, String(array_str[i])) - table.set(4, i, Double(float(array_double[i]))) + table.set(4, i, Double(float(array_double[i].item()))) df = to_python(table) # Table types cannot be the same here, unless we want to cast. From 4c6b83f85669fea578bc2ffba59a087fdcdeb480 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 8 Feb 2026 21:22:43 -0600 Subject: [PATCH 104/119] Also clean tests/.pytest_cache Otherwise, that cache folder makes it into the release tarball. --- bin/clean.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/clean.sh b/bin/clean.sh index 485ed5e..fcaa9f5 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -6,4 +6,4 @@ cd "$dir/.." find . -name __pycache__ -type d | while read d do rm -rfv "$d" done -rm -rfv .pytest_cache build dist src/*.egg-info +rm -rfv .pytest_cache build dist src/*.egg-info tests/.pytest_cache From 74b6c90075bee5379dc69cb724495765ddd75be4 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 8 Feb 2026 21:20:17 -0600 Subject: [PATCH 105/119] Release version 1.12.2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ccaa9d2..60b4869 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "scyjava" -version = "1.12.2.dev0" +version = "1.12.2" description = "Supercharged Java access from Python" license = "Unlicense" authors = [{name = "SciJava developers", email = "ctrueden@wisc.edu"}] From 55cb87e2a04a61d31c8282c435659307ed19a218 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 8 Feb 2026 21:23:49 -0600 Subject: [PATCH 106/119] Bump to next development cycle --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 60b4869..2fa3933 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "scyjava" -version = "1.12.2" +version = "1.12.3.dev0" description = "Supercharged Java access from Python" license = "Unlicense" authors = [{name = "SciJava developers", email = "ctrueden@wisc.edu"}] From cc7e66d97445efd29b69acfcdae7d11ef6597d84 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 14 Jan 2026 11:21:42 -0600 Subject: [PATCH 107/119] Make deprecation warnings visible during test runs --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2fa3933..f8aa6ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,11 @@ include-package-data = false where = ["src"] namespaces = false +[tool.pytest.ini_options] +filterwarnings = [ + "default::DeprecationWarning", +] + [tool.ruff] line-length = 88 src = ["src", "tests"] From 16fe82b03a770d518e0c2442a82711c0ae577869 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 14 Jan 2026 11:23:02 -0600 Subject: [PATCH 108/119] Stop using jgo.jgo.maven_scijava_repository() It is deprecated in jgo v2. --- src/scyjava/config.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/scyjava/config.py b/src/scyjava/config.py index cfab9d8..7d046fa 100644 --- a/src/scyjava/config.py +++ b/src/scyjava/config.py @@ -7,7 +7,9 @@ from typing import Sequence import jpype as _jpype -from jgo import maven_scijava_repository as _scijava_public + + +_SCIJAVA_PUBLIC = "https://maven.scijava.org/content/groups/public" _logger = _logging.getLogger(__name__) @@ -21,7 +23,7 @@ endpoints: list[str] = [] -_repositories = {"scijava.public": _scijava_public()} +_repositories = {"scijava.public": _SCIJAVA_PUBLIC} _verbose = 0 _manage_deps = True _cache_dir = Path.home() / ".jgo" From cf6df1465e66129cbec81bf8f832ffe7a568866d Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 28 Jan 2026 12:19:45 -0600 Subject: [PATCH 109/119] Update to jgo v2 --- README.md | 4 +- pyproject.toml | 3 +- src/scyjava/_cjdk_fetch.py | 121 ------------------------------------- src/scyjava/_jdk_fetch.py | 98 ++++++++++++++++++++++++++++++ src/scyjava/_jvm.py | 28 ++++++--- src/scyjava/config.py | 114 ++++++++++++++++++++-------------- 6 files changed, 189 insertions(+), 179 deletions(-) delete mode 100644 src/scyjava/_cjdk_fetch.py create mode 100644 src/scyjava/_jdk_fetch.py diff --git a/README.md b/README.md index 3542410..eb3127b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Supercharged Java access from Python. Built on [JPype](https://jpype.readthedocs.io/en/latest/) -and [jgo](https://github.com/scijava/jgo). +and [jgo](https://github.com/apposed/jgo). ## Use Java classes from Python @@ -83,7 +83,7 @@ u'1.8.0_152-release' +++oo*OO######OO*oo+++++oo*OO######OO*oo+++++oo*OO######OO*oo+++ ``` -See the [jgo documentation](https://github.com/scijava/jgo) for more about Maven endpoints. +See the [jgo documentation](https://github.com/apposed/jgo) for more about Maven endpoints. ## Bootstrap a Java installation diff --git a/pyproject.toml b/pyproject.toml index f8aa6ae..e684776 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,8 +33,7 @@ classifiers = [ requires-python = ">=3.9" dependencies = [ "jpype1 >= 1.3.0", - "jgo", - "cjdk", + "jgo>=2.0.0", ] [dependency-groups] diff --git a/src/scyjava/_cjdk_fetch.py b/src/scyjava/_cjdk_fetch.py deleted file mode 100644 index b8fea09..0000000 --- a/src/scyjava/_cjdk_fetch.py +++ /dev/null @@ -1,121 +0,0 @@ -""" -Utility functions for fetching JDK/JRE and Maven. -""" - -from __future__ import annotations - -import logging -import os -import shutil -import subprocess -from typing import TYPE_CHECKING, Union - -import cjdk -import jpype - -import scyjava.config - -if TYPE_CHECKING: - from pathlib import Path - -_logger = logging.getLogger(__name__) - - -def ensure_jvm_available() -> None: - """Ensure that the JVM is available and Maven is installed.""" - fetch = scyjava.config.get_fetch_java() - if fetch == "never": - # Not allowed to use cjdk. - return - if fetch == "always" or not is_jvm_available(): - cjdk_fetch_java() - if fetch == "always" or not shutil.which("mvn"): - cjdk_fetch_maven() - - -def is_jvm_available() -> bool: - """Return True if the JVM is available, suppressing stderr on macos.""" - from unittest.mock import patch - - subprocess_check_output = subprocess.check_output - - def _silent_check_output(*args, **kwargs): - # also suppress stderr on calls to subprocess.check_output - kwargs.setdefault("stderr", subprocess.DEVNULL) - return subprocess_check_output(*args, **kwargs) - - try: - with patch.object(subprocess, "check_output", new=_silent_check_output): - jpype.getDefaultJVMPath() - # on Darwin, may raise a CalledProcessError when invoking `/usr/libexec/java_home` - except (jpype.JVMNotFoundException, subprocess.CalledProcessError): - return False - return True - - -def cjdk_fetch_java(vendor: str | None = None, version: str | None = None) -> None: - """Fetch java using cjdk and add it to the PATH.""" - if vendor is None: - vendor = scyjava.config.get_java_vendor() - if version is None: - version = scyjava.config.get_java_version() - - _logger.info(f"Fetching {vendor}:{version} using cjdk...") - java_home = cjdk.java_home(vendor=vendor, version=version) - _logger.debug(f"java_home -> {java_home}") - _add_to_path(str(java_home / "bin"), front=True) - os.environ["JAVA_HOME"] = str(java_home) - - -def cjdk_fetch_maven(url: str = "", sha: str = "") -> None: - """Fetch Maven using cjdk and add it to the PATH.""" - # if url was passed as an argument, use it with provided sha - # otherwise, use default values for both - if not url: - url = scyjava.config.get_maven_url() - sha = scyjava.config.get_maven_sha() - - # fix urls to have proper prefix for cjdk - if url.startswith("http"): - if url.endswith(".tar.gz"): - url = url.replace("http", "tgz+http") - elif url.endswith(".zip"): - url = url.replace("http", "zip+http") - - # determine sha type based on length (cjdk requires specifying sha type) - # assuming hex-encoded SHA, length should be 40, 64, or 128 - kwargs = {} - if sha_len := len(sha): # empty sha is fine... we just don't pass it - sha_lengths = {40: "sha1", 64: "sha256", 128: "sha512"} - if sha_len not in sha_lengths: # pragma: no cover - raise ValueError( - "MAVEN_SHA be a valid sha1, sha256, or sha512 hash." - f"Got invalid SHA length: {sha_len}. " - ) - kwargs = {sha_lengths[sha_len]: sha} - - _logger.info("Fetching Maven using cjdk...") - maven_dir = cjdk.cache_package("Maven", url, **kwargs) - _logger.debug(f"maven_dir -> {maven_dir}") - if maven_bin := next(maven_dir.rglob("apache-maven-*/**/mvn"), None): - _add_to_path(maven_bin.parent, front=True) - else: # pragma: no cover - raise RuntimeError( - "Failed to find Maven executable on system " - "PATH, and download via cjdk failed." - ) - - -def _add_to_path(path: Union[Path, str], front: bool = False) -> None: - """Add a path to the PATH environment variable. - - If front is True, the path is added to the front of the PATH. - By default, the path is added to the end of the PATH. - If the path is already in the PATH, it is not added again. - """ - - current_path = os.environ.get("PATH", "") - if (path := str(path)) in current_path: - return - new_path = [path, current_path] if front else [current_path, path] - os.environ["PATH"] = os.pathsep.join(new_path) diff --git a/src/scyjava/_jdk_fetch.py b/src/scyjava/_jdk_fetch.py new file mode 100644 index 0000000..8d2cebf --- /dev/null +++ b/src/scyjava/_jdk_fetch.py @@ -0,0 +1,98 @@ +""" +Utility functions for fetching JDK/JRE. +""" + +from __future__ import annotations + +import logging +import os +import subprocess +from typing import TYPE_CHECKING, Union + +import jpype + +from jgo.exec import JavaLocator, JavaSource + +import scyjava.config + +if TYPE_CHECKING: + from pathlib import Path + +_logger = logging.getLogger(__name__) + + +def ensure_jvm_available() -> None: + """ + Ensure that the JVM is available. + """ + fetch = scyjava.config.get_fetch_java() + if fetch == "never": + # Not allowed to fetch Java. + return + if fetch == "always" or not is_jvm_available(): + fetch_java() + + +def is_jvm_available() -> bool: + """Return True if the JVM is available, suppressing stderr on macos.""" + from unittest.mock import patch + + subprocess_check_output = subprocess.check_output + + def _silent_check_output(*args, **kwargs): + # also suppress stderr on calls to subprocess.check_output + kwargs.setdefault("stderr", subprocess.DEVNULL) + return subprocess_check_output(*args, **kwargs) + + try: + with patch.object(subprocess, "check_output", new=_silent_check_output): + jpype.getDefaultJVMPath() + # on Darwin, may raise a CalledProcessError when invoking `/usr/libexec/java_home` + except (jpype.JVMNotFoundException, subprocess.CalledProcessError): + return False + return True + + +def fetch_java(vendor: str | None = None, version: str | None = None) -> None: + """ + Fetch Java and configure PATH/JAVA_HOME. + + Supports cjdk version syntax including "11", "17", "11+", "17+", etc. + See https://pypi.org/project/cjdk for more information. + """ + if vendor is None: + vendor = scyjava.config.get_java_vendor() + if version is None: + version = scyjava.config.get_java_version() + + _logger.info(f"Fetching {vendor}:{version}...") + + locator = JavaLocator( + java_source=JavaSource.AUTO, + java_version=version, # Pass string directly (e.g. "11", "17", "11+", "17+") + java_vendor=vendor, + verbose=True, + ) + + # Locate returns path to java executable (e.g., /path/to/java/bin/java) + java_exe = locator.locate() + java_home = java_exe.parent.parent # Navigate from bin/java to JAVA_HOME + + _logger.debug(f"java_home -> {java_home}") + _add_to_path(str(java_home / "bin"), front=True) + os.environ["JAVA_HOME"] = str(java_home) + + +def _add_to_path(path: Union[Path, str], front: bool = False) -> None: + """Add a path to the PATH environment variable. + + If front is True, the path is added to the front of the PATH. + By default, the path is added to the end of the PATH. + If the path is already in the PATH, it is not added again. + """ + + current_path = os.environ.get("PATH", "") + if (path := str(path)) in current_path: + return + new_path = [path, current_path] if front else [current_path, path] + os.environ["PATH"] = os.pathsep.join(new_path) diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index 1bd8017..350a815 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -15,11 +15,11 @@ import jpype import jpype.config -from jgo import jgo +import jgo import scyjava.config from scyjava.config import Mode, mode -from scyjava._cjdk_fetch import ensure_jvm_available +from scyjava._jdk_fetch import ensure_jvm_available _logger = logging.getLogger(__name__) @@ -151,23 +151,31 @@ def start_jvm(options: Sequence[str] = None) -> None: # use the logger to notify user that endpoints are being added _logger.debug("Adding jars from endpoints {0}".format(endpoints)) - # download JDK/JRE and Maven as appropriate + # download Java as appropriate ensure_jvm_available() # get endpoints and add to JPype class path if len(endpoints) > 0: + # sort endpoints list, except for the first one endpoints = endpoints[:1] + sorted(endpoints[1:]) _logger.debug("Using endpoints %s", endpoints) - _, workspace = jgo.resolve_dependencies( - "+".join(endpoints), - m2_repo=scyjava.config.get_m2_repo(), + + # join endpoints list to single concatenated endpoint + endpoint = "+".join(endpoints) + + env = jgo.build( + endpoint=endpoint, + #update=False, cache_dir=scyjava.config.get_cache_dir(), - manage_dependencies=scyjava.config.get_manage_deps(), repositories=repositories, - verbose=scyjava.config.get_verbose(), - shortcuts=scyjava.config.get_shortcuts(), + # The following obsolete arguments are from jgo v1: + #m2_repo=scyjava.config.get_m2_repo(), + #manage_dependencies=scyjava.config.get_manage_deps(), + #verbose=scyjava.config.get_verbose(), + #shortcuts=scyjava.config.get_shortcuts(), ) - jpype.addClassPath(os.path.join(workspace, "*")) + jpype.addClassPath(env.modules_dir / "*") + jpype.addClassPath(env.jars_dir / "*") # HACK: Try to set JAVA_HOME if it isn't already. if ( diff --git a/src/scyjava/config.py b/src/scyjava/config.py index 7d046fa..70b2467 100644 --- a/src/scyjava/config.py +++ b/src/scyjava/config.py @@ -18,8 +18,6 @@ _fetch_java: str = "always" _java_vendor: str = "zulu-jre" _java_version: str = "11" -_maven_url: str = "tgz+https://archive.apache.org/dist/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.tar.gz" # noqa: E501 -_maven_sha: str = "a555254d6b53d267965a3404ecb14e53c3827c09c3b94b5678835887ab404556bfaf78dcfe03ba76fa2508649dca8531c74bca4d5846513522404d48e8c4ac8b" # noqa: E501 endpoints: list[str] = [] @@ -56,25 +54,30 @@ def set_java_constraints( """ Set constraints on the version of Java to be used. + :return: + "always" to download (or retrieve from cache) a suitable JDK/JRE; + "never" to rely only on an existing "system Java" installation + (discovered via the JAVA_HOME environment variable or system path); + "auto" to prefer system Java, but download one if no existing JVM is found. :param fetch: - If "always" (default), cjdk will always be used; if "never", cjdk will - never be used. If "auto", when a JVM/or maven cannot be located on the system, - [`cjdk`](https://github.com/cachedjdk/cjdk) will be used to download a - JDK/JRE distribution and set up the JVM. + If "always" (default), a suitable JDK/JRE will be downloaded (or retrieved from + cache if previously downloaded) ignoring any system Java installations; + if "never", only an already-available JDK/JRE will be used, + discovered via the JAVA_HOME environment variable or system path; + If "auto", a suitable JDK/JRE will be downloaded and cached only when an + existing JDK/JRE cannot be located on the system. :param vendor: - The vendor of the JDK/JRE distribution for cjdk to download and cache. - Defaults to "zulu-jre". See the cjdk documentation for details. + The vendor of the JDK/JRE distribution to download and cache. + Defaults to "zulu-jre". Does not constrain matching of system JDK/JREs. :param version: - Expression defining the Java version for cjdk to download and cache. - Defaults to "11". See the cjdk documentation for details. + Expression defining the Java version to download and cache. + Defaults to "11". Does not constrain matching of system JDK/JREs. :param maven_url: - URL of the Maven distribution for cjdk to download and cache. - Defaults to the Maven 3.9.9 binary distribution from dlcdn.apache.org. + DEPRECATED: scyjava no longer uses Maven to resolve dependencies. :param maven_sha: - The SHA512 (or SHA256 or SHA1) hash of the Maven distribution to download, - if providing a custom maven_url. + DEPRECATED: scyjava no longer uses Maven to resolve dependencies. """ - global _fetch_java, _java_vendor, _java_version, _maven_url, _maven_sha + global _fetch_java, _java_vendor, _java_version if fetch is not None: if isinstance(fetch, bool): # Be nice and allow boolean values as a convenience. @@ -88,30 +91,38 @@ def set_java_constraints( if version is not None: _java_version = version if maven_url is not None: + _logger.warning( + "Deprecated argument: scyjava.config.set_java_constraints(maven_url). " + "scyjava no longer uses Maven to resolve dependencies." + ) _maven_url = maven_url - _maven_sha = "" if maven_sha is not None: + _logger.warning( + "Deprecated argument: scyjava.config.set_java_constraints(maven_sha). " + "scyjava no longer uses Maven to resolve dependencies." + ) _maven_sha = maven_sha def get_fetch_java() -> str: """ - Get whether [`cjdk`](https://github.com/cachedjdk/cjdk) - will be used to download a JDK/JRE distribution and set up the JVM. + Get whether to download (or retrieve from local cache if previously downloaded) + a JDK/JRE distribution and set up the JVM. To set this value, see set_java_constraints. :return: - "always" for cjdk to obtain the JDK/JRE; - "never" for cjdk *not* to obtain a JDK/JRE; - "auto" for cjdk to be used only when a JVM/or Maven is not on the system path. + "always" to download (or retrieve from cache) a suitable JDK/JRE; + "never" to fully rely on an existing installation + (discovered via the JAVA_HOME environment variable or system path); + "auto" to prefer system Java, but download one if no existing JVM is found. """ return _fetch_java def get_java_vendor() -> str: """ - Get the vendor of the JDK/JRE distribution to download. - Vendor of the Java installation for cjdk to download and cache. + Vendor of the Java installation to download and cache. Does not + constrain matching of system JDK/JREs, only those fetched and cached. To set this value, see set_java_constraints. :return: String defining the desired JDK/JRE vendor for downloaded JDK/JREs. @@ -121,7 +132,8 @@ def get_java_vendor() -> str: def get_java_version() -> str: """ - Expression defining the Java version for cjdk to download and cache. + Expression defining the Java version to download and cache. Does not + constrain matching of system JDK/JREs, only those fetched and cached. To set this value, see set_java_constraints. :return: String defining the desired JDK/JRE version for downloaded JDK/JREs. @@ -129,26 +141,6 @@ def get_java_version() -> str: return _java_version -def get_maven_url() -> str: - """ - The URL of the Maven distribution to download. - To set this value, see set_java_constraints. - - :return: URL pointing to the Maven distribution. - """ - return _maven_url - - -def get_maven_sha() -> str: - """ - The SHA512 (or SHA256 or SHA1) hash of the Maven distribution to download, - if providing a custom maven_url. To set this value, see set_java_constraints. - - :return: Hash value of the Maven distribution, or empty string to skip hash check. - """ - return _maven_sha - - def add_repositories(*args, **kwargs) -> None: """ Add one or more Maven repositories to be used by jgo for downloading dependencies. @@ -470,3 +462,37 @@ def get_endpoints(): ) global endpoints return endpoints + + +_maven_url: str = "tgz+https://archive.apache.org/dist/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.tar.gz" # noqa: E501 +_maven_sha: str = "a555254d6b53d267965a3404ecb14e53c3827c09c3b94b5678835887ab404556bfaf78dcfe03ba76fa2508649dca8531c74bca4d5846513522404d48e8c4ac8b" # noqa: E501 + + +def get_maven_url() -> str: + """ + DEPRECATED since v1.12.3 + scyjava no longer uses Maven to resolve dependencies, + but rather jgo v2's pure-Python dependency resolver. + + :return: Path to Maven 3.9.9 download (for backwards compatibility). + """ + _logger.warning( + "Deprecated method call: scyjava.config.get_maven_url(). " + "scyjava no longer uses Maven to resolve dependencies." + ) + return _maven_url + + +def get_maven_sha() -> str: + """ + DEPRECATED since v1.12.3 + scyjava no longer uses Maven to resolve dependencies, + but rather jgo v2's pure-Python dependency resolver. + + :return: Hash of Maven 3.9.9 download (for backwards compatibility). + """ + _logger.warning( + "Deprecated method call: scyjava.config.get_maven_sha(). " + "scyjava no longer uses Maven to resolve dependencies." + ) + return _maven_sha From 1f2a326fb46912921538358c9e19cd2c2a2b2d4e Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Fri, 13 Mar 2026 17:00:59 -0500 Subject: [PATCH 110/119] Fix string syntax --- tests/test_inspect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_inspect.py b/tests/test_inspect.py index d308307..f265b8c 100644 --- a/tests/test_inspect.py +++ b/tests/test_inspect.py @@ -31,7 +31,7 @@ def test_inspect_members(self): ] pattern = ( r"(https://github.com/openjdk/jdk/blob/)" - "[^ ]*(/share/classes/java/lang/Iterable\.java)" + r"[^ ]*(/share/classes/java/lang/Iterable\.java)" ) members_string = re.sub(pattern, r"\1...\2", "".join(members)) assert members_string.split("\n") == expected From ada8393ab0a012e700af1757292f0d6d0b061eb8 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Fri, 13 Mar 2026 16:57:03 -0500 Subject: [PATCH 111/119] Release version 1.12.3 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e684776..2771867 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "scyjava" -version = "1.12.3.dev0" +version = "1.12.3" description = "Supercharged Java access from Python" license = "Unlicense" authors = [{name = "SciJava developers", email = "ctrueden@wisc.edu"}] From 50006f01251e3f44d77d616f74a7f3443ff9959e Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Fri, 13 Mar 2026 17:02:19 -0500 Subject: [PATCH 112/119] Bump to next development cycle --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2771867..ddaf923 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "scyjava" -version = "1.12.3" +version = "1.12.4.dev0" description = "Supercharged Java access from Python" license = "Unlicense" authors = [{name = "SciJava developers", email = "ctrueden@wisc.edu"}] From 4f1aa5b172062ade10c0754c8d7981478e91860c Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 15 Mar 2026 13:26:30 -0500 Subject: [PATCH 113/119] Fail fast with clear error for Java < 11 JPype 1.6+ dropped Java 8 support, so surface a clear RuntimeError in start_jvm() rather than a cryptic JPype failure. Also improve macOS JVM path resolution to handle Java 8's different dylib layout (jre/lib/jli/libjli.dylib), with a further fallback to $JAVA_HOME/bin/java. Co-Authored-By: Claude Sonnet 4.6 --- src/scyjava/_jvm.py | 68 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 52 insertions(+), 16 deletions(-) diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index 350a815..0aad6f8 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -78,26 +78,49 @@ def jvm_version() -> tuple[int, ...]: ) p = Path(jvm_path) + java = None + if not p.exists(): - raise RuntimeError(f"Invalid default JVM path: {p}") + # Try Java 8 macOS dylib path (jre/lib/jli/libjli.dylib vs lib/libjli.dylib). + p8 = Path( + default_jvm_path.replace( + "/Contents/MacOS/libjli.dylib", + "/Contents/Home/jre/lib/jli/libjli.dylib", + ) + ) + if p8.exists(): + p = p8 - java = None - for _ in range(3): # The bin folder is always <=3 levels up from libjvm. - p = p.parent - if p.name == "lib": - java = p.parent / "bin" / "java" - elif p.name == "bin": - java = p / "java" - - if java is not None: + if not p.exists(): + # Fall back to JAVA_HOME if the dylib path resolution failed. + java_home = os.environ.get("JAVA_HOME") + if java_home: + candidate = Path(java_home) / "bin" / "java" if os.name == "nt": - # Good ol' Windows! Nothing beats Windows. - java = java.with_suffix(".exe") - if not java.is_file(): - raise RuntimeError(f"No ../bin/java found at: {p}") - break + candidate = candidate.with_suffix(".exe") + if candidate.is_file(): + java = candidate + + if java is None: + raise RuntimeError(f"Invalid default JVM path: {p}") + if java is None: - raise RuntimeError(f"No java executable found inside: {p}") + for _ in range(3): # The bin folder is always <=3 levels up from libjvm. + p = p.parent + if p.name == "lib": + java = p.parent / "bin" / "java" + elif p.name == "bin": + java = p / "java" + + if java is not None: + if os.name == "nt": + # Good ol' Windows! Nothing beats Windows. + java = java.with_suffix(".exe") + if not java.is_file(): + raise RuntimeError(f"No ../bin/java found at: {p}") + break + if java is None: + raise RuntimeError(f"No java executable found inside: {p}") _logger.debug(f"Invoking `{java} -version`...") try: @@ -154,6 +177,19 @@ def start_jvm(options: Sequence[str] = None) -> None: # download Java as appropriate ensure_jvm_available() + # Fail fast if Java version is too old. JPype 1.6+ dropped Java 8 support. + try: + ver = jvm_version() + if ver < (11,): + raise RuntimeError( + f"Java {'.'.join(str(v) for v in ver)} is not supported. " + "scyjava requires Java 11 or later." + ) + except RuntimeError as e: + if "not supported" in str(e): + raise + _logger.debug(f"Could not determine JVM version before start: {e}") + # get endpoints and add to JPype class path if len(endpoints) > 0: # sort endpoints list, except for the first one From 5000d739d9a192fd3591678fa78fe38cf9727ea7 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 15 Mar 2026 13:42:32 -0500 Subject: [PATCH 114/119] Set jgo's lenient mode when resolving components Aside from generally being friendlier and more likely to succeed, the specific motivation for this change is sc.fiji:fiji:2.17.0 on macos-arm64, which has a flaw in its POM hierarchy leading to: ValueError: No version available for dependency org.jogamp.gluegen:gluegen-rt:jar:natives-macosx-aarch64 (The proper classifier is natives-macosx-universal.) Setting lenient mode avoids the flaw and lets Fiji work with scyjava. Of course, the proper fix will be in pom-scijava-base + pom-scijava. --- pyproject.toml | 2 +- src/scyjava/_jvm.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ddaf923..a192f59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ classifiers = [ requires-python = ">=3.9" dependencies = [ "jpype1 >= 1.3.0", - "jgo>=2.0.0", + "jgo>=2.1.0", ] [dependency-groups] diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index 0aad6f8..1625237 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -204,6 +204,7 @@ def start_jvm(options: Sequence[str] = None) -> None: #update=False, cache_dir=scyjava.config.get_cache_dir(), repositories=repositories, + resolver=jgo.maven.PythonResolver(lenient=True), # The following obsolete arguments are from jgo v1: #m2_repo=scyjava.config.get_m2_repo(), #manage_dependencies=scyjava.config.get_manage_deps(), From f05e3054944af0086d8a3da8df324b552b8f0c1b Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 15 Mar 2026 13:54:15 -0500 Subject: [PATCH 115/119] Release version 1.12.4 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a192f59..f03f283 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "scyjava" -version = "1.12.4.dev0" +version = "1.12.4" description = "Supercharged Java access from Python" license = "Unlicense" authors = [{name = "SciJava developers", email = "ctrueden@wisc.edu"}] From 25a49841e29f7d1a8f3390fe99ad758c1838387a Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 15 Mar 2026 13:55:40 -0500 Subject: [PATCH 116/119] Bump to next development cycle --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f03f283..eec9a85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "scyjava" -version = "1.12.4" +version = "1.12.5.dev0" description = "Supercharged Java access from Python" license = "Unlicense" authors = [{name = "SciJava developers", email = "ctrueden@wisc.edu"}] From 179dcbef7c2f15841ce63aa8420bf516870c2e3f Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 15 Mar 2026 21:52:42 -0500 Subject: [PATCH 117/119] Require Python 3.10+ The rich-click >=1.9.5 from jgo, at least from conda-forge, requires Python 3.10+ anyway, so it's a losing battle trying to stick to the already-EOL Python 3.9 as minimum. --- .github/workflows/build.yml | 6 +++--- pyproject.toml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a9f51a6..d3b7767 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,14 +22,14 @@ jobs: macos-latest ] python-version: [ - '3.9', - '3.13' + '3.10', + '3.14' ] java-version: ['11'] include: # one test without java to test cjdk fallback - os: ubuntu-latest - python-version: '3.9' + python-version: '3.10' java-version: '' steps: diff --git a/pyproject.toml b/pyproject.toml index eec9a85..e32995e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,11 +16,11 @@ classifiers = [ "Intended Audience :: Education", "Intended Audience :: Science/Research", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Operating System :: Microsoft :: Windows", "Operating System :: Unix", "Operating System :: MacOS", @@ -30,7 +30,7 @@ classifiers = [ "Topic :: Utilities", ] -requires-python = ">=3.9" +requires-python = ">=3.10" dependencies = [ "jpype1 >= 1.3.0", "jgo>=2.1.0", From 2e879d9ae1169817d0d2ffe6673803665412e5c1 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 15 Mar 2026 22:24:24 -0500 Subject: [PATCH 118/119] Lint the code --- src/scyjava/_jvm.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index 1625237..e385a37 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -201,15 +201,15 @@ def start_jvm(options: Sequence[str] = None) -> None: env = jgo.build( endpoint=endpoint, - #update=False, + # update=False, cache_dir=scyjava.config.get_cache_dir(), repositories=repositories, resolver=jgo.maven.PythonResolver(lenient=True), # The following obsolete arguments are from jgo v1: - #m2_repo=scyjava.config.get_m2_repo(), - #manage_dependencies=scyjava.config.get_manage_deps(), - #verbose=scyjava.config.get_verbose(), - #shortcuts=scyjava.config.get_shortcuts(), + # m2_repo=scyjava.config.get_m2_repo(), + # manage_dependencies=scyjava.config.get_manage_deps(), + # verbose=scyjava.config.get_verbose(), + # shortcuts=scyjava.config.get_shortcuts(), ) jpype.addClassPath(env.modules_dir / "*") jpype.addClassPath(env.jars_dir / "*") From 4047c8130b3d8a6a19632c2d114a6fd897a66bef Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 15 Mar 2026 22:42:53 -0500 Subject: [PATCH 119/119] Relax Java object hash assertion --- tests/test_basics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_basics.py b/tests/test_basics.py index 76e2229..65e13d0 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -34,7 +34,7 @@ def test_jimport(self): assert str(Object) o = Object() assert scyjava.jinstance(o, "java.lang.Object") - assert re.match("java.lang.Object@[0-9a-f]{7}", str(o.toString())) + assert re.match("java.lang.Object@[0-9a-f]+", str(o.toString())) def test_jinstance(self): """