Skip to content

Commit 459a999

Browse files
committed
Add scyjava-stubs CLI and dynamic import functionality
- Introduced `scyjava-stubs` executable for generating Python type stubs from Java classes. - Implemented dynamic import logic in `_dynamic_import.py`. - Added stub generation logic in `_genstubs.py`. - Updated `pyproject.toml` to include new dependencies and scripts. - Created `__init__.py` for the `_stubs` package to expose key functionalities.
1 parent 0f70b03 commit 459a999

File tree

5 files changed

+405
-0
lines changed

5 files changed

+405
-0
lines changed

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ dependencies = [
3636
"jpype1 >= 1.3.0",
3737
"jgo",
3838
"cjdk",
39+
"stubgenj",
3940
]
4041

4142
[project.optional-dependencies]
@@ -53,6 +54,9 @@ dev = [
5354
"validate-pyproject[all]"
5455
]
5556

57+
[project.scripts]
58+
scyjava-stubgen = "scyjava._stubs._cli:main"
59+
5660
[project.urls]
5761
homepage = "https://github.com/scijava/scyjava"
5862
documentation = "https://github.com/scijava/scyjava/blob/main/README.md"

src/scyjava/_stubs/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from ._dynamic_import import dynamic_import
2+
from ._genstubs import generate_stubs
3+
4+
__all__ = ["dynamic_import", "generate_stubs"]

