Skip to content

feat: add scyjava-stubgen cli command, and scyjava.types namespace, which provide type-safe imports with lazy init #82

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

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
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
16 changes: 10 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,7 @@ classifiers = [

# NB: Keep this in sync with environment.yml AND dev-environment.yml!
requires-python = ">=3.9"
dependencies = [
"jpype1 >= 1.3.0",
"jgo",
"cjdk",
]
dependencies = ["jpype1 >= 1.3.0", "jgo", "cjdk", "stubgenj"]

[project.optional-dependencies]
# NB: Keep this in sync with dev-environment.yml!
Expand All @@ -50,9 +46,17 @@ dev = [
"pandas",
"ruff",
"toml",
"validate-pyproject[all]"
"validate-pyproject[all]",
]

[project.scripts]
scyjava-stubgen = "scyjava._stubs._cli:main"

[project.entry-points.hatch]
scyjava = "scyjava._stubs._hatchling_plugin"
[project.entry-points."distutils.commands"]
build_py = "scyjava_stubgen.build:build_py"

[project.urls]
homepage = "https://github.com/scijava/scyjava"
documentation = "https://github.com/scijava/scyjava/blob/main/README.md"
Expand Down
2 changes: 1 addition & 1 deletion src/scyjava/_jvm.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ def is_awt_initialized() -> bool:
return False
Thread = scyjava.jimport("java.lang.Thread")
threads = Thread.getAllStackTraces().keySet()
return any(t.getName().startsWith("AWT-") for t in threads)
return any(str(t.getName()).startswith("AWT-") for t in threads)


def when_jvm_starts(f) -> None:
Expand Down
4 changes: 4 additions & 0 deletions src/scyjava/_stubs/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from ._dynamic_import import setup_java_imports
from ._genstubs import generate_stubs

__all__ = ["setup_java_imports", "generate_stubs"]
154 changes: 154 additions & 0 deletions src/scyjava/_stubs/_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""The scyjava-stubs executable."""

from __future__ import annotations

import argparse
import importlib
import importlib.util
import logging
import sys
from pathlib import Path

from ._genstubs import generate_stubs


def main() -> None:
"""The main entry point for the scyjava-stubs executable."""
logging.basicConfig(level="INFO")
parser = argparse.ArgumentParser(
description="Generate Python Type Stubs for Java classes."
)
parser.add_argument(
"endpoints",
type=str,
nargs="+",
help="Maven endpoints to install and use (e.g. org.myproject:myproject:1.0.0)",
)
parser.add_argument(
"--prefix",
type=str,
help="package prefixes to generate stubs for (e.g. org.myproject), "
"may be used multiple times. If not specified, prefixes are gleaned from the "
"downloaded artifacts.",
action="append",
default=[],
metavar="PREFIX",
dest="prefix",
)
path_group = parser.add_mutually_exclusive_group()
path_group.add_argument(
"--output-dir",
type=str,
default=None,
help="Filesystem path to write stubs to.",
)
path_group.add_argument(
"--output-python-path",
type=str,
default=None,
help="Python path to write stubs to (e.g. 'scyjava.types').",
)
parser.add_argument(
"--convert-strings",
dest="convert_strings",
action="store_true",
default=False,
help="convert java.lang.String to python str in return types. "
"consult the JPype documentation on the convertStrings flag for details",
)
parser.add_argument(
"--no-javadoc",
dest="with_javadoc",
action="store_false",
default=True,
help="do not generate docstrings from JavaDoc where available",
)

rt_group = parser.add_mutually_exclusive_group()
rt_group.add_argument(
"--runtime-imports",
dest="runtime_imports",
action="store_true",
default=True,
help="Add runtime imports to the generated stubs. ",
)
rt_group.add_argument(
"--no-runtime-imports", dest="runtime_imports", action="store_false"
)

parser.add_argument(
"--remove-namespace-only-stubs",
dest="remove_namespace_only_stubs",
action="store_true",
default=False,
help="Remove stubs that export no names beyond a single __module_protocol__. "
"This leaves some folders as PEP420 implicit namespace folders.",
)

if len(sys.argv) == 1:
parser.print_help()
sys.exit(1)

args = parser.parse_args()
output_dir = _get_ouput_dir(args.output_dir, args.output_python_path)
if not output_dir.exists():
output_dir.mkdir(parents=True, exist_ok=True)

generate_stubs(
endpoints=args.endpoints,
prefixes=args.prefix,
output_dir=output_dir,
convert_strings=args.convert_strings,
include_javadoc=args.with_javadoc,
add_runtime_imports=args.runtime_imports,
remove_namespace_only_stubs=args.remove_namespace_only_stubs,
)


def _get_ouput_dir(output_dir: str | None, python_path: str | None) -> Path:
if out_dir := output_dir:
return Path(out_dir)
if pp := python_path:
return _glean_path(pp)
try:
import scyjava

return Path(scyjava.__file__).parent / "types"
except ImportError:
return Path("stubs")


def _glean_path(pp: str) -> Path:
try:
importlib.import_module(pp.split(".")[0])
except ModuleNotFoundError:
# the top level module doesn't exist:
raise ValueError(f"Module {pp} does not exist. Cannot install stubs there.")

try:
spec = importlib.util.find_spec(pp)
except ModuleNotFoundError as e:
# at least one of the middle levels doesn't exist:
raise NotImplementedError(f"Cannot install stubs to {pp}: {e}")

new_ns = None
if not spec:
# if we get here, it means everything but the last level exists:
parent, new_ns = pp.rsplit(".", 1)
spec = importlib.util.find_spec(parent)

if not spec:
# if we get here, it means the last level doesn't exist:
raise ValueError(f"Module {pp} does not exist. Cannot install stubs there.")

search_locations = spec.submodule_search_locations
if not spec.loader and search_locations:
# namespace package with submodules
return Path(search_locations[0])
if spec.origin:
return Path(spec.origin).parent
if new_ns and search_locations:
# namespace package with submodules
return Path(search_locations[0]) / new_ns

raise ValueError(f"Error finding module {pp}. Cannot install stubs there.")
102 changes: 102 additions & 0 deletions src/scyjava/_stubs/_dynamic_import.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import ast
from logging import warning
from pathlib import Path
from typing import Any, Callable, Sequence


def setup_java_imports(
module_name: str,
module_file: str,
endpoints: Sequence[str] = (),
base_prefix: str = "",
) -> tuple[list[str], Callable[[str], Any]]:
"""Setup a module to dynamically import Java class names.

This function creates a `__getattr__` function that, when called, will dynamically
import the requested class from the Java namespace corresponding to the calling
module.

:param module_name: The dotted name/identifier of the module that is calling this
function (usually `__name__` in the calling module).
:param module_file: The path to the module file (usually `__file__` in the calling
module).
:param endpoints: A list of Java endpoints to add to the scyjava configuration.
:param base_prefix: The base prefix for the Java package name. This is used when
determining the Java class path for the requested class. The java class path
will be truncated to only the part including the base_prefix and after. This
makes it possible to embed a module in a subpackage (like `scyjava.types`) and
still have the correct Java class path.
:return: A 2-tuple containing:
- A list of all classes in the module (as defined in the stub file), to be
assigned to `__all__`.
- A callable that takes a class name and returns a proxy for the Java class.
This callable should be assigned to `__getattr__` in the calling module.
The proxy object, when called, will start the JVM, import the Java class,
and return an instance of the class. The JVM will *only* be started when
the object is called.

Example:
If the module calling this function is named `scyjava.types.org.scijava.parsington`,
then it should invoke this function as:

.. code-block:: python

from scyjava._stubs import setup_java_imports

__all__, __getattr__ = setup_java_imports(
__name__,
__file__,
endpoints=["org.scijava:parsington:3.1.0"],
base_prefix="org"
)
"""
import scyjava
import scyjava.config

for ep in endpoints:
if ep not in scyjava.config.endpoints:
scyjava.config.endpoints.append(ep)

module_all = []
try:
my_stub = Path(module_file).with_suffix(".pyi")
stub_ast = ast.parse(my_stub.read_text())
module_all = sorted(
{
node.name
for node in stub_ast.body
if isinstance(node, ast.ClassDef) and not node.name.startswith("__")
}
)
except (OSError, SyntaxError):
warning(
f"Failed to read stub file {my_stub!r}. Falling back to empty __all__.",
stacklevel=3,
)

def module_getattr(name: str, mod_name: str = module_name) -> Any:
if module_all and name not in module_all:
raise AttributeError(f"module {module_name!r} has no attribute {name!r}")

# cut the mod_name to only the part including the base_prefix and after
if base_prefix in mod_name:
mod_name = mod_name[mod_name.index(base_prefix) :]

class_path = f"{mod_name}.{name}"

class ProxyMeta(type):
def __repr__(self) -> str:
return f"<scyjava class {class_path!r}>"

class Proxy(metaclass=ProxyMeta):
def __new__(_cls_, *args: Any, **kwargs: Any) -> Any:
cls = scyjava.jimport(class_path)
return cls(*args, **kwargs)

Proxy.__name__ = name
Proxy.__qualname__ = name
Proxy.__module__ = module_name
Proxy.__doc__ = f"Proxy for {class_path}"
return Proxy

return module_all, module_getattr
Loading
Loading