pyulix 1.2.0b2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyulix/__init__.py +12 -0
- pyulix/__init__.pyi +4 -0
- pyulix/_api.py +225 -0
- pyulix/_api.pyi +20 -0
- pyulix/_api_schema.json.gz +0 -0
- pyulix/_bridge.py +367 -0
- pyulix/java/__init__.py +4 -0
- pyulix/java/__init__.pyi +32438 -0
- pyulix/py.typed +0 -0
- pyulix-1.2.0b2.dist-info/METADATA +132 -0
- pyulix-1.2.0b2.dist-info/RECORD +15 -0
- pyulix-1.2.0b2.dist-info/WHEEL +5 -0
- pyulix-1.2.0b2.dist-info/licenses/LICENSE +21 -0
- pyulix-1.2.0b2.dist-info/licenses/NOTICE +8 -0
- pyulix-1.2.0b2.dist-info/top_level.txt +1 -0
pyulix/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Complete named Python facade for the pinned Oculix Java API.
|
|
2
|
+
|
|
3
|
+
Java method names, constructors and overload arguments are preserved.
|
|
4
|
+
Use ``method.overload(*java_type_names)`` for ambiguous null/reference calls.
|
|
5
|
+
"""
|
|
6
|
+
from ._bridge import Bridge, BridgeError, RemoteObject, RemoteWrapper, default_bridge
|
|
7
|
+
from ._api import JavaObject, JavaCallback, java_class
|
|
8
|
+
from .java import *
|
|
9
|
+
from .java import __all__ as _java_names
|
|
10
|
+
|
|
11
|
+
__version__ = '1.2.0b2'
|
|
12
|
+
__all__ = list(_java_names) + ['Bridge', 'BridgeError', 'JavaObject', 'JavaCallback', 'java_class', 'default_bridge']
|
pyulix/__init__.pyi
ADDED
pyulix/_api.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Java overload binding, fields and typed facades for the generated API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import inspect
|
|
5
|
+
import gzip
|
|
6
|
+
import json
|
|
7
|
+
import keyword
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ._bridge import RemoteWrapper, RemoteObject, register_wrapper, _WRAPPER_TYPES
|
|
12
|
+
|
|
13
|
+
_SCHEMA = json.loads(gzip.decompress(Path(__file__).with_name('_api_schema.json.gz').read_bytes()))
|
|
14
|
+
_TYPES = {}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _default_bridge():
|
|
18
|
+
import pyulix
|
|
19
|
+
return pyulix.default_bridge()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _identifier(name):
|
|
23
|
+
return name + '_' if keyword.iskeyword(name) or name in {'self', 'cls'} else name
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _bind(member, args, kwargs):
|
|
27
|
+
parameters = member['parameters']
|
|
28
|
+
values = list(args)
|
|
29
|
+
varargs = member['varargs']
|
|
30
|
+
fixed = len(parameters) - int(varargs)
|
|
31
|
+
if not varargs and len(values) > len(parameters): raise TypeError('Too many positional arguments')
|
|
32
|
+
for i, p in enumerate(parameters):
|
|
33
|
+
name = _identifier(p['name'])
|
|
34
|
+
if name in kwargs:
|
|
35
|
+
if i < len(args): raise TypeError('Multiple values for ' + name)
|
|
36
|
+
if i != len(values): raise TypeError('Missing preceding argument')
|
|
37
|
+
values.append(kwargs[name])
|
|
38
|
+
elif i >= len(values) and i < fixed:
|
|
39
|
+
raise TypeError('Missing argument ' + name)
|
|
40
|
+
known = {_identifier(p['name']) for p in parameters}
|
|
41
|
+
if set(kwargs) - known: raise TypeError('Unknown argument: ' + ', '.join(set(kwargs) - known))
|
|
42
|
+
if varargs:
|
|
43
|
+
# Both Java's explicit array form and Python's expanded positional form.
|
|
44
|
+
final = values[-1] if values else None
|
|
45
|
+
remote = final._remote if isinstance(final, RemoteWrapper) else final
|
|
46
|
+
explicit_array = (final is None or isinstance(final, (list, tuple))
|
|
47
|
+
or isinstance(final, (bytes, bytearray)) and parameters[-1]['type'] == 'byte[]'
|
|
48
|
+
or isinstance(remote, RemoteObject) and remote._class.startswith('['))
|
|
49
|
+
if len(values) == len(parameters) and explicit_array:
|
|
50
|
+
pass
|
|
51
|
+
else: values = values[:fixed] + [values[fixed:]]
|
|
52
|
+
elif len(values) != len(parameters): raise TypeError('Wrong argument count')
|
|
53
|
+
return values
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class BoundJavaMethod:
|
|
57
|
+
def __init__(self, descriptor, instance, owner):
|
|
58
|
+
self.descriptor, self.instance, self.owner = descriptor, instance, owner
|
|
59
|
+
self.__name__ = descriptor.name
|
|
60
|
+
self.__doc__ = descriptor.__doc__
|
|
61
|
+
self.overloads = tuple(descriptor.members)
|
|
62
|
+
member = next((m for m in descriptor.members if m['static'] or instance is not None), descriptor.members[0])
|
|
63
|
+
params = [inspect.Parameter(_identifier(p['name']), inspect.Parameter.VAR_POSITIONAL
|
|
64
|
+
if member['varargs'] and i == len(member['parameters']) - 1 else inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
|
65
|
+
for i, p in enumerate(member['parameters'])]
|
|
66
|
+
self.__signature__ = inspect.Signature(params)
|
|
67
|
+
|
|
68
|
+
def overload(self, *parameter_types):
|
|
69
|
+
"""Choose an exact Java overload where Python values cannot disambiguate it."""
|
|
70
|
+
members = [m for m in self.descriptor.members if tuple(p['type'] for p in m['parameters']) == parameter_types]
|
|
71
|
+
if not members: raise TypeError('No Java overload with types ' + repr(parameter_types))
|
|
72
|
+
return BoundJavaMethod(JavaMethod(self.descriptor.name, members), self.instance, self.owner)
|
|
73
|
+
|
|
74
|
+
def __call__(self, *args, **kwargs):
|
|
75
|
+
bridge = self.instance._remote._bridge if self.instance is not None else _default_bridge()
|
|
76
|
+
candidates = []
|
|
77
|
+
for member in self.descriptor.members:
|
|
78
|
+
if self.instance is None and not member['static'] and self.__name__ != '<init>': continue
|
|
79
|
+
try: values = _bind(member, args, kwargs)
|
|
80
|
+
except TypeError: continue
|
|
81
|
+
prepared = []
|
|
82
|
+
for parameter, value in zip(member['parameters'], values):
|
|
83
|
+
if callable(value) and not isinstance(value, JavaCallback):
|
|
84
|
+
interface = parameter['type']
|
|
85
|
+
if interface == 'java.lang.Object': interface = 'org.sikuli.script.ObserverCallBack'
|
|
86
|
+
value = JavaCallback(interface, value)
|
|
87
|
+
prepared.append(value)
|
|
88
|
+
candidates.append((member, prepared))
|
|
89
|
+
if not candidates:
|
|
90
|
+
raise TypeError(f'No matching argument names/count for {self.owner.JAVA_CLASS}.{self.__name__}; see .overloads')
|
|
91
|
+
# Java owns assignability, interface inheritance, unboxing and conversion.
|
|
92
|
+
# Python only binds argument names/counts; stubs never gate runtime values.
|
|
93
|
+
if len(candidates) > 1:
|
|
94
|
+
index = bridge.resolve_overload(self.owner.JAVA_CLASS, self.__name__, candidates)
|
|
95
|
+
else: index = 0
|
|
96
|
+
member, prepared = candidates[index]
|
|
97
|
+
for parameter, value in zip(member['parameters'], prepared):
|
|
98
|
+
if value is None and parameter.get('non_null'):
|
|
99
|
+
raise TypeError(f"{self.owner.JAVA_CLASS}.{self.__name__}: {parameter['name']} "
|
|
100
|
+
f"must not be None ({parameter['type']})")
|
|
101
|
+
types = [p['type'] for p in member['parameters']]
|
|
102
|
+
if self.__name__ == '<init>': return bridge.create(self.owner.JAVA_CLASS, prepared, parameter_types=types)
|
|
103
|
+
if member['static']:
|
|
104
|
+
return bridge.call_static(self.owner.JAVA_CLASS, self.__name__, prepared, parameter_types=types)
|
|
105
|
+
return bridge.call(self.instance._remote._ref, self.__name__, prepared, parameter_types=types)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class JavaMethod:
|
|
109
|
+
"""A declared, finite set of Java overloads; no arbitrary method forwarding."""
|
|
110
|
+
def __init__(self, name, members):
|
|
111
|
+
self.name, self.members = name, members
|
|
112
|
+
self.__doc__ = '\n'.join(m['name'] + '(' + ', '.join(p['type'] + ' ' + p['name'] for p in m['parameters']) + ')' for m in members)
|
|
113
|
+
|
|
114
|
+
def __get__(self, instance, owner=None):
|
|
115
|
+
return BoundJavaMethod(self, instance, owner)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class JavaField:
|
|
119
|
+
def __init__(self, field): self.field = field
|
|
120
|
+
def __get__(self, instance, owner=None):
|
|
121
|
+
if instance is None and not self.field['static']: return self
|
|
122
|
+
bridge = instance._remote._bridge if instance is not None else _default_bridge()
|
|
123
|
+
req = {'class': owner.JAVA_CLASS, 'field': self.field['name']}
|
|
124
|
+
if instance is not None and not self.field['static']: req = {'ref': instance._remote._ref, 'field': self.field['name']}
|
|
125
|
+
from ._bridge import _decode
|
|
126
|
+
return _decode(bridge, bridge._request(req))
|
|
127
|
+
def __set__(self, instance, value):
|
|
128
|
+
self.set(type(instance), instance, value)
|
|
129
|
+
def set(self, owner, instance, value):
|
|
130
|
+
if self.field['final']: raise AttributeError('Java field is final: ' + self.field['name'])
|
|
131
|
+
bridge = instance._remote._bridge if instance is not None else _default_bridge()
|
|
132
|
+
from ._bridge import _encode
|
|
133
|
+
req = {'class': owner.JAVA_CLASS, 'field': self.field['name'], 'value': _encode(value, bridge)}
|
|
134
|
+
if instance is not None and not self.field['static']: req['ref'] = instance._remote._ref
|
|
135
|
+
bridge._request(req)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class JavaMeta(type):
|
|
139
|
+
def overload(cls, *parameter_types):
|
|
140
|
+
"""Select an exact public constructor, including null reference calls."""
|
|
141
|
+
members = _SCHEMA.get(cls.JAVA_CLASS, {}).get('constructors', [])
|
|
142
|
+
return BoundJavaMethod(JavaMethod('<init>', members), None, cls).overload(*parameter_types)
|
|
143
|
+
def __setattr__(cls, name, value):
|
|
144
|
+
descriptor = next((p.__dict__[name] for p in cls.__mro__ if name in p.__dict__), None)
|
|
145
|
+
if isinstance(descriptor, JavaField): descriptor.set(cls, None, value)
|
|
146
|
+
else: super().__setattr__(name, value)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class JavaObject(RemoteWrapper, metaclass=JavaMeta):
|
|
150
|
+
JAVA_CLASS = 'java.lang.Object'
|
|
151
|
+
def __init__(self, *args, **kwargs):
|
|
152
|
+
schema = _SCHEMA.get(self.JAVA_CLASS)
|
|
153
|
+
if schema is None: result = _default_bridge().create(self.JAVA_CLASS, list(args))
|
|
154
|
+
else:
|
|
155
|
+
if not schema['constructors']: raise TypeError(self.JAVA_CLASS + ' has no public constructors')
|
|
156
|
+
result = BoundJavaMethod(JavaMethod('<init>', schema['constructors']), None, type(self))(*args, **kwargs)
|
|
157
|
+
self._remote = result._remote if isinstance(result, RemoteWrapper) else result
|
|
158
|
+
self._remote._bridge._cache[self._remote._ref] = self
|
|
159
|
+
@property
|
|
160
|
+
def _ref(self): return self._remote._ref
|
|
161
|
+
@property
|
|
162
|
+
def _bridge(self): return self._remote._bridge
|
|
163
|
+
def __getattr__(self, name):
|
|
164
|
+
if any(_SCHEMA.get(parent.JAVA_CLASS, {}).get('opaque') for parent in type(self).__mro__
|
|
165
|
+
if hasattr(parent, 'JAVA_CLASS')):
|
|
166
|
+
raise AttributeError(f'{self._remote._class} is an opaque Java handle; use the public owner API')
|
|
167
|
+
if self.JAVA_CLASS in _SCHEMA: raise AttributeError(name)
|
|
168
|
+
return super().__getattr__(name)
|
|
169
|
+
def __iter__(self):
|
|
170
|
+
if self._remote._class.startswith('java.util.'):
|
|
171
|
+
iterator = self if self._remote._kind == 'iterator' or 'Iterator' in self._remote._class else self._call('iterator')
|
|
172
|
+
while iterator._call('hasNext'): yield iterator._call('next')
|
|
173
|
+
else: raise TypeError(self.JAVA_CLASS + ' is not an iterable')
|
|
174
|
+
def __repr__(self): return f'<{type(self).__name__} {self._remote._class}#{self._remote._ref}>'
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class JavaCallback:
|
|
178
|
+
"""Python implementation of a Java interface or ObserverCallBack.
|
|
179
|
+
|
|
180
|
+
handler may be a function (single method) or an object/dict of method handlers.
|
|
181
|
+
Keep this object alive while Java may invoke it; the bridge retains it until stop.
|
|
182
|
+
"""
|
|
183
|
+
def __init__(self, interface, handler):
|
|
184
|
+
self.interface = interface.JAVA_CLASS if hasattr(interface, 'JAVA_CLASS') else interface
|
|
185
|
+
self.handler = handler
|
|
186
|
+
def dispatch(self, method, args):
|
|
187
|
+
if isinstance(self.handler, dict): function = self.handler[method]
|
|
188
|
+
elif callable(self.handler): function = self.handler
|
|
189
|
+
else: function = getattr(self.handler, method)
|
|
190
|
+
return function(*args)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def java_class(name, *, parent=None):
|
|
194
|
+
if name in _TYPES: return _TYPES[name]
|
|
195
|
+
cls = JavaMeta(name.rsplit('.', 1)[-1].replace('$', '_'), (parent or JavaObject,), {'JAVA_CLASS': name, '__module__': 'pyulix.java'})
|
|
196
|
+
_TYPES[name] = cls
|
|
197
|
+
register_wrapper(name, cls)
|
|
198
|
+
return cls
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def load_api(namespace):
|
|
202
|
+
todo = set(_SCHEMA)
|
|
203
|
+
while todo:
|
|
204
|
+
ready = sorted(n for n in todo if _SCHEMA[n]['superclass'] not in todo)
|
|
205
|
+
for name in ready:
|
|
206
|
+
row = _SCHEMA[name]
|
|
207
|
+
base = _TYPES.get(row['superclass'], JavaObject)
|
|
208
|
+
attrs = {'JAVA_CLASS': name, '__module__': 'pyulix.java', '__doc__': 'Java ' + name}
|
|
209
|
+
groups = {}
|
|
210
|
+
for m in row['methods']: groups.setdefault(m['name'], []).append(m)
|
|
211
|
+
for mname, members in groups.items():
|
|
212
|
+
descriptor = JavaMethod(mname, members)
|
|
213
|
+
attrs[_identifier(mname)] = descriptor
|
|
214
|
+
attrs[mname] = descriptor
|
|
215
|
+
for f in row['fields']:
|
|
216
|
+
if _identifier(f['name']) not in attrs: attrs[_identifier(f['name'])] = JavaField(f)
|
|
217
|
+
cls = JavaMeta(row['python_name'], (base,), attrs)
|
|
218
|
+
_TYPES[name] = cls
|
|
219
|
+
register_wrapper(name, cls)
|
|
220
|
+
namespace[row['python_name']] = cls
|
|
221
|
+
todo -= set(ready)
|
|
222
|
+
for name, cls in _TYPES.items():
|
|
223
|
+
if '$' in name and name.rsplit('$', 1)[0] in _TYPES:
|
|
224
|
+
setattr(_TYPES[name.rsplit('$', 1)[0]], name.rsplit('$', 1)[1], cls)
|
|
225
|
+
namespace['__all__'] = sorted(row['python_name'] for row in _SCHEMA.values())
|
pyulix/_api.pyi
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Typing facade: known Java classes expose their generated declarations only."""
|
|
2
|
+
from typing import Any, ClassVar
|
|
3
|
+
from ._bridge import Bridge
|
|
4
|
+
|
|
5
|
+
class JavaObject:
|
|
6
|
+
JAVA_CLASS: ClassVar[str]
|
|
7
|
+
_ref: str
|
|
8
|
+
_bridge: Bridge
|
|
9
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None: ...
|
|
10
|
+
def _call(self, method: str, *args: Any) -> Any: ...
|
|
11
|
+
@classmethod
|
|
12
|
+
def _wrap(cls, value: Any) -> Any: ...
|
|
13
|
+
|
|
14
|
+
class JavaCallback:
|
|
15
|
+
interface: str
|
|
16
|
+
handler: Any
|
|
17
|
+
def __init__(self, interface: Any, handler: Any) -> None: ...
|
|
18
|
+
def dispatch(self, method: str, args: Any) -> Any: ...
|
|
19
|
+
|
|
20
|
+
def java_class(name: str, *, parent: Any = None) -> Any: ...
|
|
Binary file
|
pyulix/_bridge.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""JSON-RPC client to the OculiX JVM bridge over stdin/stdout.
|
|
2
|
+
|
|
3
|
+
The bridge is a fat JAR (~160 MB) containing oculixapi + Apertix OpenCV +
|
|
4
|
+
our minimal RPC server. It's downloaded once from GitHub Releases on first
|
|
5
|
+
use and cached under ``~/.pyulix/lib/``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import atexit
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import threading
|
|
16
|
+
import queue
|
|
17
|
+
import collections
|
|
18
|
+
import urllib.request
|
|
19
|
+
import weakref
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Optional, Type
|
|
22
|
+
|
|
23
|
+
# --- bridge JAR distribution -------------------------------------------------
|
|
24
|
+
|
|
25
|
+
BRIDGE_VERSION = "1.2.0b2"
|
|
26
|
+
BRIDGE_JAR_NAME = f"operix-jvm-bridge-{BRIDGE_VERSION}.jar"
|
|
27
|
+
BRIDGE_JAR_URL = (
|
|
28
|
+
"https://github.com/ajayrakde/Operix/releases/download/"
|
|
29
|
+
f"jvm-bridge-{BRIDGE_VERSION}/{BRIDGE_JAR_NAME}"
|
|
30
|
+
)
|
|
31
|
+
JAR_DIR = Path(os.path.expanduser("~/.pyulix/lib"))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _ensure_jar() -> Path:
|
|
35
|
+
jar_path = JAR_DIR / BRIDGE_JAR_NAME
|
|
36
|
+
if jar_path.exists():
|
|
37
|
+
return jar_path
|
|
38
|
+
JAR_DIR.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
print(f"[Pyulix] Downloading {BRIDGE_JAR_NAME} (~160 MB)…")
|
|
40
|
+
# Download atomically: interrupted installs must not leave a cached broken JAR.
|
|
41
|
+
import tempfile
|
|
42
|
+
temp = None
|
|
43
|
+
try:
|
|
44
|
+
with tempfile.NamedTemporaryFile(dir=JAR_DIR, suffix=".part", delete=False) as stream:
|
|
45
|
+
temp = Path(stream.name)
|
|
46
|
+
urllib.request.urlretrieve(BRIDGE_JAR_URL, temp)
|
|
47
|
+
import zipfile
|
|
48
|
+
with zipfile.ZipFile(temp) as archive:
|
|
49
|
+
if "org/operix/rpc/Server.class" not in archive.namelist():
|
|
50
|
+
raise RuntimeError("Downloaded JAR does not contain the Operix bridge")
|
|
51
|
+
os.replace(temp, jar_path)
|
|
52
|
+
finally:
|
|
53
|
+
if temp is not None and temp.exists(): temp.unlink()
|
|
54
|
+
print(f"[Pyulix] Saved to {jar_path}")
|
|
55
|
+
return jar_path
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --- JSON-RPC client ---------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
class BridgeError(RuntimeError):
|
|
61
|
+
"""Raised when the JVM side returned an error for a request."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Bridge:
|
|
65
|
+
"""Owns the JVM child process and serialises RPC requests over stdio."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, jar_path: Optional[Path] = None, java_bin: str = "java"):
|
|
68
|
+
if shutil.which(java_bin) is None:
|
|
69
|
+
raise RuntimeError(
|
|
70
|
+
f"{java_bin!r} not found on PATH. Install Java 17+ "
|
|
71
|
+
"(https://adoptium.net) and retry."
|
|
72
|
+
)
|
|
73
|
+
self._jar = jar_path or _ensure_jar()
|
|
74
|
+
self._java = java_bin
|
|
75
|
+
self._proc: Optional[subprocess.Popen] = None
|
|
76
|
+
self._lock = threading.Lock()
|
|
77
|
+
self._next_id = 0
|
|
78
|
+
self._pending = {}
|
|
79
|
+
self._callbacks = {}
|
|
80
|
+
self._stderr_tail = collections.deque(maxlen=100)
|
|
81
|
+
self._callback_errors = collections.deque(maxlen=100)
|
|
82
|
+
# Python-side identity dedup: same Java ref always yields the same
|
|
83
|
+
# Python object so __del__ doesn't kill a ref that's still in use.
|
|
84
|
+
self._cache: "weakref.WeakValueDictionary[str, Any]" = (
|
|
85
|
+
weakref.WeakValueDictionary())
|
|
86
|
+
|
|
87
|
+
def start(self) -> None:
|
|
88
|
+
if self._proc is not None:
|
|
89
|
+
return
|
|
90
|
+
self._proc = subprocess.Popen(
|
|
91
|
+
# Force the JVM to speak UTF-8 on stdio. On Windows the JVM defaults
|
|
92
|
+
# System.out to the console code page (cp1252), so any accented OCR
|
|
93
|
+
# text (é -> 0xe9) is invalid UTF-8 and crashes the reader below.
|
|
94
|
+
# -Dstdout/stderr.encoding (Java 18+) pin the console streams; the
|
|
95
|
+
# file.encoding flag covers older JVMs.
|
|
96
|
+
[self._java,
|
|
97
|
+
"-Dfile.encoding=UTF-8",
|
|
98
|
+
"-Dstdout.encoding=UTF-8",
|
|
99
|
+
"-Dstderr.encoding=UTF-8",
|
|
100
|
+
"-jar", str(self._jar)],
|
|
101
|
+
stdin=subprocess.PIPE,
|
|
102
|
+
stdout=subprocess.PIPE,
|
|
103
|
+
stderr=subprocess.PIPE,
|
|
104
|
+
bufsize=1, # line buffered
|
|
105
|
+
text=True,
|
|
106
|
+
encoding="utf-8",
|
|
107
|
+
errors="replace", # belt-and-suspenders: never crash on a stray byte
|
|
108
|
+
)
|
|
109
|
+
threading.Thread(target=self._read_responses, daemon=True, name="pyulix-rpc").start()
|
|
110
|
+
threading.Thread(target=self._read_stderr, daemon=True, name="pyulix-stderr").start()
|
|
111
|
+
atexit.register(self.stop)
|
|
112
|
+
|
|
113
|
+
def stop(self) -> None:
|
|
114
|
+
if self._proc is None:
|
|
115
|
+
return
|
|
116
|
+
try:
|
|
117
|
+
self._proc.stdin.close()
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
try:
|
|
121
|
+
self._proc.terminate()
|
|
122
|
+
self._proc.wait(timeout=5)
|
|
123
|
+
except Exception:
|
|
124
|
+
self._proc.kill()
|
|
125
|
+
self._proc = None
|
|
126
|
+
self._callbacks.clear()
|
|
127
|
+
|
|
128
|
+
def _read_stderr(self):
|
|
129
|
+
proc = self._proc
|
|
130
|
+
for line in proc.stderr:
|
|
131
|
+
self._stderr_tail.append(line.rstrip())
|
|
132
|
+
|
|
133
|
+
def _read_responses(self):
|
|
134
|
+
proc = self._proc
|
|
135
|
+
try:
|
|
136
|
+
for line in proc.stdout:
|
|
137
|
+
response = json.loads(line)
|
|
138
|
+
if "callback" in response:
|
|
139
|
+
threading.Thread(target=self._handle_callback, args=(response,), daemon=True).start()
|
|
140
|
+
continue
|
|
141
|
+
with self._lock:
|
|
142
|
+
pending = self._pending.pop(response.get("id"), None)
|
|
143
|
+
if pending is not None: pending.put(response)
|
|
144
|
+
except Exception as error:
|
|
145
|
+
self._stderr_tail.append(str(error))
|
|
146
|
+
finally:
|
|
147
|
+
with self._lock:
|
|
148
|
+
pending = list(self._pending.values())
|
|
149
|
+
self._pending.clear()
|
|
150
|
+
for waiter in pending:
|
|
151
|
+
waiter.put({"error": "JVM bridge died. stderr: " + "\n".join(self._stderr_tail)})
|
|
152
|
+
|
|
153
|
+
def _write(self, payload):
|
|
154
|
+
self._proc.stdin.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
155
|
+
self._proc.stdin.flush()
|
|
156
|
+
|
|
157
|
+
def _handle_callback(self, message):
|
|
158
|
+
reply = {"callback_result": message["callback_id"]}
|
|
159
|
+
try:
|
|
160
|
+
callback = self._callbacks[message["callback"]]
|
|
161
|
+
result = callback.dispatch(message["method"], _decode(self, message["args"]))
|
|
162
|
+
reply["result"] = _encode(result, self)
|
|
163
|
+
except Exception as error:
|
|
164
|
+
reply["error"] = type(error).__name__ + ": " + str(error)
|
|
165
|
+
self._callback_errors.append(error)
|
|
166
|
+
try:
|
|
167
|
+
with self._lock: self._write(reply)
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
def _encode_callback(self, callback):
|
|
172
|
+
key = "p" + str(id(callback))
|
|
173
|
+
self._callbacks[key] = callback
|
|
174
|
+
return {"__callback": key, "interface": callback.interface}
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def callback_errors(self):
|
|
178
|
+
return tuple(self._callback_errors)
|
|
179
|
+
|
|
180
|
+
def _request(self, payload: dict) -> Any:
|
|
181
|
+
with self._lock:
|
|
182
|
+
if self._proc is None: self.start()
|
|
183
|
+
self._next_id += 1
|
|
184
|
+
payload["id"] = self._next_id
|
|
185
|
+
pending = queue.Queue(maxsize=1)
|
|
186
|
+
self._pending[payload["id"]] = pending
|
|
187
|
+
try: self._write(payload)
|
|
188
|
+
except Exception:
|
|
189
|
+
self._pending.pop(payload["id"], None)
|
|
190
|
+
raise
|
|
191
|
+
# The reader and callback handlers remain live during this wait, so
|
|
192
|
+
# background observation and callbacks making nested Java calls work.
|
|
193
|
+
response = pending.get()
|
|
194
|
+
if "error" in response: raise BridgeError(response["error"])
|
|
195
|
+
return response["result"]
|
|
196
|
+
|
|
197
|
+
# --- public RPC operations ----------------------------------------------
|
|
198
|
+
|
|
199
|
+
def create(self, classname: str, args: list, parameter_types=None) -> Any:
|
|
200
|
+
payload = {"class": classname, "args": _encode_args(args, self)}
|
|
201
|
+
if parameter_types is not None: payload["parameter_types"] = parameter_types
|
|
202
|
+
result = self._request(payload)
|
|
203
|
+
return _decode(self, result)
|
|
204
|
+
|
|
205
|
+
def call(self, ref: str, method: str, args: list, parameter_types=None) -> Any:
|
|
206
|
+
payload = {"ref": ref, "method": method, "args": _encode_args(args, self)}
|
|
207
|
+
if parameter_types is not None: payload["parameter_types"] = parameter_types
|
|
208
|
+
result = self._request(payload)
|
|
209
|
+
return _decode(self, result)
|
|
210
|
+
|
|
211
|
+
def call_static(self, classname: str, method: str, args: list, parameter_types=None) -> Any:
|
|
212
|
+
payload = {"class": classname, "method": method,
|
|
213
|
+
"static": True, "args": _encode_args(args, self)}
|
|
214
|
+
if parameter_types is not None: payload["parameter_types"] = parameter_types
|
|
215
|
+
result = self._request(payload)
|
|
216
|
+
return _decode(self, result)
|
|
217
|
+
|
|
218
|
+
def resolve_overload(self, classname, method, candidates):
|
|
219
|
+
return self._request({'class': classname, 'resolve': method, 'candidates': [
|
|
220
|
+
{'parameter_types': [p['type'] for p in member['parameters']],
|
|
221
|
+
'args': _encode_args(values, self)} for member, values in candidates]})
|
|
222
|
+
|
|
223
|
+
def release(self, ref: str) -> None:
|
|
224
|
+
# A dead bridge must not restart just to release a stale object.
|
|
225
|
+
if self._proc is None: return
|
|
226
|
+
try:
|
|
227
|
+
self._request({"ref": ref, "release": True})
|
|
228
|
+
except Exception:
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# --- value codec -------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
_WRAPPER_TYPES = {}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def register_wrapper(java_class: str, wrapper: Type["RemoteWrapper"]) -> None:
|
|
238
|
+
"""Register the Python result type for an exact Java runtime class."""
|
|
239
|
+
_WRAPPER_TYPES[java_class] = wrapper
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class RemoteWrapper:
|
|
243
|
+
"""Typed facade whose RemoteObject owns the Java reference lifetime."""
|
|
244
|
+
|
|
245
|
+
def _call(self, method: str, *args) -> Any:
|
|
246
|
+
return self._remote._call(method, *args)
|
|
247
|
+
|
|
248
|
+
def __getattr__(self, name: str):
|
|
249
|
+
if name.startswith("_"):
|
|
250
|
+
raise AttributeError(name)
|
|
251
|
+
# Keep existing Java methods callable while explicit signatures are added.
|
|
252
|
+
return getattr(self._remote, name)
|
|
253
|
+
|
|
254
|
+
@classmethod
|
|
255
|
+
def _wrap(cls, remote):
|
|
256
|
+
if isinstance(remote, cls):
|
|
257
|
+
return remote
|
|
258
|
+
if not isinstance(remote, RemoteObject):
|
|
259
|
+
raise TypeError("Expected a Java object reference")
|
|
260
|
+
instance = cls.__new__(cls)
|
|
261
|
+
instance._remote = remote
|
|
262
|
+
return instance
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class RemoteObject:
|
|
266
|
+
"""Opaque handle to a Java object held by the JVM bridge."""
|
|
267
|
+
|
|
268
|
+
__slots__ = ("_bridge", "_ref", "_class", "_kind", "__weakref__")
|
|
269
|
+
|
|
270
|
+
def __init__(self, bridge: Bridge, ref: str, java_class: str):
|
|
271
|
+
self._bridge = bridge
|
|
272
|
+
self._ref = ref
|
|
273
|
+
self._class = java_class
|
|
274
|
+
self._kind = None
|
|
275
|
+
|
|
276
|
+
def _call(self, method: str, *args) -> Any:
|
|
277
|
+
return self._bridge.call(self._ref, method, list(args))
|
|
278
|
+
|
|
279
|
+
def __getattr__(self, name: str):
|
|
280
|
+
# Dynamic method proxy: any Java method of the remote object becomes
|
|
281
|
+
# callable directly (loc.getX(), rect.getWidth(), img.getSize()...).
|
|
282
|
+
# Only reached for attributes NOT found through __slots__, so the
|
|
283
|
+
# internal fields never collide. Dunder/underscore names are refused
|
|
284
|
+
# so pickling, copying and introspection keep their normal semantics.
|
|
285
|
+
if name.startswith("_"):
|
|
286
|
+
raise AttributeError(name)
|
|
287
|
+
|
|
288
|
+
def _proxy(*args) -> Any:
|
|
289
|
+
return self._bridge.call(self._ref, name, list(args))
|
|
290
|
+
|
|
291
|
+
_proxy.__name__ = name
|
|
292
|
+
_proxy.__qualname__ = f"RemoteObject.{name}"
|
|
293
|
+
return _proxy
|
|
294
|
+
|
|
295
|
+
def __repr__(self) -> str:
|
|
296
|
+
return f"<RemoteObject {self._class}#{self._ref}>"
|
|
297
|
+
|
|
298
|
+
def __del__(self):
|
|
299
|
+
# Best-effort GC. May fail during interpreter shutdown.
|
|
300
|
+
try:
|
|
301
|
+
self._bridge.release(self._ref)
|
|
302
|
+
except Exception:
|
|
303
|
+
pass
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _encode_args(args: list, bridge: Optional[Bridge] = None) -> list:
|
|
307
|
+
return [_encode(a, bridge) for a in args]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _encode(v: Any, bridge: Optional[Bridge] = None) -> Any:
|
|
311
|
+
if isinstance(v, RemoteWrapper):
|
|
312
|
+
v = v._remote
|
|
313
|
+
if isinstance(v, RemoteObject):
|
|
314
|
+
if bridge is not None and v._bridge is not bridge:
|
|
315
|
+
raise ValueError("Cannot pass a Java object to a different JVM bridge")
|
|
316
|
+
return {"__ref": v._ref}
|
|
317
|
+
from ._api import JavaCallback
|
|
318
|
+
if isinstance(v, JavaCallback):
|
|
319
|
+
if bridge is None: raise ValueError("A callback requires a bridge")
|
|
320
|
+
return bridge._encode_callback(v)
|
|
321
|
+
if isinstance(v, (bytes, bytearray)):
|
|
322
|
+
return [(n if n < 128 else n - 256) for n in v]
|
|
323
|
+
if isinstance(v, (list, tuple)):
|
|
324
|
+
return [_encode(item, bridge) for item in v]
|
|
325
|
+
if isinstance(v, dict):
|
|
326
|
+
if any(not isinstance(key, str) for key in v) or any(key.startswith('__') for key in v):
|
|
327
|
+
return {'__map': [[_encode(key, bridge), _encode(item, bridge)] for key, item in v.items()]}
|
|
328
|
+
return {key: _encode(item, bridge) for key, item in v.items()}
|
|
329
|
+
return v
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _decode(bridge: Bridge, v: Any) -> Any:
|
|
333
|
+
if isinstance(v, dict) and "__map" in v:
|
|
334
|
+
return {_decode(bridge, k): _decode(bridge, value) for k, value in v["__map"]}
|
|
335
|
+
if isinstance(v, dict) and "__ref" in v:
|
|
336
|
+
ref = v["__ref"]
|
|
337
|
+
cached = bridge._cache.get(ref)
|
|
338
|
+
if cached is not None:
|
|
339
|
+
return cached
|
|
340
|
+
remote = RemoteObject(bridge, ref, v.get("__class", "?"))
|
|
341
|
+
remote._kind = v.get("__kind")
|
|
342
|
+
wrapper = _WRAPPER_TYPES.get(remote._class)
|
|
343
|
+
if wrapper is None:
|
|
344
|
+
from ._api import java_class
|
|
345
|
+
parent = next((_WRAPPER_TYPES[name] for name in v.get('__types', [])
|
|
346
|
+
if name in _WRAPPER_TYPES and name != 'java.lang.Object'), None)
|
|
347
|
+
wrapper = java_class(remote._class, parent=parent)
|
|
348
|
+
obj = wrapper._wrap(remote)
|
|
349
|
+
bridge._cache[ref] = obj
|
|
350
|
+
return obj
|
|
351
|
+
if isinstance(v, list):
|
|
352
|
+
return [_decode(bridge, item) for item in v]
|
|
353
|
+
if isinstance(v, dict):
|
|
354
|
+
return {key: _decode(bridge, item) for key, item in v.items()}
|
|
355
|
+
return v
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# --- module-wide singleton ---------------------------------------------------
|
|
359
|
+
|
|
360
|
+
_default_bridge: Optional[Bridge] = None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def default_bridge() -> Bridge:
|
|
364
|
+
global _default_bridge
|
|
365
|
+
if _default_bridge is None:
|
|
366
|
+
_default_bridge = Bridge()
|
|
367
|
+
return _default_bridge
|
pyulix/java/__init__.py
ADDED