src/scyjava/_stubs/_cli.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""The scyjava-stubs executable."""
2+
3+
import argparse
4+
import importlib
5+
import importlib.util
6+
import logging
7+
import sys
8+
from pathlib import Path
9+
10+
from ._genstubs import generate_stubs
11+
12+
13+
def main() -> None:
14+
"""The main entry point for the scyjava-stubs executable."""
15+
logging.basicConfig(level="INFO")
16+
parser = argparse.ArgumentParser(
17+
description="Generate Python Type Stubs for Java classes."
18+
)
19+
parser.add_argument(
20+
"endpoints",
21+
type=str,
22+
nargs="+",
23+
help="Maven endpoints to install and use (e.g. org.myproject:myproject:1.0.0)",
24+
)
25+
parser.add_argument(
26+
"--prefix",
27+
type=str,
28+
help="package prefixes to generate stubs for (e.g. org.myproject), "
29+
"may be used multiple times. If not specified, prefixes are gleaned from the "
30+
"downloaded artifacts.",
31+
action="append",
32+
default=[],
33+
metavar="PREFIX",
34+
dest="prefix",
35+
)
36+
path_group = parser.add_mutually_exclusive_group()
37+
path_group.add_argument(
38+
"--output-dir",
39+
type=str,
40+
default=None,
41+
help="Filesystem path to write stubs to.",
42+
)
43+
path_group.add_argument(
44+
"--output-python-path",
45+
type=str,
46+
default=None,
47+
help="Python path to write stubs to (e.g. 'scyjava.types').",
48+
)
49+
parser.add_argument(
50+
"--convert-strings",
51+
dest="convert_strings",
52+
action="store_true",
53+
default=False,
54+
help="convert java.lang.String to python str in return types. "
55+
"consult the JPype documentation on the convertStrings flag for details",
56+
)
57+
parser.add_argument(
58+
"--no-javadoc",
59+
dest="with_javadoc",
60+
action="store_false",
61+
default=True,
62+
help="do not generate docstrings from JavaDoc where available",
63+
)
64+
65+
rt_group = parser.add_mutually_exclusive_group()
66+
rt_group.add_argument(
67+
"--runtime-imports",
68+
dest="runtime_imports",
69+
action="store_true",
70+
default=True,
71+
help="Add runtime imports to the generated stubs. ",
72+
)
73+
rt_group.add_argument(
74+
"--no-runtime-imports", dest="runtime_imports", action="store_false"
75+
)
76+
77+
parser.add_argument(
78+
"--remove-namespace-only-stubs",
79+
dest="remove_namespace_only_stubs",
80+
action="store_true",
81+
default=False,
82+
help="Remove stubs that export no names beyond a single __module_protocol__. "
83+
"This leaves some folders as PEP420 implicit namespace folders.",
84+
)
85+
86+
if len(sys.argv) == 1:
87+
parser.print_help()
88+
sys.exit(1)
89+
90+
args = parser.parse_args()
91+
output_dir = _get_ouput_dir(args.output_dir, args.output_python_path)
92+
if not output_dir.exists():
93+
output_dir.mkdir(parents=True, exist_ok=True)
94+
95+
generate_stubs(
96+
endpoints=args.endpoints,
97+
prefixes=args.prefix,
98+
output_dir=output_dir,
99+
convert_strings=args.convert_strings,
100+
include_javadoc=args.with_javadoc,
101+
add_runtime_imports=args.runtime_imports,
102+
remove_namespace_only_stubs=args.remove_namespace_only_stubs,
103+
)
104+
105+
106+
def _get_ouput_dir(output_dir: str | None, python_path: str | None) -> Path:
107+
if out_dir := output_dir:
108+
return Path(out_dir)
109+
if pp := python_path:
110+
return _glean_path(pp)
111+
try:
112+
import scyjava
113+
114+
return Path(scyjava.__file__).parent / "types"
115+
except ImportError:
116+
return Path("stubs")
117+
118+
119+
def _glean_path(pp: str) -> Path:
120+
try:
121+
importlib.import_module(pp.split(".")[0])
122+
except ModuleNotFoundError:
123+
# the top level module doesn't exist:
124+
raise ValueError(f"Module {pp} does not exist. Cannot install stubs there.")
125+
126+
try:
127+
spec = importlib.util.find_spec(pp)
128+
except ModuleNotFoundError as e:
129+
# at least one of the middle levels doesn't exist:
130+
raise NotImplementedError(f"Cannot install stubs to {pp}: {e}")
131+
132+
new_ns = None
133+
if not spec:
134+
# if we get here, it means everything but the last level exists:
135+
parent, new_ns = pp.rsplit(".", 1)
136+
spec = importlib.util.find_spec(parent)
137+
138+
if not spec:
139+
# if we get here, it means the last level doesn't exist:
140+
raise ValueError(f"Module {pp} does not exist. Cannot install stubs there.")
141+
142+
search_locations = spec.submodule_search_locations
143+
if not spec.loader and search_locations:
144+
# namespace package with submodules
145+
return Path(search_locations[0])
146+
if spec.origin:
147+
return Path(spec.origin).parent
148+
if new_ns and search_locations:
149+
# namespace package with submodules
150+
return Path(search_locations[0]) / new_ns
151+
152+
raise ValueError(f"Error finding module {pp}. Cannot install stubs there.")
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import ast
2+
from logging import warning
3+
from pathlib import Path
4+
from typing import Any, Callable
5+
6+
7+
def dynamic_import(
8+
module_name: str, module_file: str, *endpoints: str
9+
) -> tuple[list[str], Callable[[str], Any]]:
10+
import scyjava
11+
import scyjava.config
12+
13+
for ep in endpoints:
14+
if ep not in scyjava.config.endpoints:
15+
scyjava.config.endpoints.append(ep)
16+
17+
module_all = []
18+
try:
19+
my_stub = Path(module_file).with_suffix(".pyi")
20+
stub_ast = ast.parse(my_stub.read_text())
21+
module_all = sorted(
22+
{
23+
node.name
24+
for node in stub_ast.body
25+
if isinstance(node, ast.ClassDef) and not node.name.startswith("__")
26+
}
27+
)
28+
except (OSError, SyntaxError):
29+
warning(
30+
f"Failed to read stub file {my_stub!r}. Falling back to empty __all__.",
31+
stacklevel=3,
32+
)
33+
34+
def module_getattr(name: str, mod_name: str = module_name) -> Any:
35+
if module_all and name not in module_all:
36+
raise AttributeError(f"module {module_name!r} has no attribute {name!r}")
37+
38+
# this strip is important... and tricky, because it depends on the
39+
# namespace that we intend to install the stubs into.
40+
install_path = "scyjava.types."
41+
if mod_name.startswith(install_path):
42+
mod_name = mod_name[len(install_path) :]
43+
44+
full_name = f"{mod_name}.{name}"
45+
46+
class ProxyMeta(type):
47+
def __repr__(self) -> str:
48+
return f"<scyjava class {full_name!r}>"
49+
50+
class Proxy(metaclass=ProxyMeta):
51+
def __new__(_cls_, *args: Any, **kwargs: Any) -> Any:
52+
cls = scyjava.jimport(full_name)
53+
return cls(*args, **kwargs)
54+
55+
Proxy.__name__ = name
56+
Proxy.__qualname__ = name
57+
Proxy.__module__ = module_name
58+
Proxy.__doc__ = f"Proxy for {full_name}"
59+
return Proxy
60+
61+
return module_all, module_getattr

0 commit comments

Comments
 (0)