diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8b246a01..09a2f3d2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,7 @@ jobs: python-version: [ '3.8', '3.10', - '3.12' + '3.13' ] steps: diff --git a/README.md b/README.md index eb22bbb8..cedcfcb1 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ AttributeError: 'list' object has no attribute 'stream' Traceback (most recent call last): File "", line 1, in TypeError: No matching overloads found for java.util.Set.addAll(set), options are: - public abstract boolean java.util.Set.addAll(java.util.Collection) + public abstract boolean java.util.Set.addAll(java.util.Collection) >>> from scyjava import to_java as p2j >>> jset.addAll(p2j(pset)) True @@ -216,6 +216,22 @@ FUNCTIONS is_jarray(data: Any) -> bool Return whether the given data object is a Java array. + is_jboolean(the_type: type) -> bool + + is_jbyte(the_type: type) -> bool + + is_jcharacter(the_type: type) -> bool + + is_jdouble(the_type: type) -> bool + + is_jfloat(the_type: type) -> bool + + is_jinteger(the_type: type) -> bool + + is_jlong(the_type: type) -> bool + + is_jshort(the_type: type) -> bool + is_jvm_headless() -> bool Return true iff Java is running in headless mode. @@ -267,6 +283,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 +325,14 @@ FUNCTIONS :param jtype: The Java type, as either a jimported class or as a string. :return: True iff the object is an instance of that Java type. + jreflect(data, aspect: str = "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. diff --git a/pyproject.toml b/pyproject.toml index bbcc8b80..3a559b35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "scyjava" -version = "1.10.3.dev0" +version = "1.11.0.dev0" description = "Supercharged Java access from Python" license = {text = "The Unlicense"} authors = [{name = "SciJava developers", email = "ctrueden@wisc.edu"}] diff --git a/src/scyjava/__init__.py b/src/scyjava/__init__.py index f622685c..e19cc790 100644 --- a/src/scyjava/__init__.py +++ b/src/scyjava/__init__.py @@ -71,6 +71,7 @@ from functools import lru_cache from typing import Any, Callable, Dict +from . import config, inspect from ._arrays import is_arraylike, is_memoryarraylike, is_xarraylike from ._convert import ( Converter, @@ -91,6 +92,10 @@ to_java, to_python, ) +from ._introspect import ( + jreflect, + jsource, +) from ._jvm import ( # noqa: F401 available_processors, gc, diff --git a/src/scyjava/_introspect.py b/src/scyjava/_introspect.py new file mode 100644 index 00000000..9449fc8d --- /dev/null +++ b/src/scyjava/_introspect.py @@ -0,0 +1,126 @@ +""" +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. + java_version = jvm_version()[0] + + # Note: some classes (e.g. corba and jaxp) will not be located correctly before + # Java 10, because they fall under a different subtree than `jdk`. But Java 11+ + # dispenses with such subtrees in favor of using only the module designations. + if java_version <= 7: + return f"https://github.com/openjdk/jdk/blob/jdk7-b147/jdk/src/share/classes/{cls_path}.java" + elif java_version == 8: + return f"https://github.com/openjdk/jdk/blob/jdk8-b120/jdk/src/share/classes/{cls_path}.java" + else: # java_version >= 9 + module_name = jcls.getModule().getName() + # if module_name is null, it's in the unnamed module + if java_version == 9: + suffix = "%2B181/jdk" + elif java_version == 10: + suffix = "%2B46" + else: + suffix = "-ga" + return f"https://github.com/openjdk/jdk/blob/jdk-{java_version}{suffix}/src/{module_name}/share/classes/{cls_path}.java" + + # Ask scijava-search for the source location. + SourceFinder = jimport("org.scijava.search.SourceFinder") + url = SourceFinder.sourceLocation(jcls, None) + urlstring = url.toString() + return urlstring diff --git a/src/scyjava/_jvm.py b/src/scyjava/_jvm.py index 1a6c5ca1..742b6eb9 100644 --- a/src/scyjava/_jvm.py +++ b/src/scyjava/_jvm.py @@ -226,7 +226,7 @@ def shutdown_jvm() -> None: try: callback() except Exception as e: - print(f"Exception during shutdown callback: {e}") + _logger.error(f"Exception during shutdown callback: {e}") # dispose AWT resources if applicable if is_awt_initialized(): @@ -238,7 +238,7 @@ def shutdown_jvm() -> None: try: jpype.shutdownJVM() except Exception as e: - print(f"Exception during JVM shutdown: {e}") + _logger.error(f"Exception during JVM shutdown: {e}") def jvm_started() -> bool: 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..712b2ab1 100644 --- a/src/scyjava/config.py +++ b/src/scyjava/config.py @@ -1,24 +1,26 @@ -import enum -import logging -import os -import pathlib +import enum as _enum +import logging as _logging +import os as _os +import pathlib as _pathlib -import jpype -from jgo import maven_scijava_repository +import jpype as _jpype +from jgo import maven_scijava_repository as _scijava_public -_logger = logging.getLogger(__name__) + +_logger = _logging.getLogger(__name__) endpoints = [] -_repositories = {"scijava.public": maven_scijava_repository()} + +_repositories = {"scijava.public": _scijava_public()} _verbose = 0 _manage_deps = True -_cache_dir = pathlib.Path.home() / ".jgo" -_m2_repo = pathlib.Path.home() / ".m2" / "repository" +_cache_dir = _pathlib.Path.home() / ".jgo" +_m2_repo = _pathlib.Path.home() / ".m2" / "repository" _options = [] _shortcuts = {} -class Mode(enum.Enum): +class Mode(_enum.Enum): JEP = "jep" JPYPE = "jpype" @@ -143,7 +145,7 @@ def add_classpath(*path): foo.bar.Fubar. """ for p in path: - jpype.addClassPath(p) + _jpype.addClassPath(p) def find_jars(directory): @@ -154,16 +156,16 @@ def find_jars(directory): :return: a list of JAR files """ jars = [] - for root, _, files in os.walk(directory): + for root, _, files in _os.walk(directory): for f in files: if f.lower().endswith(".jar"): - path = os.path.join(root, f) + path = _os.path.join(root, f) jars.append(path) return jars def get_classpath(): - return jpype.getClassPath() + return _jpype.getClassPath() def set_heap_min(mb: int = None, gb: int = None): diff --git a/src/scyjava/inspect.py b/src/scyjava/inspect.py new file mode 100644 index 00000000..6b0d9e8e --- /dev/null +++ b/src/scyjava/inspect.py @@ -0,0 +1,179 @@ +""" +High-level convenience functions for inspecting Java objects. +""" + +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/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..4eca8d95 --- /dev/null +++ b/tests/test_inspect.py @@ -0,0 +1,31 @@ +""" +Tests for functions in inspect submodule. +""" + +from scyjava import inspect +from scyjava.config import mode, Mode + + +class TestInspect(object): + """ + Test scyjava.inspect convenience functions. + """ + + def test_inspect_members(self): + if mode == Mode.JEP: + # JEP does not support the jclass function. + return + members = [] + inspect.members("java.lang.Iterable", writer=members.append) + expected = [ + "Source code URL: " + "https://github.com/openjdk/jdk/blob/jdk-11-ga/" + "src/java.base/share/classes/java/lang/Iterable.java", + " * indicates static modifier", + "java.util.Iterator = iterator()", + "java.util.Spliterator = spliterator()", + "void = forEach(java.util.function.Consumer)", + "", + "", + ] + assert "".join(members).split("\n") == expected diff --git a/tests/test_introspect.py b/tests/test_introspect.py new file mode 100644 index 00000000..7c7fe994 --- /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 = scyjava.jvm_version()[0] + source = scyjava.jsource("java.util.List") + assert ( + source == f"https://github.com/openjdk/jdk/blob/jdk-{jv}-ga/" + "src/java.base/share/classes/java/util/List.java" + ) + + def test_imagej_legacy(self): + if mode == Mode.JEP: + # JEP does not support the jclass function. + 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 7ccf23be..e4bdbc92 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,3 +1,7 @@ +""" +Tests for functions in _types submodule. +""" + from scyjava import jclass, jimport, numeric_bounds, to_java from scyjava.config import Mode, mode diff --git a/tests/test_version.py b/tests/test_version.py 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)