Skip to content

Object introspection with pretty printing #77

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
python-version: [
'3.8',
'3.10',
'3.12'
'3.13'
]

steps:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "[email protected]"}]
Expand Down
9 changes: 8 additions & 1 deletion src/scyjava/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@
to_java,
to_python,
)
from ._introspect import (
attrs,
fields,
find_java,
java_source,
methods,
src,
)
from ._jvm import ( # noqa: F401
available_processors,
gc,
Expand Down Expand Up @@ -124,7 +132,6 @@
jclass,
jinstance,
jstacktrace,
methods,
numeric_bounds,
)
from ._versions import compare_version, get_version, is_version_at_least
Expand Down
203 changes: 203 additions & 0 deletions src/scyjava/_introspect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
"""
Introspection functions for reporting java class 'methods', 'fields', and source code URL.
"""

from functools import partial
from typing import Any, Dict, List, Optional

from scyjava._jvm import jimport
from scyjava._types import isjava, jinstance, jclass


def find_java(data, aspect: str) -> List[Dict[str, Any]]:
"""
Use Java reflection to introspect the given Java object,
returning a table of its available methods.

:param data: The object or class or fully qualified class name to inspect.
:param aspect: Either 'methods' or 'fields'
:return: List of dicts with keys: "name", "static", "arguments", and "returns".
"""

if not isjava(data) and isinstance(data, str):
try:
data = jimport(data)
except Exception as err:
raise ValueError(f"Not a Java object {err}")

Modifier = jimport("java.lang.reflect.Modifier")
jcls = data if jinstance(data, "java.lang.Class") else jclass(data)

if aspect == "methods":
cls_aspects = jcls.getMethods()
elif aspect == "fields":
cls_aspects = jcls.getFields()
else:
return "`aspect` must be either 'fields' or 'methods'"

table = []

for m in cls_aspects:
name = m.getName()
if aspect == "methods":
args = [c.getName() for c in m.getParameterTypes()]
returns = m.getReturnType().getName()
elif aspect == "fields":
args = None
returns = m.getType().getName()
mods = Modifier.isStatic(m.getModifiers())
table.append(
{
"name": name,
"static": mods,
"arguments": args,
"returns": returns,
}
)
sorted_table = sorted(table, key=lambda d: d["name"])

return sorted_table


def _map_syntax(base_type):
"""
Maps a Java BaseType annotation (see link below) in an Java array
to a specific type with an Python interpretable syntax.
https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.3
"""
basetype_mapping = {
"[B": "byte[]",
"[C": "char[]",
"[D": "double[]",
"[F": "float[]",
"[I": "int[]",
"[J": "long[]",
"[L": "[]", # array
"[S": "short[]",
"[Z": "boolean[]",
}

if base_type in basetype_mapping:
return basetype_mapping[base_type]
# Handle the case of a returned array of an object
elif base_type.__str__().startswith("[L"):
return base_type.__str__()[2:-1] + "[]"
else:
return base_type


def _make_pretty_string(entry, offset):
"""
Prints the entry with a specific formatting and aligned style
:param entry: Dictionary of class names, modifiers, arguments, and return values.
:param offset: Offset between the return value and the method.
"""

# A star implies that the method is a static method
return_val = f"{entry['returns'].__str__():<{offset}}"
# Handle whether to print static/instance modifiers
obj_name = f"{entry['name']}"
modifier = f"{'*':>4}" if entry["static"] else f"{'':>4}"

# Handle fields
if entry["arguments"] is None:
return f"{return_val} {modifier} = {obj_name}\n"

# Handle methods with no arguments
if len(entry["arguments"]) == 0:
return f"{return_val} {modifier} = {obj_name}()\n"
else:
arg_string = ", ".join([r.__str__() for r in entry["arguments"]])
return f"{return_val} {modifier} = {obj_name}({arg_string})\n"


def java_source(data):
"""
Tries to find the source code using Scijava's SourceFinder
:param data: The object or class or fully qualified class name to check for source code.
:return: The URL of the java class
"""
types = jimport("org.scijava.util.Types")
sf = jimport("org.scijava.search.SourceFinder")
jstring = jimport("java.lang.String")
try:
if not isjava(data) and isinstance(data, str):
try:
data = jimport(data) # check if data can be imported
except Exception as err:
raise ValueError(f"Not a Java object {err}")
jcls = data if jinstance(data, "java.lang.Class") else jclass(data)
if types.location(jcls).toString().startsWith(jstring("jrt")):
# Handles Java RunTime (jrt) exceptions.
raise ValueError("Java Builtin: GitHub source code not available")
url = sf.sourceLocation(jcls, None)
urlstring = url.toString()
return urlstring
except jimport("java.lang.IllegalArgumentException") as err:
return f"Illegal argument provided {err=}, {type(err)=}"
except ValueError as err:
return f"{err}"
except TypeError:
return f"Not a Java class {str(type(data))}"
except Exception as err:
return f"Unexpected {err=}, {type(err)=}"


def _print_data(data, aspect, static: Optional[bool] = None, source: bool = True):
"""
Writes data to a printed string of class methods with inputs, static modifier, arguments, and return values.

:param data: The object or class to inspect or fully qualified class name.
:param aspect: Whether to print class 'fields' or 'methods'.
:param static: Boolean filter on Static or Instance methods. Optional, default is None (prints all).
:param source: Whether to print any available source code. Default True.
"""
table = find_java(data, aspect)
if len(table) == 0:
print(f"No {aspect} found")
return

