diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8b246a01..a9f51a69 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,8 +11,8 @@ on: - main jobs: - build-cross-platform: - name: test ${{matrix.os}} - ${{matrix.python-version}} + build: + name: test ${{matrix.os}} - ${{matrix.python-version}} - ${{matrix.java-version}} runs-on: ${{ matrix.os }} strategy: matrix: @@ -22,84 +22,41 @@ jobs: macos-latest ] python-version: [ - '3.8', - '3.10', - '3.12' + '3.9', + '3.13' ] + java-version: ['11'] + include: + # one test without java to test cjdk fallback + - os: ubuntu-latest + python-version: '3.9' + java-version: '' steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - uses: actions/setup-python@v3 + - uses: actions/setup-python@v5 with: python-version: ${{matrix.python-version}} - - uses: actions/setup-java@v3 + - uses: actions/setup-java@v4 + if: matrix.java-version != '' with: - java-version: '11' + java-version: ${{matrix.java-version}} distribution: 'zulu' cache: 'maven' - - name: Install ScyJava + - name: Set up uv run: | python -m pip install --upgrade pip - python -m pip install -e '.[dev]' + python -m pip install uv - - name: Test ScyJava + - name: Run tests + shell: bash run: | - bin/test.sh --color=yes + bin/test.sh - ensure-clean-code: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v3 - - - name: Lint code - run: | - python -m pip install ruff - ruff check - ruff format --check - - - name: Validate pyproject.toml - run: | - python -m pip install validate-pyproject[all] - python -m validate_pyproject pyproject.toml - - conda-dev-test: - name: Conda Setup & Code Coverage - runs-on: ubuntu-latest - defaults: - # Steps that rely on the activated environment must be run with this shell setup. - # See https://github.com/marketplace/actions/setup-miniconda#important - run: - shell: bash -l {0} - steps: - - uses: actions/checkout@v2 - - name: Cache conda - uses: actions/cache@v4 - env: - # Increase this value to reset cache if dev-environment.yml has not changed - CACHE_NUMBER: 0 - with: - path: ~/conda_pkgs_dir - key: - ${{ runner.os }}-conda-${{ env.CACHE_NUMBER }}-${{ hashFiles('dev-environment.yml') }} - - uses: conda-incubator/setup-miniconda@v3 - with: - # Create env with dev packages - auto-update-conda: true - python-version: 3.9 - miniforge-version: latest - environment-file: dev-environment.yml - # Activate scyjava-dev environment - activate-environment: scyjava-dev - auto-activate-base: false - # Use mamba for faster setup - use-mamba: true - - name: Test scyjava - run: | - bin/test.sh --cov-report=xml --cov=. - # We could do this in its own action, but we'd have to setup the environment again. - - name: Upload Coverage to Codecov - uses: codecov/codecov-action@v2 + - name: Lint code + shell: bash + run: | + bin/lint.sh diff --git a/.gitignore b/.gitignore index 7fdf6cba..56372b82 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ coverage.xml # IDEA .idea/ *.iml + +# uv +/.venv/ +/uv.lock diff --git a/Makefile b/Makefile index 5827c144..ed19ec6f 100644 --- a/Makefile +++ b/Makefile @@ -1,32 +1,22 @@ help: @echo "Available targets:\n\ clean - remove build files and directories\n\ - setup - create mamba developer environment\n\ lint - run code formatters and linters\n\ test - run automated test suite\n\ dist - generate release archives\n\ - \n\ - Remember to 'mamba activate scyjava-dev' first!" + " clean: bin/clean.sh -setup: - bin/setup.sh - check: @bin/check.sh lint: check bin/lint.sh -fmt: check - bin/fmt.sh - test: check bin/test.sh dist: check clean - python -m build - -.PHONY: test + bin/dist.sh diff --git a/README.md b/README.md index eb22bbb8..35424101 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,24 @@ u'1.8.0_152-release' See the [jgo documentation](https://github.com/scijava/jgo) for more about Maven endpoints. +## Bootstrap a Java installation + +```python +>>> from scyjava import config, jimport +>>> config.set_java_constraints(fetch=True, vendor='zulu', version='17') +>>> System = jimport('java.lang.System') +cjdk: Installing JDK zulu:17.0.15 to /home/chuckles/.cache/cjdk +Download 100% of 189.4 MiB |##########| Elapsed Time: 0:00:02 Time: 0:00:02 +Extract | | # | 714 Elapsed Time: 0:00:01 +cjdk: Installing Maven to /home/chuckles/.cache/cjdk +Download 100% of 8.7 MiB |##########| Elapsed Time: 0:00:00 Time: 0:00:00 +Extract | |# | 102 Elapsed Time: 0:00:00 +>>> System.getProperty('java.vendor') +'Azul Systems, Inc.' +>>> System.getProperty('java.version') +'17.0.15' +``` + ## Convert between Python and Java data structures ### Convert Java collections to Python @@ -135,7 +153,7 @@ AttributeError: 'list' object has no attribute 'stream' Traceback (most recent call last): File "", line 1, in TypeError: No matching overloads found for java.util.Set.addAll(set), options are: - public abstract boolean java.util.Set.addAll(java.util.Collection) + public abstract boolean java.util.Set.addAll(java.util.Collection) >>> from scyjava import to_java as p2j >>> jset.addAll(p2j(pset)) True @@ -216,6 +234,22 @@ FUNCTIONS is_jarray(data: Any) -> bool Return whether the given data object is a Java array. + is_jboolean(the_type: type) -> bool + + is_jbyte(the_type: type) -> bool + + is_jcharacter(the_type: type) -> bool + + is_jdouble(the_type: type) -> bool + + is_jfloat(the_type: type) -> bool + + is_jinteger(the_type: type) -> bool + + is_jlong(the_type: type) -> bool + + is_jshort(the_type: type) -> bool + is_jvm_headless() -> bool Return true iff Java is running in headless mode. @@ -267,6 +301,12 @@ FUNCTIONS 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. @@ -303,6 +343,14 @@ FUNCTIONS :param jtype: The Java type, as either a jimported class or as a string. :return: True iff the object is an instance of that Java type. + jreflect(data, aspect: str = "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. @@ -322,7 +370,7 @@ FUNCTIONS jvm_started() -> bool Return true iff a Java virtual machine (JVM) has been started. - jvm_version() -> str + jvm_version() -> tuple[int, ...] Gets the version of the JVM as a tuple, with each dot-separated digit as one element. Characters in the version string beyond only numbers and dots are ignored, in line with the java.version system property. diff --git a/bin/check.sh b/bin/check.sh index 4a9db614..7ef142cb 100755 --- a/bin/check.sh +++ b/bin/check.sh @@ -1,10 +1,6 @@ #!/bin/sh -case "$CONDA_PREFIX" in - */scyjava-dev) - ;; - *) - echo "Please run 'make setup' and then 'mamba activate scyjava-dev' first." - exit 1 - ;; -esac +if ! command -v uv >/dev/null 2>&1; then + echo "Please install uv (https://docs.astral.sh/uv/getting-started/installation/)." + exit 1 +fi diff --git a/bin/setup.sh b/bin/dist.sh similarity index 52% rename from bin/setup.sh rename to bin/dist.sh index 3c711c75..f21fbf48 100755 --- a/bin/setup.sh +++ b/bin/dist.sh @@ -3,4 +3,4 @@ dir=$(dirname "$0") cd "$dir/.." -mamba env create -f dev-environment.yml +uv run python -m build diff --git a/bin/fmt.sh b/bin/fmt.sh deleted file mode 100755 index cd04d02e..00000000 --- a/bin/fmt.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh - -dir=$(dirname "$0") -cd "$dir/.." - -exitCode=0 -ruff check --fix -code=$?; test $code -eq 0 || exitCode=$code -ruff format -code=$?; test $code -eq 0 || exitCode=$code -exit $exitCode diff --git a/bin/lint.sh b/bin/lint.sh index 1cf86826..978c1aa5 100755 --- a/bin/lint.sh +++ b/bin/lint.sh @@ -4,10 +4,19 @@ dir=$(dirname "$0") cd "$dir/.." exitCode=0 -ruff check + +# 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 -ruff format --check +uv run ruff format --check >/dev/null 2>&1 code=$?; test $code -eq 0 || exitCode=$code -validate-pyproject pyproject.toml + +# 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 index da27567c..fc172376 100755 --- a/bin/test.sh +++ b/bin/test.sh @@ -1,6 +1,6 @@ #!/bin/sh -# Executes the pytest framework in both JPype and Jep modes. +# Runs the unit tests. # # Usage examples: # bin/test.sh @@ -13,26 +13,26 @@ dir=$(dirname "$0") cd "$dir/.." echo -echo "-------------------------------------------" -echo "| Testing JPype mode (Java inside Python) |" -echo "-------------------------------------------" +echo "----------------------" +echo "| Running unit tests |" +echo "----------------------" if [ $# -gt 0 ] then - python -m pytest -p no:faulthandler $@ + uv run python -m pytest -v -p no:faulthandler $@ else - python -m pytest -p no:faulthandler tests/ + uv run python -m pytest -v -p no:faulthandler tests/ fi jpypeCode=$? echo -echo "-------------------------------------------" -echo "| Running integration tests (JPype only) |" -echo "-------------------------------------------" +echo "-----------------------------" +echo "| Running integration tests |" +echo "-----------------------------" itCode=0 for t in tests/it/*.py do - python "$t" + uv run python "$t" code=$? printf -- "--> %s " "$t" if [ "$code" -eq 0 ] @@ -44,59 +44,6 @@ do fi done -echo -echo "-------------------------------------------" -echo "| Testing Jep mode (Python inside Java) |" -echo "-------------------------------------------" - -# Discern the Jep installation. -site_packages=$(python -c 'import sys; print(next(p for p in sys.path if p.endswith("site-packages")))') -test -d "$site_packages/jep" || { - echo "[ERROR] Failed to detect Jep installation in current environment!" 1>&2 - exit 1 -} - -# We execute the pytest framework through Jep via jgo, so that -# the surrounding JVM includes scijava-table on the classpath. -# -# Arguments to the shell script are translated into an argument -# list to the pytest.main function. A weak attempt at handling -# special characters, e.g. single quotation marks and backslashes, -# is made, but there are surely other non-working cases. - -if [ $# -gt 0 ] -then - a=$(echo "$@" | sed 's/\\/\\\\/g') # escape backslashes - a=$(echo "$a" | sed 's/'\''/\\'\''/g') # escape single quotes - a=$(echo "$a" | sed 's/ /'\'','\''/g') # replace space with ',' - argString="['-v', '$a']" -else - argString="" -fi -if [ "$(uname -s)" = "Darwin" ] -then - echo "Skipping jep tests on macOS due to flakiness" - jepCode=0 -else - echo "# AUTOGENERATED test file for jep; safe to delete. -import logging, sys, pytest, scyjava -scyjava._logger.addHandler(logging.StreamHandler(sys.stderr)) -scyjava._logger.setLevel(logging.INFO) -scyjava.config.set_verbose(2) -result = pytest.main($argString) -if result: - sys.exit(result) -" > jep_test.py - jgo -vv \ - -r scijava.public=https://maven.scijava.org/content/groups/public \ - -Djava.library.path="$site_packages/jep" \ - black.ninia:jep:jep.Run+org.scijava:scijava-table \ - jep_test.py - jepCode=$? - rm -f jep_test.py -fi - test "$jpypeCode" -ne 0 && exit "$jpypeCode" test "$itCode" -ne 0 && exit "$itCode" -test "$jepCode" -ne 0 && exit "$jepCode" exit 0 diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index aa84c676..00000000 --- a/codecov.yml +++ /dev/null @@ -1,2 +0,0 @@ -ignore: - - "*/tests/*" diff --git a/dev-environment.yml b/dev-environment.yml deleted file mode 100644 index 2243e1c0..00000000 --- a/dev-environment.yml +++ /dev/null @@ -1,41 +0,0 @@ -# Use this file to construct an environment -# for developing scyjava from source. -# -# First, install mambaforge: -# -# https://github.com/conda-forge/miniforge#mambaforge -# -# Then run: -# -# mamba env create -f dev-environment.yml -# conda activate scyjava-dev -# -# In addition to the dependencies needed for using scyjava, it -# includes tools for developer-related actions like running -# automated tests (pytest) and linting the code (black). If you -# want an environment without these tools, use environment.yml. -name: scyjava-dev -channels: - - conda-forge -dependencies: - - python >= 3.8 - # Project dependencies - - jpype1 >= 1.3.0, <= 1.5.0 - - jgo - - openjdk >= 8, < 12 - # Test dependencies - - numpy - - pandas - # Developer tools - - assertpy - - pytest - - pytest-cov - - python-build - - ruff - - toml - - validate-pyproject - # Project from source - - pip - - pip: - - git+https://github.com/ninia/jep.git@cfca63f8b3398daa6d2685428660dc4b2bfab67d - - -e . diff --git a/environment.yml b/environment.yml deleted file mode 100644 index b5bfa733..00000000 --- a/environment.yml +++ /dev/null @@ -1,30 +0,0 @@ -# Use this file to construct an environment for -# working with scyjava in a runtime setting. -# -# First, install mambaforge: -# -# https://github.com/conda-forge/miniforge#mambaforge -# -# Then run: -# -# mamba env create -# mamba activate scyjava -# -# It includes the dependencies needed for using scyjava, but not tools -# for developer-related actions like running automated tests (pytest), -# linting the code (black), and generating the API documentation (sphinx). -# If you want an environment including these tools, use dev-environment.yml. - -name: scyjava -channels: - - conda-forge -dependencies: - - python >= 3.8 - # Project dependencies - - jpype1 >= 1.3.0, <= 1.5.0 - - jgo - - openjdk >= 8 - # Project from source - - pip - - pip: - - -e . diff --git a/pyproject.toml b/pyproject.toml index d0b7197d..ccaa9d20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [build-system] -requires = ["setuptools>=61.2"] +requires = ["setuptools>=77.0.0"] build-backend = "setuptools.build_meta" [project] name = "scyjava" -version = "1.10.2.dev0" +version = "1.12.2.dev0" description = "Supercharged Java access from Python" -license = {text = "The Unlicense"} +license = "Unlicense" authors = [{name = "SciJava developers", email = "ctrueden@wisc.edu"}] readme = "README.md" keywords = ["java", "maven", "cross-language"] @@ -16,12 +16,11 @@ classifiers = [ "Intended Audience :: Education", "Intended Audience :: Science/Research", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", - "License :: OSI Approved :: The Unlicense (Unlicense)", + "Programming Language :: Python :: 3.13", "Operating System :: Microsoft :: Windows", "Operating System :: Unix", "Operating System :: MacOS", @@ -31,26 +30,24 @@ classifiers = [ "Topic :: Utilities", ] -# NB: Keep this in sync with environment.yml AND dev-environment.yml! -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ - "jpype1 >= 1.3.0, <= 1.5.0", + "jpype1 >= 1.3.0", "jgo", + "cjdk", ] -[project.optional-dependencies] -# NB: Keep this in sync with dev-environment.yml! +[dependency-groups] dev = [ "assertpy", "build", - "jep", "pytest", "pytest-cov", "numpy", "pandas", "ruff", "toml", - "validate-pyproject[all]" + "validate-pyproject[all]", ] [project.urls] @@ -68,7 +65,6 @@ include-package-data = false where = ["src"] namespaces = false -# ruff configuration [tool.ruff] line-length = 88 src = ["src", "tests"] diff --git a/src/scyjava/__init__.py b/src/scyjava/__init__.py index ce20173b..6d23d097 100644 --- a/src/scyjava/__init__.py +++ b/src/scyjava/__init__.py @@ -40,6 +40,22 @@ +++oo*OO######O**oo+++++oo*OO######O**oo+++++oo*OO######O**oo+++ +++oo*OO######OO*oo+++++oo*OO######OO*oo+++++oo*OO######OO*oo+++ +Bootstrap a Java installation: + + >>> from scyjava import config, jimport + >>> config.set_java_constraints(fetch=True, vendor='zulu', version='17') + >>> System = jimport('java.lang.System') + cjdk: Installing JDK zulu:17.0.15 to /home/chuckles/.cache/cjdk + Download 100% of 189.4 MiB |##########| Elapsed Time: 0:00:02 Time: 0:00:02 + Extract | | # | 714 Elapsed Time: 0:00:01 + cjdk: Installing Maven to /home/chuckles/.cache/cjdk + Download 100% of 8.7 MiB |##########| Elapsed Time: 0:00:00 Time: 0:00:00 + Extract | |# | 102 Elapsed Time: 0:00:00 + >>> System.getProperty('java.vendor') + 'Azul Systems, Inc.' + >>> System.getProperty('java.version') + '17.0.15' + Convert Java collections to Python: >>> from scyjava import jimport @@ -71,6 +87,7 @@ from functools import lru_cache from typing import Any, Callable, Dict +from . import config, inspect from ._arrays import is_arraylike, is_memoryarraylike, is_xarraylike from ._convert import ( Converter, @@ -91,6 +108,10 @@ to_java, to_python, ) +from ._introspect import ( + jreflect, + jsource, +) from ._jvm import ( # noqa: F401 available_processors, gc, @@ -111,6 +132,14 @@ from ._types import ( JavaClasses, is_jarray, + is_jboolean, + is_jbyte, + is_jcharacter, + is_jdouble, + is_jfloat, + is_jinteger, + is_jlong, + is_jshort, isjava, jarray, jclass, diff --git a/src/scyjava/_cjdk_fetch.py b/src/scyjava/_cjdk_fetch.py new file mode 100644 index 00000000..b8fea094 --- /dev/null +++ b/src/scyjava/_cjdk_fetch.py @@ -0,0 +1,121 @@ +""" +Utility functions for fetching JDK/JRE and Maven. +""" + +from __future__ import annotations + +import logging +import os +import shutil +import subprocess +from typing import TYPE_CHECKING, Union + +import cjdk +import jpype + +import scyjava.config + +if TYPE_CHECKING: + from pathlib import Path + +_logger = logging.getLogger(__name__) + + +def ensure_jvm_available() -> None: + """Ensure that the JVM is available and Maven is installed.""" + fetch = scyjava.config.get_fetch_java() + if fetch == "never": + # Not allowed to use cjdk. + return + if fetch == "always" or not is_jvm_available(): + cjdk_fetch_java() + if fetch == "always" or not shutil.which("mvn"): + cjdk_fetch_maven() + + +def is_jvm_available() -> bool: + """Return True if the JVM is available, suppressing stderr on macos.""" + from unittest.mock import patch + + subprocess_check_output = subprocess.check_output + + def _silent_check_output(*args, **kwargs): + # also suppress stderr on calls to subprocess.check_output + kwargs.setdefault("stderr", subprocess.DEVNULL) + return subprocess_check_output(*args, **kwargs) + + try: + with patch.object(subprocess, "check_output", new=_silent_check_output): + jpype.getDefaultJVMPath() + # on Darwin, may raise a CalledProcessError when invoking `/usr/libexec/java_home` + except (jpype.JVMNotFoundException, subprocess.CalledProcessError): + return False + return True + + +def cjdk_fetch_java(vendor: str | None = None, version: str | None = None) -> None: + """Fetch java using cjdk and add it to the PATH.""" + if vendor is None: + vendor = scyjava.config.get_java_vendor() + if version is None: + version = scyjava.config.get_java_version() + + _logger.info(f"Fetching {vendor}:{version} using cjdk...") + java_home = cjdk.java_home(vendor=vendor, version=version) + _logger.debug(f"java_home -> {java_home}") + _add_to_path(str(java_home / "bin"), front=True) + os.environ["JAVA_HOME"] = str(java_home) + + +def cjdk_fetch_maven(url: str = "", sha: str = "") -> None: + """Fetch Maven using cjdk and add it to the PATH.""" + # if url was passed as an argument, use it with provided sha + # otherwise, use default values for both + if not url: + url = scyjava.config.get_maven_url() + sha = scyjava.config.get_maven_sha() + + # fix urls to have proper prefix for cjdk + if url.startswith("http"): + if url.endswith(".tar.gz"): + url = url.replace("http", "tgz+http") + elif url.endswith(".zip"): + url = url.replace("http", "zip+http") + + # determine sha type based on length (cjdk requires specifying sha type) + # assuming hex-encoded SHA, length should be 40, 64, or 128 + kwargs = {} + if sha_len := len(sha): # empty sha is fine... we just don't pass it + sha_lengths = {40: "sha1", 64: "sha256", 128: "sha512"} + if sha_len not in sha_lengths: # pragma: no cover + raise ValueError( + "MAVEN_SHA be a valid sha1, sha256, or sha512 hash." + f"Got invalid SHA length: {sha_len}. " + ) + kwargs = {sha_lengths[sha_len]: sha} + + _logger.info("Fetching Maven using cjdk...") + maven_dir = cjdk.cache_package("Maven", url, **kwargs) + _logger.debug(f"maven_dir -> {maven_dir}") + if maven_bin := next(maven_dir.rglob("apache-maven-*/**/mvn"), None): + _add_to_path(maven_bin.parent, front=True) + else: # pragma: no cover + raise RuntimeError( + "Failed to find Maven executable on system " + "PATH, and download via cjdk failed." + ) + + +def _add_to_path(path: Union[Path, str], front: bool = False) -> None: + """Add a path to the PATH environment variable. + + If front is True, the path is added to the front of the PATH. + By default, the path is added to the end of the PATH. + If the path is already in the PATH, it is not added again. + """ + + current_path = os.environ.get("PATH", "") + if (path := str(path)) in current_path: + return + new_path = [path, current_path] if front else [current_path, path] + os.environ["PATH"] = os.pathsep.join(new_path) diff --git a/src/scyjava/_convert.py b/src/scyjava/_convert.py index af1583b5..4a04f279 100644 --- a/src/scyjava/_convert.py +++ b/src/scyjava/_convert.py @@ -7,6 +7,7 @@ import logging import math from bisect import insort +from importlib.util import find_spec from pathlib import Path from typing import Any, Callable, Dict, List, NamedTuple @@ -677,7 +678,7 @@ def _stock_py_converters() -> List: priority=Priority.VERY_LOW, ), ] - if _import_pandas(required=False): + if find_spec("pandas"): converters.append( Converter( name="org.scijava.table.Table -> pandas.DataFrame", @@ -716,7 +717,7 @@ def _stock_py_converters() -> List: ), ] ) - if _import_numpy(required=False): + if find_spec("numpy"): converters.append( Converter( name="primitive array -> numpy.ndarray", @@ -803,16 +804,15 @@ def _jarray_shape(jarr): return shape -def _import_numpy(required=True): +def _import_numpy(): try: import numpy as np return np except ImportError as e: - if required: - msg = "The NumPy library is missing (https://numpy.org/). " - msg += "Please install it before using this function." - raise RuntimeError(msg) from e + msg = "The NumPy library is missing (https://numpy.org/). " + msg += "Please install it before using this function." + raise RuntimeError(msg) from e ###################################### @@ -838,16 +838,15 @@ def _convert_table(obj: Any): return None -def _import_pandas(required=True): +def _import_pandas(): try: import pandas as pd return pd except ImportError as e: - if required: - msg = "The Pandas library is missing (http://pandas.pydata.org/). " - msg += "Please install it before using this function." - raise RuntimeError(msg) from e + msg = "The Pandas library is missing (http://pandas.pydata.org/). " + msg += "Please install it before using this function." + raise RuntimeError(msg) from e def _table_to_pandas(table): diff --git a/src/scyjava/_introspect.py b/src/scyjava/_introspect.py new file mode 100644 index 00000000..a9ab98a9 --- /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, Dict, List + +from scyjava._jvm import jimport, jvm_version +from scyjava._types import isjava, jinstance, jclass + + +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}") + 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/_jvm.py b/src/scyjava/_jvm.py index 2e2350a8..29b499b3 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -11,6 +11,7 @@ from functools import lru_cache from importlib import import_module from pathlib import Path +from typing import Sequence import jpype import jpype.config @@ -18,6 +19,7 @@ import scyjava.config from scyjava.config import Mode, mode +from scyjava._cjdk_fetch import ensure_jvm_available _logger = logging.getLogger(__name__) @@ -25,16 +27,16 @@ _shutdown_callbacks = [] -def jvm_version() -> str: +def jvm_version() -> tuple[int, ...]: """ Gets the version of the JVM as a tuple, with each dot-separated digit as one element. Characters in the version string beyond only numbers and dots are ignored, in line with the java.version system property. Examples: - * OpenJDK 17.0.1 -> [17, 0, 1] - * OpenJDK 11.0.9.1-internal -> [11, 0, 9, 1] - * OpenJDK 1.8.0_312 -> [1, 8, 0] + * OpenJDK 17.0.1 -> (17, 0, 1) + * OpenJDK 11.0.9.1-internal -> (11, 0, 9, 1) + * OpenJDK 1.8.0_312 -> (1, 8, 0) If the JVM is already started, this function returns the equivalent of: jimport('java.lang.System') @@ -55,12 +57,12 @@ def jvm_version() -> str: assert mode == Mode.JPYPE - jvm_version = jpype.getJVMVersion() - if jvm_version and jvm_version[0]: + jvm_ver = jpype.getJVMVersion() + if jvm_ver and jvm_ver[0]: # JPype already knew the version. # JVM is probably already started. # Or JPype got smarter since 1.3.0. - return jvm_version + return jvm_ver # JPype was clueless, which means the JVM has probably not started yet. # Let's look for a java executable, and ask via 'java -version'. @@ -68,8 +70,14 @@ def jvm_version() -> str: default_jvm_path = jpype.getDefaultJVMPath() if not default_jvm_path: raise RuntimeError("Cannot glean the default JVM path") + print(f"Default JVM path from JPype: {default_jvm_path}") - p = Path(default_jvm_path) + # Good ol' macOS! Nothing beats macOS. + jvm_path = default_jvm_path.replace( + "/Contents/MacOS/libjli.dylib", "/Contents/Home/lib/libjli.dylib" + ) + + p = Path(jvm_path) if not p.exists(): raise RuntimeError(f"Invalid default JVM path: {p}") @@ -91,6 +99,7 @@ def jvm_version() -> str: 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 @@ -98,14 +107,23 @@ def jvm_version() -> str: except subprocess.CalledProcessError as e: raise RuntimeError("System call to java failed") from e - m = re.match('.*version "(([0-9]+\\.)+[0-9]+)', output) + output = output.replace("\n", " ").replace("\r", "") + m = re.match('.* version "([^"]*)"', output) if not m: - raise RuntimeError(f"Inscrutable java command output:\n{output}") + raise RuntimeError( + f"Inscrutable java command output:\n$ {java} -version\n{output}" + ) - return tuple(map(int, m.group(1).split("."))) + 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=None) -> None: + +def start_jvm(options: Sequence[str] = None) -> None: """ Explicitly connect to the Java virtual machine (JVM). Only one JVM can be active; does nothing if the JVM has already been started. Calling @@ -116,10 +134,12 @@ def start_jvm(options=None) -> None: :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(): - _logger.debug("The JVM is already running.") + if options is not None and len(options) > 0: + _logger.debug(f"Options ignored due to already running JVM: {options}") return assert mode == Mode.JPYPE @@ -131,6 +151,9 @@ def start_jvm(options=None) -> None: # use the logger to notify user that endpoints are being added _logger.debug("Adding jars from endpoints {0}".format(endpoints)) + # download JDK/JRE and Maven as appropriate + ensure_jvm_available() + # get endpoints and add to JPype class path if len(endpoints) > 0: endpoints = endpoints[:1] + sorted(endpoints[1:]) @@ -179,7 +202,8 @@ def start_jvm(options=None) -> None: _logger.debug("Starting JVM") if options is None: options = scyjava.config.get_options() - jpype.startJVM(*options, interrupt=True) + kwargs = scyjava.config.get_kwargs() + jpype.startJVM(*options, **kwargs) # replace JPype/JVM shutdown handling with our own jpype.config.onexit = False @@ -225,7 +249,7 @@ def shutdown_jvm() -> None: try: callback() except Exception as e: - print(f"Exception during shutdown callback: {e}") + _logger.error(f"Exception during shutdown callback: {e}") # dispose AWT resources if applicable if is_awt_initialized(): @@ -237,7 +261,7 @@ def shutdown_jvm() -> None: try: jpype.shutdownJVM() except Exception as e: - print(f"Exception during JVM shutdown: {e}") + _logger.error(f"Exception during JVM shutdown: {e}") def jvm_started() -> bool: diff --git a/src/scyjava/_script.py b/src/scyjava/_script.py index ec73f906..a371b2bb 100644 --- a/src/scyjava/_script.py +++ b/src/scyjava/_script.py @@ -91,7 +91,15 @@ def apply(self, arg): # Last statement looks like an expression. Evaluate! last = ast.Expression(block.body.pop().value) - _globals = {} + # NB: When `exec` gets two separate objects as *globals* and + # *locals*, the code will be executed as if it were embedded in + # a class definition. This means functions and classes defined + # in the executed code will not be able to access variables + # assigned at the top level, because the "top level" variables + # are treated as class variables in a class definition. + # See: https://docs.python.org/3/library/functions.html#exec + _globals = script_locals + exec( compile(block, "", mode="exec"), _globals, script_locals ) @@ -102,9 +110,15 @@ def apply(self, arg): script_locals, ) except Exception: + error_message = traceback.format_exc() error_writer = arg.scriptContext.getErrorWriter() - if error_writer is not None: - error_writer.write(to_java(traceback.format_exc())) + if error_writer is None: + # Emit error message to stderr stream. + error_writer = sys.stderr + else: + # Emit error message to designated error writer. + error_message = to_java(error_message) + error_writer.write(error_message) stdoutContextWriter.removeScriptContext(threading.currentThread()) @@ -114,9 +128,6 @@ def apply(self, arg): arg.vars[key] = to_java(script_locals[key]) except Exception: arg.vars[key] = PythonObjectSupplier(script_locals[key]) - # error_writer = arg.scriptContext.getErrorWriter() - # if error_writer is not None: - # error_writer.write(to_java(traceback.format_exc())) return to_java(return_value) diff --git a/src/scyjava/_types.py b/src/scyjava/_types.py index 34c2cafc..ef0318ad 100644 --- a/src/scyjava/_types.py +++ b/src/scyjava/_types.py @@ -275,6 +275,9 @@ def jarray(kind, lengths: Sequence): # instantiate the n-dimensional array arr = arraytype(lengths[0]) + else: + raise RuntimeError(f"Invalid mode: {mode}") + if len(lengths) > 1: for i in range(len(arr)): arr[i] = jarray(kind, lengths[1:]) diff --git a/src/scyjava/_versions.py b/src/scyjava/_versions.py index c1695db7..f1632195 100644 --- a/src/scyjava/_versions.py +++ b/src/scyjava/_versions.py @@ -15,8 +15,8 @@ def get_version(java_class_or_python_package) -> str: """ Return the version of a Java class or Python package. - For Python package, uses importlib.metadata.version if available - (Python 3.8+), with pkg_resources.get_distribution as a fallback. + For Python packages, invokes importlib.metadata.version on the given + object's base __module__ or __package__ (before the first dot symbol). For Java classes, requires org.scijava:scijava-common on the classpath. @@ -32,8 +32,16 @@ def get_version(java_class_or_python_package) -> str: VersionUtils = jimport("org.scijava.util.VersionUtils") return str(VersionUtils.getVersion(java_class_or_python_package)) - # Assume we were given a Python package name. - return version(java_class_or_python_package) + # Assume we were given a Python package name or module. + package_name = None + if hasattr(java_class_or_python_package, "__module__"): + package_name = java_class_or_python_package.__module__ + elif hasattr(java_class_or_python_package, "__package__"): + package_name = java_class_or_python_package.__package__ + else: + package_name = str(java_class_or_python_package) + + return version(package_name.split(".")[0]) def is_version_at_least(actual_version: str, minimum_version: str) -> bool: diff --git a/src/scyjava/config.py b/src/scyjava/config.py index e2cc0073..0b85bc82 100644 --- a/src/scyjava/config.py +++ b/src/scyjava/config.py @@ -1,24 +1,37 @@ -import enum -import logging -import os -import pathlib +from __future__ import annotations -import jpype -from jgo import maven_scijava_repository +import enum as _enum +import logging as _logging +import os as _os +from pathlib import Path +from typing import Sequence -_logger = logging.getLogger(__name__) +import jpype as _jpype +from jgo import maven_scijava_repository as _scijava_public -endpoints = [] -_repositories = {"scijava.public": maven_scijava_repository()} + +_logger = _logging.getLogger(__name__) + +# Constraints on the Java installation to be used. +_fetch_java: str = "auto" +_java_vendor: str = "zulu-jre" +_java_version: str = "11" +_maven_url: str = "tgz+https://archive.apache.org/dist/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.tar.gz" # noqa: E501 +_maven_sha: str = "a555254d6b53d267965a3404ecb14e53c3827c09c3b94b5678835887ab404556bfaf78dcfe03ba76fa2508649dca8531c74bca4d5846513522404d48e8c4ac8b" # noqa: E501 + +endpoints: list[str] = [] + +_repositories = {"scijava.public": _scijava_public()} _verbose = 0 _manage_deps = True -_cache_dir = pathlib.Path.home() / ".jgo" -_m2_repo = pathlib.Path.home() / ".m2" / "repository" +_cache_dir = Path.home() / ".jgo" +_m2_repo = Path.home() / ".m2" / "repository" _options = [] +_kwargs = {"interrupt": True} _shortcuts = {} -class Mode(enum.Enum): +class Mode(_enum.Enum): JEP = "jep" JPYPE = "jpype" @@ -31,34 +44,114 @@ class Mode(enum.Enum): mode = Mode.JPYPE -def add_endpoints(*new_endpoints): +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: """ - DEPRECATED since v1.2.1 - Please modify the endpoints field directly instead. + Set constraints on the version of Java to be used. + + :param fetch: + If "auto" (default), when a JVM/or maven cannot be located on the system, + [`cjdk`](https://github.com/cachedjdk/cjdk) will be used to download + a JDK/JRE distribution and set up the JVM. + If "always", cjdk will always be used; if "never", cjdk will never be used. + :param vendor: + The vendor of the JDK/JRE distribution for cjdk to download and cache. + Defaults to "zulu-jre". See the cjdk documentation for details. + :param version: + Expression defining the Java version for cjdk to download and cache. + Defaults to "11". See the cjdk documentation for details. + :param maven_url: + URL of the Maven distribution for cjdk to download and cache. + Defaults to the Maven 3.9.9 binary distribution from dlcdn.apache.org. + :param maven_sha: + The SHA512 (or SHA256 or SHA1) hash of the Maven distribution to download, + if providing a custom maven_url. """ - _logger.warning( - "Deprecated method call: scyjava.config.add_endpoints(). " - "Please modify scyjava.config.endpoints directly instead." - ) - global endpoints - _logger.debug("Adding endpoints %s to %s", new_endpoints, endpoints) - endpoints.extend(new_endpoints) + global _fetch_java, _java_vendor, _java_version, _maven_url, _maven_sha + if fetch is not None: + if isinstance(fetch, bool): + # Be nice and allow boolean values as a convenience. + fetch = "always" if fetch else "never" + expected = ["auto", "always", "never"] + if fetch not in expected: + raise ValueError(f"Fetch mode {fetch} is not one of {expected}") + _fetch_java = fetch + if vendor is not None: + _java_vendor = vendor + if version is not None: + _java_version = version + if maven_url is not None: + _maven_url = maven_url + _maven_sha = "" + if maven_sha is not None: + _maven_sha = maven_sha + + +def get_fetch_java() -> str: + """ + Get whether [`cjdk`](https://github.com/cachedjdk/cjdk) + will be used to download a JDK/JRE distribution and set up the JVM. + To set this value, see set_java_constraints. + + :return: + "always" for cjdk to obtain the JDK/JRE; + "never" for cjdk *not* to obtain a JDK/JRE; + "auto" for cjdk to be used only when a JVM/or Maven is not on the system path. + """ + return _fetch_java -def get_endpoints(): +def get_java_vendor() -> str: """ - DEPRECATED since v1.2.1 - Please access the endpoints field directly instead. + Get the vendor of the JDK/JRE distribution to download. + Vendor of the Java installation for cjdk to download and cache. + To set this value, see set_java_constraints. + + :return: String defining the desired JDK/JRE vendor for downloaded JDK/JREs. """ - _logger.warning( - "Deprecated method call: scyjava.config.get_endpoints(). " - "Please access scyjava.config.endpoints directly instead." - ) - global endpoints - return endpoints + return _java_vendor -def add_repositories(*args, **kwargs): +def get_java_version() -> str: + """ + Expression defining the Java version for cjdk to download and cache. + To set this value, see set_java_constraints. + + :return: String defining the desired JDK/JRE version for downloaded JDK/JREs. + """ + return _java_version + + +def get_maven_url() -> str: + """ + The URL of the Maven distribution to download. + To set this value, see set_java_constraints. + + :return: URL pointing to the Maven distribution. + """ + return _maven_url + + +def get_maven_sha() -> str: + """ + The SHA512 (or SHA256 or SHA1) hash of the Maven distribution to download, + if providing a custom maven_url. To set this value, see set_java_constraints. + + :return: Hash value of the Maven distribution, or empty string to skip hash check. + """ + return _maven_sha + + +def add_repositories(*args, **kwargs) -> None: + """ + Add one or more Maven repositories to be used by jgo for downloading dependencies. + See the jgo documentation for details. + """ global _repositories for arg in args: _logger.debug("Adding repositories %s to %s", arg, _repositories) @@ -67,57 +160,92 @@ def add_repositories(*args, **kwargs): _repositories.update(kwargs) -def get_repositories(): +def get_repositories() -> dict[str, str]: + """ + Get the Maven repositories jgo will use for downloading dependencies. + See the jgo documentation for details. + """ global _repositories return _repositories -def set_verbose(level): +def set_verbose(level: int) -> None: + """ + Set the level of verbosity for logging environment construction details. + + :param level: + 0 for quiet (default), 1 for verbose, 2 for extra verbose. + """ global _verbose _logger.debug("Setting verbose level to %d (was %d)", level, _verbose) _verbose = level -def get_verbose(): +def get_verbose() -> int: + """ + Get the level of verbosity for logging environment construction details. + """ global _verbose _logger.debug("Getting verbose level: %d", _verbose) return _verbose -def set_manage_deps(manage): +def set_manage_deps(manage: bool) -> None: + """ + Set whether jgo will resolve dependencies in managed mode. + See the jgo documentation for details. + """ global _manage_deps _logger.debug("Setting manage deps to %d (was %d)", manage, _manage_deps) _manage_deps = manage -def get_manage_deps(): +def get_manage_deps() -> bool: + """ + Get whether jgo will resolve dependencies in managed mode. + See the jgo documentation for details. + """ global _manage_deps return _manage_deps -def set_cache_dir(dir): +def set_cache_dir(cache_dir: Path | str) -> None: + """ + Set the location to use for the jgo environment cache. + See the jgo documentation for details. + """ global _cache_dir - _logger.debug("Setting cache dir to %s (was %s)", dir, _cache_dir) - _cache_dir = dir + _logger.debug("Setting cache dir to %s (was %s)", cache_dir, _cache_dir) + _cache_dir = cache_dir -def get_cache_dir(): +def get_cache_dir() -> Path: + """ + Get the location to use for the jgo environment cache. + See the jgo documentation for details. + """ global _cache_dir return _cache_dir -def set_m2_repo(dir): +def set_m2_repo(repo_dir: Path | str) -> None: + """ + Set the location to use for the local Maven repository cache. + """ global _m2_repo - _logger.debug("Setting m2 repo dir to %s (was %s)", dir, _m2_repo) - _m2_repo = dir + _logger.debug("Setting m2 repo dir to %s (was %s)", repo_dir, _m2_repo) + _m2_repo = repo_dir -def get_m2_repo(): +def get_m2_repo() -> Path: + """ + Get the location to use for the local Maven repository cache. + """ global _m2_repo return _m2_repo -def add_classpath(*path): +def add_classpath(*path) -> None: """ Add elements to the Java class path. @@ -143,10 +271,10 @@ def add_classpath(*path): foo.bar.Fubar. """ for p in path: - jpype.addClassPath(p) + _jpype.addClassPath(p) -def find_jars(directory): +def find_jars(directory: Path | str) -> list[str]: """ Find .jar files beneath a given directory. @@ -154,19 +282,22 @@ def find_jars(directory): :return: a list of JAR files """ jars = [] - for root, _, files in os.walk(directory): + for root, _, files in _os.walk(directory): for f in files: if f.lower().endswith(".jar"): - path = os.path.join(root, f) + path = _os.path.join(root, f) jars.append(path) return jars -def get_classpath(): - return jpype.getClassPath() +def get_classpath() -> str: + """ + Get the classpath to be passed to the JVM at startup. + """ + return _jpype.getClassPath() -def set_heap_min(mb: int = None, gb: int = None): +def set_heap_min(mb: int = None, gb: int = None) -> None: """ Set the initial amount of memory to allocate to the Java heap. @@ -183,7 +314,7 @@ def set_heap_min(mb: int = None, gb: int = None): add_option(f"-Xms{_mem_value(mb, gb)}") -def set_heap_max(mb: int = None, gb: int = None): +def set_heap_max(mb: int = None, gb: int = None) -> None: """ Shortcut for passing -Xmx###m or -Xmx###g to Java. @@ -206,7 +337,7 @@ def _mem_value(mb: int = None, gb: int = None) -> str: raise ValueError("Exactly one of mb or gb must be given.") -def enable_headless_mode(): +def enable_headless_mode() -> None: """ Enable headless mode, for running Java without a display. This mode prevents any graphical elements from popping up. @@ -235,12 +366,29 @@ def enable_remote_debugging(port: int = 8000, suspend: bool = False): add_option(f"-agentlib:jdwp={arg_string}") -def add_option(option): +def add_option(option: str) -> None: + """ + Add an option to pass at JVM startup. Examples: + + -Djava.awt.headless=true + -Xmx10g + --add-opens=java.base/java.lang=ALL-UNNAMED + -XX:+UnlockExperimentalVMOptions + + :param option: + The option to add. + """ global _options _options.append(option) -def add_options(options): +def add_options(options: str | Sequence) -> None: + """ + Add one or more options to pass at JVM startup. + + :param options: + Sequence of options to add, or single string to pass as an individual option. + """ global _options if isinstance(options, str): _options.append(options) @@ -248,16 +396,75 @@ def add_options(options): _options.extend(options) -def get_options(): +def get_options() -> list[str]: + """ + Get the list of options to be passed at JVM startup. + """ global _options return _options -def add_shortcut(k, v): +def add_kwargs(**kwargs) -> None: + """ + Add keyword arguments to be passed to JPype at JVM startup. Examples: + + jvmpath = "/path/to/my_jvm" + ignoreUnrecognized = True + convertStrings = True + interrupt = True + """ + global _kwargs + _kwargs.update(kwargs) + + +def get_kwargs() -> dict[str, str]: + """ + Get the keyword arguments to be passed to JPype at JVM startup. + """ + global _kwargs + return _kwargs + + +def add_shortcut(k: str, v: str): + """ + Add a shortcut key/value to be used by jgo for evaluating endpoints. + See the jgo documentation for details. + """ global _shortcuts _shortcuts[k] = v -def get_shortcuts(): +def get_shortcuts() -> dict[str, str]: + """ + Get the dictionary of shorts that jgo will use for evaluating endpoints. + See the jgo documentation for details. + """ global _shortcuts return _shortcuts + + +def add_endpoints(*new_endpoints): + """ + DEPRECATED since v1.2.1 + Please modify the endpoints field directly instead. + """ + _logger.warning( + "Deprecated method call: scyjava.config.add_endpoints(). " + "Please modify scyjava.config.endpoints directly instead." + ) + global endpoints + _logger.debug("Adding endpoints %s to %s", new_endpoints, endpoints) + endpoints.extend(new_endpoints) + + +def get_endpoints(): + """ + DEPRECATED since v1.2.1 + Please access the endpoints field directly instead. + """ + _logger.warning( + "Deprecated method call: scyjava.config.get_endpoints(). " + "Please access scyjava.config.endpoints directly instead." + ) + global endpoints + return endpoints diff --git a/src/scyjava/inspect.py b/src/scyjava/inspect.py new file mode 100644 index 00000000..3058822e --- /dev/null +++ b/src/scyjava/inspect.py @@ -0,0 +1,181 @@ +""" +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(list(map(lambda entry: len(entry["returns"] or "void"), 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: + entry_string = _pretty_string(entry, offset) + all_methods += entry_string + + elif static and "static" in entry["mods"]: + entry_string = _pretty_string(entry, offset) + all_methods += entry_string + elif not static and "static" not in entry["mods"]: + entry_string = _pretty_string(entry, offset) + all_methods += entry_string + else: + continue + 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 index 452cc309..9e746715 100644 --- a/tests/it/awt.py +++ b/tests/it/awt.py @@ -7,11 +7,14 @@ import scyjava +from assertpy import assert_that + if platform.system() == "Darwin": # NB: This test would hang on macOS, due to AWT threading issues. sys.exit(0) -assert not scyjava.jvm_started() +assert_that(scyjava.jvm_started()).is_false() + scyjava.start_jvm() if scyjava.is_jvm_headless(): @@ -21,9 +24,9 @@ # In that case, we are not able to perform this test. sys.exit(0) -assert not scyjava.is_awt_initialized() +assert_that(scyjava.is_awt_initialized()).is_false() Frame = scyjava.jimport("java.awt.Frame") f = Frame() -assert scyjava.is_awt_initialized() +assert_that(scyjava.is_awt_initialized()).is_true() diff --git a/tests/it/headless.py b/tests/it/headless.py index 97ee3852..6f21f376 100644 --- a/tests/it/headless.py +++ b/tests/it/headless.py @@ -4,16 +4,15 @@ import scyjava +from assertpy import assert_that + scyjava.config.enable_headless_mode() -assert not scyjava.jvm_started() +assert_that(scyjava.jvm_started()).is_false() scyjava.start_jvm() - -assert scyjava.is_jvm_headless() +assert_that(scyjava.is_jvm_headless()).is_true() Frame = scyjava.jimport("java.awt.Frame") -try: - f = Frame() - assert False, "HeadlessException should have occurred" -except Exception as e: - assert "java.awt.HeadlessException" == str(e) +assert_that(Frame).raises(Exception).when_called_with().is_equal_to( + "java.awt.HeadlessException" +) diff --git a/tests/it/java_heap.py b/tests/it/java_heap.py index 0d0461a9..77267ae3 100644 --- a/tests/it/java_heap.py +++ b/tests/it/java_heap.py @@ -2,20 +2,21 @@ Test scyjava JVM memory-related functions. """ -from assertpy import assert_that - import scyjava +from assertpy import assert_that + mb_initial = 50 # initial MB of memory to snarf up mb_tolerance = 10 # ceiling of expected MB in use scyjava.config.set_heap_min(mb=mb_initial) scyjava.config.set_heap_max(gb=1) -assert not scyjava.jvm_started() +assert_that(scyjava.jvm_started()).is_false() + scyjava.start_jvm() -assert scyjava.available_processors() >= 1 +assert_that(scyjava.available_processors()).is_greater_than_or_equal_to(1) mb_max = scyjava.memory_max() // 1024 // 1024 mb_total = scyjava.memory_total() // 1024 // 1024 diff --git a/tests/it/jvm_version.py b/tests/it/jvm_version.py index 669875bf..98103bca 100644 --- a/tests/it/jvm_version.py +++ b/tests/it/jvm_version.py @@ -4,19 +4,21 @@ import scyjava -assert not scyjava.jvm_started() +from assertpy import assert_that + +assert_that(scyjava.jvm_started()).is_false() before_version = scyjava.jvm_version() -assert before_version is not None -assert len(before_version) >= 3 -assert before_version[0] > 0 +assert_that(before_version).is_not_none() +assert_that(len(before_version)).is_greater_than_or_equal_to(1) +assert_that(before_version[0]).is_greater_than(0) scyjava.config.enable_headless_mode() scyjava.start_jvm() after_version = scyjava.jvm_version() -assert after_version is not None -assert len(after_version) >= 3 -assert after_version[0] > 0 +assert_that(after_version).is_not_none() +assert_that(len(after_version)).is_greater_than_or_equal_to(1) +assert_that(after_version[0]).is_greater_than(0) -assert before_version == after_version +assert_that(before_version).is_equal_to(after_version) diff --git a/tests/it/script_scope.py b/tests/it/script_scope.py new file mode 100644 index 00000000..fc751fd8 --- /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 + +import scyjava + +from assertpy import assert_that + +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 e + +assert_that(statement).is_equal_to("2") +assert_that(return_value).is_equal_to("The rounded cube root of my age is 2") diff --git a/tests/it/scripting.py b/tests/it/scripting.py index 0a8bf684..48d24b5a 100644 --- a/tests/it/scripting.py +++ b/tests/it/scripting.py @@ -9,6 +9,8 @@ import scyjava +from assertpy import assert_that + scyjava.config.endpoints.extend( ["org.scijava:scijava-common:2.94.2", "org.scijava:scripting-python:MANAGED"] ) @@ -26,7 +28,8 @@ # Assert that the Python script language is available. ss = ctx.service("org.scijava.script.ScriptService") lang = ss.getLanguageByName("Python") -assert lang is not None and "Python" in lang.getNames() +assert_that(lang).is_not_none() +assert_that(lang.getNames()).contains("Python") # Construct a script. script = """ @@ -55,5 +58,7 @@ sys.stderr.write(f"{trace}\n") raise e -assert statement == "Hello, Chuckles! In one year you will be 14 years old." -assert return_value == "A wild return value appears!" +assert_that(statement).is_equal_to( + "Hello, Chuckles! In one year you will be 14 years old." +) +assert_that(return_value).is_equal_to("A wild return value appears!") diff --git a/tests/test_arrays.py b/tests/test_arrays.py index fca796d3..80f18911 100644 --- a/tests/test_arrays.py +++ b/tests/test_arrays.py @@ -1,3 +1,7 @@ +""" +Tests for array-related functions in _types submodule. +""" + import numpy as np from scyjava import is_jarray, jarray, to_python diff --git a/tests/test_basics.py b/tests/test_basics.py index 042b436c..76e2229c 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -1,3 +1,7 @@ +""" +Tests for key functions across all scyjava submodules. +""" + import re import pytest diff --git a/tests/test_convert.py b/tests/test_convert.py index e9f0489d..fcfabe10 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -1,3 +1,7 @@ +""" +Tests for functions in _convert submodule. +""" + import math from os import getcwd from pathlib import Path diff --git a/tests/test_inspect.py b/tests/test_inspect.py new file mode 100644 index 00000000..d308307d --- /dev/null +++ b/tests/test_inspect.py @@ -0,0 +1,37 @@ +""" +Tests for functions in inspect submodule. +""" + +import re + +from scyjava import inspect +from scyjava.config import mode, Mode + + +class TestInspect(object): + """ + Test scyjava.inspect convenience functions. + """ + + def test_inspect_members(self): + if mode == Mode.JEP: + # JEP does not support the jclass function. + return + members = [] + inspect.members("java.lang.Iterable", writer=members.append) + expected = [ + "Source code URL: 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/)" + "[^ ]*(/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..e986dffd --- /dev/null +++ b/tests/test_introspect.py @@ -0,0 +1,114 @@ +""" +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(object): + """ + 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_pandas.py b/tests/test_pandas.py index 8fc4bc3a..c18d2435 100644 --- a/tests/test_pandas.py +++ b/tests/test_pandas.py @@ -1,3 +1,7 @@ +""" +Tests for functions in _pandas submodule. +""" + import numpy as np import numpy.testing as npt import pandas as pd diff --git a/tests/test_types.py b/tests/test_types.py index cc6adc44..e4bdbc92 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,4 +1,9 @@ -from scyjava import numeric_bounds, to_java +""" +Tests for functions in _types submodule. +""" + +from scyjava import jclass, jimport, numeric_bounds, to_java +from scyjava.config import Mode, mode class TestTypes(object): @@ -30,3 +35,22 @@ def test_numeric_bounds(self): type(v_double) ) assert (None, None) == numeric_bounds(type(v_bigdec)) + + def test_jclass(self): + if mode == Mode.JEP: + # JEP does not support the jclass function. + return + + # A. Name of a class to look up -- e.g. "java.lang.String" -> String.class + a_cls = jclass("java.lang.String") + assert a_cls.getName() == "java.lang.String" + + # B. A static-style class reference -- String -> String.class + String = jimport("java.lang.String") + b_cls = jclass(String) + assert b_cls.getName() == "java.lang.String" + + # C. A Java object -- String("hello") -> "hello".getClass() + v_str = to_java("gubernatorial") + c_cls = jclass(v_str) + assert c_cls.getName() == "java.lang.String" diff --git a/tests/test_version.py b/tests/test_version.py deleted file mode 100644 index 54113873..00000000 --- a/tests/test_version.py +++ /dev/null @@ -1,21 +0,0 @@ -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(): - # First, ensure that the version is correct - assert _expected_version() == scyjava.__version__ - - # Then, ensure that we get the correct version via get_version - assert _expected_version() == scyjava.get_version("scyjava") 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)