Skip to content
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
12 changes: 10 additions & 2 deletions regi-headless/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"))

Expand Down Expand Up @@ -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 = [
Expand All @@ -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 }
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*
* @author ryan
*/
interface ScriptableGateSettings
public interface ScriptableGateSettings
{
/**
* @deprecated Use {@link #createGateSettings(String, String, Instant, Instant)} instead. java.util.Date
Expand Down
80 changes: 80 additions & 0 deletions regi-headless/src/test/python/conftest.py
Original file line number Diff line number Diff line change
@@ -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.
187 changes: 147 additions & 40 deletions regi-headless/src/test/python/test_district_scripts.py
Original file line number Diff line number Diff line change
@@ -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<name>[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__)


Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -238,7 +349,3 @@ def getTime(self):

def toString(self):
return "FakeDate"


def _noop(*args, **kwargs):
return None
Loading