# Print source code
offset = max(list(map(lambda entry: len(entry["returns"]), table)))
all_methods = ""
if source:
urlstring = java_source(data)
print(f"Source code URL: {urlstring}")

# Print methods
for entry in table:
entry["returns"] = _map_syntax(entry["returns"])
if entry["arguments"]:
entry["arguments"] = [_map_syntax(e) for e in entry["arguments"]]
if static is None:
entry_string = _make_pretty_string(entry, offset)
all_methods += entry_string

elif static and entry["static"]:
entry_string = _make_pretty_string(entry, offset)
all_methods += entry_string
elif not static and not entry["static"]:
entry_string = _make_pretty_string(entry, offset)
all_methods += entry_string
else:
continue

# 4 added to align the asterisk with output.
print(f"{'':<{offset + 4}}* indicates static modifier")
print(all_methods)


# The functions with short names for quick usage.
methods = partial(_print_data, aspect="methods")
fields = partial(_print_data, aspect="fields")
attrs = partial(_print_data, aspect="fields")


def src(data):
"""
Prints the source code URL for a Java class, object, or class name.

:param data: The Java class, object, or fully qualified class name as string
"""
source_url = java_source(data)
print(f"Source code URL: {source_url}")
49 changes: 0 additions & 49 deletions src/scyjava/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,55 +324,6 @@ def numeric_bounds(
return None, None


def methods(data) -> list[dict[str, Any]]:
"""
Use Java reflection to introspect the given Java object,
returning a table of its available methods.

:param data: The object or class to inspect.
:return: List of table rows with columns "name", "arguments", and "returns".
"""

if not isjava(data):
raise ValueError("Not a Java object")

cls = data if jinstance(data, "java.lang.Class") else jclass(data)

methods = cls.getMethods()

# NB: Methods are returned in inconsistent order.
# Arrays.sort(methods, (m1, m2) -> {
# final int nameComp = m1.getName().compareTo(m2.getName())
# if (nameComp != 0) return nameComp
# final int pCount1 = m1.getParameterCount()
# final int pCount2 = m2.getParameterCount()
# if (pCount1 != pCount2) return pCount1 - pCount2
# final Class<?>[] pTypes1 = m1.getParameterTypes()
# final Class<?>[] pTypes2 = m2.getParameterTypes()
# for (int i = 0; i < pTypes1.length; i++) {
# final int typeComp = ClassUtils.compare(pTypes1[i], pTypes2[i])
# if (typeComp != 0) return typeComp
# }
# return ClassUtils.compare(m1.getReturnType(), m2.getReturnType())
# })

table = []

for m in methods:
name = m.getName()
args = [c.getName() for c in m.getParameterTypes()]
returns = m.getReturnType().getName()
table.append(
{
"name": name,
"arguments": args,
"returns": returns,
}
)

return table


def _is_jtype(the_type: type, class_name: str) -> bool:
"""
Test if the given type object is *exactly* the specified Java type.
Expand Down
4 changes: 4 additions & 0 deletions tests/test_arrays.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
"""
Tests for array-related functions in _types submodule.
"""

import numpy as np

from scyjava import is_jarray, jarray, to_python
Expand Down
4 changes: 4 additions & 0 deletions tests/test_basics.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
"""
Tests for key functions across all scyjava submodules.
"""

import re

import pytest
Expand Down
4 changes: 4 additions & 0 deletions tests/test_convert.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
"""
Tests for functions in _convert submodule.
"""

import math
from os import getcwd
from pathlib import Path
Expand Down
67 changes: 67 additions & 0 deletions tests/test_introspect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""
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.append("net.imagej:imagej")
scyjava.config.endpoints.append("net.imagej:imagej-legacy:MANAGED")


class TestIntrospection(object):
"""
Test introspection functionality.
"""

def test_find_java_methods(self):
if mode == Mode.JEP:
# JEP does not support the jclass function.
return
str_String = "java.lang.String"
String = scyjava.jimport(str_String)
str_Obj = scyjava.find_java(str_String, "methods")
jimport_Obj = scyjava.find_java(String, "methods")
assert len(str_Obj) > 0
assert len(jimport_Obj) > 0
assert jimport_Obj is not None
assert jimport_Obj == str_Obj

def test_find_java_fields(self):
if mode == Mode.JEP:
# JEP does not support the jclass function.
return
str_BitSet = "java.util.BitSet"
BitSet = scyjava.jimport(str_BitSet)
str_Obj = scyjava.find_java(str_BitSet, "fields")
bitset_Obj = scyjava.find_java(BitSet, "fields")
assert len(str_Obj) == 0
assert len(bitset_Obj) == 0
assert bitset_Obj is not None
assert bitset_Obj == str_Obj

def test_find_source(self):
if mode == Mode.JEP:
# JEP does not support the jclass function.
return
str_SF = "org.scijava.search.SourceFinder"
SF = scyjava.jimport(str_SF)
source_strSF = scyjava.java_source(str_SF)
source_SF = scyjava.java_source(SF)
github_home = "https://github.com/"
assert source_strSF.startsWith(github_home)
assert source_SF.startsWith(github_home)
assert source_strSF == source_SF

def test_imagej_legacy(self):
if mode == Mode.JEP:
# JEP does not support the jclass function.
return
str_RE = "ij.plugin.RoiEnlarger"
table = scyjava.find_java(str_RE, aspect="methods")
assert len([entry for entry in table if entry["static"]]) == 3
github_home = "https://github.com/"
assert scyjava.java_source(str_RE).startsWith(github_home)
Loading