diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..d3b77674 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,62 @@ +name: build + +on: + push: + branches: + - main + tags: + - "*-[0-9]+.*" + pull_request: + branches: + - main + +jobs: + build: + name: test ${{matrix.os}} - ${{matrix.python-version}} - ${{matrix.java-version}} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ + ubuntu-latest, + windows-latest, + macos-latest + ] + python-version: [ + '3.10', + '3.14' + ] + java-version: ['11'] + include: + # one test without java to test cjdk fallback + - os: ubuntu-latest + python-version: '3.10' + java-version: '' + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{matrix.python-version}} + + - uses: actions/setup-java@v4 + if: matrix.java-version != '' + with: + java-version: ${{matrix.java-version}} + distribution: 'zulu' + cache: 'maven' + + - name: Set up uv + run: | + python -m pip install --upgrade pip + python -m pip install uv + + - name: Run tests + shell: bash + run: | + bin/test.sh + + - name: Lint code + shell: bash + run: | + bin/lint.sh diff --git a/.gitignore b/.gitignore index 38a533bc..56372b82 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,29 @@ -__pycache__/ +# Byte-compiled / optimized / DLL files +*__pycache__/ +*.py[cod] + +# Distribution / packaging /build/ /dist/ -/scyjava.egg-info/ +/eggs/ +/.eggs/ +*egg-info/ + +# Vi +*.swp + +# VSCode +.vscode + +# Unit test / coverage reports +.coverage +.coverage.* +coverage.xml + +# IDEA +.idea/ +*.iml + +# uv +/.venv/ +/uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..08149aa4 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,13 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + # ruff version + rev: v0.9.1 + hooks: + # run the linter + - id: ruff + # run the formatter + - id: ruff-format + - repo: https://github.com/abravalheri/validate-pyproject + rev: v0.10.1 + hooks: + - id: validate-pyproject diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 2b3fb544..00000000 --- a/.travis.yml +++ /dev/null @@ -1,24 +0,0 @@ -# Adapted from: -# https://conda.io/docs/user-guide/tasks/use-conda-with-travis-ci.html -language: python -python: "3.6" -branches: - only: - - master -install: - - sudo apt-get update - - wget https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh -O miniconda.sh - - bash miniconda.sh -b -p $HOME/miniconda - - export PATH="$HOME/miniconda/bin:$PATH" - - hash -r - - conda config --set always_yes yes --set changeps1 no - - conda update -q conda - # Useful for debugging any issues with conda - - conda info -a - - conda create -q -n test-environment python=$TRAVIS_PYTHON_VERSION - - source activate test-environment - - conda install -c conda-forge pyjnius - - pip install jgo - -script: - - python -m unittest discover tests -v diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..b362ccce --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,19 @@ +# Metadata +include MANIFEST.in +include Makefile +include README.md +include UNLICENSE +include pyproject.toml + +# Conda Environment Files +include environment.yml +include dev-environment.yml + +# Directory inclusion +graft src +graft tests +graft bin + +# File exclusion +global-exclude __pycache__ +global-exclude *.py[doc] diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..ed19ec6f --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +help: + @echo "Available targets:\n\ + clean - remove build files and directories\n\ + lint - run code formatters and linters\n\ + test - run automated test suite\n\ + dist - generate release archives\n\ + " + +clean: + bin/clean.sh + +check: + @bin/check.sh + +lint: check + bin/lint.sh + +test: check + bin/test.sh + +dist: check clean + bin/dist.sh diff --git a/README.md b/README.md index 77d76b18..fcbaaaac 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,72 @@ # scyjava -Supercharged Java access from Python. +[![License](https://img.shields.io/pypi/l/scyjava.svg)](https://github.com/scijava/scyjava/raw/main/UNLICENSE) +[![PyPI](https://img.shields.io/pypi/v/scyjava.svg)](https://pypi.org/project/scyjava) +[![Python Version](https://img.shields.io/pypi/pyversions/scyjava.svg)](https://python.org) +[![Build Status](https://github.com/scijava/scyjava/actions/workflows/build.yml/badge.svg)](https://github.com/scijava/scyjava/actions/workflows/build.yml) -Built on [pyjnius](https://pyjnius.readthedocs.io/en/latest/) and [jgo](https://github.com/scijava/jgo). +*Supercharged Java access from Python.* + +Built on [JPype](https://jpype.readthedocs.io/en/latest/) +and [jgo](https://github.com/apposed/jgo). ## Use Java classes from Python ```python ->>> import scyjava, jnius ->>> System = jnius.autoclass('java.lang.System') +>>> from scyjava import jimport +>>> System = jimport('java.lang.System') >>> System.getProperty('java.version') -'1.8.0_152-release' +'1.8.0_252' +``` + +To pass parameters to the JVM, such as an increased max heap size: + +```python +>>> from scyjava import config, jimport +>>> config.add_option('-Xmx6g') +>>> Runtime = jimport('java.lang.Runtime') +>>> Runtime.getRuntime().maxMemory() / 2**30 +5.33349609375 ``` -See the [Pyjnius documentation](https://pyjnius.readthedocs.io/en/latest/) for more about calling Java from Python. +See the [JPype documentation](https://jpype.readthedocs.io/en/latest/) +for all the gritty details on how this wrapping works. ## Use Maven artifacts from remote repositories ### From Maven Central ```python ->>> import sys; sys.version_info -sys.version_info(major=3, minor=6, micro=5, releaselevel='final', serial=0) ->>> import scyjava_config ->>> scyjava_config.add_endpoints('org.python:jython-standalone:2.7.1') ->>> import scyjava, jnius ->>> jython = jnius.autoclass('org.python.util.jython') +>>> import sys +>>> sys.version_info +sys.version_info(major=3, minor=8, micro=5, releaselevel='final', serial=0) +>>> from scyjava import config, jimport +>>> config.endpoints.append('org.python:jython-slim:2.7.2') +>>> jython = jimport('org.python.util.jython') >>> jython.main([]) -Jython 2.7.1 (default:0df7adb1b397, Jun 30 2017, 19:02:43) +Jython 2.7.2 (v2.7.2:925a3cc3b49d, Mar 21 2020, 10:12:24) [OpenJDK 64-Bit Server VM (JetBrains s.r.o)] on java1.8.0_152-release Type "help", "copyright", "credits" or "license" for more information. ->>> import sys; sys.version_info +>>> import sys +>>> sys.version_info sys.version_info(major=2, minor=7, micro=1, releaselevel='final', serial=0) +>>> from java.lang import System +>>> System.getProperty('java.version') +u'1.8.0_152-release' ``` ### From other Maven repositories ```python ->>> import scyjava_config ->>> scyjava_config.add_repositories({'imagej.public': 'https://maven.imagej.net/content/groups/public'}) ->>> scyjava_config.add_endpoints('net.imagej:imagej:2.0.0-rc-65') ->>> import scyjava, jnius ->>> System = jnius.autoclass('java.lang.System') ->>> System.setProperty('java.awt.headless', 'true') ->>> ImageJ = jnius.autoclass('net.imagej.ImageJ') +>>> from scyjava import config, jimport +>>> config.enable_headless_mode() +>>> config.add_repositories({'scijava.public': 'https://maven.scijava.org/content/groups/public'}) +>>> config.endpoints.append('net.imagej:imagej:2.1.0') +>>> ImageJ = jimport('net.imagej.ImageJ') >>> ij = ImageJ() >>> formula = "10 * (Math.cos(0.3*p[0]) + Math.sin(0.3*p[1]))" ->>> blank = ij.op().create().img([64, 16]) +>>> ArrayImgs = jimport('net.imglib2.img.array.ArrayImgs') +>>> blank = ArrayImgs.floats(64, 16) >>> sinusoid = ij.op().image().equation(blank, formula) >>> print(ij.op().image().ascii(sinusoid)) ,,,--+oo******oo+--,,,,,--+oo******o++--,,,,,--+oo******o++--,,, @@ -67,24 +87,50 @@ sys.version_info(major=2, minor=7, micro=1, releaselevel='final', serial=0) +++oo*OO######OO*oo+++++oo*OO######OO*oo+++++oo*OO######OO*oo+++ ``` -See the [jgo documentation](https://github.com/scijava/jgo) documentation for more about Maven endpoints. +See the [jgo documentation](https://github.com/apposed/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 ```python ->>> import scyjava, jnius ->>> System = jnius.autoclass('java.lang.System') ->>> props = System.getProperties() ->>> props -> ->>> [k for k in props] +>>> from scyjava import jimport +>>> HashSet = jimport('java.util.HashSet') +>>> moves = {'jump', 'duck', 'dodge'} +>>> fish = {'walleye', 'pike', 'trout'} +>>> jbirds = HashSet() +>>> for bird in ('duck', 'goose', 'swan'): jbirds.add(bird) +... +True +True +True +>>> jbirds.isdisjoint(moves) Traceback (most recent call last): File "", line 1, in -TypeError: 'java.util.Properties' object is not iterable ->>> [k for k in scyjava.to_python(props) if k.startswith('java.vm.')] -['java.vm.version', 'java.vm.vendor', 'java.vm.name', 'java.vm.specification.name', 'java.vm.specification.vendor', 'java.vm.specification.version', 'java.vm.info'] +AttributeError: 'java.util.HashSet' object has no attribute 'isdisjoint' +>>> from scyjava import to_python as j2p +>>> j2p(jbirds).isdisjoint(moves) +False +>>> j2p(jbirds).isdisjoint(fish) +True ``` ### Convert Python collections to Java @@ -97,52 +143,340 @@ TypeError: 'java.util.Properties' object is not iterable Traceback (most recent call last): File "", line 1, in AttributeError: 'list' object has no attribute 'stream' ->>> scyjava.to_java(squares).stream() -> +>>> from scyjava import to_java as p2j +>>> p2j(squares).stream() + ``` -### Introspect Java classes - ```python ->>> import scyjava ->>> NumberClass = scyjava.jclass('java.lang.Number') ->>> NumberClass -> ->>> NumberClass.getName() -'java.lang.Number' ->>> NumberClass.isInstance(scyjava.to_java(5)) +>>> from scyjava import jimport +>>> HashSet = jimport('java.util.HashSet') +>>> jset = HashSet() +>>> pset = {1, 2, 3} +>>> jset.addAll(pset) +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) +>>> from scyjava import to_java as p2j +>>> jset.addAll(p2j(pset)) True ->>> NumberClass.isInstance(scyjava.to_java('Hello')) -False +>>> jset.toString() +'[1, 2, 3]' ``` ## Available functions ``` >>> import scyjava ->>> help(scyjava.convert) +>>> help(scyjava) ... FUNCTIONS - isjava(data) + add_java_converter(converter: scyjava._convert.Converter) -> None + Add a converter to the list used by to_java. + :param converter: A Converter going from python to java + + add_py_converter(converter: scyjava._convert.Converter) -> None + Add a converter to the list used by to_python. + :param converter: A Converter from java to python + + available_processors() -> int + Get the number of processors available to the JVM. + + This function is a shortcut for Java's + Runtime.getRuntime().availableProcessors(). + + :return: The number of available processors. + :raise RuntimeError: if the JVM has not yet been started. + + enable_python_scripting(context) + Adds a Python script runner object to the ObjectService of the given + SciJava context. Intended for use in conjunction with + 'org.scijava:scripting-python'. + + :param context: The org.scijava.Context containing the ObjectService + where the PythonScriptRunner should be injected. + + gc() -> None + Do a round of Java garbage collection. + + This function is a shortcut for Java's System.gc(). + + :raise RuntimeError: If the JVM has not started yet. + + 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 Java classes, requires org.scijava:scijava-common on the classpath. + + The version string is extracted from the given class's associated JAR + artifact (if any), either the embedded Maven POM if the project was built + with Maven, or the JAR manifest's Specification-Version value if it exists. + + See org.scijava.VersionUtils.getVersion(Class) for further details. + + is_arraylike(arr: Any) -> bool + Return True iff the object is arraylike: possessing + .shape, .dtype, .__array__, and .ndim attributes. + + :param arr: The object to check for arraylike properties + :return: True iff the object is arraylike + + is_awt_initialized() -> bool + Return true iff the AWT subsystem has been initialized. + + Java starts up its AWT subsystem automatically and implicitly, as + soon as an action is performed requiring it -- for example, if you + jimport a java.awt or javax.swing class. This can lead to deadlocks + on macOS if you are not running in headless mode and did not invoke + those actions via the jpype.setupGuiEnvironment wrapper function; + see the Troubleshooting section of the scyjava README for details. + + 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. + + :raise RuntimeError: If the JVM has not started yet. + + is_memoryarraylike(arr: Any) -> bool + Return True iff the object is memoryarraylike: + an arraylike object whose .data type is memoryview. + + :param arr: The object to check for memoryarraylike properties + :return: True iff the object is memoryarraylike + + is_version_at_least(actual_version: str, minimum_version: str) -> bool + Return a boolean on a version comparison. + Requires org.scijava:scijava-common on the classpath. + + Returns True if the given actual version is greater than or + equal to the specified minimum version, or False otherwise. + + See org.scijava.VersionUtils.compare(String, String) for further details. + + is_xarraylike(xarr: Any) -> bool + Return True iff the object is xarraylike: + possessing .values, .dims, and .coords attributes, + and whose .values are arraylike. + + :param arr: The object to check for xarraylike properties + :return: True iff the object is xarraylike + + isjava(data) -> bool Return whether the given data object is a Java object. + jarray(kind, lengths: Sequence) + Create a new n-dimensional Java array. + + :param kind: The type of array to create. This can either be a particular + type of object as obtained from jimport, or else a special code for one of + the eight primitive array types: + * 'b' for byte + * 'c' for char + * 'd' for double + * 'f' for float + * 'i' for int + * 'j' for long + * 's' for short + * 'z' for boolean + :param lengths: List of lengths for the array. For example: + `jarray('z', [3, 7])` is the equivalent of `new boolean[3][7]` in Java. + You can pass a single integer to make a 1-dimensional array of that length. + :return: The newly allocated array + + 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. + :return: The URL of the java class + jclass(data) Obtain a Java class object. - :param data: The object from which to glean the class. Supported types include: - A. Name of a class to look up, analogous to - Class.forName("java.lang.String"); - B. A jnius.MetaJavaClass object e.g. from jnius.autoclass, analogous to - String.class; - C. A jnius.JavaClass object e.g. instantiated from a jnius.MetaJavaClass, - analogous to "Hello".getClass(). - :returns: A java.lang.Class object, suitable for use with reflection. - :raises TypeError: if the argument is not one of the aforementioned types. - - to_java(data) + + A. Name of a class to look up -- e.g. "java.lang.String" -- + which returns the equivalent of Class.forName("java.lang.String"). + + B. A static-style class reference -- e.g. String -- + which returns the equivalent of String.class. + + C. A Java object -- e.g. foo -- + which returns the equivalent of foo.getClass(). + + Note that if you pass a java.lang.Class object, you will get back Class.class, + i.e. the Java class for the Class class. :-) + + :param data: The object from which to glean the class. + :return: A java.lang.Class object, suitable for use with reflection. + :raise TypeError: if the argument is not one of the aforementioned types. + + jimport(class_name: str) + Import a class from Java to Python. + + :param class_name: Name of the class to import. + :return: + A pointer to the class, which can be used to + e.g. instantiate objects of that class. + + jinstance(obj, jtype) -> bool + Test if the given object is an instance of a particular Java type. + + :param obj: The object to check. + :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 = "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: 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. + + Example of usage: + + from scyjava import jimport, jstacktrace + try: + Integer = jimport('java.lang.Integer') + nan = Integer.parseInt('not a number') + except Exception as exc: + print(jstacktrace(exc)) + + :param exc: The Java Throwable from which to extract the stack trace. + :return: A multi-line string containing the stack trace, or empty string + if no stack trace could be extracted. + + jvm_started() -> bool + Return true iff a Java virtual machine (JVM) has been started. + + 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] + + If the JVM is already started, this function returns the equivalent of: + jimport('java.lang.System') + .getProperty('java.version') + .split('.') + + In case the JVM is not started yet, a best effort is made to deduce + the version from the environment without actually starting up the + JVM in-process. If the version cannot be deduced, a RuntimeError + with the cause is raised. + + memory_max() -> int + Get the maximum amount of memory that the JVM will attempt to use. + + This number will always be greater than or equal to memory_total(). + + In case the JVM was configured with -Xmx flag upon startup (e.g. using + the scyjava.config.set_heap_max function), the value will typically + correspond approximately, but not exactly, to the configured value. + + This function is a shortcut for Java's Runtime.getRuntime().maxMemory(). + + :return: The maximum memory in bytes. + :raise RuntimeError: if the JVM has not yet been started. + + memory_total() -> int + Get the total amount of memory currently reserved by the JVM. + + This number will always be less than or equal to memory_max(). + + In case the JVM was configured with -Xms flag upon startup (e.g. using + the scyjava.config.set_heap_min function), the initial value will typically + correspond approximately, but not exactly, to the configured value, + although it is likely to grow over time as more Java objects are allocated. + + This function is a shortcut for Java's Runtime.getRuntime().totalMemory(). + + :return: The total memory in bytes. + :raise RuntimeError: if the JVM has not yet been started. + + memory_used() -> int + Get the amount of memory currently in use by the JVM. + + This function is a shortcut for + Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(). + + :return: The used memory in bytes. + :raise RuntimeError: if the JVM has not yet been started. + + numeric_bounds(the_type: type) -> Union[Tuple[int, int], Tuple[float, float], Tuple[NoneType, NoneType]] + Get the minimum and maximum values for the given numeric type. + For example, a Java long returns (int(Long.MIN_VALUE), int(Long.MAX_VALUE)), + whereas a Java double returns (float(-Double.MAX_VALUE), float(Double.MAX_VALUE)). + + :param the_type: The type whose minimum and maximum values are needed. + :return: + The minimum and maximum values as a two-element tuple of int or float, + or a two-element tuple of None if no known bounds. + + shutdown_jvm() -> None + Shut down the JVM. + + This function makes a best effort to clean up Java resources first. + In particular, shutdown hooks registered with scyjava.when_jvm_stops + are sequentially invoked. + + Then, if the AWT subsystem has started, all AWT windows (as identified + by the java.awt.Window.getWindows() method) are disposed to reduce the + risk of GUI resources delaying JVM shutdown. + + Finally, the jpype.shutdownJVM() function is called. Note that you can + set the jpype.config.destroy_jvm flag to request JPype to destroy the + JVM explicitly, although setting this flag can lead to delayed shutdown + times while the JVM is waiting for threads to finish. + + Note that if the JVM is not already running, then this function does + nothing! In particular, shutdown hooks are skipped in this situation. + + :raise RuntimeError: if this method is called while in Jep mode. + + 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 + this function directly is typically not necessary, because the first + time a scyjava function needing a JVM is invoked, one is started on the + fly with the configuration specified via the scijava.config mechanism. + + :param options: + List of options to pass to the JVM. + For example: ['-Dfoo=bar', '-XX:+UnlockExperimentalVMOptions'] + + to_java(obj: Any, **hints: Dict) -> Any Recursively convert a Python object to a Java object. - :param data: The Python object to convert. + Supported types include: * str -> String * bool -> Boolean @@ -151,23 +485,115 @@ FUNCTIONS * dict -> LinkedHashMap * set -> LinkedHashSet * list -> ArrayList - :returns: A corresponding Java object with the same contents. - :raises TypeError: if the argument is not one of the aforementioned types. - to_python(data) + There is typically one single destination conversion type and value that + makes sense. For example, Python str always converts to java.lang.String. + But in some cases, there are multiple options that can be controlled by + passing key/value pairs as hints. The base scyjava library includes: + + * int + type='byte' -> Byte + * int + type='short' -> Short + * int + type='int' -> Integer + * int + type='long' -> Long + * int + type='bigint' -> BigInteger + * float + type='float' -> Float + * float + type='double' -> Double + * float + type='bigdec' -> BigDecimal + + But the scyjava conversion framework is extensible and other + packages may introduce converters supporting additional hints. + + In the absence of a hint, scyjava makes a best effort to use a sensible + destination type and value: + + * int values in [-2**31, 2**31-1] convert to Integer + * int values in [-2**63, 2**63-1] but outside int range convert to Long + * int values outside Java long range convert to BigInteger + * conversion of int to Byte or Short must be requested via a hint + * float values in Float range convert to Float + * float inf, -inf, and nan convert to Float + * float values in Double range but outside float range convert to Double + * float values outside double range convert to BigDecimal + + :param obj: + The Python object to convert. + :param hints: + An optional dictionary of hints, to help scyjava + make decisions about how to do the conversion. + :return: A corresponding Java object with the same contents. + :raise TypeError: if the argument is not one of the aforementioned types. + + to_python(data: Any, gentle: bool = False) -> Any Recursively convert a Java object to a Python object. - :param data: The Java object to convert. + Supported types include: * String, Character -> str * Boolean -> bool * Byte, Short, Integer, Long, BigInteger -> int * Float, Double, BigDecimal -> float - * Map -> collections.MutableMapping (dict-like) - * Set -> collections.MutableSet (set-like) - * List -> collections.MutableSequence (list-like) - * Collection -> collections.Collection - * Iterable -> collections.Iterable - * Iterator -> collections.Iterator - :returns: A corresponding Python object with the same contents. - :raises TypeError: if the argument is not one of the aforementioned types. + * Map -> collections.abc.MutableMapping (dict-like) + * Set -> collections.abc.MutableSet (set-like) + * List -> collections.abc.MutableSequence (list-like) + * Collection -> collections.abc.Collection + * Iterable -> collections.abc.Iterable + * Iterator -> collections.abc.Iterator + + :param data: + The Java object to convert. + :param gentle: + If set, and the type cannot be converted, leaves + the data alone rather than raising a TypeError. + :return: A corresponding Python object with the same contents. + :raise TypeError: + if the argument is not one of the aforementioned types, + and the gentle flag is not set. + + when_jvm_starts(f) -> None + Registers a function to be called when the JVM starts (or immediately). + This is useful to defer construction of Java-dependent data structures + until the JVM is known to be available. If the JVM has already been + started, the function executes immediately. + + :param f: Function to invoke when scyjava.start_jvm() is called. + + when_jvm_stops(f) -> None + Registers a function to be called just before the JVM shuts down. + This is useful to perform cleanup of Java-dependent data structures. + + Note that if the JVM is not already running when shutdown_jvm is + called, then these registered callback functions will be skipped! + + :param f: Function to invoke when scyjava.shutdown_jvm() is called. ``` + +## Troubleshooting + +On macOS, attempting to use AWT/Swing from Python will cause a hang, +unless you do one of two things: + +1. Start Java in headless mode: + + ```python + from scyjava import config, jimport + config.enable_headless_mode() + ``` + + In which case, you'll get `java.awt.HeadlessException` instead of a + hang when you attempt to do something graphical, e.g. create a window. + +2. Or install [PyObjC](https://pyobjc.readthedocs.io/), specifically the + `pyobjc-core` and `pyobjc-framework-cocoa` packages from conda-forge, + or `pyobjc` from PyPI; and then do your AWT-related things inside of + a `jpype.setupGuiEnvironment` call on the main Python thread: + + ```python + import jpype, scyjava + scyjava.start_jvm() + def hello(): + JOptionPane = scyjava.jimport('javax.swing.JOptionPane') + JOptionPane.showMessageDialog(None, "Hello world") + jpype.setupGuiEnvironment(hello) + ``` + + In which case, the `setupGuiEnvironment` call will block the main Python + thread forever. diff --git a/bin/check.sh b/bin/check.sh new file mode 100755 index 00000000..7ef142cb --- /dev/null +++ b/bin/check.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +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/clean.sh b/bin/clean.sh new file mode 100755 index 00000000..fcaa9f51 --- /dev/null +++ b/bin/clean.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +dir=$(dirname "$0") +cd "$dir/.." + +find . -name __pycache__ -type d | while read d + do rm -rfv "$d" +done +rm -rfv .pytest_cache build dist src/*.egg-info tests/.pytest_cache diff --git a/bin/dist.sh b/bin/dist.sh new file mode 100755 index 00000000..f21fbf48 --- /dev/null +++ b/bin/dist.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +dir=$(dirname "$0") +cd "$dir/.." + +uv run python -m build diff --git a/bin/lint.sh b/bin/lint.sh new file mode 100755 index 00000000..978c1aa5 --- /dev/null +++ b/bin/lint.sh @@ -0,0 +1,22 @@ +#!/bin/sh + +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/bin/test.sh b/bin/test.sh new file mode 100755 index 00000000..fc172376 --- /dev/null +++ b/bin/test.sh @@ -0,0 +1,49 @@ +#!/bin/sh + +# Runs the unit tests. +# +# Usage examples: +# bin/test.sh +# bin/test.sh tests/test_basics.py +# bin/test.sh tests/test_convert.py::TestConvert::test2DStringArray + +set -e + +dir=$(dirname "$0") +cd "$dir/.." + +echo +echo "----------------------" +echo "| Running unit tests |" +echo "----------------------" + +if [ $# -gt 0 ] +then + uv run python -m pytest -v -p no:faulthandler $@ +else + uv run python -m pytest -v -p no:faulthandler tests/ +fi +jpypeCode=$? + +echo +echo "-----------------------------" +echo "| Running integration tests |" +echo "-----------------------------" +itCode=0 +for t in tests/it/*.py +do + uv run python "$t" + code=$? + printf -- "--> %s " "$t" + if [ "$code" -eq 0 ] + then + echo "[OK]" + else + echo "[FAILED]" + itCode=$code + fi +done + +test "$jpypeCode" -ne 0 && exit "$jpypeCode" +test "$itCode" -ne 0 && exit "$itCode" +exit 0 diff --git a/pom.xml b/pom.xml new file mode 100644 index 00000000..fe3d1b25 --- /dev/null +++ b/pom.xml @@ -0,0 +1,74 @@ + + + 4.0.0 + + org.scijava + scyjava + 0-SNAPSHOT + + + validate + + + maven-enforcer-plugin + 3.1.0 + + + enforce-rules + + enforce + + + + + + + + +/* _\|/_ + (o o) + +----oOO-{_}-OOo-------------------------------------------------------------------+ + | ┌────────────────────────────────────────────────────────┐ | + | │╺┳╸╻ ╻╻┏━┓ ╻┏━┓ ┏┓╻┏━┓╺┳╸ ┏━┓┏━╸╺┳╸╻ ╻┏━┓╻ ╻ ╻ ╻│ | + | │ ┃ ┣━┫┃┗━┓ ┃┗━┓ ┃┗┫┃ ┃ ┃ ┣━┫┃ ┃ ┃ ┃┣━┫┃ ┃ ┗┳┛│ | + | │ ╹ ╹ ╹╹┗━┛ ╹┗━┛ ╹ ╹┗━┛ ╹ ╹ ╹┗━╸ ╹ ┗━┛╹ ╹┗━╸┗━╸ ╹ │ | + | └────────────────────────────────────────────────────────┘ | + | ┌──────────────────────────────────────────────┐ | + | │┏━┓ ┏┳┓┏━┓╻ ╻┏━╸┏┓╻ ┏━┓┏━┓┏━┓ ┏┓┏━╸┏━╸╺┳╸╻│ | + | │┣━┫ ┃┃┃┣━┫┃┏┛┣╸ ┃┗┫ ┣━┛┣┳┛┃ ┃ ┃┣╸ ┃ ┃ ╹│ | + | │╹ ╹ ╹ ╹╹ ╹┗┛ ┗━╸╹ ╹ ╹ ╹┗╸┗━┛┗━┛┗━╸┗━╸ ╹ ╹│ | + | └──────────────────────────────────────────────┘ | + | ┌───────────────────────────────────────────────────────────────────────────┐ | + | │╺┳╸╻ ╻╻┏━┓ ┏━┓┏━┓┏┳┓ ╻ ╻┏┳┓╻ ┏━╸╻ ╻╻┏━┓╺┳╸┏━┓ ┏━┓┏┓╻╻ ╻ ╻ ╺┳╸┏━┓│ | + | │ ┃ ┣━┫┃┗━┓ ┣━┛┃ ┃┃┃┃ ┏╋┛┃┃┃┃ ┣╸ ┏╋┛┃┗━┓ ┃ ┗━┓ ┃ ┃┃┗┫┃ ┗┳┛ ┃ ┃ ┃│ | + | │ ╹ ╹ ╹╹┗━┛ ╹ ┗━┛╹ ╹╹╹ ╹╹ ╹┗━╸ ┗━╸╹ ╹╹┗━┛ ╹ ┗━┛ ┗━┛╹ ╹┗━╸ ╹ ╹ ┗━┛│ | + | └───────────────────────────────────────────────────────────────────────────┘ | + |┌────────────────────────────────────────────────────────────────────────────────┐| + |│┏┳┓┏━┓╻┏ ┏━╸ ╺┳╸╻ ╻┏━╸ ┏━┓┏━╸╺┳╸╻ ╻┏━┓ ┏┓┏━┓╻ ╻┏━┓ ┏━┓┏━╸╺┳╸╻┏━┓┏┓╻╻┏━┓│| + |│┃┃┃┣━┫┣┻┓┣╸ ┃ ┣━┫┣╸ ┗━┓┣╸ ┃ ┃ ┃┣━┛╺━╸ ┃┣━┫┃┏┛┣━┫ ┣━┫┃ ┃ ┃┃ ┃┃┗┫ ┗━┓│| + |│╹ ╹╹ ╹╹ ╹┗━╸ ╹ ╹ ╹┗━╸ ┗━┛┗━╸ ╹ ┗━┛╹ ┗━┛╹ ╹┗┛ ╹ ╹ ╹ ╹┗━╸ ╹ ╹┗━┛╹ ╹ ┗━┛│| + |└────────────────────────────────────────────────────────────────────────────────┘| + |┌───────────────────────────────────────────────────────────────────────────────┐ | + |│ ┓┏━╸┏━┓┏━╸╻ ╻┏━╸ ┏┳┓┏━┓╻ ╻┏━╸┏┓╻ ┓ ┏━╸╻ ╻┏┓╻┏━╸╺┳╸╻┏━┓┏┓╻ ╻ ╻┏━┓┏━┓╻┏ │ | + |│ ┃ ┣━┫┃ ┣━┫┣╸ ╹ ┃┃┃┣━┫┃┏┛┣╸ ┃┗┫ ┣╸ ┃ ┃┃┗┫┃ ┃ ┃┃ ┃┃┗┫ ┃╻┃┃ ┃┣┳┛┣┻┓ │ | + |│ ┗━╸╹ ╹┗━╸╹ ╹┗━╸╹ ╹ ╹╹ ╹┗┛ ┗━╸╹ ╹ ╹ ┗━┛╹ ╹┗━╸ ╹ ╹┗━┛╹ ╹ ┗┻┛┗━┛╹┗╸╹ ╹╹│ | + |└───────────────────────────────────────────────────────────────────────────────┘ | + | ┌──────────────────────────────────────────────────────────┐ | + | │┏━┓╻ ┏━╸┏━┓┏━┓┏━╸ ┏━┓╻ ╻┏┓╻ ┓┏┳┓┏━┓╻┏ ┏━╸ ┓ ╺┳╸┏━┓│ | + | │┣━┛┃ ┣╸ ┣━┫┗━┓┣╸ ┣┳┛┃ ┃┃┗┫ ┃┃┃┣━┫┣┻┓┣╸ ┃ ┃ ┃│ | + | │╹ ┗━╸┗━╸╹ ╹┗━┛┗━╸ ╹┗╸┗━┛╹ ╹ ╹ ╹╹ ╹╹ ╹┗━╸ ╹ ┗━┛│ | + | └──────────────────────────────────────────────────────────┘ | + | ┌────────────────────────────────────────────────────────────────────────────┐ | + | │┏━┓┏━╸┏━╸ ┏━┓╻ ╻┏━┓╻╻ ┏━┓┏┓ ╻ ┏━╸ ┏┓ ╻ ╻╻╻ ╺┳┓ ┏━┓┏━╸╺┳╸╻┏━┓┏┓╻┏━┓ │ | + | │┗━┓┣╸ ┣╸ ┣━┫┃┏┛┣━┫┃┃ ┣━┫┣┻┓┃ ┣╸ ┣┻┓┃ ┃┃┃ ┃┃ ┣━┫┃ ┃ ┃┃ ┃┃┗┫┗━┓ │ | + | │┗━┛┗━╸┗━╸ ╹ ╹┗┛ ╹ ╹╹┗━╸╹ ╹┗━┛┗━╸┗━╸ ┗━┛┗━┛╹┗━╸╺┻┛ ╹ ╹┗━╸ ╹ ╹┗━┛╹ ╹┗━┛╹│ | + | └────────────────────────────────────────────────────────────────────────────┘ | + +---------------------------------------------------------------------------------*/ + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..c6a81646 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,83 @@ +[build-system] +requires = ["setuptools>=77.0.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "scyjava" +version = "1.12.6.dev0" +description = "Supercharged Java access from Python" +license = "Unlicense" +authors = [{name = "SciJava developers", email = "ctrueden@wisc.edu"}] +readme = "README.md" +keywords = ["java", "maven", "cross-language"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: Education", + "Intended Audience :: Science/Research", + "Programming Language :: Python :: 3 :: Only", + "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", + "Topic :: Scientific/Engineering", + "Topic :: Software Development :: Libraries :: Java Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Utilities", +] + +requires-python = ">=3.10" +dependencies = [ + "jpype1 >= 1.3.0", + "jgo>=2.2.0", +] + +[dependency-groups] +dev = [ + "assertpy", + "build", + "pytest", + "pytest-cov", + "numpy", + "pandas", + "ruff", + "toml", + "validate-pyproject[all]", +] + +[project.urls] +homepage = "https://github.com/scijava/scyjava" +documentation = "https://github.com/scijava/scyjava/blob/main/README.md" +source = "https://github.com/scijava/scyjava" +download = "https://pypi.org/project/scyjava/" +tracker = "https://github.com/scijava/scyjava/issues" + +[tool.setuptools] +package-dir = {"" = "src"} +include-package-data = false + +[tool.setuptools.packages.find] +where = ["src"] +namespaces = false + +[tool.pytest.ini_options] +filterwarnings = [ + "default::DeprecationWarning", +] + +[tool.ruff] +line-length = 88 +src = ["src", "tests"] +include = ["pyproject.toml", "src/**/*.py", "tests/**/*.py"] +extend-exclude = ["bin", "build", "dist"] + +[tool.ruff.lint] +extend-ignore = ["E203"] + +[tool.ruff.lint.per-file-ignores] +# Ignore `E402` (import violations) in all `__init__.py` files, and in `path/to/file.py`. +"__init__.py" = ["E402", "F401"] diff --git a/scyjava/__init__.py b/scyjava/__init__.py deleted file mode 100644 index 63357597..00000000 --- a/scyjava/__init__.py +++ /dev/null @@ -1,59 +0,0 @@ -import logging -import os - -_logger = logging.getLogger(__name__) - -def _init_jvm(): - import scyjava_config - import jnius_config - import jgo - - if jnius_config.vm_running: - _logger.warning('JVM is already running, will not add endpoints to classpath -- required classes might not be on classpath..') - import jnius - return jnius - - PYJNIUS_JAR_STR = 'PYJNIUS_JAR' - if PYJNIUS_JAR_STR not in globals(): - try: - PYJNIUS_JAR = os.environ[PYJNIUS_JAR_STR] - jnius_config.add_classpath(PYJNIUS_JAR) - except KeyError as e: - if e.args[0] == PYJNIUS_JAR_STR: - _logger.error('Unable to import scyjava: %s environment variable not defined.', PYJNIUS_JAR_STR) - else: - raise e - return None - - endpoints = scyjava_config.get_endpoints() - repositories = scyjava_config.get_repositories() - - _logger.debug('Adding jars from endpoints %s', endpoints) - - if len(endpoints) > 0: - 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(), - cache_dir=scyjava_config.get_cache_dir(), - repositories=repositories, - verbose=scyjava_config.get_verbose() - ) - jnius_config.add_classpath(os.path.join(workspace, '*')) - - try: - import jnius - return jnius - except KeyError as e: - if e.args[0] == 'JAVA_HOME': - _logger.error('Unable to import scyjava: JAVA_HOME environment variable not defined, cannot import jnius.') - else: - raise e - return None - -jnius = _init_jvm() -if (jnius == None): - raise ImportError('Unable to import scyjava dependency jnius.') - -from .convert import isjava, jclass, to_java, to_python diff --git a/scyjava/convert.py b/scyjava/convert.py deleted file mode 100644 index 13707ca7..00000000 --- a/scyjava/convert.py +++ /dev/null @@ -1,342 +0,0 @@ -# General-purpose utility methods for Python <-> Java type conversion. - -import jnius, collections - -String = jnius.autoclass('java.lang.String') -Boolean = jnius.autoclass('java.lang.Boolean') -Integer = jnius.autoclass('java.lang.Integer') -Long = jnius.autoclass('java.lang.Long') -BigInteger = jnius.autoclass('java.math.BigInteger') -Float = jnius.autoclass('java.lang.Float') -Double = jnius.autoclass('java.lang.Double') -BigDecimal = jnius.autoclass('java.math.BigDecimal') -LinkedHashMap = jnius.autoclass('java.util.LinkedHashMap') -LinkedHashSet = jnius.autoclass('java.util.LinkedHashSet') -ArrayList = jnius.autoclass('java.util.ArrayList') - -# -- Python to Java -- - -# Adapted from code posted by vslotman on GitHub: -# https://github.com/kivy/pyjnius/issues/217#issue-145981070 - -def isjava(data): - """Return whether the given data object is a Java object.""" - return isinstance(data, jnius.JavaClass) or isinstance(data, jnius.MetaJavaClass) - - -def jclass(data): - """ - Obtain a Java class object. - - :param data: The object from which to glean the class. - Supported types include: - A. Name of a class to look up, analogous to - Class.forName("java.lang.String"); - B. A jnius.MetaJavaClass object e.g. from jnius.autoclass, analogous to - String.class; - C. A jnius.JavaClass object e.g. instantiated from a jnius.MetaJavaClass, - analogous to "Hello".getClass(). - :returns: A java.lang.Class object, suitable for use with reflection. - :raises TypeError: if the argument is not one of the aforementioned types. - """ - if isinstance(data, jnius.JavaClass): - return data.getClass() - if isinstance(data, jnius.MetaJavaClass): - return jnius.find_javaclass(data.__name__) - if isinstance(data, str): - return jnius.find_javaclass(data) - raise TypeError('Cannot glean class from data of type: ' + str(type(data))) - - -def to_java(data): - """ - Recursively convert a Python object to a Java object. - :param data: The Python object to convert. - Supported types include: - * str -> String - * bool -> Boolean - * int -> Integer, Long or BigInteger as appropriate - * float -> Float, Double or BigDecimal as appropriate - * dict -> LinkedHashMap - * set -> LinkedHashSet - * list -> ArrayList - :returns: A corresponding Java object with the same contents. - :raises TypeError: if the argument is not one of the aforementioned types. - """ - if isjava(data): - return data - - if isinstance(data, str): - return String(data.encode('utf-8'), 'utf-8') - - if isinstance(data, bool): - return Boolean(data) - - if isinstance(data, int): - if data <= Integer.MAX_VALUE: - return Integer(data) - elif data <= Long.MAX_VALUE: - return Long(data) - else: - return BigInteger(str(data)) - - if isinstance(data, float): - if data <= Float.MAX_VALUE: - return Float(data) - elif data <= Double.MAX_VALUE: - return Double(data) - else: - return BigDecimal(str(data)) - - if isinstance(data, collections.Mapping): - jmap = LinkedHashMap() - for k, v in data.items(): - jk = to_java(k) - jv = to_java(v) - jmap.put(jk, jv) - return jmap - - if isinstance(data, collections.Set): - jset = LinkedHashSet() - for item in data: - jitem = to_java(item) - jset.add(jitem) - return jset - - if isinstance(data, collections.Iterable): - jlist = ArrayList() - for item in data: - jitem = to_java(item) - jlist.add(jitem) - return jlist - - raise TypeError('Unsupported type: ' + str(type(data))) - -# -- Java to Python -- - -BooleanClass = jclass('java.lang.Boolean') -ByteClass = jclass('java.lang.Byte') -CharacterClass = jclass('java.lang.Character') -DoubleClass = jclass('java.lang.Double') -FloatClass = jclass('java.lang.Float') -IntegerClass = jclass('java.lang.Integer') -LongClass = jclass('java.lang.Long') -ShortClass = jclass('java.lang.Short') -VoidClass = jclass('java.lang.Void') - -BigIntegerClass = jclass('java.math.BigInteger') -BigDecimalClass = jclass('java.math.BigDecimal') -StringClass = jclass('java.lang.String') - -ObjectClass = jclass('java.lang.Object') -IterableClass = jclass('java.lang.Iterable') -CollectionClass = jclass('java.util.Collection') -IteratorClass = jclass('java.util.Iterator') -ListClass = jclass('java.util.List') -MapClass = jclass('java.util.Map') -SetClass = jclass('java.util.Set') - - -def _jstr(data): - if isinstance(data, JavaObject): - return str(data) - # NB: We want Python strings to render in single quotes. - return '{!r}'.format(data) - - -class JavaObject(): - def __init__(self, jobj, intended_class=ObjectClass): - if not intended_class.isInstance(jobj): - raise TypeError('Not a ' + intended_class.getName() + ': ' + jclass(jobj).getName()) - self.jobj = jobj - - def __str__(self): - return _jstr(self.jobj) - - -class JavaIterable(JavaObject, collections.Iterable): - def __init__(self, jobj): - JavaObject.__init__(self, jobj, IterableClass) - - def __iter__(self): - return to_python(self.jobj.iterator()) - - def __str__(self): - return '[' + ', '.join(_jstr(v) for v in self) + ']' - - -class JavaCollection(JavaIterable, collections.Collection): - def __init__(self, jobj): - JavaObject.__init__(self, jobj, CollectionClass) - - def __contains__(self, item): - return to_python(self.jobj.contains(to_java(item))) - - def __len__(self): - return to_python(self.jobj.size()) - - def __eq__(self, other): - try: - if len(self) != len(other): - return False - for e1, e2 in zip(self, other): - if e1 != e2: - return False - return True - except TypeError: - return False - - -class JavaIterator(JavaObject, collections.Iterator): - def __init__(self, jobj): - JavaObject.__init__(self, jobj, IteratorClass) - - def __next__(self): - if self.jobj.hasNext(): - return to_python(self.jobj.next()) - raise StopIteration - - -class JavaList(JavaCollection, collections.MutableSequence): - def __init__(self, jobj): - JavaObject.__init__(self, jobj, ListClass) - - def __getitem__(self, key): - return to_python(self.jobj.get(key)) - - def __setitem__(self, key, value): - return to_python(self.jobj.set(key, value)) - - def __delitem__(self, key): - return to_python(self.jobj.remove(key)) - - def insert(self, index, object): - return to_python(self.jobj.set(index, object)) - - -class JavaMap(JavaObject, collections.MutableMapping): - def __init__(self, jobj): - JavaObject.__init__(self, jobj, MapClass) - - def __getitem__(self, key): - return to_python(self.jobj.get(to_java(key))) - - def __setitem__(self, key, value): - return to_python(self.jobj.put(to_java(key), to_java(value))) - - def __delitem__(self, key): - return to_python(self.jobj.remove(to_python(key))) - - def keys(self): - return to_python(self.jobj.keySet()) - - def __iter__(self): - return self.keys().__iter__() - - def __len__(self): - return to_python(self.jobj.size()) - - def __eq__(self, other): - try: - if len(self) != len(other): - return False - for k in self: - if not k in other or self[k] != other[k]: - return False - return True - except TypeError: - return False - - def __str__(self): - return '{' + ', '.join(_jstr(k) + ': ' + _jstr(v) for k,v in self.items()) + '}' - - -class JavaSet(JavaCollection, collections.MutableSet): - def __init__(self, jobj): - JavaObject.__init__(self, jobj, SetClass) - - def add(self, item): - return to_python(self.jobj.add(to_java(item))) - - def discard(self, item): - return to_python(self.jobj.remove(to_java(item))) - - def __iter__(self): - return to_python(self.jobj.iterator()) - - def __eq__(self, other): - try: - if len(self) != len(other): - return False - for k in self: - if not k in other: - return False - return True - except TypeError: - return False - - def __str__(self): - return '{' + ', '.join(_jstr(v) for v in self) + '}' - - -def to_python(data): - """ - Recursively convert a Java object to a Python object. - :param data: The Java object to convert. - Supported types include: - * String, Character -> str - * Boolean -> bool - * Byte, Short, Integer, Long, BigInteger -> int - * Float, Double, BigDecimal -> float - * Map -> collections.MutableMapping (dict-like) - * Set -> collections.MutableSet (set-like) - * List -> collections.MutableSequence (list-like) - * Collection -> collections.Collection - * Iterable -> collections.Iterable - * Iterator -> collections.Iterator - :returns: A corresponding Python object with the same contents. - :raises TypeError: if the argument is not one of the aforementioned types. - """ - if not isjava(data): - return data - - if BooleanClass.isInstance(data): - return data.booleanValue() - if ByteClass.isInstance(data): - return data.byteValue() - if CharacterClass.isInstance(data): - return data.toString() - if DoubleClass.isInstance(data): - return data.doubleValue() - if FloatClass.isInstance(data): - return data.floatValue() - if IntegerClass.isInstance(data): - return data.intValue() - if LongClass.isInstance(data): - return data.longValue() - if ShortClass.isInstance(data): - return data.shortValue() - if VoidClass.isInstance(data): - return None - - if BigIntegerClass.isInstance(data): - return int(data.toString()) - if BigDecimalClass.isInstance(data): - return float(data.toString()) - if StringClass.isInstance(data): - return data.toString() - - if ListClass.isInstance(data): - return JavaList(data) - if MapClass.isInstance(data): - return JavaMap(data) - if SetClass.isInstance(data): - return JavaSet(data) - if CollectionClass.isInstance(data): - return JavaCollection(data) - if IterableClass.isInstance(data): - return JavaIterable(data) - if IteratorClass.isInstance(data): - return JavaIterator(data) - - raise TypeError('Unsupported data type: ' + str(type(data))) diff --git a/scyjava_config.py b/scyjava_config.py deleted file mode 100644 index 2925fb26..00000000 --- a/scyjava_config.py +++ /dev/null @@ -1,127 +0,0 @@ -__all__ = ( - 'maven_scijava_repository', - 'add_endpoints', - 'get_endpoints', - 'add_repositories', - 'get_repositories', - 'set_verbose', - 'get_verbose', - 'set_cache_dir', - 'get_cache_dir', - 'set_m2_repo', - 'get_m2_repo', - 'set_options', - 'add_options', - 'get_options', - 'set_classpath', - 'add_classpath', - 'get_classpath', - 'expand_classpath') - -import logging -import jnius_config -import pathlib - -version = '0.1.1.dev0' - -_logger = logging.getLogger(__name__) - -_endpoints = [] -_repositories = {} -_verbose = 0 -_cache_dir = pathlib.Path.home() / '.jgo' -_m2_repo = pathlib.Path.home() / '.m2' / 'repository' - -def maven_scijava_repository(): - """ - :return: url for public scijava maven repo - """ - return 'https://maven.imagej.net/content/groups/public' - -def add_endpoints(*endpoints): - global _endpoints - _logger.debug('Adding endpoints %s to %s', endpoints, _endpoints) - _endpoints.extend(endpoints) - - -def get_endpoints(): - global _endpoints - return _endpoints - - -def add_repositories(*args, **kwargs): - global _repositories - for arg in args: - _logger.debug('Adding repositories %s to %s', arg, _repositories) - _repositories.update(arg) - _logger.debug('Adding repositories %s to %s', kwargs, _repositories) - _repositories.update(kwargs) - - -def get_repositories(): - global _repositories - return _repositories - - -def set_verbose(level): - global _verbose - _logger.debug('Setting verbose level to %d (was %d)', level, _verbose) - _verbose = level - - -def get_verbose(): - global _verbose - _logger.debug('Getting verbose level: %d', _verbose) - return _verbose - - -def set_cache_dir(dir): - global _cache_dir - _logger.debug('Setting cache dir to %s (was %s)', dir, _cache_dir) - _cache_dir = dir - - -def get_cache_dir(): - global _cache_dir - return _cache_dir - - -def set_m2_repo(dir): - global _m2_repo - _logger.debug('Setting m2 repo dir to %s (was %s)', dir, _m2_repo) - _m2_repo = dir - - -def get_m2_repo(): - global _m2_repo - return _m2_repo - - -# directly delegating to jnius_config -def add_classpath(*path): - jnius_config.add_classpath(*path) - - -def set_classpath(*path): - jnius_config.set_classpath(*path) - - -def get_classpath(): - return jnius_config.get_classpath() - - -def add_options(*opts): - jnius_config.add_options(*opts) - - -def set_options(*opts): - jnius_config.set_options(*opts) - - -def get_options(): - return jnius_config.get_options() - - -def expand_classpath(): - return jnius_config.expand_classpath() - diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 12871ff0..00000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[metadata] -description-file=README.md diff --git a/setup.py b/setup.py deleted file mode 100644 index 49aff707..00000000 --- a/setup.py +++ /dev/null @@ -1,24 +0,0 @@ -import setuptools -import scyjava_config -from os import path - -here = path.abspath(path.dirname(__file__)) - -with open(path.join(here, 'README.md')) as f: - scyjava_long_description = f.read() - -setuptools.setup( - name='scyjava', - python_requires='>=3', - packages=['scyjava'], - py_modules=['scyjava_config'], - version=scyjava_config.version, - author='Philipp Hanslovsky, Curtis Rueden', - author_email='hanslovskyp@janelia.hhmi.org', - description='scyjava', - long_description=scyjava_long_description, - long_description_content_type='text/markdown', - license='Public domain', - url='https://github.com/scijava/scyjava', - install_requires=['pyjnius', 'jgo'], -) diff --git a/src/scyjava/__init__.py b/src/scyjava/__init__.py new file mode 100644 index 00000000..a30ec14e --- /dev/null +++ b/src/scyjava/__init__.py @@ -0,0 +1,219 @@ +""" +Supercharged Java access from Python, built on JPype and jgo. + +Use Java classes from Python: + + >>> from scyjava import jimport + >>> System = jimport('java.lang.System') + >>> System.getProperty('java.version') + '1.8.0_252' + +Use Maven artifacts from remote repositories: + + >>> from scyjava import config, jimport + >>> config.enable_headless_mode() + >>> config.add_repositories({ + ... 'scijava.public': 'https://maven.scijava.org/content/groups/public', + ... }) + >>> config.endpoints.append('net.imagej:imagej:2.1.0') + >>> ImageJ = jimport('net.imagej.ImageJ') + >>> ij = ImageJ() + >>> formula = "10 * (Math.cos(0.3*p[0]) + Math.sin(0.3*p[1]))" + >>> ArrayImgs = jimport('net.imglib2.img.array.ArrayImgs') + >>> blank = ArrayImgs.floats(64, 16) + >>> sinusoid = ij.op().image().equation(blank, formula) + >>> print(ij.op().image().ascii(sinusoid)) + ,,,--+oo******oo+--,,,,,--+oo******o++--,,,,,--+oo******o++--,,, + ...,--+ooo**oo++--,....,,--+ooo**oo++-,,....,,--+ooo**oo++-,,... + ...,--++oooo++--,... ...,--++oooo++--,... ...,--++oooo++-,,... + ..,--++++++--,.. ..,--++o+++--,.. .,,--++o+++--,.. + ..,,-++++++-,,. ..,,-++++++-,,. ..,--++++++-,,. + .,,--++++--,,. .,,--++++--,,. .,,--++++--,.. + .,,--++++--,,. .,,-+++++--,,. .,,-+++++--,,. + ..,--++++++--,.. ..,--++++++--,.. ..,--++++++-,,.. + ..,,-++oooo++-,,.. ..,,-++oooo++-,,.. ..,,-++ooo+++-,,.. + ...,,-++oooooo++-,,.....,,-++oooooo++-,,.....,,-++oooooo+--,,... + .,,,-++oo****oo++-,,,.,,,-++oo****oo+--,,,.,,,-++oo****oo+--,,,. + ,,--++o***OO**oo++-,,,,--++o***OO**oo+--,,,,--++o***OO**oo+--,,, + ---++o**OOOOOO**o++-----++o**OOOOOO*oo++-----++o**OOOOOO*oo++--- + --++oo*OO####OO*oo++---++oo*OO####OO*oo++---++o**OO####OO*oo++-- + +++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 + >>> HashSet = jimport('java.util.HashSet') + >>> moves = {'jump', 'duck', 'dodge'} + >>> fish = {'walleye', 'pike', 'trout'} + >>> jbirds = HashSet() + >>> for bird in ('duck', 'goose', 'swan'): jbirds.add(bird) + >>> from scyjava import to_python as j2p + >>> j2p(jbirds).isdisjoint(moves) + False + >>> j2p(jbirds).isdisjoint(fish) + True + +Convert Python collections to Java: + + >>> from scyjava import jimport + >>> HashSet = jimport('java.util.HashSet') + >>> jset = HashSet() + >>> pset = {1, 2, 3} + >>> from scyjava import to_java as p2j + >>> jset.addAll(p2j(pset)) + True + >>> jset.toString() + '[1, 2, 3]' +""" + +import logging +from collections.abc import Callable +from functools import lru_cache +from typing import Any + +from . import config, inspect +from ._arrays import is_arraylike, is_memoryarraylike, is_xarraylike +from ._convert import ( + Converter, + JavaCollection, + JavaIterable, + JavaIterator, + JavaList, + JavaMap, + JavaObject, + JavaSet, + Priority, + _stock_java_converters, + _stock_py_converters, + add_java_converter, + add_py_converter, + java_converters, + py_converters, + to_java, + to_python, +) +from ._introspect import ( + jreflect, + jsource, +) +from ._jvm import ( + available_processors, + gc, + is_awt_initialized, + is_jvm_headless, + jimport, + jvm_started, + jvm_version, + memory_max, + memory_total, + memory_used, + shutdown_jvm, + start_jvm, + when_jvm_starts, + when_jvm_stops, +) +from ._script import enable_python_scripting +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, + jinstance, + jstacktrace, + numeric_bounds, +) +from ._versions import compare_version, get_version, is_version_at_least + +__version__ = get_version("scyjava") +__all__ = [ + k + for k, v in globals().items() + if not k.startswith("_") + and hasattr(v, "__module__") + and v.__module__.startswith("scyjava.") +] + +_logger = logging.getLogger(__name__) + +# Set of module properties +_CONSTANTS: dict[str, Callable] = {} + + +def constant(func: Callable[[], Any], cache=True) -> Callable[[], Any]: + """ + Turns a function into a property of this module + Functions decorated with this property must have a + leading underscore! + :param func: The function to turn into a property + """ + if func.__name__[0] != "_": + raise ValueError( + f"""Function {func.__name__} must have + a leading underscore in its name + to become a module property!""" + ) + name = func.__name__[1:] + if cache: + func = (lru_cache(maxsize=None))(func) + _CONSTANTS[name] = func + return func + + +def __getattr__(name): + """ + Runs as a fallback when this module does not have an attribute. + :param name: The name of the attribute being searched for. + """ + if name in _CONSTANTS: + return _CONSTANTS[name]() + raise AttributeError(f"module '{__name__}' has no attribute '{name}'") + + +# -- JVM startup callbacks -- + +# NB: These must be performed last, because if this class is imported after the +# JVM is already running -- for example, if we are running in Jep mode, where +# Python is started from inside the JVM -- then these functions execute the +# callbacks immediately, which means the involved functions must be defined and +# functional at this point. + + +def _initialize_converters(): + _logger.debug("Initializing type converters") + + for converter in _stock_java_converters(): + add_java_converter(converter) + _logger.debug("Java converters:{'\n-'.join(java_converters)}") + + for converter in _stock_py_converters(): + add_py_converter(converter) + _logger.debug("Python converters:{'\n-'.join(py_converters)}") + + +when_jvm_starts(_initialize_converters) diff --git a/src/scyjava/_arrays.py b/src/scyjava/_arrays.py new file mode 100644 index 00000000..be3b9715 --- /dev/null +++ b/src/scyjava/_arrays.py @@ -0,0 +1,53 @@ +""" +Utility functions for working with and reasoning about arrays. +""" + +from typing import Any + + +def is_arraylike(arr: Any) -> bool: + """ + Return True iff the object is arraylike: possessing + .shape, .dtype, .__array__, and .ndim attributes. + + :param arr: The object to check for arraylike properties + :return: True iff the object is arraylike + """ + return ( + hasattr(arr, "shape") + and hasattr(arr, "dtype") + and hasattr(arr, "__array__") + and hasattr(arr, "ndim") + ) + + +def is_memoryarraylike(arr: Any) -> bool: + """ + Return True iff the object is memoryarraylike: + an arraylike object whose .data type is memoryview. + + :param arr: The object to check for memoryarraylike properties + :return: True iff the object is memoryarraylike + """ + return ( + is_arraylike(arr) + and hasattr(arr, "data") + and type(arr.data).__name__ == "memoryview" + ) + + +def is_xarraylike(xarr: Any) -> bool: + """ + Return True iff the object is xarraylike: + possessing .values, .dims, and .coords attributes, + and whose .values are arraylike. + + :param arr: The object to check for xarraylike properties + :return: True iff the object is xarraylike + """ + return ( + hasattr(xarr, "values") + and hasattr(xarr, "dims") + and hasattr(xarr, "coords") + and is_arraylike(xarr.values) + ) diff --git a/src/scyjava/_convert.py b/src/scyjava/_convert.py new file mode 100644 index 00000000..2ba7d024 --- /dev/null +++ b/src/scyjava/_convert.py @@ -0,0 +1,965 @@ +""" +The scyjava conversion subsystem, and built-in conversion functions. +""" + +import collections +import inspect +import logging +import math +from bisect import insort +from collections.abc import Callable +from importlib.util import find_spec +from pathlib import Path +from typing import Any, NamedTuple + +from jpype import JBoolean, JByte, JChar, JDouble, JFloat, JInt, JLong, JShort + +from scyjava._jvm import jimport, start_jvm +from scyjava._types import JavaClasses, is_jarray, isjava, jarray, jclass, jinstance +from scyjava.config import Mode, mode + +_logger = logging.getLogger(__name__) + + +# NB: We cannot use org.scijava.priority.Priority or other Java-side class +# here because we don't want to impose Java-side dependencies, and we don't +# want to require Java to be started before reasoning about priorities. +class Priority: + FIRST = 1e300 + EXTREMELY_HIGH = 1e6 + VERY_HIGH = 1e4 + HIGH = 1e2 + NORMAL = 0 + LOW = -1e2 + VERY_LOW = -1e4 + EXTREMELY_LOW = -1e6 + LAST = -1e300 + + +def _priority(thing): + return getattr(thing, "priority", Priority.NORMAL) + + +def _has_kwargs(f): + return not isjava(f) and any( + p.kind == inspect.Parameter.VAR_KEYWORD + for p in inspect.signature(f).parameters.values() + ) + + +class Converter(NamedTuple): + predicate: Callable[[Any], bool] + converter: Callable[[Any], Any] + priority: float = Priority.NORMAL + name: str = "" + + def supports(self, obj: Any, **hints: dict) -> bool: + return ( + self.predicate(obj, **hints) + if _has_kwargs(self.predicate) + else self.predicate(obj) + ) + + def convert(self, obj: Any, **hints: dict) -> Any: + return ( + self.converter(obj, **hints) + if _has_kwargs(self.converter) + else self.converter(obj) + ) + + def __lt__(self, other): + return self.priority < _priority(other) + + def __le__(self, other): + return self.priority <= _priority(other) + + def __gt__(self, other): + return self.priority > _priority(other) + + def __ge__(self, other): + return self.priority >= _priority(other) + + def __str__(self): + return self.name + + +def _convert(obj: Any, converters: list[Converter], **hints: dict) -> Any: + # NB: The given converters are assumed to be sorted ascending by priority, + # meaning lower-priority items appear earlier than higher-priority ones. + # But we want to try the higher priority converters first, so we + # need to iterate the given converters list starting at the end. + debug = hints.get("debug", False) + log = _logger.info if debug else _logger.debug + log(f"Converting object of type {type(obj)} with hints {hints}") + for converter in reversed(converters): + if converter.supports(obj, **hints): + log(f"- {converter} supports") + return converter.convert(obj, **hints) + log(f"- {converter} does not support") + + +# -- Python to Java -- + +# Adapted from code posted by vslotman on GitHub: +# https://github.com/kivy/pyjnius/issues/217#issue-145981070 + + +def _raise_type_exception(obj: Any): + raise TypeError("Unsupported type: " + str(type(obj))) + + +def _convertMap(obj: collections.abc.Mapping): + jmap = _jc.LinkedHashMap() + for k, v in obj.items(): + jk = to_java(k) + jv = to_java(v) + jmap.put(jk, jv) + return jmap + + +def _convertSet(obj: collections.abc.Set): + jset = _jc.LinkedHashSet() + for item in obj: + jitem = to_java(item) + jset.add(jitem) + return jset + + +def _convertIterable(obj: collections.abc.Iterable): + jlist = _jc.ArrayList() + for item in obj: + jitem = to_java(item) + jlist.add(jitem) + return jlist + + +java_converters: list[Converter] = [] + + +def add_java_converter(converter: Converter) -> None: + """ + Add a converter to the list used by to_java. + :param converter: A Converter going from python to java + """ + insort(java_converters, converter) + + +def to_java(obj: Any, **hints: dict) -> Any: + """ + Recursively convert a Python object to a Java object. + + Supported types include: + * str -> String + * bool -> Boolean + * int -> Integer, Long or BigInteger as appropriate + * float -> Float, Double or BigDecimal as appropriate + * dict -> LinkedHashMap + * set -> LinkedHashSet + * list -> ArrayList + + There is typically one single destination conversion type and value that + makes sense. For example, Python str always converts to java.lang.String. + But in some cases, there are multiple options that can be controlled by + passing key/value pairs as hints. The base scyjava library includes: + + * int + type='byte' -> Byte + * int + type='short' -> Short + * int + type='int' -> Integer + * int + type='long' -> Long + * int + type='bigint' -> BigInteger + * float + type='float' -> Float + * float + type='double' -> Double + * float + type='bigdec' -> BigDecimal + + But the scyjava conversion framework is extensible and other + packages may introduce converters supporting additional hints. + + In the absence of a hint, scyjava makes a best effort to use a sensible + destination type and value: + + * int values in [-2**31, 2**31-1] convert to Integer + * int values in [-2**63, 2**63-1] but outside int range convert to Long + * int values outside Java long range convert to BigInteger + * conversion of int to Byte or Short must be requested via a hint + * float values in Float range convert to Float + * float inf, -inf, and nan convert to Float + * float values in Double range but outside float range convert to Double + * float values outside double range convert to BigDecimal + + :param obj: + The Python object to convert. + :param hints: + An optional dictionary of hints, to help scyjava + make decisions about how to do the conversion. + :return: A corresponding Java object with the same contents. + :raise TypeError: if the argument is not one of the aforementioned types. + """ + start_jvm() + return _convert(obj, java_converters, **hints) + + +def _stock_java_converters() -> list[Converter]: + """ + Construct the Python-to-Java converters supported out of the box. + :return: A list of Converters + """ + start_jvm() + return [ + Converter( + name="Other (Exceptional) converter", + predicate=lambda obj: True, + converter=_raise_type_exception, + priority=Priority.EXTREMELY_LOW - 1, + ), + Converter( + name="None -> None", + predicate=lambda obj: obj is None, + converter=lambda obj: None, + priority=Priority.EXTREMELY_HIGH + 1, + ), + Converter( + name="Java object identity", + predicate=isjava, + converter=lambda obj: obj, + priority=Priority.EXTREMELY_HIGH, + ), + Converter( + name="str -> java.lang.String", + predicate=lambda obj: isinstance(obj, str), + converter=lambda obj: _jc.String(obj.encode("utf-8"), "utf-8"), + ), + Converter( + name="bool -> java.lang.Boolean", + predicate=lambda obj: isinstance(obj, bool), + converter=_jc.Boolean, + # NB: Must be higher priority than the int converters, + # because the bool type extends the int type! + priority=Priority.NORMAL + 1, + ), + 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 + ), + 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 + ), + 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 + ), + 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 + ), + 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") + ) + ), + 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 + ) + ), + 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 + ) + ), + 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") + ) + ), + converter=lambda obj: _jc.BigDecimal(str(obj)), + priority=Priority.NORMAL - 2, + ), + Converter( + name="pathlib.Path -> java.nio.file.Path", + predicate=lambda obj: isinstance(obj, Path), + # Pass an empty String array in addition to our path + # To make it clear to jep that we want the string-args version + # JPype is smart enough to know that, but it doesn't mind the extra args + converter=lambda obj: _jc.Paths.get(str(obj), jarray(_jc.String, [0])), + priority=Priority.NORMAL + 1, + ), + Converter( + name="pandas.DataFrame -> org.scijava.table.Table", + predicate=lambda obj: type(obj).__name__ == "DataFrame", + converter=_pandas_to_table, + priority=Priority.NORMAL + 1, + ), + Converter( + name="collections.abc.Mapping -> java.util.Map", + predicate=lambda obj: isinstance(obj, collections.abc.Mapping), + converter=_convertMap, + ), + Converter( + name="collections.abc.Set -> java.util.Set", + predicate=lambda obj: isinstance(obj, collections.abc.Set), + converter=_convertSet, + ), + Converter( + name="collections.abc.Iterable -> java.util.Iterable", + predicate=lambda obj: isinstance(obj, collections.abc.Iterable), + converter=_convertIterable, + priority=Priority.NORMAL - 1, + ), + ] + + +# -- Java to Python -- + + +def _jstr(data): + if isinstance(data, JavaObject): + return str(data) + # NB: We want Python strings to render in single quotes. + return f"{data!r}" + + +class JavaObject: + def __init__(self, jobj, intended_class=None): + if intended_class is None: + intended_class = _jc.Object + if not jinstance(jobj, intended_class): + raise TypeError( + f"Not a {intended_class.getName()}: {jclass(jobj).getName()}" + ) + self.jobj = jobj + + def __str__(self): + return _jstr(self.jobj) + + +class JavaIterable(JavaObject, collections.abc.Iterable): + def __init__(self, jobj): + JavaObject.__init__(self, jobj, _jc.Iterable) + + def __iter__(self): + return to_python(self.jobj.iterator()) + + def __str__(self): + return "[" + ", ".join(_jstr(v) for v in self) + "]" + + +class JavaCollection(JavaIterable, collections.abc.Collection): + def __init__(self, jobj): + JavaObject.__init__(self, jobj, _jc.Collection) + + def __contains__(self, item): + # NB: Collection.contains returns boolean, so no need for gentleness. + return to_python(self.jobj.contains(to_java(item))) + + def __len__(self): + return to_python(self.jobj.size()) + + def __eq__(self, other): + try: + if len(self) != len(other): + return False + for e1, e2 in zip(self, other): + if e1 != e2: + return False + return True + except TypeError: + return False + + +class JavaIterator(JavaObject, collections.abc.Iterator): + def __init__(self, jobj): + JavaObject.__init__(self, jobj, _jc.Iterator) + + def __next__(self): + if self.jobj.hasNext(): + # NB: Even if an element cannot be converted, + # we still want to support Pythonic iteration. + return to_python(self.jobj.next(), gentle=True) + raise StopIteration + + +class JavaList(JavaCollection, collections.abc.MutableSequence): + def __init__(self, jobj): + JavaObject.__init__(self, jobj, _jc.List) + + def __getitem__(self, key): + # NB: Even if an element cannot be converted, + # we still want Pythonic access to elements. + return to_python(self.jobj.get(key), gentle=True) + + def __setitem__(self, key, value): + # NB: List.set(int, Object) returns inserted element; be gentle here. + return to_python(self.jobj.set(key, to_java(value)), gentle=True) + + def __delitem__(self, key): + # NB: List.remove(Object) returns boolean, so no need for gentleness. + return to_python(self.jobj.remove(to_java(key))) + + def insert(self, index, object): + # NB: List.set(int, Object) returns inserted element; be gentle here. + return to_python(self.jobj.set(index, to_java(object)), gentle=True) + + +class JavaMap(JavaObject, collections.abc.MutableMapping): + def __init__(self, jobj): + JavaObject.__init__(self, jobj, _jc.Map) + + def __getitem__(self, key): + # NB: Even if an element cannot be converted, + # we still want Pythonic access to elements. + return to_python(self.jobj.get(to_java(key)), gentle=True) + + def __setitem__(self, key, value): + # NB: Map.put(Object, Object) returns inserted value; be gentle here. + put_return: bool = self.jobj.put(to_java(key), to_java(value)) + return to_python(put_return, gentle=True) + + def __delitem__(self, key): + # NB: Map.remove(Object) returns the removed key; be gentle here. + return to_python(self.jobj.remove(to_java(key)), gentle=True) + + def keys(self): + return to_python(self.jobj.keySet()) + + def __iter__(self): + return self.keys().__iter__() + + def __len__(self): + return to_python(self.jobj.size()) + + def __eq__(self, other): + try: + if len(self) != len(other): + return False + for k in self: + if k not in other or self[k] != other[k]: + return False + return True + except TypeError: + return False + + def __str__(self): + def item_str(k, v): + return _jstr(k) + ": " + _jstr(v) + + return "{" + ", ".join(item_str(k, v) for k, v in self.items()) + "}" + + +class JavaSet(JavaCollection, collections.abc.MutableSet): + def __init__(self, jobj): + JavaObject.__init__(self, jobj, _jc.Set) + + def add(self, item): + # NB: Set.add returns boolean, so no need for gentleness. + return to_python(self.jobj.add(to_java(item))) + + def discard(self, item): + # NB: Set.remove returns boolean, so no need for gentleness. + return to_python(self.jobj.remove(to_java(item))) + + def __iter__(self): + return to_python(self.jobj.iterator()) + + def __eq__(self, other): + try: + if len(self) != len(other): + return False + for k in self: + if k not in other: + return False + return True + except TypeError: + return False + + def __str__(self): + return "{" + ", ".join(_jstr(v) for v in self) + "}" + + +py_converters: list[Converter] = [] + + +def add_py_converter(converter: Converter) -> None: + """ + Add a converter to the list used by to_python. + :param converter: A Converter from java to python + """ + insort(py_converters, converter) + + +def to_python(data: Any, gentle: bool = False) -> Any: + """ + Recursively convert a Java object to a Python object. + + Supported types include: + * String, Character -> str + * Boolean -> bool + * Byte, Short, Integer, Long, BigInteger -> int + * Float, Double, BigDecimal -> float + * Map -> collections.abc.MutableMapping (dict-like) + * Set -> collections.abc.MutableSet (set-like) + * List -> collections.abc.MutableSequence (list-like) + * Collection -> collections.abc.Collection + * Iterable -> collections.abc.Iterable + * Iterator -> collections.abc.Iterator + + :param data: + The Java object to convert. + :param gentle: + If set, and the type cannot be converted, leaves + the data alone rather than raising a TypeError. + :return: A corresponding Python object with the same contents. + :raise TypeError: + if the argument is not one of the aforementioned types, + and the gentle flag is not set. + """ + start_jvm() + try: + return _convert(data, py_converters) + except TypeError: + if gentle: + return data + raise + + +def _stock_py_converters() -> list: + """ + Construct the Java-to-Python converters supported out of the box. + :return: A list of Converters + """ + start_jvm() + + converters = [ + Converter( + name="Other (Exceptional) converter", + predicate=lambda obj: True, + converter=_raise_type_exception, + priority=Priority.EXTREMELY_LOW - 1, + ), + Converter( + name="Python object identity", + predicate=lambda obj: not isjava(obj), + converter=lambda obj: obj, + priority=Priority.EXTREMELY_HIGH, + ), + Converter( + name="java.lang.Boolean -> bool", + predicate=lambda obj: jinstance(obj, _jc.Boolean), + converter=lambda obj: obj.booleanValue(), + ), + Converter( + name="java.lang.Byte -> int", + predicate=lambda obj: jinstance(obj, _jc.Byte), + converter=lambda obj: int(obj.byteValue()), + ), + Converter( + name="java.lang.Character -> str", + predicate=lambda obj: jinstance(obj, _jc.Character), + converter=lambda obj: str, + ), + Converter( + name="java.lang.Double -> float", + predicate=lambda obj: jinstance(obj, _jc.Double), + converter=lambda obj: float(obj.doubleValue()), + ), + Converter( + name="java.lang.Float -> float", + predicate=lambda obj: jinstance(obj, _jc.Float), + converter=lambda obj: float(obj.floatValue()), + ), + Converter( + name="java.lang.Integer -> int", + predicate=lambda obj: jinstance(obj, _jc.Integer), + converter=lambda obj: int(obj.intValue()), + ), + Converter( + name="java.lang.Long -> int", + predicate=lambda obj: jinstance(obj, _jc.Long), + converter=lambda obj: int(obj.longValue()), + ), + Converter( + name="java.lang.Short -> int", + predicate=lambda obj: jinstance(obj, _jc.Short), + converter=lambda obj: int(obj.shortValue()), + ), + Converter( + name="java.lang.String -> str", + predicate=lambda obj: jinstance(obj, _jc.String), + converter=lambda obj: str(obj), + ), + Converter( + name="java.math.BigInteger -> int", + predicate=lambda obj: jinstance(obj, _jc.BigInteger), + converter=lambda obj: int(str(obj)), + ), + Converter( + name="java.math.BigDecimal -> float", + predicate=lambda obj: jinstance(obj, _jc.BigDecimal), + converter=lambda obj: float(str(obj)), + ), + Converter( + name="java.util.List -> scyjava.JavaList (list-like)", + predicate=lambda obj: jinstance(obj, _jc.List), + converter=JavaList, + ), + Converter( + name="java.util.Map -> scyjava.JavaMap (dict-like)", + predicate=lambda obj: jinstance(obj, _jc.Map), + converter=JavaMap, + ), + Converter( + name="java.util.Set -> scyjava.JavaSet (set-like)", + predicate=lambda obj: jinstance(obj, _jc.Set), + converter=JavaSet, + ), + Converter( + name="java.util.Collection -> " + "scyjava.JavaCollection (collections.abc.Collection)", + predicate=lambda obj: jinstance(obj, _jc.Collection), + converter=JavaCollection, + priority=Priority.NORMAL - 1, + ), + Converter( + name="java.lang.Iterable -> " + "scyjava.JavaIterable (collections.abc.Iterable)", + predicate=lambda obj: jinstance(obj, _jc.Iterable), + converter=JavaIterable, + priority=Priority.NORMAL - 1, + ), + Converter( + name="java.util.Iterator -> " + "scyjava.JavaIterator (collections.abc.Iterator)", + predicate=lambda obj: jinstance(obj, _jc.Iterator), + converter=JavaIterator, + priority=Priority.NORMAL - 1, + ), + Converter( + name="java.nio.file.Path -> pathlib.Path", + predicate=lambda obj: jinstance(obj, _jc.Path), + converter=lambda obj: Path(str(obj)), + priority=Priority.NORMAL + 1, + ), + Converter( + name="jarray -> list", + predicate=lambda obj: is_jarray(obj), + converter=lambda obj: [to_python(o) for o in obj], + priority=Priority.VERY_LOW, + ), + ] + if find_spec("pandas"): + converters.append( + Converter( + name="org.scijava.table.Table -> pandas.DataFrame", + predicate=_is_table, + converter=_convert_table, + priority=Priority.HIGH, + ) + ) + + if mode == Mode.JPYPE: + converters.extend( + [ + Converter( + name="JBoolean -> bool", + predicate=lambda obj: isinstance(obj, JBoolean), + converter=bool, + priority=Priority.NORMAL + 1, + ), + Converter( + name="JByte/JInt/JLong/JShort -> int", + predicate=lambda obj: isinstance(obj, (JByte, JInt, JLong, JShort)), + converter=int, + priority=Priority.NORMAL + 1, + ), + Converter( + name="JDouble/JFloat -> float", + predicate=lambda obj: isinstance(obj, (JDouble, JFloat)), + converter=float, + priority=Priority.NORMAL + 1, + ), + Converter( + name="JChar -> str", + predicate=lambda obj: isinstance(obj, JChar), + converter=str, + priority=Priority.NORMAL + 1, + ), + ] + ) + if find_spec("numpy"): + converters.append( + Converter( + name="primitive array -> numpy.ndarray", + predicate=_supports_jarray_to_ndarray, + converter=_jarray_to_ndarray, + ) + ) + + return converters + + +############################### +# Java array -> NumPy ndarray # +############################### + + +def _jarray_to_ndarray(jarr): + """ + Convert the given Java primitive array into a NumPy ndarray. + + :param jarr: The Java primitive array + :return: The converted NumPy ndarray + """ + np = _import_numpy() + assert _supports_jarray_to_ndarray(jarr) + element_type = _jarray_element_type(jarr) + # fmt: off + jarraytype_map = { + JBoolean: np.bool_, + JByte: np.int8, + # JChar: np.???, + JDouble: np.float64, + JFloat: np.float32, + JInt: np.int32, + JLong: np.int64, + JShort: np.int16, + } + # fmt: on + dtype = jarraytype_map[element_type] + # Use a bytearray instead of memoryview for np.frombuffer. + # Casting memoryview() on a Java array copies the array's content + # into a buffer which does not get released when a new view is + # requested. If the Java array's data changes a new memoryview will + # contain the old buffer data. The view and any object created with + # it must be deleted (del or =None) to release the buffer before + # requesting a new view. Instead of utilizing the buffer + # memoryview creates of the Java array, we obtain the buffer ouselves + # as a mutable bytearray. + bb = bytearray(jarr) + ndarray = np.frombuffer(bb, dtype=dtype) + del bb # release the buffer + return ndarray.reshape(_jarray_shape(jarr)) + + +def _supports_jarray_to_ndarray(obj): + """ + Return True iff the given object is convertible to a NumPy ndarray + via the _jarray_to_ndarray function. + + :param obj: The object to check for convertibility + :return: True iff conversion to a NumPy ndarray is possible + """ + element_type = _jarray_element_type(obj) + return element_type in (JBoolean, JByte, JDouble, JFloat, JInt, JLong, JShort) + + +def _jarray_element_type(jarr): + if not is_jarray(jarr): + return None + element = jarr + while is_jarray(element): + element = element[0] + return type(element) + + +def _jarray_shape(jarr): + if not is_jarray(jarr): + return None + shape = [] + element = jarr + while is_jarray(element): + shape.append(len(element)) + element = element[0] + return shape + + +def _import_numpy(): + try: + import numpy as np + + return np + except ImportError as e: + msg = "The NumPy library is missing (https://numpy.org/). " + msg += "Please install it before using this function." + raise RuntimeError(msg) from e + + +###################################### +# SciJava table <-> pandas DataFrame # +###################################### + + +def _is_table(obj: Any) -> bool: + """Check if obj is a table.""" + try: + return jinstance(obj, "org.scijava.table.Table") + except BaseException: # noqa: BLE001 + # No worries if scijava-table is not available. + return False + + +def _convert_table(obj: Any): + """Convert obj to a table.""" + try: + return _table_to_pandas(obj) + except BaseException: # noqa: BLE001 + # No worries if scijava-table is not available. + return None + + +def _import_pandas(): + try: + import pandas as pd + + return pd + except ImportError as 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): + pd = _import_pandas() + + data = [] + headers = [] + for i, column in enumerate(table.toArray()): + data.append(column.toArray()) + headers.append(str(table.getColumnHeader(i))) + for j in range(len(data)): + data[j] = to_python(data[j]) + df = pd.DataFrame(data).T + df.columns = headers + return df + + +def _pandas_to_table(df): + if len(df.dtypes.unique()) > 1: + TableClass = jimport("org.scijava.table.DefaultGenericTable") + else: + table_type = df.dtypes.unique()[0] + if table_type.name.startswith("float"): + TableClass = jimport("org.scijava.table.DefaultFloatTable") + elif table_type.name.startswith("int"): + TableClass = jimport("org.scijava.table.DefaultIntTable") + elif table_type.name.startswith("bool"): + TableClass = jimport("org.scijava.table.DefaultBoolTable") + else: + msg = f"The type '{table_type.name}' is not supported." + raise ValueError(msg) + + table = TableClass(*df.shape[::-1]) + + for c, column_name in enumerate(df.columns): + table.setColumnHeader(c, column_name) + + for i, (_, row) in enumerate(df.iterrows()): + for c, value in enumerate(row): + header = df.columns[c] + table.set(header, i, to_java(value)) + + return table + + +# fmt: off +class _JavaClasses(JavaClasses): + @JavaClasses.java_import + def Boolean(self): return "java.lang.Boolean" + @JavaClasses.java_import + def Byte(self): return "java.lang.Byte" + @JavaClasses.java_import + def Character(self): return "java.lang.Character" + @JavaClasses.java_import + def Double(self): return "java.lang.Double" + @JavaClasses.java_import + def Float(self): return "java.lang.Float" + @JavaClasses.java_import + def Integer(self): return "java.lang.Integer" + @JavaClasses.java_import + def Iterable(self): return "java.lang.Iterable" + @JavaClasses.java_import + def Long(self): return "java.lang.Long" + @JavaClasses.java_import + def Object(self): return "java.lang.Object" + @JavaClasses.java_import + def Short(self): return "java.lang.Short" + @JavaClasses.java_import + def String(self): return "java.lang.String" + @JavaClasses.java_import + def BigDecimal(self): return "java.math.BigDecimal" + @JavaClasses.java_import + def BigInteger(self): return "java.math.BigInteger" + @JavaClasses.java_import + def Path(self): return "java.nio.file.Path" + @JavaClasses.java_import + def Paths(self): return "java.nio.file.Paths" + @JavaClasses.java_import + def ArrayList(self): return "java.util.ArrayList" + @JavaClasses.java_import + def Collection(self): return "java.util.Collection" + @JavaClasses.java_import + def Iterator(self): return "java.util.Iterator" + @JavaClasses.java_import + def LinkedHashMap(self): return "java.util.LinkedHashMap" + @JavaClasses.java_import + def LinkedHashSet(self): return "java.util.LinkedHashSet" + @JavaClasses.java_import + def List(self): return "java.util.List" + @JavaClasses.java_import + def Map(self): return "java.util.Map" + @JavaClasses.java_import + def Set(self): return "java.util.Set" +# fmt: on + + +_jc = _JavaClasses() diff --git a/src/scyjava/_introspect.py b/src/scyjava/_introspect.py new file mode 100644 index 00000000..4d657815 --- /dev/null +++ b/src/scyjava/_introspect.py @@ -0,0 +1,128 @@ +""" +Introspection functions for reporting Java +class methods, fields, and source code URL. +""" + +from typing import Any + +from scyjava._jvm import jimport, jvm_version +from scyjava._types import isjava, jclass, jinstance + + +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: 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 e: + raise ValueError( + f"Object of type '{type(data).__name__}' is not a Java object" + ) from e + + jcls = data if jinstance(data, "java.lang.Class") else jclass(data) + + 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 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 name) + ) + table.append( + { + "type": mtype, + "name": name, + "mods": mods, + "arguments": args, + "returns": returns, + } + ) + + return table + + +def jsource(data) -> str: + """ + 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: + Object, class, or fully qualified class name for which to discern the source code location. + :return: URL of the class's source code. + """ + + 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}") from 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. + jv_digits = jvm_version() + 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 + # 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") + url = SourceFinder.sourceLocation(jcls, None) + urlstring = url.toString() + return urlstring diff --git a/src/scyjava/_jdk_fetch.py b/src/scyjava/_jdk_fetch.py new file mode 100644 index 00000000..6c0f36d1 --- /dev/null +++ b/src/scyjava/_jdk_fetch.py @@ -0,0 +1,79 @@ +""" +Utility functions for fetching JDK/JRE. +""" + +from __future__ import annotations + +import logging +import os +from typing import TYPE_CHECKING + +from jgo.exec import JavaLocator, JavaSource + +import scyjava.config + +if TYPE_CHECKING: + from pathlib import Path + +_logger = logging.getLogger(__name__) + + +def resolve_java(vendor: str | None = None, version: str | None = None) -> None: + """ + Resolve JDK installation location and configure PATH/JAVA_HOME. + Might download Java or use the system Java, depending on the + scyjava.config.fetch_java setting. + + 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}...") + + fetch = scyjava.config.get_fetch_java() + + # Map scyjava fetch mode to jgo JavaSource strategy. + # "always" -> DOWNLOAD: always use cjdk-managed Java, ignoring system Java. + # "never" -> SYSTEM: always use system Java, never downloading via cjdk. + # "auto" -> AUTO: prefer system Java, fall back to cjdk if absent/too old. + _FETCH_MODES = { + "always": JavaSource.DOWNLOAD, + "download": JavaSource.DOWNLOAD, + "never": JavaSource.SYSTEM, + "system": JavaSource.SYSTEM, + } + java_source = _FETCH_MODES.get(fetch, JavaSource.AUTO) + + locator = JavaLocator( + java_source=java_source, + 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: 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 new file mode 100644 index 00000000..c3b75541 --- /dev/null +++ b/src/scyjava/_jvm.py @@ -0,0 +1,491 @@ +""" +Utility functions for working with the Java Virtual Machine. +""" + +import atexit +import logging +import os +import re +import subprocess +import sys +from collections.abc import Sequence +from functools import cache +from importlib import import_module +from pathlib import Path + +import jgo +import jpype +import jpype.config + +import scyjava.config +from scyjava._jdk_fetch import resolve_java +from scyjava.config import Mode, mode + +_logger = logging.getLogger(__name__) + +_startup_callbacks = [] +_shutdown_callbacks = [] + + +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) + + If the JVM is already started, this function returns the equivalent of: + jimport('java.lang.System') + .getProperty('java.version') + .split('.') + + In case the JVM is not started yet, a best effort is made to deduce + the version from the environment without actually starting up the + JVM in-process. If the version cannot be deduced, a RuntimeError + with the cause is raised. + """ + if mode == Mode.JEP: + System = jimport("java.lang.System") + version = str(System.getProperty("java.version")) + # Get everything up to the hyphen + version = version.split("-")[0] + return tuple(map(int, version.split("."))) + + assert mode == Mode.JPYPE + + 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_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'. + + default_jvm_path = jpype.getDefaultJVMPath() + if not default_jvm_path: + raise RuntimeError("Cannot glean the 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( + "/Contents/MacOS/libjli.dylib", "/Contents/Home/lib/libjli.dylib" + ) + + p = Path(jvm_path) + java = None + + if not p.exists(): + # 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 + + 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": + 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: + 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: + output = subprocess.check_output( + [str(java), "-version"], stderr=subprocess.STDOUT + ).decode() + except subprocess.CalledProcessError as e: + raise RuntimeError("System call to java failed") from e + + return _jvm_version_str_to_tuple(output, java) + + +def _jvm_version_str_to_tuple(java_version_output: str, java: str) -> tuple[int, ...]: + java_version_output = java_version_output.replace("\n", " ").replace("\r", "") + m = re.match(r'.*version "(\d+(?:\.\d+)*)', java_version_output) + if not m: + raise RuntimeError( + f"Inscrutable java command output:\n$ {java} -version\n{java_version_output}" + ) + + v = m.group(1) + _logger.debug(f"Got Java version: {v}") + + try: + return tuple(map(int, v.split("."))) + except ValueError: + raise RuntimeError(f"Inscrutable java version: {v}") + + +def start_jvm(options: Sequence[str] | 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 + this function directly is typically not necessary, because the first + time a scyjava function needing a JVM is invoked, one is started on the + fly with the configuration specified via the scijava.config mechanism. + + :param options: + List of options to pass to the JVM. + For example: ['-Dfoo=bar', '-XX:+UnlockExperimentalVMOptions'] + See also scyjava.config.add_options. + """ + # if JVM is already running -- break + if jvm_started(): + 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 + + # retrieve endpoint and repositories from scyjava config + endpoints = scyjava.config.endpoints + repositories = scyjava.config.get_repositories() + + # use the logger to notify user that endpoints are being added + _logger.debug(f"Adding jars from endpoints {endpoints}") + + # download Java as appropriate + resolve_java() + + # 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 + endpoints = endpoints[:1] + sorted(endpoints[1:]) + _logger.debug("Using endpoints %s", endpoints) + + # join endpoints list to single concatenated endpoint + endpoint = "+".join(endpoints) + + env = jgo.build( + endpoint=endpoint, + # 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(), + ) + jpype.addClassPath(env.modules_dir / "*") + jpype.addClassPath(env.jars_dir / "*") + + # HACK: Try to set JAVA_HOME if it isn't already. + if ( + "JAVA_HOME" not in os.environ + or not os.environ["JAVA_HOME"] + or not os.path.isdir(os.environ["JAVA_HOME"]) + ): + _logger.debug("JAVA_HOME not set. Will try to infer it from sys.path.") + + libjvm_win = Path("Library") / "jre" / "bin" / "server" / "jvm.dll" + libjvm_macos = Path("lib") / "server" / "libjvm.dylib" + libjvm_linux = Path("lib") / "server" / "libjvm.so" + libjvm_paths = { + libjvm_win: Path("Library"), + libjvm_macos: Path(), + libjvm_linux: Path(), + } + for p in sys.path: + if not p.endswith("site-packages"): + continue + # e.g. $CONDA_PREFIX/lib/python3.10/site-packages -> $CONDA_PREFIX + # But we want it to work outside of Conda as well, theoretically. + base = Path(p).parent.parent.parent + for libjvm_path, java_home_path in libjvm_paths.items(): + if (base / libjvm_path).exists(): + java_home = str((base / java_home_path).resolve()) + _logger.debug(f"Detected JAVA_HOME: {java_home}") + os.environ["JAVA_HOME"] = java_home + break + + # initialize JPype JVM + _logger.debug("Starting JVM") + if options is None: + options = scyjava.config.get_options() + kwargs = scyjava.config.get_kwargs() + jpype.startJVM(*options, **kwargs) + + # replace JPype/JVM shutdown handling with our own + jpype.config.onexit = False + jpype.config.free_resources = False + atexit.register(shutdown_jvm) + + # invoke registered callback functions + for callback in _startup_callbacks: + callback() + + +def shutdown_jvm() -> None: + """Shut down the JVM. + + This function makes a best effort to clean up Java resources first. + In particular, shutdown hooks registered with scyjava.when_jvm_stops + are sequentially invoked. + + Then, if the AWT subsystem has started, all AWT windows (as identified + by the java.awt.Window.getWindows() method) are disposed to reduce the + risk of GUI resources delaying JVM shutdown. + + Finally, the jpype.shutdownJVM() function is called. Note that you can + set the jpype.config.destroy_jvm flag to request JPype to destroy the + JVM explicitly, although setting this flag can lead to delayed shutdown + times while the JVM is waiting for threads to finish. + + Note that if the JVM is not already running, then this function does + nothing! In particular, shutdown hooks are skipped in this situation. + + :raise RuntimeError: if this method is called while in Jep mode. + """ + if not jvm_started(): + return + + if mode == Mode.JEP: + raise RuntimeError("Cannot shut down the JVM in Jep mode.") + + assert mode == Mode.JPYPE + + # invoke registered shutdown callback functions + for callback in _shutdown_callbacks: + try: + callback() + except Exception as e: # noqa: BLE001 + _logger.error(f"Exception during shutdown callback: {e}") + + # dispose AWT resources if applicable + if is_awt_initialized(): + Window = jimport("java.awt.Window") + for w in Window.getWindows(): + w.dispose() + + # okay to shutdown JVM + try: + jpype.shutdownJVM() + except Exception as e: # noqa: BLE001 + _logger.error(f"Exception during JVM shutdown: {e}") + + +def jvm_started() -> bool: + """Return true iff a Java virtual machine (JVM) has been started.""" + if mode == Mode.JEP: + return True + + assert mode == Mode.JPYPE + + return jpype.isJVMStarted() + + +def gc() -> None: + """ + Do a round of Java garbage collection. + + This function is a shortcut for Java's System.gc(). + + :raise RuntimeError: If the JVM has not started yet. + """ + _assert_jvm_started() + System = jimport("java.lang.System") + System.gc() + + +def memory_total() -> int: + """ + Get the total amount of memory currently reserved by the JVM. + + This number will always be less than or equal to memory_max(). + + In case the JVM was configured with -Xms flag upon startup (e.g. using + the scyjava.config.set_heap_min function), the initial value will typically + correspond approximately, but not exactly, to the configured value, + although it is likely to grow over time as more Java objects are allocated. + + This function is a shortcut for Java's Runtime.getRuntime().totalMemory(). + + :return: The total memory in bytes. + :raise RuntimeError: if the JVM has not yet been started. + """ + return int(_runtime().totalMemory()) + + +def memory_max() -> int: + """ + Get the maximum amount of memory that the JVM will attempt to use. + + This number will always be greater than or equal to memory_total(). + + In case the JVM was configured with -Xmx flag upon startup (e.g. using + the scyjava.config.set_heap_max function), the value will typically + correspond approximately, but not exactly, to the configured value. + + This function is a shortcut for Java's Runtime.getRuntime().maxMemory(). + + :return: The maximum memory in bytes. + :raise RuntimeError: if the JVM has not yet been started. + """ + return int(_runtime().maxMemory()) + + +def memory_used() -> int: + """ + Get the amount of memory currently in use by the JVM. + + This function is a shortcut for + Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(). + + :return: The used memory in bytes. + :raise RuntimeError: if the JVM has not yet been started. + """ + return memory_total() - int(_runtime().freeMemory()) + + +def available_processors() -> int: + """ + Get the number of processors available to the JVM. + + This function is a shortcut for Java's + Runtime.getRuntime().availableProcessors(). + + :return: The number of available processors. + :raise RuntimeError: if the JVM has not yet been started. + """ + return int(_runtime().availableProcessors()) + + +def is_jvm_headless() -> bool: + """ + Return true iff Java is running in headless mode. + + :raise RuntimeError: If the JVM has not started yet. + """ + if not jvm_started(): + raise RuntimeError("JVM has not started yet!") + + GraphicsEnvironment = scyjava.jimport("java.awt.GraphicsEnvironment") + return bool(GraphicsEnvironment.isHeadless()) + + +def is_awt_initialized() -> bool: + """ + Return true iff the AWT subsystem has been initialized. + + Java starts up its AWT subsystem automatically and implicitly, as + soon as an action is performed requiring it -- for example, if you + jimport a java.awt or javax.swing class. This can lead to deadlocks + on macOS if you are not running in headless mode and did not invoke + those actions via the jpype.setupGuiEnvironment wrapper function; + see the Troubleshooting section of the scyjava README for details. + """ + if not jvm_started(): + return False + Thread = scyjava.jimport("java.lang.Thread") + threads = Thread.getAllStackTraces().keySet() + return any(t.getName().startsWith("AWT-") for t in threads) + + +def when_jvm_starts(f) -> None: + """ + Registers a function to be called when the JVM starts (or immediately). + This is useful to defer construction of Java-dependent data structures + until the JVM is known to be available. If the JVM has already been + started, the function executes immediately. + + :param f: Function to invoke when scyjava.start_jvm() is called. + """ + if jvm_started(): + # JVM was already started; invoke callback function immediately. + f() + else: + # Add function to the list of callbacks to invoke upon start_jvm(). + _startup_callbacks.append(f) + + +def when_jvm_stops(f) -> None: + """ + Registers a function to be called just before the JVM shuts down. + This is useful to perform cleanup of Java-dependent data structures. + + Note that if the JVM is not already running when shutdown_jvm is + called, then these registered callback functions will be skipped! + + :param f: Function to invoke when scyjava.shutdown_jvm() is called. + """ + _shutdown_callbacks.append(f) + + +@cache +def jimport(class_name: str): + """ + Import a class from Java to Python. + + :param class_name: Name of the class to import. + :return: + A pointer to the class, which can be used to + e.g. instantiate objects of that class. + """ + if mode == Mode.JEP: + module_path = class_name.rsplit(".", 1) + module = import_module(module_path[0], module_path[1]) + return getattr(module, module_path[1]) + + assert mode == Mode.JPYPE + start_jvm() + return jpype.JClass(class_name) + + +def _assert_jvm_started(): + if not jvm_started(): + raise RuntimeError("JVM has not started yet!") + + +def _runtime(): + _assert_jvm_started() + Runtime = jimport("java.lang.Runtime") + return Runtime.getRuntime() diff --git a/src/scyjava/_script.py b/src/scyjava/_script.py new file mode 100644 index 00000000..5302a667 --- /dev/null +++ b/src/scyjava/_script.py @@ -0,0 +1,135 @@ +""" +Logic for making Python available to Java as a SciJava scripting language. + +For the Java side of this functionality, see +https://github.com/scijava/scripting-python. +""" + +import ast +import sys +import threading +import traceback +from contextlib import redirect_stdout + +from jpype import JImplements, JOverride + +from scyjava._convert import to_java +from scyjava._jvm import jimport + + +def enable_python_scripting(context): + """ + Adds a Python script runner object to the ObjectService of the given + SciJava context. Intended for use in conjunction with + 'org.scijava:scripting-python'. + + :param context: The org.scijava.Context containing the ObjectService + where the PythonScriptRunner should be injected. + """ + ObjectService = jimport("org.scijava.object.ObjectService") + + class ScriptContextWriter: + def __init__(self, std): + self._std_default = std + self._thread_to_context = {} + + def addScriptContext(self, thread, scriptContext): + self._thread_to_context[thread] = scriptContext + + def removeScriptContext(self, thread): + if thread in self._thread_to_context: + del self._thread_to_context[thread] + + def flush(self): + self._writer().flush() + + def write(self, s): + self._writer().write(s) + + def _writer(self): + ctx = self._thread_to_context.get(threading.currentThread()) + return self._std_default if ctx is None else ctx.getWriter() + + stdoutContextWriter = ScriptContextWriter(sys.stdout) + + @JImplements("java.util.function.Supplier") + class PythonObjectSupplier: + def __init__(self, obj): + self.obj = obj + + @JOverride + def get(self): + return self.obj + + @JImplements("java.util.function.Function") + class PythonScriptRunner: + @JOverride + def apply(self, arg): + # Copy script bindings/vars into script locals. + script_locals = {} + for key in arg.vars: + script_locals[key] = arg.vars[key] + + stdoutContextWriter.addScriptContext( + threading.currentThread(), arg.scriptContext + ) + + return_value = None + with redirect_stdout(stdoutContextWriter): + try: + # NB: Execute the block, except for the last statement, + # which we evaluate instead to get its return value. + # Credit: https://stackoverflow.com/a/39381428/1207769 + + block = ast.parse(str(arg.script), mode="exec") + last = None + if ( + len(block.body) > 0 + and hasattr(block.body[-1], "value") + and not isinstance(block.body[-1], ast.Assign) + ): + # Last statement looks like an expression. Evaluate! + last = ast.Expression(block.body.pop().value) + + # 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( # noqa: S102 + compile(block, "", mode="exec"), _globals, script_locals + ) + if last is not None: + return_value = eval( + compile(last, "", mode="eval"), + _globals, + script_locals, + ) + except Exception: # noqa: BLE001 + error_message = traceback.format_exc() + error_writer = arg.scriptContext.getErrorWriter() + 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()) + + # Copy script locals back into script bindings/vars. + for key, value in script_locals.items(): + try: + arg.vars[key] = to_java(value) + except Exception: # noqa: BLE001 + arg.vars[key] = PythonObjectSupplier(value) + + return to_java(return_value) + + objectService = context.service(ObjectService) + objectService.addObject(PythonScriptRunner(), "PythonScriptRunner") diff --git a/src/scyjava/_types.py b/src/scyjava/_types.py new file mode 100644 index 00000000..f49d75fa --- /dev/null +++ b/src/scyjava/_types.py @@ -0,0 +1,339 @@ +""" +Utility functions for working with and reasoning about Java types. +""" + +from collections.abc import Callable, Sequence +from typing import Any + +import jpype + +from scyjava._jvm import jimport, jvm_started, start_jvm +from scyjava.config import Mode, mode + + +class JavaClasses: + """ + Utility class used to make importing frequently-used Java classes + significantly easier and more readable. + + Benefits: + * Minimal boilerplate + * Lazy evaluation + * Usable within type hints + + Example: + + from scyjava import JavaClasses + + class MyJavaClasses(JavaClasses): + @JavaClasses.java_import + def String(self): return "java.lang.String" + @JavaClasses.java_import + def Integer(self): return "java.lang.Integer" + # ... and many more ... + + jc = MyJavaClasses() + + def parse_number_with_java(s: "jc.String") -> "jc.Integer": + return jc.Integer.parseInt(s) + """ + + def java_import(func: Callable[[], str]) -> property: + """ + A decorator used to lazily evaluate a java import. + func is a function of a Python class that takes no arguments and + returns a string identifying a Java class by name. + + Using that function, this decorator creates a property + that when called, imports the class identified by the function. + """ + + @property + def inner(self): + if not jvm_started(): + raise RuntimeError("The JVM has not been started yet.") + try: + return jimport(func(self)) + except TypeError: + return None + + return inner + + +def jclass(data): + """ + Obtain a Java class object. + + Supported types include: + + A. Name of a class to look up -- e.g. "java.lang.String" -- + which returns the equivalent of Class.forName("java.lang.String"). + + B. A static-style class reference -- e.g. String -- + which returns the equivalent of String.class. + + C. A Java object -- e.g. foo -- + which returns the equivalent of foo.getClass(). + + Note that if you pass a java.lang.Class object, you will get back Class.class, + i.e. the Java class for the Class class. :-) + + :param data: The object from which to glean the class. + :return: A java.lang.Class object, suitable for use with reflection. + :raise TypeError: if the argument is not one of the aforementioned types. + """ + if isinstance(data, str): + # Name of a class -- case (A) above. + return jclass(jimport(data)) + + if mode == Mode.JPYPE: + start_jvm() + if isinstance(data, jpype.JClass): + # JPype object representing a static-style class -- case (B) above. + return data.class_ + elif mode == Mode.JEP: + if str(type(data.getClass())) == "": + # Jep object representing a static-style class -- case (B) above. + raise ValueError( + "Jep does not support Java class objects " + + "-- see https://github.com/ninia/jep/issues/405" + ) + + # A Java object -- case (C) above. + if jinstance(data, "java.lang.Object"): + return data.getClass() + + raise TypeError("Cannot glean class from data of type: " + str(type(data))) + + +def jstacktrace(exc) -> str: + """ + Extract the Java-side stack trace from a Java exception. + + Example of usage: + + from scyjava import jimport, jstacktrace + try: + Integer = jimport('java.lang.Integer') + nan = Integer.parseInt('not a number') + except Exception as exc: + print(jstacktrace(exc)) + + :param exc: The Java Throwable from which to extract the stack trace. + :return: A multi-line string containing the stack trace, or empty string + if no stack trace could be extracted. + """ + try: + StringWriter = jimport("java.io.StringWriter") + PrintWriter = jimport("java.io.PrintWriter") + sw = StringWriter() + exc.printStackTrace(PrintWriter(sw, True)) + return str(sw) + except BaseException: # noqa: BLE001 + return "" + + +def isjava(data) -> bool: + """Return whether the given data object is a Java object.""" + if mode == Mode.JEP: + return jinstance(data, "java.lang.Object") + + assert mode == Mode.JPYPE + return isinstance(data, (jpype.JClass, jpype.JObject)) + + +def is_jbyte(the_type: type) -> bool: + return _is_jtype(the_type, "java.lang.Byte") + + +def is_jshort(the_type: type) -> bool: + return _is_jtype(the_type, "java.lang.Short") + + +def is_jinteger(the_type: type) -> bool: + return _is_jtype(the_type, "java.lang.Integer") + + +def is_jlong(the_type: type) -> bool: + return _is_jtype(the_type, "java.lang.Long") + + +def is_jfloat(the_type: type) -> bool: + return _is_jtype(the_type, "java.lang.Float") + + +def is_jdouble(the_type: type) -> bool: + return _is_jtype(the_type, "java.lang.Double") + + +def is_jboolean(the_type: type) -> bool: + return _is_jtype(the_type, "java.lang.Boolean") + + +def is_jcharacter(the_type: type) -> bool: + return _is_jtype(the_type, "java.lang.Character") + + +def is_jarray(data: Any) -> bool: + """Return whether the given data object is a Java array.""" + if mode == Mode.JEP: + return str(type(data)) == "" + + assert mode == Mode.JPYPE + return isinstance(data, jpype.JArray) + + +def jinstance(obj, jtype) -> bool: + """ + Test if the given object is an instance of a particular Java type. + + :param obj: The object to check. + :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. + """ + if isinstance(jtype, str): + jtype = jimport(jtype) + + if mode == Mode.JEP: + return isinstance(obj, jtype.__pytype__) + + assert mode == Mode.JPYPE + return isinstance(obj, jtype) + + +def jarray(kind, lengths: Sequence): + """ + Create a new n-dimensional Java array. + + :param kind: The type of array to create. This can either be a particular + type of object as obtained from jimport, or else a special code for one of + the eight primitive array types: + * 'b' for byte + * 'c' for char + * 'd' for double + * 'f' for float + * 'i' for int + * 'j' for long + * 's' for short + * 'z' for boolean + :param lengths: List of lengths for the array. For example: + `jarray('z', [3, 7])` is the equivalent of `new boolean[3][7]` in Java. + You can pass a single integer to make a 1-dimensional array of that length. + :return: The newly allocated array + """ + if isinstance(kind, str): + kind = kind.lower() + if isinstance(lengths, int): + lengths = [lengths] + arraytype = kind + + if mode == Mode.JEP: + import jep + + if len(lengths) == 1: + # Fast case: 1-d array (we can use primitives) + arr = jep.jarray(lengths[0], arraytype) + else: + # Slow case: n-d array (we cannot use primitives) + # See https://github.com/ninia/jep/issues/439 + kinds = { + "b": jimport("java.lang.Byte"), + "c": jimport("java.lang.Character"), + "d": jimport("java.lang.Double"), + "f": jimport("java.lang.Float"), + "i": jimport("java.lang.Integer"), + "j": jimport("java.lang.Long"), + "s": jimport("java.lang.Short"), + "z": jimport("java.lang.Boolean"), + } + if arraytype in kinds: + arraytype = kinds[arraytype] + kind = arraytype + # build up the array type + for _ in range(len(lengths) - 1): + arraytype = jep.jarray(0, arraytype) + # instantiate the n-dimensional array + arr = jep.jarray(lengths[0], arraytype) + + elif mode == Mode.JPYPE: + start_jvm() + + # build up the array type + kinds = { + "b": jpype.JByte, + "c": jpype.JChar, + "d": jpype.JDouble, + "f": jpype.JFloat, + "i": jpype.JInt, + "j": jpype.JLong, + "s": jpype.JShort, + "z": jpype.JBoolean, + } + if arraytype in kinds: + arraytype = kinds[arraytype] + for _ in range(len(lengths)): + arraytype = jpype.JArray(arraytype) + # 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:]) + return arr + + +def numeric_bounds( + the_type: type, +) -> tuple[int, int] | tuple[float, float] | tuple[None, None]: + """ + Get the minimum and maximum values for the given numeric type. + For example, a Java long returns (int(Long.MIN_VALUE), int(Long.MAX_VALUE)), + whereas a Java double returns (float(-Double.MAX_VALUE), float(Double.MAX_VALUE)). + + :param the_type: The type whose minimum and maximum values are needed. + :return: + The minimum and maximum values as a two-element tuple of int or float, + or a two-element tuple of None if no known bounds. + """ + if is_jbyte(the_type): + Byte = jimport("java.lang.Byte") + return int(Byte.MIN_VALUE), int(Byte.MAX_VALUE) + + if is_jshort(the_type): + Short = jimport("java.lang.Short") + return int(Short.MIN_VALUE), int(Short.MAX_VALUE) + + if is_jinteger(the_type): + Integer = jimport("java.lang.Integer") + return int(Integer.MIN_VALUE), int(Integer.MAX_VALUE) + + if is_jlong(the_type): + Long = jimport("java.lang.Long") + return int(Long.MIN_VALUE), int(Long.MAX_VALUE) + + if is_jfloat(the_type): + Float = jimport("java.lang.Float") + return float(-Float.MAX_VALUE), float(Float.MAX_VALUE) + + if is_jdouble(the_type): + Double = jimport("java.lang.Double") + return float(-Double.MAX_VALUE), float(Double.MAX_VALUE) + + return None, None + + +def _is_jtype(the_type: type, class_name: str) -> bool: + """ + Test if the given type object is *exactly* the specified Java type. + + :param the_type: The type object to check. + :param class_name: The fully qualified Java class name in string form. + :return: True iff the type is exactly that Java type. + """ + # NB: Stringify the type to support both bridge modes. Ex: + # * JPype: + # * Jep: + return f"class '{class_name}'" in str(the_type) diff --git a/src/scyjava/_versions.py b/src/scyjava/_versions.py new file mode 100644 index 00000000..f1632195 --- /dev/null +++ b/src/scyjava/_versions.py @@ -0,0 +1,70 @@ +""" +Utility functions for working with and reasoning about software component versions. +""" + +import logging +from importlib.metadata import version + +from scyjava._jvm import jimport +from scyjava._types import isjava + +_logger = logging.getLogger(__name__) + + +def get_version(java_class_or_python_package) -> str: + """ + Return the version of a Java class or Python package. + + 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. + + The version string is extracted from the given class's associated JAR + artifact (if any), either the embedded Maven POM if the project was built + with Maven, or the JAR manifest's Specification-Version value if it exists. + + See org.scijava.VersionUtils.getVersion(Class) for further details. + """ + + if isjava(java_class_or_python_package): + # Assume we were given a Java class object. + VersionUtils = jimport("org.scijava.util.VersionUtils") + return str(VersionUtils.getVersion(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: + """ + Return a boolean on a version comparison. + Requires org.scijava:scijava-common on the classpath. + + Returns True if the given actual version is greater than or + equal to the specified minimum version, or False otherwise. + + See org.scijava.VersionUtils.compare(String, String) for further details. + """ + VersionUtils = jimport("org.scijava.util.VersionUtils") + return bool(VersionUtils.compare(actual_version, minimum_version) >= 0) + + +def compare_version(version, java_class_version): + """ + This function is deprecated. Use is_version_at_least instead. + """ + _logger.warning( + "The compare_version function is deprecated. Use is_version_at_least instead." + ) + return version != java_class_version and is_version_at_least( + java_class_version, version + ) diff --git a/src/scyjava/config.py b/src/scyjava/config.py new file mode 100644 index 00000000..76271739 --- /dev/null +++ b/src/scyjava/config.py @@ -0,0 +1,482 @@ +from __future__ import annotations + +import enum as _enum +import logging as _logging +import os as _os +from collections.abc import Sequence +from pathlib import Path + +import jpype as _jpype + +_SCIJAVA_PUBLIC = "https://maven.scijava.org/content/groups/public" + + +_logger = _logging.getLogger(__name__) + +# Constraints on the Java installation to be used. +_fetch_java: str = "always" +_java_vendor: str = "zulu-jre" +_java_version: str = "11" + +endpoints: list[str] = [] + +_repositories = {"scijava.public": _SCIJAVA_PUBLIC} +_verbose = 0 +_manage_deps = True +_cache_dir = Path.home() / ".jgo" +_m2_repo = Path.home() / ".m2" / "repository" +_options = [] +_kwargs = {"interrupt": True} +_shortcuts = {} + + +class Mode(_enum.Enum): + JEP = "jep" + JPYPE = "jpype" + + +try: + import jep # noqa: F401 + + mode = Mode.JEP +except ImportError: + 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. + + :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), 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 to download and cache. + Defaults to "zulu-jre". Does not constrain matching of system JDK/JREs. + :param version: + Expression defining the Java version to download and cache. + Defaults to "11". Does not constrain matching of system JDK/JREs. + :param maven_url: + DEPRECATED: scyjava no longer uses Maven to resolve dependencies. + :param maven_sha: + DEPRECATED: scyjava no longer uses Maven to resolve dependencies. + """ + 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. + 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: + _logger.warning( + "Deprecated argument: scyjava.config.set_java_constraints(maven_url). " + "scyjava no longer uses Maven to resolve dependencies." + ) + _maven_url = maven_url + 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 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" 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: + """ + 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. + """ + return _java_vendor + + +def get_java_version() -> str: + """ + 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. + """ + return _java_version + + +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. + """ + for arg in args: + _logger.debug("Adding repositories %s to %s", arg, _repositories) + _repositories.update(arg) + _logger.debug("Adding repositories %s to %s", kwargs, _repositories) + _repositories.update(kwargs) + + +def get_repositories() -> dict[str, str]: + """ + Get the Maven repositories jgo will use for downloading dependencies. + See the jgo documentation for details. + """ + return _repositories + + +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() -> int: + """ + Get the level of verbosity for logging environment construction details. + """ + _logger.debug("Getting verbose level: %d", _verbose) + return _verbose + + +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() -> bool: + """ + Get whether jgo will resolve dependencies in managed mode. + See the jgo documentation for details. + """ + return _manage_deps + + +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)", cache_dir, _cache_dir) + _cache_dir = cache_dir + + +def get_cache_dir() -> Path: + """ + Get the location to use for the jgo environment cache. + See the jgo documentation for details. + """ + return _cache_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)", repo_dir, _m2_repo) + _m2_repo = repo_dir + + +def get_m2_repo() -> Path: + """ + Get the location to use for the local Maven repository cache. + """ + return _m2_repo + + +def add_classpath(*path) -> None: + """ + Add elements to the Java class path. + + See also find_jars, which can be combined with add_classpath to + add all the JARs beneath a given directory to the class path, a la: + + add_classpath(*find_jars('/path/to/folder-of-jars')) + + :param path: + One or more file paths to add to the Java class path. + + A valid Java class path element is typically either a .jar file or a + directory. When a class needs to be loaded, the Java runtime looks + beneath each class path element for the .class file, nested in a folder + structure matching the class's package name. For example, when loading + a class foo.bar.Fubar, if a directory /home/jdoe/classes is included as + a class path element, the class file at + /home/jdoe/classes/foo/bar/Fubar.class will be used. It works the same + for JAR files, except that the class files are loaded from the + directory structure inside the JAR; in this example, a JAR file + /home/jdoe/jars/fubar.jar on the class path containing file + foo/bar/Fubar.class inside would be another way to provide the class + foo.bar.Fubar. + """ + for p in path: + _jpype.addClassPath(p) + + +def find_jars(directory: Path | str) -> list[str]: + """ + Find .jar files beneath a given directory. + + :param directory: the folder to be searched + :return: a list of JAR files + """ + jars = [] + for root, _, files in _os.walk(directory): + for f in files: + if f.lower().endswith(".jar"): + path = _os.path.join(root, f) + jars.append(path) + return jars + + +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 = None, gb: int | None = None) -> None: + """ + Set the initial amount of memory to allocate to the Java heap. + + Either mb or gb, but not both, must be given. + + Shortcut for passing -Xms###m or -Xms###g to Java. + + :param mb: + The ### of megabytes of memory Java should start with. + :param gb: + The ### of gigabytes of memory Java should start with. + :raise ValueError: If exactly one of mb or gb is not given. + """ + add_option(f"-Xms{_mem_value(mb, gb)}") + + +def set_heap_max(mb: int | None = None, gb: int | None = None) -> None: + """ + Shortcut for passing -Xmx###m or -Xmx###g to Java. + + Either mb or gb, but not both, must be given. + + :param mb: + The maximum ### of megabytes of memory Java is allowed to use. + :param gb: + The maximum ### of gigabytes of memory Java is allowed to use. + :raise ValueError: If exactly one of mb or gb is not given. + """ + add_option(f"-Xmx{_mem_value(mb, gb)}") + + +def _mem_value(mb: int | None = None, gb: int | None = None) -> str: + # fmt: off + if mb is not None and gb is None: return f"{mb}m" + if gb is not None and mb is None: return f"{gb}g" + # fmt: on + raise ValueError("Exactly one of mb or gb must be given.") + + +def enable_headless_mode() -> None: + """ + Enable headless mode, for running Java without a display. + This mode prevents any graphical elements from popping up. + Shortcut for passing -Djava.awt.headless=true to Java. + """ + add_option("-Djava.awt.headless=true") + + +def enable_remote_debugging(port: int = 8000, suspend: bool = False): + """ + Enable the JDWP debugger, listening on the given port of localhost. + Shortcut for -agentlib:jdwp=transport=dt_socket,address=localhost:. + + :param port: + The port to listen on for client debuggers (e.g. IDEs). + :param suspend: + If True, pause when starting up the JVM until a client debugger connects. + """ + jdwp_args = { + "transport": "dt_socket", + "server": "y", + "suspend": "y" if suspend else "n", + "address": f"localhost:{port}", + } + arg_string = ",".join(f"{k}={v}" for k, v in jdwp_args.items()) + add_option(f"-agentlib:jdwp={arg_string}") + + +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. + """ + _options.append(option) + + +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. + """ + if isinstance(options, str): + _options.append(options) + else: + _options.extend(options) + + +def get_options() -> list[str]: + """ + Get the list of options to be passed at JVM startup. + """ + 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 + """ + _kwargs.update(kwargs) + + +def get_kwargs() -> dict[str, str]: + """ + Get the keyword arguments to be passed to JPype at JVM startup. + """ + return _kwargs + + +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. + """ + _shortcuts[k] = v + + +def get_shortcuts() -> dict[str, str]: + """ + Get the dictionary of shorts that jgo will use for evaluating endpoints. + See the jgo documentation for details. + """ + 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." + ) + _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." + ) + 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" +_maven_sha: str = "a555254d6b53d267965a3404ecb14e53c3827c09c3b94b5678835887ab404556bfaf78dcfe03ba76fa2508649dca8531c74bca4d5846513522404d48e8c4ac8b" + + +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 diff --git a/src/scyjava/inspect.py b/src/scyjava/inspect.py new file mode 100644 index 00000000..a726d86c --- /dev/null +++ b/src/scyjava/inspect.py @@ -0,0 +1,180 @@ +""" +High-level convenience functions for inspecting Java objects. +""" + +from __future__ import annotations + +from sys import stdout as _stdout + +from scyjava import _introspect + + +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. + + :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", static=static, source=source, writer=writer) + + +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", static=static, source=source, writer=writer + ) + + +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", static=static, source=source, writer=writer) + + +def methods(data, static: bool | None = None, source: bool | None = None, 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, 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) + writer(f"Source code URL: {source_url}\n") + + +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: bool | None = None, source: bool | None = None, writer=None +): + """ + 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 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) + if len(table) == 0: + writer(f"No {aspect} found\n") + return + + # Print source code + offset = max(len(entry["returns"] or "void") for entry in table) + all_methods = "" + 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: + 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 + or static + and "static" in entry["mods"] + or not static + and "static" not in entry["mods"] + ): + entry_string = _pretty_string(entry, offset) + all_methods += entry_string + else: + continue + all_methods += "\n" + + # 4 added to align the asterisk with output. + writer(f"{'':<{offset + 4}}* indicates static modifier\n") + writer(all_methods) diff --git a/tests/it/awt.py b/tests/it/awt.py new file mode 100644 index 00000000..4d0bd4b5 --- /dev/null +++ b/tests/it/awt.py @@ -0,0 +1,32 @@ +""" +Test scyjava AWT-related functions. +""" + +import platform +import sys + +from assertpy import assert_that + +import scyjava + +if platform.system() == "Darwin": + # NB: This test would hang on macOS, due to AWT threading issues. + sys.exit(0) + +assert_that(scyjava.jvm_started()).is_false() + +scyjava.start_jvm() + +if scyjava.is_jvm_headless(): + # NB: We did not configure the JVM to run in headless mode. + # But it is still headless, which indicates we are running + # on a headless system, such as continuous integration (CI). + # In that case, we are not able to perform this test. + sys.exit(0) + +assert_that(scyjava.is_awt_initialized()).is_false() + +Frame = scyjava.jimport("java.awt.Frame") +f = Frame() + +assert_that(scyjava.is_awt_initialized()).is_true() diff --git a/tests/it/headless.py b/tests/it/headless.py new file mode 100644 index 00000000..abe37e59 --- /dev/null +++ b/tests/it/headless.py @@ -0,0 +1,18 @@ +""" +Test scyjava headless mode. +""" + +from assertpy import assert_that + +import scyjava + +scyjava.config.enable_headless_mode() + +assert_that(scyjava.jvm_started()).is_false() +scyjava.start_jvm() +assert_that(scyjava.is_jvm_headless()).is_true() + +Frame = scyjava.jimport("java.awt.Frame") +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 new file mode 100644 index 00000000..5b14939f --- /dev/null +++ b/tests/it/java_heap.py @@ -0,0 +1,36 @@ +""" +Test scyjava JVM memory-related functions. +""" + +from assertpy import assert_that + +import scyjava + +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_that(scyjava.jvm_started()).is_false() + +scyjava.start_jvm() + +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 +mb_used = scyjava.memory_used() // 1024 // 1024 + +assert_that( + mb_used, "Used memory should be less than the current memory total" +).is_less_than_or_equal_to(mb_total) +assert_that( + mb_total, "current memory total should be less than maximum memory" +).is_less_than_or_equal_to(mb_max) +assert_that(mb_max, "maximum heap size should be approx. 1 GB").is_between(900, 1024) + +assert_that(mb_used, "most memory should be available").is_less_than(mb_tolerance) +assert_that(mb_total, "total memory should be close to initial").is_close_to( + mb_initial, tolerance=mb_tolerance +) diff --git a/tests/it/jvm_version.py b/tests/it/jvm_version.py new file mode 100644 index 00000000..f488c7cf --- /dev/null +++ b/tests/it/jvm_version.py @@ -0,0 +1,25 @@ +""" +Test the jvm_version() function. +""" + +from assertpy import assert_that + +import scyjava + +assert_that(scyjava.jvm_started()).is_false() + +before_version = scyjava.jvm_version() +assert_that(before_version).is_not_none() +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() + +after_version = scyjava.jvm_version() +assert_that(after_version).is_not_none() +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) diff --git a/tests/it/script_scope.py b/tests/it/script_scope.py new file mode 100644 index 00000000..9d99e16e --- /dev/null +++ b/tests/it/script_scope.py @@ -0,0 +1,65 @@ +""" +Test the enable_python_scripting function, but here explictly testing import scope for declared functions. +""" + +import sys + +from assertpy import assert_that + +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_that(lang).is_not_none() +assert_that(lang.getNames()).contains("Python") + +# Construct a script. +script = """ +#@ int age +#@output String cbrt_age +import numpy as np + +def calculate_cbrt(age): + # 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) +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 + +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 new file mode 100644 index 00000000..6026d663 --- /dev/null +++ b/tests/it/scripting.py @@ -0,0 +1,64 @@ +""" +Test the enable_python_scripting function, and subsequent use of +the SciJava Python script language (org.scijava:scripting-python). + +As a side effect, this script also tests Maven dependency resolution. +""" + +import sys + +from assertpy import assert_that + +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_that(lang).is_not_none() +assert_that(lang.getNames()).contains("Python") + +# Construct a script. +script = """ +#@ String name +#@ int age +#@output String statement +statement = f"Hello, {name}! In one year you will be {age + 1} years old." +"A wild return value appears!" +""" +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, "name", "Chuckles", "age", 13) +try: + module = future.get() + outputs = module.getOutputs() + statement = outputs["statement"] + 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 + +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!") diff --git a/tests/test_arrays.py b/tests/test_arrays.py new file mode 100644 index 00000000..b419066d --- /dev/null +++ b/tests/test_arrays.py @@ -0,0 +1,138 @@ +""" +Tests for array-related functions in _types submodule. +""" + +import numpy as np + +from scyjava import is_jarray, jarray, to_python +from scyjava.config import Mode, mode + + +class TestArrays: + def test_non_primitive_jarray(self): + pass + + def test_jarray1d_to_python(self): + nums = [11, 6, 2, 15, 5] + jints = jarray("i", len(nums)) + for i in range(len(nums)): + jints[i] = nums[i] + + assert is_jarray(jints) + assert len(nums) == len(jints) + for i in range(len(nums)): + assert nums[i] == jints[i] + + def assert_array_conversion_works(jarr, expected): + pobj = to_python(jarr) + + if mode == Mode.JEP: + assert isinstance(pobj, list) + assert all(isinstance(v, int) for v in pobj) + assert len(expected) == len(pobj) + + elif mode == Mode.JPYPE: + assert isinstance(pobj, np.ndarray) + assert np.int32 == pobj.dtype + assert (len(expected),) == pobj.shape + + for i in range(len(expected)): + assert expected[i] == pobj[i] + + assert_array_conversion_works(jints, nums) + + # mutate Java array element values + deltas = [4, 100, 36, 133, 3] + for i in range(len(deltas)): + jints[i] = deltas[i] + + # convert to Python again and make sure it matches + assert_array_conversion_works(jints, deltas) + + def test_jarray2d_to_python(self): + nums = [ + [1.2, 3.4, 5.6], + [7.8, 9.1, 2.3], + [4.5, 6.7, 8.9], + [0.2, 4.6, 8.0], + [1.3, 5.7, 9.1], + ] + jdoubles = jarray("d", [len(nums), len(nums[0])]) + for i in range(len(nums)): + for j in range(len(nums[i])): + jdoubles[i][j] = nums[i][j] + + assert is_jarray(jdoubles) + assert 5 == len(jdoubles) + assert 3 == len(jdoubles[0]) + + pdoubles = to_python(jdoubles) + + if mode == Mode.JEP: + assert isinstance(pdoubles, list) + assert all(isinstance(v, list) for v in pdoubles) + assert len(nums) == len(pdoubles) + + elif mode == Mode.JPYPE: + assert isinstance(pdoubles, np.ndarray) + assert np.float64 == pdoubles.dtype + assert (5, 3) == pdoubles.shape + + for i in range(len(nums)): + for j in range(len(nums[i])): + assert nums[i][j] == pdoubles[i][j] + + def test_jarray2d_to_python_updates(self): + nums_init = [ + [1.2, 3.4, 5.6], + [7.8, 9.1, 2.3], + [4.5, 6.7, 8.9], + [0.2, 4.6, 8.0], + [1.3, 5.7, 9.1], + ] + nums_delta = [ + [15.3, 3.4, 5.6], + [7.8, 9.1, 22.3], + [90.5, 0.7, 8.9], + [80.2, 3.6, 59.0], + [1.5, 95.4, 9.1], + ] + jdoubles = jarray("d", [len(nums_init), len(nums_init[0])]) + for i in range(len(nums_init)): + for j in range(len(nums_init[i])): + jdoubles[i][j] = nums_init[i][j] + + # assert narr initial state + pdoubles = to_python(jdoubles) + if mode == Mode.JEP: + assert isinstance(pdoubles, list) + assert isinstance(pdoubles[0][0], float) + assert len(pdoubles) == 5 + assert len(pdoubles[0]) == 3 + elif mode == Mode.JPYPE: + assert isinstance(pdoubles, np.ndarray) + assert np.float64 == pdoubles.dtype + assert (5, 3) == pdoubles.shape + for i in range(len(nums_init)): + for j in range(len(nums_init[i])): + assert nums_init[i][j] == pdoubles[i][j] + + # change jdoubles data state + for i in range(len(nums_delta)): + for j in range(len(nums_delta[i])): + jdoubles[i][j] = nums_delta[i][j] + + # assert narr delta state + pdoubles = to_python(jdoubles) + if mode == Mode.JEP: + assert isinstance(pdoubles, list) + assert isinstance(pdoubles[0][0], float) + assert len(pdoubles) == 5 + assert len(pdoubles[0]) == 3 + elif mode == Mode.JPYPE: + assert isinstance(pdoubles, np.ndarray) + assert np.float64 == pdoubles.dtype + assert (5, 3) == pdoubles.shape + for i in range(len(nums_delta)): + for j in range(len(nums_delta[i])): + assert nums_delta[i][j] == pdoubles[i][j] diff --git a/tests/test_basics.py b/tests/test_basics.py new file mode 100644 index 00000000..00aa98a9 --- /dev/null +++ b/tests/test_basics.py @@ -0,0 +1,54 @@ +""" +Tests for key functions across all scyjava submodules. +""" + +import re + +import pytest + +import scyjava +from scyjava.config import Mode, mode + + +class TestBasics: + """ + Test basic scyjava functions. + """ + + def test_jclass(self): + """ + Test the jclass function. + """ + if mode == Mode.JEP: + pytest.skip("Jep does not support Java class objects!") + c = scyjava.jclass("java.lang.Object") + assert scyjava.jinstance(c, "java.lang.Class") + assert str(c.toString()) == "class java.lang.Object" + + def test_jimport(self): + """ + Test the jimport function. + """ + Object = scyjava.jimport("java.lang.Object") + assert Object is not None + assert str(Object) + o = Object() + assert scyjava.jinstance(o, "java.lang.Object") + assert re.match("java.lang.Object@[0-9a-f]+", str(o.toString())) + + def test_jinstance(self): + """ + Test the jinstance function. + """ + jstr = scyjava.to_java("Hello") + assert scyjava.jinstance(jstr, "java.lang.String") + + jint = scyjava.to_java(5) + assert scyjava.jinstance(jint, "java.lang.Integer") + + jfloat = scyjava.to_java(3.5) + assert scyjava.jinstance(jfloat, "java.lang.Float") + + jlist = scyjava.to_java([3, 2, 1]) + assert scyjava.jinstance(jlist, "java.util.List") + assert scyjava.jinstance(jlist, "java.util.ArrayList") diff --git a/tests/test_convert.py b/tests/test_convert.py index d2605a56..128975df 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -1,149 +1,369 @@ -import unittest -from scyjava.convert import jclass, to_java, to_python +""" +Tests for functions in _convert submodule. +""" -class TestConvert(unittest.TestCase): +import math +from os import getcwd +from pathlib import Path +import numpy as np +import pytest + +from scyjava import ( + Converter, + add_java_converter, + config, + jarray, + java_converters, + jclass, + jimport, + jinstance, + py_converters, + to_java, + to_python, +) +from scyjava.config import Mode, mode + +config.endpoints.append("org.scijava:scijava-table") +config.enable_headless_mode() + + +class TestConvert: def testClass(self): """ - Tests class detection from Java objects. + Test class detection from Java objects. """ + if mode == Mode.JEP: + pytest.skip("The jclass function does not work yet in Jep mode.") + int_class = jclass(to_java(5)) - self.assertEqual('java.lang.Integer', int_class.getName()) + assert "java.lang.Integer" == int_class.getName() long_class = jclass(to_java(4000000001)) - self.assertEqual('java.lang.Long', long_class.getName()) + assert "java.lang.Long" == long_class.getName() bigint_class = jclass(to_java(9879999999999999789)) - self.assertEqual('java.math.BigInteger', bigint_class.getName()) + assert "java.math.BigInteger" == bigint_class.getName() - string_class = jclass(to_java('foobar')) - self.assertEqual('java.lang.String', string_class.getName()) + string_class = jclass(to_java("foobar")) + assert "java.lang.String" == string_class.getName() list_class = jclass(to_java([1, 2, 3])) - self.assertEqual('java.util.ArrayList', list_class.getName()) + assert "java.util.ArrayList" == list_class.getName() - map_class = jclass(to_java({'a':'b'})) - self.assertEqual('java.util.LinkedHashMap', map_class.getName()) + map_class = jclass(to_java({"a": "b"})) + assert "java.util.LinkedHashMap" == map_class.getName() - self.assertEqual('java.util.Map', jclass('java.util.Map').getName()) + assert "java.util.Map" == jclass("java.util.Map").getName() def testBoolean(self): - jt = to_java(True) - self.assertEqual(True, jt.booleanValue()) - pt = to_python(jt) - self.assertEqual(True, pt) - self.assertEqual('True', str(pt)) - jf = to_java(False) - self.assertEqual(False, jf.booleanValue()) - pf = to_python(jf) - self.assertEqual(False, pf) - self.assertEqual('False', str(pf)) + jtrue = to_java(True) + assert jinstance(jtrue, "java.lang.Boolean") + assert jtrue.booleanValue() is True + ptrue = to_python(jtrue) + assert isinstance(ptrue, bool) + assert ptrue is True + + jfalse = to_java(False) + assert jinstance(jfalse, "java.lang.Boolean") + assert jfalse.booleanValue() is False + pfalse = to_python(jfalse) + assert isinstance(pfalse, bool) + assert pfalse is False + + def testByte(self): + obyte = 5 + jbyte = to_java(obyte, type="b") + assert jinstance(jbyte, "java.lang.Byte") + assert obyte == jbyte.byteValue() + pbyte = to_python(jbyte) + assert isinstance(pbyte, int) + assert obyte == pbyte + + def testShort(self): + oshort = 5 + jshort = to_java(oshort, type="s") + assert jinstance(jshort, "java.lang.Short") + assert oshort == jshort.shortValue() + pshort = to_python(jshort) + assert isinstance(pshort, int) + assert oshort == pshort def testInteger(self): - i = 5 - ji = to_java(i) - self.assertEqual(i, ji.intValue()) - pi = to_python(ji) - self.assertEqual(i, pi) - self.assertEqual(str(i), str(pi)) + oint = 5 + jint = to_java(oint) + assert jinstance(jint, "java.lang.Integer") + assert oint == jint.intValue() + pint = to_python(jint) + assert isinstance(pint, int) + assert oint == pint def testLong(self): - l = 4000000001 - jl = to_java(l) - self.assertEqual(l, jl.longValue()) - pl = to_python(jl) - self.assertEqual(l, pl) - self.assertEqual(str(l), str(pl)) + olong = 4000000001 + jlong = to_java(olong) + assert jinstance(jlong, "java.lang.Long") + assert olong == jlong.longValue() + plong = to_python(jlong) + assert isinstance(plong, int) + assert olong == plong def testBigInteger(self): - bi = 9879999999999999789 - jbi = to_java(bi) - self.assertEqual(bi, int(jbi.toString())) + obi = 9879999999999999789 + jbi = to_java(obi) + assert jinstance(jbi, "java.math.BigInteger") + assert str(obi) == str(jbi.toString()) pbi = to_python(jbi) - self.assertEqual(bi, pbi) - self.assertEqual(str(bi), str(pbi)) + assert isinstance(pbi, int) + assert obi == pbi + + def testFloat(self): + ofloat = 5.0 + jfloat = to_java(ofloat) + assert jinstance(jfloat, "java.lang.Float") + assert ofloat == jfloat.floatValue() + pfloat = to_python(jfloat) + assert isinstance(pfloat, float) + assert ofloat == pfloat + + def testDouble(self): + odouble = 4.56e123 + jdouble = to_java(odouble) + assert jinstance(jdouble, "java.lang.Double") + assert odouble == jdouble.doubleValue() + pdouble = to_python(jdouble) + assert isinstance(pdouble, float) + assert odouble == pdouble + + def testInf(self): + jinf = to_java(math.inf) + assert jinstance(jinf, "java.lang.Float") + assert math.inf == jinf.floatValue() + pinf = to_python(jinf) + assert isinstance(pinf, float) + assert math.inf == pinf + + jninf = to_java(-math.inf) + assert jinstance(jninf, "java.lang.Float") + assert -math.inf == jninf.floatValue() + pninf = to_python(jninf) + assert isinstance(pninf, float) + assert -math.inf == pninf + + def testNaN(self): + jnan = to_java(math.nan) + assert jinstance(jnan, "java.lang.Float") + assert math.isnan(jnan.floatValue()) + pnan = to_python(jnan) + assert isinstance(pnan, float) + assert math.isnan(pnan) def testString(self): - s = 'Hello world!' - js = to_java(s) - for e, a in zip(s, js.toCharArray()): - self.assertEqual(e, a) - ps = to_python(js) - self.assertEqual(s, ps) - self.assertEqual(str(s), str(ps)) + ostring = "Hello world!" + jstring = to_java(ostring) + assert jinstance(jstring, "java.lang.String") + for e, a in zip(ostring, jstring.toCharArray()): + assert e == a + pstring = to_python(jstring) + assert ostring == pstring def testList(self): - l = 'The quick brown fox jumps over the lazy dogs'.split() - jl = to_java(l) - for e, a in zip(l, jl): - self.assertEqual(e, to_python(a)) - pl = to_python(jl) - self.assertEqual(l, pl) - self.assertEqual(str(l), str(pl)) + olist = ["The", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dogs"] + jlist = to_java(olist) + for e, a in zip(olist, jlist): + assert e == to_python(a) + plist = to_python(jlist) + assert olist == plist + assert str(olist) == str(plist) + assert plist[1] == "quick" + plist[7] = "silly" + assert "The quick brown fox jumps over the silly dogs" == " ".join(plist) def testSet(self): - s = set(['orange', 'apple', 'pineapple', 'plum']) + s = {"orange", "apple", "pineapple", "plum"} js = to_java(s) - self.assertEqual(len(s), js.size()) + assert len(s) == js.size() for e in s: - self.assertTrue(js.contains(to_java(e))) + assert js.contains(to_java(e)) ps = to_python(js) - self.assertEqual(s, ps) - self.assertEqual(str(s), str(ps)) + assert s == ps + assert str(s) == str(ps) + + def testPrimitiveIntArray(self): + arr = jarray("i", 4) + for i in range(len(arr)): + arr[i] = i # NB: assign Python int into Java int! + py_arr = to_python(arr) + if mode == Mode.JEP: + assert type(py_arr).__name__ == "list" + # JPype brings in a Numpy dependency from the start. + # This dependency enables the Numpy converters + # Since they take precedence, we'll actually see a ndarray + # output from the conversion. + elif mode == Mode.JPYPE: + assert type(py_arr).__name__ == "ndarray" + # NB: Comparing ndarray vs list results in a list of bools. + assert np.array_equal(py_arr, [0, 1, 2, 3]) + + def test2DStringArray(self): + if mode == Mode.JEP: + pytest.skip("jep cannot support 2+ dimensional arrays!") + + String = jimport("java.lang.String") + arr = jarray(String, [3, 5]) + for i in range(len(arr)): + for j in range(len(arr[i])): + s = f"{i}, {j}" + arr[i][j] = s # NB: assign Python str to Java String! + py_arr = to_python(arr) + assert isinstance(py_arr, list) + assert py_arr == [ + ["0, 0", "0, 1", "0, 2", "0, 3", "0, 4"], + ["1, 0", "1, 1", "1, 2", "1, 3", "1, 4"], + ["2, 0", "2, 1", "2, 2", "2, 3", "2, 4"], + ] def testDict(self): d = { - 'access_log': [ - {'stored_proc': 'getsomething'}, - {'uses': [ - {'usedin': 'some->bread->crumb'}, - {'usedin': 'something else here'}, - {'stored_proc': 'anothersp'} - ]}, - {'uses': [ - {'usedin': 'blahblah'} - ]} + "access_log": [ + {"stored_proc": "getsomething"}, + { + "uses": [ + {"usedin": "some->bread->crumb"}, + {"usedin": "something else here"}, + {"stored_proc": "anothersp"}, + ] + }, + {"uses": [{"usedin": "blahblah"}]}, + ], + "reporting": [ + {"stored_proc": "reportingsp"}, + {"uses": [{"usedin": "breadcrumb"}]}, ], - 'reporting': [ - {'stored_proc': 'reportingsp'}, - {'uses': [{'usedin': 'breadcrumb'}]} - ] } jd = to_java(d) - self.assertEqual(len(d), jd.size()) + assert len(d) == jd.size() for k, v in d.items(): jk = to_java(k) - self.assertTrue(jd.containsKey(jk)) - self.assertEqual(v, to_python(jd.get(jk))) + jd.containsKey(jk) + assert v == to_python(jd.get(jk)) pd = to_python(jd) - self.assertEqual(d, pd) - self.assertEqual(str(d), str(pd)) + assert d == pd + assert str(d) == str(pd) + + def testPath(self): + py_path = Path(getcwd()) + j_path = to_java(py_path) + assert jinstance(j_path, "java.nio.file.Path") + assert str(j_path) == str(py_path) + + actual = to_python(j_path) + assert actual == py_path def testMixed(self): - d = {'a':'b', 'c':'d'} - l = ['e', 'f', 'g', 'h'] - s = set(['i', 'j', 'k']) + test_dict = {"a": "b", "c": "d"} + test_list = ["e", "f", "g", "h"] + test_set = {"i", "j", "k"} # mixed types in a dictionary - md = {'d': d, 'l': l, 's': s, 'str': 'hello'} - jmd = to_java(md) - self.assertEqual(len(md), jmd.size()) - for k, v in md.items(): - jk = to_java(k) - self.assertTrue(jmd.containsKey(jk)) - self.assertEqual(v, to_python(jmd.get(jk))) - pmd = to_python(jmd) - self.assertEqual(md, pmd) - self.assertEqual(str(md), str(pmd)) + mixed_dict = {"d": test_dict, "l": test_list, "s": test_set, "str": "hello"} + j_mixed_dict = to_java(mixed_dict) + assert len(mixed_dict) == j_mixed_dict.size() + for k, v in mixed_dict.items(): + j_k = to_java(k) + j_mixed_dict.containsKey(j_k) + assert v == to_python(j_mixed_dict.get(j_k)) + p_mixed_dict = to_python(j_mixed_dict) + assert mixed_dict == p_mixed_dict + assert str(mixed_dict) == str(p_mixed_dict) # mixed types in a list - ml = [d, l, s, 'hello'] - jml = to_java(ml) - for e, a in zip(ml, jml): - self.assertEqual(e, to_python(a)) - pml = to_python(jml) - self.assertEqual(ml, pml) - self.assertEqual(str(ml), str(pml)) - -if __name__ == '__main__': - unittest.main() + mixed_list = [test_dict, test_list, test_set, "hello"] + j_mixed_list = to_java(mixed_list) + for e, a in zip(mixed_list, j_mixed_list): + assert e == to_python(a) + p_mixed_list = to_python(j_mixed_list) + assert mixed_list == p_mixed_list + assert str(mixed_list) == str(p_mixed_list) + + def testNone(self): + d = {"key": None, None: "value", "foo": "bar"} + jd = to_java(d) + assert 3 == jd.size() + assert None is jd.get("key") + assert "value" == jd.get(None) + assert "bar" == jd.get("foo") + pd = to_python(jd) + assert d == pd + + def testGentle(self): + Object = jimport("java.lang.Object") + unknown_thing = Object() + converted_thing = to_python(unknown_thing, gentle=True) + assert jinstance(converted_thing, Object) + bad_conversion = None + try: + bad_conversion = to_python(unknown_thing) + except TypeError: + # NB: Failure is expected here. + pass + assert bad_conversion is None + + def testStructureWithSomeUnsupportedItems(self): + # Create Java data structure with some challenging items. + Object = jimport("java.lang.Object") + jmap = to_java( + { + "list": ["a", Object(), 1], + "set": {"x", Object(), 2}, + "object": Object(), + "foo": "bar", + } + ) + + if mode == Mode.JPYPE: + assert "java.util.LinkedHashMap" == jclass(jmap).getName() + elif mode == Mode.JEP: + with pytest.raises(ValueError) as exc: + assert "java.util.LinkedHashMap" == jclass(jmap).getName() + assert ( + "ValueError: Jep does not support Java class objects " + + "-- see https://github.com/ninia/jep/issues/405" + ) == exc.exconly() + + # Convert it back to Python. + pdict = to_python(jmap) + assert pdict["list"][0] == "a" + assert jinstance(pdict["list"][1], Object) + assert pdict["list"][2] == 1 + assert "x" in pdict["set"] + assert 2 in pdict["set"] + assert len(pdict["set"]) == 3 + assert jinstance(pdict["object"], Object) + assert pdict["foo"] == "bar" + + def test_conversion_priority(self): + # Add a converter prioritized over the default converter + String = jimport("java.lang.String") + invader = "Not Hello World" + + bad_converter = Converter( + name=f"test_conversion_priority: str -> '{invader}'", + predicate=lambda obj: isinstance(obj, str), + converter=lambda obj: String(invader.encode("utf-8"), "utf-8"), + priority=100, + ) + add_java_converter(bad_converter) + + # Ensure that the conversion uses our new converter + s = "Hello world!" + js = to_java(s) + for e, a in zip(invader, js.toCharArray()): + assert e == a + + java_converters.remove(bad_converter) + + def test_converter_priority(self): + assert len(java_converters) > 0 + assert sorted(java_converters) == java_converters + assert len(py_converters) > 0 + assert sorted(py_converters) == py_converters diff --git a/tests/test_inspect.py b/tests/test_inspect.py new file mode 100644 index 00000000..0a314f8f --- /dev/null +++ b/tests/test_inspect.py @@ -0,0 +1,39 @@ +""" +Tests for functions in inspect submodule. +""" + +import re + +from scyjava import inspect +from scyjava.config import Mode, mode + + +class TestInspect: + """ + 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: https://github.com/openjdk/jdk/blob/" + ".../share/classes/java/lang/Iterable.java" + ), + " * indicates static modifier", + "java.util.Iterator = iterator()", + "java.util.Spliterator = spliterator()", + "void = forEach(java.util.function.Consumer)", + "", + "", + ] + pattern = ( + r"(https://github.com/openjdk/jdk/blob/)" + r"[^ ]*(/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 new file mode 100644 index 00000000..cfc895ef --- /dev/null +++ b/tests/test_introspect.py @@ -0,0 +1,116 @@ +""" +Tests for functions in _introspect submodule. +Created on Fri Mar 28 13:58:54 2025 + +@author: ian-coccimiglio +""" + +import scyjava +from scyjava.config import Mode, mode + +scyjava.config.endpoints.extend( + ["net.imagej:imagej", "net.imagej:imagej-legacy:MANAGED"] +) + + +class TestIntrospection: + """ + Test introspection functionality. + """ + + 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.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_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.jreflect(str_BitSet, "fields") + bitset_Obj = scyjava.jreflect(BitSet, "fields") + 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. + return + str_SF = "org.scijava.search.SourceFinder" + SF = scyjava.jimport(str_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) + assert source_strSF == source_SF + + def test_jsource_jdk_class(self): + if mode == Mode.JEP: + # JEP does not support the jclass function. + return + 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.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: + # JEP does not support the jclass function. + return + str_RE = "ij.plugin.RoiEnlarger" + table = scyjava.jreflect(str_RE, aspect="methods") + 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) diff --git a/tests/test_jvm_version.py b/tests/test_jvm_version.py new file mode 100644 index 00000000..6d60bedf --- /dev/null +++ b/tests/test_jvm_version.py @@ -0,0 +1,22 @@ +""" +Tests for functions in _versions submodule. +""" + +from scyjava._jvm import _jvm_version_str_to_tuple + + +def test_jvm_version(): + assert _jvm_version_str_to_tuple(' version "17.0.1"', "java") == (17, 0, 1) + assert _jvm_version_str_to_tuple(' version "17.0.18-internal"', "java") == ( + 17, + 0, + 18, + ) + assert _jvm_version_str_to_tuple(' version "11.0.9.1-internal"', "java") == ( + 11, + 0, + 9, + 1, + ) + assert _jvm_version_str_to_tuple(' version "1.8.0_312"', "java") == (1, 8, 0) + assert _jvm_version_str_to_tuple(' version "25"', "java") == (25,) diff --git a/tests/test_pandas.py b/tests/test_pandas.py new file mode 100644 index 00000000..a2f28308 --- /dev/null +++ b/tests/test_pandas.py @@ -0,0 +1,160 @@ +""" +Tests for functions in _pandas submodule. +""" + +import numpy as np +import numpy.testing as npt +import pandas as pd + +from scyjava import config, jarray, jimport, jinstance, to_java, to_python + +config.endpoints.append("org.scijava:scijava-table") +config.enable_headless_mode() + + +def assert_same_table(table, df): + assert len(table.toArray()) == df.shape[1] + assert len(table.toArray()[0].toArray()) == df.shape[0] + + for i, column in enumerate(table.toArray()): + npt.assert_array_almost_equal(df.iloc[:, i].values, column.toArray()) + + assert table.getColumnHeader(i) == df.columns[i] + + +class TestPandas: + def testPandasToTable(self): + columns = ["header1", "header2", "header3", "header4", "header5"] + + # Float table. + array = np.random.random(size=(7, 5)) + + df = pd.DataFrame(array, columns=columns) + table = to_java(df) + + assert_same_table(table, df) + assert jinstance(table, "org.scijava.table.DefaultFloatTable") + + # Int table. + array = np.random.random(size=(7, 5)) * 100 + array = array.astype("int") + + df = pd.DataFrame(array, columns=columns) + table = to_java(df) + + assert_same_table(table, df) + assert jinstance(table, "org.scijava.table.DefaultIntTable") + + # Bool table. + array = np.random.random(size=(7, 5)) > 0.5 + + df = pd.DataFrame(array, columns=columns) + table = to_java(df) + + assert_same_table(table, df) + assert jinstance(table, "org.scijava.table.DefaultBoolTable") + + # Mixed table. + array = np.random.random(size=(7, 5)) + + df = pd.DataFrame(array, columns=columns) + + # Convert column 0 to integer + df[columns[0]] = (df[columns[0]] * 100).astype("int") + # Convert column 1 to bool + df[columns[1]] = df[columns[1]] > 0.5 + # Convert column 2 to string + df[columns[2]] = df[columns[2]].to_string(index=False).split("\n") + + table = to_java(df) + + # Table types cannot be the same here, unless we want to cast. + # assert_same_table(table, df) + assert jinstance(table, "org.scijava.table.DefaultGenericTable") + + def testTabletoPandas(self): + Boolean = jimport("java.lang.Boolean") + Double = jimport("java.lang.Double") + Float = jimport("java.lang.Float") + Integer = jimport("java.lang.Integer") + String = jimport("java.lang.String") + + columns = jarray(String, [5]) + for i in range(5): + columns[i] = f"header{i + 1}" + + # Float table + table = jimport("org.scijava.table.DefaultFloatTable")() + table.appendColumns(columns) + table.setRowCount(7) + array = np.random.random(size=(7, 5)) + + table = self._fill_table(table, array, lambda v: Float(float(v))) + df = to_python(table) + + assert_same_table(table, df) + for col in df.columns: + assert df.dtypes[col] == np.float64 + + # Int table + table = jimport("org.scijava.table.DefaultIntTable")() + table.appendColumns(columns) + table.setRowCount(7) + array = np.random.random(size=(7, 5)) * 100 + array = array.astype("int32") + + table = self._fill_table(table, array, lambda v: Integer(int(v))) + df = to_python(table) + + assert_same_table(table, df) + for col in df.columns: + assert df.dtypes[col] == np.int64 + + # Bool table + table = jimport("org.scijava.table.DefaultBoolTable")() + table.appendColumns(columns) + table.setRowCount(7) + array = np.random.random(size=(7, 5)) > 0.5 + + table = self._fill_table(table, array, lambda v: Boolean(bool(v))) + df = to_python(table) + + assert_same_table(table, df) + for col in df.columns: + assert df.dtypes[col] == np.bool_ + + # Mixed table + table = jimport("org.scijava.table.DefaultGenericTable")() + table.appendColumns(columns) + table.setRowCount(7) + array_float = np.random.random(size=(7, 1)) + array_int = np.random.random(size=(7, 1)) * 100 + array_int = array_int.astype("int32") + array_bool = np.random.random(size=(7, 1)) > 0.5 + array_str = np.array(["foo", "bar", "foobar", "barfoo", "oofrab", "oof", "rab"]) + array_double = np.random.random(size=(7, 1)) + array_double = array_double.astype("float64") + + # fill mixed table + for i in range(table.getRowCount()): + 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].item()))) + table.set(3, i, String(array_str[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. + # assert_same_table(table, df) + assert type(df["header1"][0]) is float + assert type(df["header2"][0]) is int + assert type(df["header3"][0]) is bool + assert type(df["header4"][0]) is str + assert type(df["header5"][0]) is float + + def _fill_table(self, table, ndarr, ctor): + for i in range(table.getColumnCount()): + s = ndarr[:, i] + for j in range(table.getRowCount()): + table.setValue(i, j, ctor(s[j])) + return table diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 00000000..b302665b --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,56 @@ +""" +Tests for functions in _types submodule. +""" + +from scyjava import jclass, jimport, numeric_bounds, to_java +from scyjava.config import Mode, mode + + +class TestTypes: + """ + Test Java-type-related functions. + """ + + def test_numeric_bounds(self): + v_byte = to_java(1, type="byte") + v_short = to_java(2, type="short") + v_int = to_java(3, type="int") + v_long = to_java(4, type="long") + v_bigint = to_java(5, type="bigint") + v_float = to_java(6.7, type="float") + v_double = to_java(7.8, type="double") + v_bigdec = to_java(8.9, type="bigdec") + + assert (-128, 127) == numeric_bounds(type(v_byte)) + assert (-32768, 32767) == numeric_bounds(type(v_short)) + assert (-2147483648, 2147483647) == numeric_bounds(type(v_int)) + assert (-9223372036854775808, 9223372036854775807) == numeric_bounds( + type(v_long) + ) + assert (None, None) == numeric_bounds(type(v_bigint)) + assert (-3.4028234663852886e38, 3.4028234663852886e38) == numeric_bounds( + type(v_float) + ) + assert (-1.7976931348623157e308, 1.7976931348623157e308) == numeric_bounds( + 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" diff --git a/tests/test_versions.py b/tests/test_versions.py new file mode 100644 index 00000000..d588a0b8 --- /dev/null +++ b/tests/test_versions.py @@ -0,0 +1,36 @@ +""" +Tests for functions in _versions submodule. +""" + +from importlib.metadata import version +from pathlib import Path + +import toml + +import scyjava + + +def _expected_version(): + """ + Get the project version from pyproject.toml. + """ + pyproject = toml.load(Path(__file__).parents[1] / "pyproject.toml") + return pyproject["project"]["version"] + + +def test_version(): + sjver = _expected_version() + + # 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)