From 8c76bd5ca65a7a9a082a34235cf7544d20ea0ef4 Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Thu, 16 Jul 2026 13:45:50 -0700 Subject: [PATCH 1/6] migrate Java/Jython focused REGI Headless to regi-python via jPype --- regi-headless/build.gradle | 21 ++ .../src/test/python/test_district_scripts.py | 244 ++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 regi-headless/src/test/python/test_district_scripts.py diff --git a/regi-headless/build.gradle b/regi-headless/build.gradle index edc66ab..c278b30 100644 --- a/regi-headless/build.gradle +++ b/regi-headless/build.gradle @@ -163,6 +163,27 @@ tasks.register('testPythonWheel', VenvTask) { outputs.upToDateWhen { false } } +tasks.register('smokeTestDistrictScripts', VenvTask) { + group = 'verification' + description = 'Validates migrated district and example scripts against the Python entrypoint shape and Java scriptable APIs.' + + dependsOn installPythonBuildTools + + venvExec = 'python' + args = [ + '-m', 'pytest', + '-o', 'log_cli=true', + '--log-cli-level=INFO', + 'src/test/python/test_district_scripts.py' + ] + + 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') + outputs.upToDateWhen { false } +} + check { dependsOn testPythonWheel } diff --git a/regi-headless/src/test/python/test_district_scripts.py b/regi-headless/src/test/python/test_district_scripts.py new file mode 100644 index 0000000..44704d4 --- /dev/null +++ b/regi-headless/src/test/python/test_district_scripts.py @@ -0,0 +1,244 @@ +import importlib.util +import io +import logging +import re +import sys +import types +from contextlib import redirect_stdout +from pathlib import Path + + +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*\(" +) +LOGGER = logging.getLogger(__name__) + + +def test_migrated_scripts_only_call_known_scriptable_api(monkeypatch): + java_api = _load_java_api() + _install_fake_modules(monkeypatch, java_api) + + _validate_scripts("district", DISTRICT_SCRIPTS_ROOT, _district_scripts(), java_api) + _validate_scripts("example", EXAMPLE_SCRIPTS_ROOT, _example_scripts(), java_api) + + +def _validate_scripts(label, root, scripts, java_api): + assert scripts, f"No {label} scripts found under {root}" + LOGGER.info("Validating %s %s script(s)", len(scripts), label) + + failures = [] + for script in scripts: + relative_script = script.relative_to(REPOSITORY_ROOT) + LOGGER.info("Validating %s script: %s", label, relative_script) + try: + module = _load_script(script) + with redirect_stdout(io.StringIO()): + _script_callback(module)(FakeRegistry(java_api)) + except Exception as exc: + failures.append(f"{relative_script}: {type(exc).__name__}: {exc}") + else: + LOGGER.info("Validated %s script: %s", label, relative_script) + + assert not failures, f"{label.title()} script API validation failed:\n" + "\n".join(failures) + + +def _district_scripts(): + return sorted( + path + for path in DISTRICT_SCRIPTS_ROOT.rglob("*.py") + if path.name != "__init__.py" + ) + + +def _example_scripts(): + return sorted( + path + for path in EXAMPLE_SCRIPTS_ROOT.rglob("*.py") + if path.name != "__init__.py" + ) + + +def _script_callback(module): + for name in ( + "run_calculations", + "calculate_inflow", + "calculate_gate_flow", + "calculate_gate_settings", + "configure_logging_options", + ): + callback = getattr(module, name, None) + if callback is not None: + return callback + raise AssertionError(f"No migrated script callback found in {module.__file__}") + + +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) + + +def _load_script(path): + module_name = "district_script_" + re.sub(r"\W+", "_", str(path.relative_to(REPOSITORY_ROOT))) + spec = importlib.util.spec_from_file_location(module_name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _install_fake_modules(monkeypatch, java_api): + modules = {} + + def module(name): + value = modules.get(name) + if value is None: + value = types.ModuleType(name) + modules[name] = value + monkeypatch.setitem(sys.modules, name, value) + if "." in name: + parent_name, child_name = name.rsplit(".", 1) + setattr(module(parent_name), child_name, value) + return value + + regi_python = module("regi_python") + regi_python.regi_session = _fake_regi_session + regi_python.run_headless = lambda callback: callback(FakeRegistry(java_api)) + + java_util = module("java.util") + java_util.Calendar = FakeCalendar + java_util.TimeZone = FakeTimeZone + + java_lang = module("java.lang") + java_lang.System = FakeSystem + + headless = module("usace.rowcps.headless") + headless.LoggingOptions = type( + "LoggingOptions", + (), + {name: staticmethod(_noop) for name in java_api["LoggingOptions"]}, + ) + + inflow = module("usace.rowcps.headless.calculator.inflow") + inflow.InflowComputationStorageOption = types.SimpleNamespace( + EVAP_AS_FLOW="EVAP_AS_FLOW", + PROJECT_RELEASES="PROJECT_RELEASES", + ) + + +class _fake_regi_session: + def __enter__(self): + return None + + def __exit__(self, exc_type, exc, traceback): + return False + + +class FakeRegistry: + def __init__(self, java_api): + self._java_api = java_api + + def getNames(self, version): + return ["Inflow", "Gate Flow", "Gate Settings"] + + def getCalculation(self, version, name): + if name not in self._java_api: + raise AssertionError(f"Unknown calculation requested: {name!r}") + return FakeJavaObject(name, self._java_api[name]) + + +class FakeJavaObject: + def __init__(self, display_name, method_names): + self._display_name = display_name + self._method_names = method_names + + def __getattr__(self, name): + if name not in self._method_names: + raise AttributeError(f"{self._display_name} has no Java method {name!r}") + return _noop + + +class FakeTimeZone: + @staticmethod + def getTimeZone(name): + return FakeTimeZone() + + +class FakeSystem: + @staticmethod + def getProperty(name): + return "" + + +class FakeCalendar: + DATE = 1 + DAY_OF_MONTH = 2 + HOUR = 3 + HOUR_OF_DAY = 4 + MILLISECOND = 5 + MINUTE = 6 + MONTH = 7 + SECOND = 8 + YEAR = 9 + + @staticmethod + def getInstance(time_zone=None): + return FakeCalendar() + + def add(self, field, amount): + return None + + def clear(self): + return None + + def getTime(self): + return FakeDate() + + def getTimeInMillis(self): + return 0 + + def get(self, field): + return 0 + + def set(self, field, value): + return None + + +class FakeDate: + def getTime(self): + return 0 + + def toString(self): + return "FakeDate" + + +def _noop(*args, **kwargs): + return None From 07b4438902dddc808d24978c23477c3b37bc6f72 Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Thu, 16 Jul 2026 13:49:51 -0700 Subject: [PATCH 2/6] disable smoke testing until district script migration is complete --- regi-headless/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/regi-headless/build.gradle b/regi-headless/build.gradle index c278b30..8f3c4f7 100644 --- a/regi-headless/build.gradle +++ b/regi-headless/build.gradle @@ -186,4 +186,5 @@ tasks.register('smokeTestDistrictScripts', VenvTask) { check { dependsOn testPythonWheel +// dependsOn smokeTestDistrictScripts } From 65f78e0ea0624ad76edb2f5eab13bbd79807e105 Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Thu, 16 Jul 2026 13:50:36 -0700 Subject: [PATCH 3/6] disable smoke testing until district script migration is complete --- regi-headless/build.gradle | 22 -- .../src/test/python/test_district_scripts.py | 244 ------------------ 2 files changed, 266 deletions(-) delete mode 100644 regi-headless/src/test/python/test_district_scripts.py diff --git a/regi-headless/build.gradle b/regi-headless/build.gradle index 8f3c4f7..edc66ab 100644 --- a/regi-headless/build.gradle +++ b/regi-headless/build.gradle @@ -163,28 +163,6 @@ tasks.register('testPythonWheel', VenvTask) { outputs.upToDateWhen { false } } -tasks.register('smokeTestDistrictScripts', VenvTask) { - group = 'verification' - description = 'Validates migrated district and example scripts against the Python entrypoint shape and Java scriptable APIs.' - - dependsOn installPythonBuildTools - - venvExec = 'python' - args = [ - '-m', 'pytest', - '-o', 'log_cli=true', - '--log-cli-level=INFO', - 'src/test/python/test_district_scripts.py' - ] - - 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') - outputs.upToDateWhen { false } -} - check { dependsOn testPythonWheel -// dependsOn smokeTestDistrictScripts } diff --git a/regi-headless/src/test/python/test_district_scripts.py b/regi-headless/src/test/python/test_district_scripts.py deleted file mode 100644 index 44704d4..0000000 --- a/regi-headless/src/test/python/test_district_scripts.py +++ /dev/null @@ -1,244 +0,0 @@ -import importlib.util -import io -import logging -import re -import sys -import types -from contextlib import redirect_stdout -from pathlib import Path - - -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*\(" -) -LOGGER = logging.getLogger(__name__) - - -def test_migrated_scripts_only_call_known_scriptable_api(monkeypatch): - java_api = _load_java_api() - _install_fake_modules(monkeypatch, java_api) - - _validate_scripts("district", DISTRICT_SCRIPTS_ROOT, _district_scripts(), java_api) - _validate_scripts("example", EXAMPLE_SCRIPTS_ROOT, _example_scripts(), java_api) - - -def _validate_scripts(label, root, scripts, java_api): - assert scripts, f"No {label} scripts found under {root}" - LOGGER.info("Validating %s %s script(s)", len(scripts), label) - - failures = [] - for script in scripts: - relative_script = script.relative_to(REPOSITORY_ROOT) - LOGGER.info("Validating %s script: %s", label, relative_script) - try: - module = _load_script(script) - with redirect_stdout(io.StringIO()): - _script_callback(module)(FakeRegistry(java_api)) - except Exception as exc: - failures.append(f"{relative_script}: {type(exc).__name__}: {exc}") - else: - LOGGER.info("Validated %s script: %s", label, relative_script) - - assert not failures, f"{label.title()} script API validation failed:\n" + "\n".join(failures) - - -def _district_scripts(): - return sorted( - path - for path in DISTRICT_SCRIPTS_ROOT.rglob("*.py") - if path.name != "__init__.py" - ) - - -def _example_scripts(): - return sorted( - path - for path in EXAMPLE_SCRIPTS_ROOT.rglob("*.py") - if path.name != "__init__.py" - ) - - -def _script_callback(module): - for name in ( - "run_calculations", - "calculate_inflow", - "calculate_gate_flow", - "calculate_gate_settings", - "configure_logging_options", - ): - callback = getattr(module, name, None) - if callback is not None: - return callback - raise AssertionError(f"No migrated script callback found in {module.__file__}") - - -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) - - -def _load_script(path): - module_name = "district_script_" + re.sub(r"\W+", "_", str(path.relative_to(REPOSITORY_ROOT))) - spec = importlib.util.spec_from_file_location(module_name, path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _install_fake_modules(monkeypatch, java_api): - modules = {} - - def module(name): - value = modules.get(name) - if value is None: - value = types.ModuleType(name) - modules[name] = value - monkeypatch.setitem(sys.modules, name, value) - if "." in name: - parent_name, child_name = name.rsplit(".", 1) - setattr(module(parent_name), child_name, value) - return value - - regi_python = module("regi_python") - regi_python.regi_session = _fake_regi_session - regi_python.run_headless = lambda callback: callback(FakeRegistry(java_api)) - - java_util = module("java.util") - java_util.Calendar = FakeCalendar - java_util.TimeZone = FakeTimeZone - - java_lang = module("java.lang") - java_lang.System = FakeSystem - - headless = module("usace.rowcps.headless") - headless.LoggingOptions = type( - "LoggingOptions", - (), - {name: staticmethod(_noop) for name in java_api["LoggingOptions"]}, - ) - - inflow = module("usace.rowcps.headless.calculator.inflow") - inflow.InflowComputationStorageOption = types.SimpleNamespace( - EVAP_AS_FLOW="EVAP_AS_FLOW", - PROJECT_RELEASES="PROJECT_RELEASES", - ) - - -class _fake_regi_session: - def __enter__(self): - return None - - def __exit__(self, exc_type, exc, traceback): - return False - - -class FakeRegistry: - def __init__(self, java_api): - self._java_api = java_api - - def getNames(self, version): - return ["Inflow", "Gate Flow", "Gate Settings"] - - def getCalculation(self, version, name): - if name not in self._java_api: - raise AssertionError(f"Unknown calculation requested: {name!r}") - return FakeJavaObject(name, self._java_api[name]) - - -class FakeJavaObject: - def __init__(self, display_name, method_names): - self._display_name = display_name - self._method_names = method_names - - def __getattr__(self, name): - if name not in self._method_names: - raise AttributeError(f"{self._display_name} has no Java method {name!r}") - return _noop - - -class FakeTimeZone: - @staticmethod - def getTimeZone(name): - return FakeTimeZone() - - -class FakeSystem: - @staticmethod - def getProperty(name): - return "" - - -class FakeCalendar: - DATE = 1 - DAY_OF_MONTH = 2 - HOUR = 3 - HOUR_OF_DAY = 4 - MILLISECOND = 5 - MINUTE = 6 - MONTH = 7 - SECOND = 8 - YEAR = 9 - - @staticmethod - def getInstance(time_zone=None): - return FakeCalendar() - - def add(self, field, amount): - return None - - def clear(self): - return None - - def getTime(self): - return FakeDate() - - def getTimeInMillis(self): - return 0 - - def get(self, field): - return 0 - - def set(self, field, value): - return None - - -class FakeDate: - def getTime(self): - return 0 - - def toString(self): - return "FakeDate" - - -def _noop(*args, **kwargs): - return None From 4f414df0abddb06b65cf66c991a74f111b76c995 Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Thu, 16 Jul 2026 13:39:46 -0700 Subject: [PATCH 4/6] modularize GitHub Actions workflows: separate build-wheel and release processes --- .github/workflows/build-wheel.yml | 50 +++++++++++++++++++++++++++++++ .github/workflows/build.yml | 31 +++++-------------- .github/workflows/release.yml | 46 ++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/build-wheel.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/build-wheel.yml b/.github/workflows/build-wheel.yml new file mode 100644 index 0000000..7b3fdae --- /dev/null +++ b/.github/workflows/build-wheel.yml @@ -0,0 +1,50 @@ +name: Build Python Wheel + +on: + workflow_call: + inputs: + ref: + description: Git ref to check out + required: false + type: string + default: "" + +jobs: + build-wheel: + name: Build Python wheel + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ inputs.ref || github.ref }} + + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Make Gradle wrapper executable + run: chmod +x ./gradlew + + - name: Build and test + run: ./gradlew clean build buildPythonWheel + + - name: Collect wheel + run: | + mkdir -p dist + find . -path "*/build/install/*/dist/*.whl" -exec cp {} dist/ \; + + - name: Upload Python wheel artifact + uses: actions/upload-artifact@v4 + with: + name: python-wheel + path: dist/*.whl + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0be5339..c9f1b40 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,35 +9,18 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - build: + build-wheel: name: Build and test - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - - name: Set up Java - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: 21 - - - name: Set up Gradle - uses: gradle/actions/setup-gradle@v6 - - - name: Make Gradle wrapper executable - run: chmod +x ./gradlew - - - name: Build and test - run: ./gradlew clean build + uses: ./.github/workflows/build-wheel.yml dependency-submission: name: Submit Gradle dependencies - needs: build + needs: build-wheel runs-on: ubuntu-latest if: github.event_name == 'push' && github.ref == 'refs/heads/main' permissions: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e0833f6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,46 @@ +name: Release + +on: + release: + types: + - published + +permissions: + contents: write + actions: read + +concurrency: + group: release-${{ github.event.release.id }} + cancel-in-progress: false + +jobs: + build-wheel: + name: Build wheel from release tag + uses: ./.github/workflows/build-wheel.yml + with: + ref: ${{ github.event.release.tag_name }} + + publish-wheel: + name: Publish Python wheel to GitHub Release + needs: build-wheel + runs-on: ubuntu-latest + + steps: + - name: Download Python wheel artifact + uses: actions/download-artifact@v4 + with: + name: python-wheel + path: dist + + - name: Generate checksums + run: | + cd dist + sha256sum *.whl > SHA256SUMS.txt + + - name: Publish wheel to GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.release.tag_name }} + files: | + dist/*.whl + dist/SHA256SUMS.txt \ No newline at end of file From 20d224653489449dcfbb9921279a6d9119946b71 Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Fri, 24 Jul 2026 12:20:59 -0700 Subject: [PATCH 5/6] add pytest-cov support and integrate coverage reporting in build and CI --- .github/scripts/python_coverage_summary.py | 82 ++++++++++++++++++++++ .github/workflows/build-wheel.yml | 16 +++++ .gitignore | 1 + regi-headless/build.gradle | 16 ++++- 4 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 .github/scripts/python_coverage_summary.py diff --git a/.github/scripts/python_coverage_summary.py b/.github/scripts/python_coverage_summary.py new file mode 100644 index 0000000..7f52430 --- /dev/null +++ b/.github/scripts/python_coverage_summary.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Render a Cobertura-style coverage.xml (as produced by `coverage xml` / +pytest-cov's --cov-report=xml) as Markdown suitable for a GitHub Actions job +summary. + +Usage: + python3 python_coverage_summary.py + +Prints to stdout; the caller is expected to redirect into $GITHUB_STEP_SUMMARY. +Deliberately has no third-party dependencies so it can run with the stock +`python3` already available on GitHub-hosted runners -- no extra permissions +or installs are needed, which keeps it safe to run on pull requests from +forks. +""" + +import sys +import xml.etree.ElementTree as ET + + +def _pct(rate_attr): + try: + return float(rate_attr) * 100 + except (TypeError, ValueError): + return 0.0 + + +def main(argv): + if len(argv) != 2: + print("Usage: python_coverage_summary.py ", file=sys.stderr) + return 2 + + path = argv[1] + + try: + root = ET.parse(path).getroot() + except (OSError, ET.ParseError) as exc: + print("## Python test coverage") + print() + print(f"No coverage report found at `{path}` ({exc}).") + return 0 + + line_rate = _pct(root.get("line-rate")) + branch_rate = _pct(root.get("branch-rate")) + lines_covered = root.get("lines-covered", "?") + lines_valid = root.get("lines-valid", "?") + + print("## Python test coverage") + print() + print( + f"**Overall line coverage: {line_rate:.1f}%** " + f"({lines_covered}/{lines_valid} lines)  |  " + f"branch coverage: {branch_rate:.1f}%" + ) + print() + print("
Per-file coverage") + print() + print("| File | Line coverage | Lines covered |") + print("| --- | --- | --- |") + + classes = sorted(root.iter("class"), key=lambda c: c.get("filename", "")) + for cls in classes: + filename = cls.get("filename", "?") + file_line_rate = _pct(cls.get("line-rate")) + + total = covered = 0 + lines_elem = cls.find("lines") + if lines_elem is not None: + for line in lines_elem.findall("line"): + total += 1 + if int(line.get("hits", "0")) > 0: + covered += 1 + + print(f"| `{filename}` | {file_line_rate:.1f}% | {covered}/{total} |") + + print() + print("
") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/.github/workflows/build-wheel.yml b/.github/workflows/build-wheel.yml index 7b3fdae..5e138fe 100644 --- a/.github/workflows/build-wheel.yml +++ b/.github/workflows/build-wheel.yml @@ -36,6 +36,22 @@ jobs: - name: Build and test run: ./gradlew clean build buildPythonWheel + - name: Publish Python coverage summary + if: always() + run: | + python3 .github/scripts/python_coverage_summary.py \ + regi-headless/build/reports/coverage/coverage.xml \ + >> "$GITHUB_STEP_SUMMARY" + + - name: Upload Python coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: python-coverage-html + path: regi-headless/build/reports/coverage/html + if-no-files-found: warn + retention-days: 14 + - name: Collect wheel run: | mkdir -p dist diff --git a/.gitignore b/.gitignore index 2e40c33..2367b78 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ *.iml .DS_STORE */build/ +.coverage* \ No newline at end of file diff --git a/regi-headless/build.gradle b/regi-headless/build.gradle index edc66ab..4e9d195 100644 --- a/regi-headless/build.gradle +++ b/regi-headless/build.gradle @@ -60,7 +60,7 @@ tasks.register('installPythonBuildTools', VenvTask) { description = 'Installs Python packages needed to build and test the wheel.' venvExec = 'pip' - args = ['install', '--upgrade', 'pip', 'build', 'pytest'] + args = ['install', '--upgrade', 'pip', 'build', 'pytest', 'pytest-cov'] outputs.file(layout.buildDirectory.file("python-build-tools/install.marker")) @@ -152,15 +152,25 @@ tasks.register('installPythonWheelForSmokeTest', VenvTask) { } tasks.register('testPythonWheel', VenvTask) { group = 'verification' - description = 'Runs pytest against the installed Python wheel.' + description = 'Runs pytest (with coverage) against the installed Python wheel.' dependsOn installPythonWheelForSmokeTest + def coverageDir = layout.buildDirectory.dir('reports/coverage') + venvExec = 'python' - args = ['-m', 'pytest', 'src/test/python'] + args = [ + '-m', 'pytest', 'src/test/python', + '--cov=regi_python', + '--cov-branch', + '--cov-report=term-missing', + "--cov-report=xml:${coverageDir.get().file('coverage.xml').asFile}", + "--cov-report=html:${coverageDir.get().dir('html').asFile}", + ] inputs.files(fileTree(dir: 'src/test/python', include: '**/*.py')) outputs.upToDateWhen { false } + outputs.dir(coverageDir) } check { From 6f1cc1786dc28c69ddda71a6e03f8a534e14537b Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Tue, 28 Jul 2026 14:21:25 -0700 Subject: [PATCH 6/6] code review feedback --- .github/scripts/python_coverage_summary.py | 12 ++++++++---- .github/workflows/release.yml | 14 +++++++------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.github/scripts/python_coverage_summary.py b/.github/scripts/python_coverage_summary.py index 7f52430..d3199c0 100644 --- a/.github/scripts/python_coverage_summary.py +++ b/.github/scripts/python_coverage_summary.py @@ -21,7 +21,11 @@ def _pct(rate_attr): try: return float(rate_attr) * 100 except (TypeError, ValueError): - return 0.0 + return None + + +def _fmt_pct(value): + return "N/A" if value is None else f"{value:.1f}%" def main(argv): @@ -47,9 +51,9 @@ def main(argv): print("## Python test coverage") print() print( - f"**Overall line coverage: {line_rate:.1f}%** " + f"**Overall line coverage: {_fmt_pct(line_rate)}** " f"({lines_covered}/{lines_valid} lines)  |  " - f"branch coverage: {branch_rate:.1f}%" + f"branch coverage: {_fmt_pct(branch_rate)}" ) print() print("
Per-file coverage") @@ -70,7 +74,7 @@ def main(argv): if int(line.get("hits", "0")) > 0: covered += 1 - print(f"| `{filename}` | {file_line_rate:.1f}% | {covered}/{total} |") + print(f"| `{filename}` | {_fmt_pct(file_line_rate)} | {covered}/{total} |") print() print("
") diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e0833f6..3c0e6d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ on: - published permissions: - contents: write + contents: read actions: read concurrency: @@ -24,6 +24,8 @@ jobs: name: Publish Python wheel to GitHub Release needs: build-wheel runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Download Python wheel artifact @@ -38,9 +40,7 @@ jobs: sha256sum *.whl > SHA256SUMS.txt - name: Publish wheel to GitHub Release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ github.event.release.tag_name }} - files: | - dist/*.whl - dist/SHA256SUMS.txt \ No newline at end of file + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "${{ github.event.release.tag_name }}" dist/*.whl dist/SHA256SUMS.txt --clobber \ No newline at end of file