diff --git a/regi-headless/build.gradle b/regi-headless/build.gradle index 77a2567..903c0cc 100644 --- a/regi-headless/build.gradle +++ b/regi-headless/build.gradle @@ -59,8 +59,8 @@ tasks.register('installPythonBuildTools', VenvTask) { group = 'build setup' description = 'Installs Python packages needed to build and test the wheel.' - venvExec = 'pip' - args = ['install', '--upgrade', 'pip', 'build', 'pytest', 'pytest-cov'] + venvExec = 'python' + args = ['-m', 'pip', 'install', '--upgrade', 'pip', 'build', 'pytest', 'pytest-cov'] outputs.file(layout.buildDirectory.file("python-build-tools/install.marker")) @@ -178,6 +178,7 @@ tasks.register('smokeTestDistrictScripts', VenvTask) { description = 'Validates migrated district and example scripts against the Python entrypoint shape and Java scriptable APIs.' dependsOn installPythonBuildTools + dependsOn installPythonWheelForSmokeTest venvExec = 'python' args = [ @@ -187,10 +188,17 @@ tasks.register('smokeTestDistrictScripts', VenvTask) { 'src/test/python/test_district_scripts.py' ] + environment = (environment ?: [:]) + [ + 'DISTRICT_SCRIPTS_DIR' : file('../district-scripts').absolutePath, + 'EXAMPLE_SCRIPTS_DIR' : file('src/test/resources/usace/rowcps/headless/examples').absolutePath, + 'REGI_HEADLESS_JAVA_LIB_DIR': layout.buildDirectory.dir('install/regi_python/regi_python/lib').get().asFile.absolutePath, + ] + inputs.files(fileTree(dir: '../district-scripts', include: '**/*.py')) inputs.files(fileTree(dir: 'src/test/resources/usace/rowcps/headless/examples', include: '**/*.py')) inputs.files(fileTree(dir: 'src/main/java/usace/rowcps/headless', include: '**/*.java')) inputs.file('src/test/python/test_district_scripts.py') + inputs.file('src/test/python/conftest.py') outputs.upToDateWhen { false } } diff --git a/regi-headless/src/main/java/usace/rowcps/headless/calculator/gatesettings/ScriptableGateSettings.java b/regi-headless/src/main/java/usace/rowcps/headless/calculator/gatesettings/ScriptableGateSettings.java index dab3685..d85ed59 100644 --- a/regi-headless/src/main/java/usace/rowcps/headless/calculator/gatesettings/ScriptableGateSettings.java +++ b/regi-headless/src/main/java/usace/rowcps/headless/calculator/gatesettings/ScriptableGateSettings.java @@ -7,7 +7,7 @@ * * @author ryan */ -interface ScriptableGateSettings +public interface ScriptableGateSettings { /** * @deprecated Use {@link #createGateSettings(String, String, Instant, Instant)} instead. java.util.Date diff --git a/regi-headless/src/test/python/conftest.py b/regi-headless/src/test/python/conftest.py new file mode 100644 index 0000000..57f3f89 --- /dev/null +++ b/regi-headless/src/test/python/conftest.py @@ -0,0 +1,80 @@ +""" +Starts the one JVM this pytest session gets, before any individual test +module's own JVM-start logic runs. + +JPype allows only a single JVM per process, and its classpath is fixed at +startup. Two test modules under this directory start a real JVM: + + * test_datetime_instant_conversion.py needs no project jars -- it only + touches java.time, which ships in every JDK. + * test_district_scripts.py reflects over the compiled REGI Headless + classes (ScriptableInflowImpl, etc.), so it needs those jars on the + classpath. +""" + +import os +from pathlib import Path + +import jpype +import pytest + +# .../regi-headless/regi-headless (the Gradle module root, three levels up +# from this file's directory: src/test/python -> src/test -> src -> module root) +MODULE_ROOT = Path(__file__).resolve().parents[3] + + +def _discover_classpath(): + """ + Locates the REGI Headless jars for the classpath. + + Gradle knows this path authoritatively (see `smokeTestDistrictScripts` + in build.gradle, which sets REGI_HEADLESS_JAVA_LIB_DIR) and passes it in + as an environment variable. The fallbacks below + -- an installed `regi_python` wheel, then the raw `bundlePython` build + output -- only exist so the JVM still gets a useful classpath when this + test is run outside Gradle (e.g. directly from an IDE). + """ + env_lib_dir = os.environ.get("REGI_HEADLESS_JAVA_LIB_DIR") + if env_lib_dir: + lib_dir = Path(env_lib_dir) + if lib_dir.is_dir() and any(lib_dir.glob("*.jar")): + return str(lib_dir / "*") + return None + + candidate_lib_dirs = [] + + try: + import regi_python + except ImportError: + pass + else: + candidate_lib_dirs.append(Path(regi_python.__file__).resolve().parent / "lib") + + candidate_lib_dirs.append( + MODULE_ROOT / "build" / "install" / "regi_python" / "regi_python" / "lib" + ) + + for lib_dir in candidate_lib_dirs: + if lib_dir.is_dir() and any(lib_dir.glob("*.jar")): + return str(lib_dir / "*") + return None + + +@pytest.fixture(scope="session", autouse=True) +def _session_jvm(): + if jpype.isJVMStarted(): + yield + return + + classpath = _discover_classpath() + try: + if classpath: + jpype.startJVM(jpype.getDefaultJVMPath(), classpath=[classpath]) + else: + jpype.startJVM(jpype.getDefaultJVMPath()) + except Exception as exc: # pragma: no cover - environment dependent + pytest.skip(f"No usable JVM available for jpype: {exc}") + yield + # Deliberately not shut down: a JVM cannot be restarted once stopped in + # the same process, and other test modules in this session may still + # need it. diff --git a/regi-headless/src/test/python/test_district_scripts.py b/regi-headless/src/test/python/test_district_scripts.py index 44704d4..61261e1 100644 --- a/regi-headless/src/test/python/test_district_scripts.py +++ b/regi-headless/src/test/python/test_district_scripts.py @@ -1,23 +1,48 @@ import importlib.util import io import logging +import os import re import sys import types from contextlib import redirect_stdout from pathlib import Path +import jpype +import pytest + MODULE_ROOT = Path(__file__).resolve().parents[3] REPOSITORY_ROOT = MODULE_ROOT.parent -DISTRICT_SCRIPTS_ROOT = REPOSITORY_ROOT / "district-scripts" -EXAMPLE_SCRIPTS_ROOT = MODULE_ROOT / "src" / "test" / "resources" / "usace" / "rowcps" / "headless" / "examples" -JAVA_SOURCE_ROOT = MODULE_ROOT / "src" / "main" / "java" - -JAVA_METHOD_PATTERN = re.compile( - r"\bpublic\s+(?:static\s+)?(?:[\w<>\[\], ?]+\s+)+(?P[A-Za-z_]\w*)\s*\(" +# Gradle knows these paths authoritatively (see the `smokeTestDistrictScripts` +# task in build.gradle) and passes them in as environment variables. The +# fallback here -- walking up from this file's own location -- only exists +# so the test still works when run directly without going through Gradle. +DISTRICT_SCRIPTS_ROOT = Path( + os.environ.get("DISTRICT_SCRIPTS_DIR", str(REPOSITORY_ROOT / "district-scripts")) +) +EXAMPLE_SCRIPTS_ROOT = Path( + os.environ.get( + "EXAMPLE_SCRIPTS_DIR", + str(MODULE_ROOT / "src" / "test" / "resources" / "usace" / "rowcps" / "headless" / "examples"), + ) ) + +# Fully-qualified Java type names this test reflects over (via the real JVM +# -- see conftest.py for the classpath setup) to build the "known scriptable +# API" that district/example scripts are allowed to call. +# +# These target the *interfaces* (ScriptableInflow, ScriptableGateFlowCalc, +# ScriptableGateSettings). LoggingOptions has no separate interface -- +# it's a plain static utility class -- so it's targeted directly. +SCRIPTABLE_JAVA_CLASSES = { + "Inflow": "usace.rowcps.headless.calculator.inflow.ScriptableInflow", + "Gate Flow": "usace.rowcps.headless.calculator.flowgroup.ScriptableGateFlowCalc", + "Gate Settings": "usace.rowcps.headless.calculator.gatesettings.ScriptableGateSettings", + "LoggingOptions": "usace.rowcps.headless.LoggingOptions", +} + LOGGER = logging.getLogger(__name__) @@ -80,31 +105,38 @@ def _script_callback(module): def _load_java_api(): - return { - "Inflow": _java_methods( - "usace/rowcps/headless/calculator/inflow/ScriptableInflowImpl.java" - ), - "Gate Flow": _java_methods( - "usace/rowcps/headless/calculator/flowgroup/ScriptableGateFlowImpl.java" - ), - "Gate Settings": _java_methods( - "usace/rowcps/headless/calculator/gatesettings/ScriptableGateSettingsImpl.java" - ), - "LoggingOptions": _java_methods("usace/rowcps/headless/LoggingOptions.java"), - } - - -def _java_methods(relative_path): - source = (JAVA_SOURCE_ROOT / relative_path).read_text(encoding="utf-8") - return { - match.group("name") - for match in JAVA_METHOD_PATTERN.finditer(_strip_java_comments(source)) - } - - -def _strip_java_comments(source): - source = re.sub(r"/\*.*?\*/", "", source, flags=re.DOTALL) - return re.sub(r"//.*", "", source) + """ + Reflects over the actual compiled classes via JPype/JVM reflection to + build: display name -> {method name -> [overload parameter-type tuples]}. + """ + java_lang_class = jpype.JClass("java.lang.Class") + modifier = jpype.JClass("java.lang.reflect.Modifier") + + try: + return { + display_name: _java_method_signatures(java_lang_class, modifier, fqcn) + for display_name, fqcn in SCRIPTABLE_JAVA_CLASSES.items() + } + except jpype.JException as exc: + pytest.skip( + "Could not load the REGI Headless classes for reflection. Run the " + "Gradle 'bundlePython' (or 'installPythonWheelForSmokeTest') task " + f"to build/install the jars first: {exc}" + ) + + +def _java_method_signatures(java_lang_class, modifier, fully_qualified_name): + java_class = java_lang_class.forName(fully_qualified_name) + signatures = {} + for method in java_class.getDeclaredMethods(): + if not modifier.isPublic(method.getModifiers()): + continue + name = str(method.getName()) + parameter_types = tuple( + str(parameter_type.getName()) for parameter_type in method.getParameterTypes() + ) + signatures.setdefault(name, []).append(parameter_types) + return signatures def _load_script(path): @@ -144,7 +176,10 @@ def module(name): headless.LoggingOptions = type( "LoggingOptions", (), - {name: staticmethod(_noop) for name in java_api["LoggingOptions"]}, + { + name: staticmethod(_make_validated_stub("LoggingOptions", name, overloads)) + for name, overloads in java_api["LoggingOptions"].items() + }, ) inflow = module("usace.rowcps.headless.calculator.inflow") @@ -162,6 +197,81 @@ def __exit__(self, exc_type, exc, traceback): return False +# Best-effort category buckets used to flag obviously-wrong argument types +# (e.g. a string passed where every known overload expects a number) without +# trying to fully replicate the JVM's overload resolution -- JPype's own +# implicit Python -> Java conversions make mirroring that exactly impractical. +_NUMERIC_JAVA_TYPES = { + "int", "long", "short", "byte", "float", "double", + "java.lang.Integer", "java.lang.Long", "java.lang.Short", "java.lang.Byte", + "java.lang.Float", "java.lang.Double", "java.math.BigDecimal", "java.math.BigInteger", +} +_BOOLEAN_JAVA_TYPES = {"boolean", "java.lang.Boolean"} +_STRING_JAVA_TYPES = {"java.lang.String", "java.lang.CharSequence"} +_KNOWN_CATEGORIES = {"numeric", "boolean", "string"} + + +def _java_type_category(type_name): + if type_name in _NUMERIC_JAVA_TYPES: + return "numeric" + if type_name in _BOOLEAN_JAVA_TYPES: + return "boolean" + if type_name in _STRING_JAVA_TYPES: + return "string" + return "other" + + +def _python_type_category(value): + if isinstance(value, bool): # must precede int check: bool is an int subclass + return "boolean" + if isinstance(value, (int, float)): + return "numeric" + if isinstance(value, str): + return "string" + return "other" + + +def _overload_accepts(parameter_types, args): + for type_name, arg in zip(parameter_types, args): + java_category = _java_type_category(type_name) + python_category = _python_type_category(arg) + if ( + java_category in _KNOWN_CATEGORIES + and python_category in _KNOWN_CATEGORIES + and java_category != python_category + ): + return False + return True + + +def _make_validated_stub(display_name, method_name, overloads): + """ + Builds a fake implementation of a Java method that validates positional + call arguments against the method's real, reflected overload(s): arity + always, and argument type "category" (numeric/boolean/string) wherever + that's unambiguous. + """ + arities = sorted({len(parameter_types) for parameter_types in overloads}) + + def stub(*args, **kwargs): + same_arity_overloads = [p for p in overloads if len(p) == len(args)] + if not same_arity_overloads: + raise TypeError( + f"{display_name}.{method_name}() called with {len(args)} positional " + f"argument(s); known overload(s) take {arities} argument(s)" + ) + if any(_overload_accepts(p, args) for p in same_arity_overloads): + return None + expected = " or ".join(f"({', '.join(p)})" for p in same_arity_overloads) + got = ", ".join(type(arg).__name__ for arg in args) + raise TypeError( + f"{display_name}.{method_name}() called with argument types ({got}), " + f"which does not match any known overload: {expected}" + ) + + return stub + + class FakeRegistry: def __init__(self, java_api): self._java_api = java_api @@ -176,14 +286,15 @@ def getCalculation(self, version, name): class FakeJavaObject: - def __init__(self, display_name, method_names): + def __init__(self, display_name, method_signatures): self._display_name = display_name - self._method_names = method_names + self._method_signatures = method_signatures def __getattr__(self, name): - if name not in self._method_names: + overloads = self._method_signatures.get(name) + if overloads is None: raise AttributeError(f"{self._display_name} has no Java method {name!r}") - return _noop + return _make_validated_stub(self._display_name, name, overloads) class FakeTimeZone: @@ -238,7 +349,3 @@ def getTime(self): def toString(self): return "FakeDate" - - -def _noop(*args, **kwargs): - return None