-
Notifications
You must be signed in to change notification settings - Fork 15
Expand file tree
/
Copy path_jvm.py
More file actions
489 lines (386 loc) · 16 KB
/
_jvm.py
File metadata and controls
489 lines (386 loc) · 16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
"""
Utility functions for working with the Java Virtual Machine.
"""
import atexit
import logging
import os
import re
import subprocess
import sys
from functools import lru_cache
from importlib import import_module
from pathlib import Path
from typing import Sequence
import jpype
import jpype.config
import jgo
import scyjava.config
from scyjava.config import Mode, mode
from scyjava._jdk_fetch import resolve_java
_logger = logging.getLogger(__name__)
_startup_callbacks = []
_shutdown_callbacks = []
def jvm_version() -> tuple[int, ...]:
"""
Gets the version of the JVM as a tuple, with each dot-separated digit
as one element. Characters in the version string beyond only numbers
and dots are ignored, in line with the java.version system property.
Examples:
* OpenJDK 17.0.1 -> (17, 0, 1)
* OpenJDK 11.0.9.1-internal -> (11, 0, 9, 1)
* OpenJDK 1.8.0_312 -> (1, 8, 0)
If the JVM is already started, this function returns the equivalent of:
jimport('java.lang.System')
.getProperty('java.version')
.split('.')
In case the JVM is not started yet, a best effort is made to deduce
the version from the environment without actually starting up the
JVM in-process. If the version cannot be deduced, a RuntimeError
with the cause is raised.
"""
if mode == Mode.JEP:
System = jimport("java.lang.System")
version = str(System.getProperty("java.version"))
# Get everything up to the hyphen
version = version.split("-")[0]
return tuple(map(int, version.split(".")))
assert mode == Mode.JPYPE
jvm_ver = jpype.getJVMVersion()
if jvm_ver and jvm_ver[0]:
# JPype already knew the version.
# JVM is probably already started.
# Or JPype got smarter since 1.3.0.
return jvm_ver
# JPype was clueless, which means the JVM has probably not started yet.
# Let's look for a java executable, and ask via 'java -version'.
default_jvm_path = jpype.getDefaultJVMPath()
if not default_jvm_path:
raise RuntimeError("Cannot glean the default JVM path")
_logger.debug(f"Default JVM path from JPype: {default_jvm_path}")
# Good ol' macOS! Nothing beats macOS.
jvm_path = default_jvm_path.replace(
"/Contents/MacOS/libjli.dylib", "/Contents/Home/lib/libjli.dylib"
)
p = Path(jvm_path)
java = None
if not p.exists():
# Try Java 8 macOS dylib path (jre/lib/jli/libjli.dylib vs lib/libjli.dylib).
p8 = Path(
default_jvm_path.replace(
"/Contents/MacOS/libjli.dylib",
"/Contents/Home/jre/lib/jli/libjli.dylib",
)
)
if p8.exists():
p = p8
if not p.exists():
# Fall back to JAVA_HOME if the dylib path resolution failed.
java_home = os.environ.get("JAVA_HOME")
if java_home:
candidate = Path(java_home) / "bin" / "java"
if os.name == "nt":
candidate = candidate.with_suffix(".exe")
if candidate.is_file():
java = candidate
if java is None:
raise RuntimeError(f"Invalid default JVM path: {p}")
if java is None:
for _ in range(3): # The bin folder is always <=3 levels up from libjvm.
p = p.parent
if p.name == "lib":
java = p.parent / "bin" / "java"
elif p.name == "bin":
java = p / "java"
if java is not None:
if os.name == "nt":
# Good ol' Windows! Nothing beats Windows.
java = java.with_suffix(".exe")
if not java.is_file():
raise RuntimeError(f"No ../bin/java found at: {p}")
break
if java is None:
raise RuntimeError(f"No java executable found inside: {p}")
_logger.debug(f"Invoking `{java} -version`...")
try:
output = subprocess.check_output(
[str(java), "-version"], stderr=subprocess.STDOUT
).decode()
except subprocess.CalledProcessError as e:
raise RuntimeError("System call to java failed") from e
output = output.replace("\n", " ").replace("\r", "")
m = re.match('.* version "([^"]*)"', output)
if not m:
raise RuntimeError(
f"Inscrutable java command output:\n$ {java} -version\n{output}"
)
v = m.group(1)
_logger.debug(f"Got Java version: {v}")
try:
return tuple(map(int, v.split(".")))
except ValueError:
raise RuntimeError(f"Inscrutable java version: {v}")
def start_jvm(options: Sequence[str] | None = None) -> None:
"""
Explicitly connect to the Java virtual machine (JVM). Only one JVM can
be active; does nothing if the JVM has already been started. Calling
this function directly is typically not necessary, because the first
time a scyjava function needing a JVM is invoked, one is started on the
fly with the configuration specified via the scijava.config mechanism.
:param options:
List of options to pass to the JVM.
For example: ['-Dfoo=bar', '-XX:+UnlockExperimentalVMOptions']
See also scyjava.config.add_options.
"""
# if JVM is already running -- break
if jvm_started():
if options is not None and len(options) > 0:
_logger.debug(f"Options ignored due to already running JVM: {options}")
return
assert mode == Mode.JPYPE
# retrieve endpoint and repositories from scyjava config
endpoints = scyjava.config.endpoints
repositories = scyjava.config.get_repositories()
# use the logger to notify user that endpoints are being added
_logger.debug("Adding jars from endpoints {0}".format(endpoints))
# download Java as appropriate
resolve_java()
# Fail fast if Java version is too old. JPype 1.6+ dropped Java 8 support.
try:
ver = jvm_version()
if ver < (11,):
raise RuntimeError(
f"Java {'.'.join(str(v) for v in ver)} is not supported. "
"scyjava requires Java 11 or later."
)
except RuntimeError as e:
if "not supported" in str(e):
raise
_logger.debug(f"Could not determine JVM version before start: {e}")
# get endpoints and add to JPype class path
if len(endpoints) > 0:
# sort endpoints list, except for the first one
endpoints = endpoints[:1] + sorted(endpoints[1:])
_logger.debug("Using endpoints %s", endpoints)
# join endpoints list to single concatenated endpoint
endpoint = "+".join(endpoints)
env = jgo.build(
endpoint=endpoint,
# update=False,
cache_dir=scyjava.config.get_cache_dir(),
repositories=repositories,
resolver=jgo.maven.PythonResolver(lenient=True),
# The following obsolete arguments are from jgo v1:
# m2_repo=scyjava.config.get_m2_repo(),
# manage_dependencies=scyjava.config.get_manage_deps(),
# verbose=scyjava.config.get_verbose(),
# shortcuts=scyjava.config.get_shortcuts(),
)
jpype.addClassPath(env.modules_dir / "*")
jpype.addClassPath(env.jars_dir / "*")
# HACK: Try to set JAVA_HOME if it isn't already.
if (
"JAVA_HOME" not in os.environ
or not os.environ["JAVA_HOME"]
or not os.path.isdir(os.environ["JAVA_HOME"])
):
_logger.debug("JAVA_HOME not set. Will try to infer it from sys.path.")
libjvm_win = Path("Library") / "jre" / "bin" / "server" / "jvm.dll"
libjvm_macos = Path("lib") / "server" / "libjvm.dylib"
libjvm_linux = Path("lib") / "server" / "libjvm.so"
libjvm_paths = {
libjvm_win: Path("Library"),
libjvm_macos: Path(),
libjvm_linux: Path(),
}
for p in sys.path:
if not p.endswith("site-packages"):
continue
# e.g. $CONDA_PREFIX/lib/python3.10/site-packages -> $CONDA_PREFIX
# But we want it to work outside of Conda as well, theoretically.
base = Path(p).parent.parent.parent
for libjvm_path, java_home_path in libjvm_paths.items():
if (base / libjvm_path).exists():
java_home = str((base / java_home_path).resolve())
_logger.debug(f"Detected JAVA_HOME: {java_home}")
os.environ["JAVA_HOME"] = java_home
break
# initialize JPype JVM
_logger.debug("Starting JVM")
if options is None:
options = scyjava.config.get_options()
kwargs = scyjava.config.get_kwargs()
jpype.startJVM(*options, **kwargs)
# replace JPype/JVM shutdown handling with our own
jpype.config.onexit = False
jpype.config.free_resources = False
atexit.register(shutdown_jvm)
# invoke registered callback functions
for callback in _startup_callbacks:
callback()
def shutdown_jvm() -> None:
"""Shut down the JVM.
This function makes a best effort to clean up Java resources first.
In particular, shutdown hooks registered with scyjava.when_jvm_stops
are sequentially invoked.
Then, if the AWT subsystem has started, all AWT windows (as identified
by the java.awt.Window.getWindows() method) are disposed to reduce the
risk of GUI resources delaying JVM shutdown.
Finally, the jpype.shutdownJVM() function is called. Note that you can
set the jpype.config.destroy_jvm flag to request JPype to destroy the
JVM explicitly, although setting this flag can lead to delayed shutdown
times while the JVM is waiting for threads to finish.
Note that if the JVM is not already running, then this function does
nothing! In particular, shutdown hooks are skipped in this situation.
:raise RuntimeError: if this method is called while in Jep mode.
"""
if not jvm_started():
return
if mode == Mode.JEP:
raise RuntimeError("Cannot shut down the JVM in Jep mode.")
assert mode == Mode.JPYPE
# invoke registered shutdown callback functions
for callback in _shutdown_callbacks:
try:
callback()
except Exception as e:
_logger.error(f"Exception during shutdown callback: {e}")
# dispose AWT resources if applicable
if is_awt_initialized():
Window = jimport("java.awt.Window")
for w in Window.getWindows():
w.dispose()
# okay to shutdown JVM
try:
jpype.shutdownJVM()
except Exception as e:
_logger.error(f"Exception during JVM shutdown: {e}")
def jvm_started() -> bool:
"""Return true iff a Java virtual machine (JVM) has been started."""
if mode == Mode.JEP:
return True
assert mode == Mode.JPYPE
return jpype.isJVMStarted()
def gc() -> None:
"""
Do a round of Java garbage collection.
This function is a shortcut for Java's System.gc().
:raise RuntimeError: If the JVM has not started yet.
"""
_assert_jvm_started()
System = jimport("java.lang.System")
System.gc()
def memory_total() -> int:
"""
Get the total amount of memory currently reserved by the JVM.
This number will always be less than or equal to memory_max().
In case the JVM was configured with -Xms flag upon startup (e.g. using
the scyjava.config.set_heap_min function), the initial value will typically
correspond approximately, but not exactly, to the configured value,
although it is likely to grow over time as more Java objects are allocated.
This function is a shortcut for Java's Runtime.getRuntime().totalMemory().
:return: The total memory in bytes.
:raise RuntimeError: if the JVM has not yet been started.
"""
return int(_runtime().totalMemory())
def memory_max() -> int:
"""
Get the maximum amount of memory that the JVM will attempt to use.
This number will always be greater than or equal to memory_total().
In case the JVM was configured with -Xmx flag upon startup (e.g. using
the scyjava.config.set_heap_max function), the value will typically
correspond approximately, but not exactly, to the configured value.
This function is a shortcut for Java's Runtime.getRuntime().maxMemory().
:return: The maximum memory in bytes.
:raise RuntimeError: if the JVM has not yet been started.
"""
return int(_runtime().maxMemory())
def memory_used() -> int:
"""
Get the amount of memory currently in use by the JVM.
This function is a shortcut for
Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory().
:return: The used memory in bytes.
:raise RuntimeError: if the JVM has not yet been started.
"""
return memory_total() - int(_runtime().freeMemory())
def available_processors() -> int:
"""
Get the number of processors available to the JVM.
This function is a shortcut for Java's
Runtime.getRuntime().availableProcessors().
:return: The number of available processors.
:raise RuntimeError: if the JVM has not yet been started.
"""
return int(_runtime().availableProcessors())
def is_jvm_headless() -> bool:
"""
Return true iff Java is running in headless mode.
:raise RuntimeError: If the JVM has not started yet.
"""
if not jvm_started():
raise RuntimeError("JVM has not started yet!")
GraphicsEnvironment = scyjava.jimport("java.awt.GraphicsEnvironment")
return bool(GraphicsEnvironment.isHeadless())
def is_awt_initialized() -> bool:
"""
Return true iff the AWT subsystem has been initialized.
Java starts up its AWT subsystem automatically and implicitly, as
soon as an action is performed requiring it -- for example, if you
jimport a java.awt or javax.swing class. This can lead to deadlocks
on macOS if you are not running in headless mode and did not invoke
those actions via the jpype.setupGuiEnvironment wrapper function;
see the Troubleshooting section of the scyjava README for details.
"""
if not jvm_started():
return False
Thread = scyjava.jimport("java.lang.Thread")
threads = Thread.getAllStackTraces().keySet()
return any(t.getName().startsWith("AWT-") for t in threads)
def when_jvm_starts(f) -> None:
"""
Registers a function to be called when the JVM starts (or immediately).
This is useful to defer construction of Java-dependent data structures
until the JVM is known to be available. If the JVM has already been
started, the function executes immediately.
:param f: Function to invoke when scyjava.start_jvm() is called.
"""
if jvm_started():
# JVM was already started; invoke callback function immediately.
f()
else:
# Add function to the list of callbacks to invoke upon start_jvm().
global _startup_callbacks
_startup_callbacks.append(f)
def when_jvm_stops(f) -> None:
"""
Registers a function to be called just before the JVM shuts down.
This is useful to perform cleanup of Java-dependent data structures.
Note that if the JVM is not already running when shutdown_jvm is
called, then these registered callback functions will be skipped!
:param f: Function to invoke when scyjava.shutdown_jvm() is called.
"""
global _shutdown_callbacks
_shutdown_callbacks.append(f)
@lru_cache(maxsize=None)
def jimport(class_name: str):
"""
Import a class from Java to Python.
:param class_name: Name of the class to import.
:return:
A pointer to the class, which can be used to
e.g. instantiate objects of that class.
"""
if mode == Mode.JEP:
module_path = class_name.rsplit(".", 1)
module = import_module(module_path[0], module_path[1])
return getattr(module, module_path[1])
assert mode == Mode.JPYPE
start_jvm()
return jpype.JClass(class_name)
def _assert_jvm_started():
if not jvm_started():
raise RuntimeError("JVM has not started yet!")
def _runtime():
_assert_jvm_started()
Runtime = jimport("java.lang.Runtime")
return Runtime.getRuntime()