This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/main/pr-8050-eddec267a2c6974d0ea0f29b63e9c7506e12f9b4
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 8b3aefec9dadd2933fa63b53c05e6a152f435175
Author: Xinyuan Lin <[email protected]>
AuthorDate: Fri Aug 28 02:41:42 2026 +0000

    test(pyamber): cover the python worker entry point (#8050)
    
    ### What changes were proposed in this PR?
    
    `test_run_python_worker.py` goes from 27 tests to 42, covering the
    worker entry point's configuration-failure paths.
    
    | Metric | Before | After |
    |---|---|---|
    | Codecov fully-covered lines **today** | 32/39 | **39/39** |
    | Codecov fully-covered lines **after #8040** | 31/39 | **39/39** |
    | Branch arms | 9/12 | **12/12** |
    
    **`texera_run_python_worker.py` reaches 100% on both lines and
    branches.** +7 fully-covered lines today, +8 and +3 arms after #8040 —
    line 155 is a partial that the current branch-less report already counts
    covered, because it executes at import.
    
    The uncovered set was the argument-parsing failures (26–28), the loguru
    level configuration (35), and the missing-dependency and R_HOME paths
    (48, 51, 155–156). These are what run when the worker starts with a
    malformed or incomplete configuration, so a regression there surfaces as
    a worker that fails to start with an unhelpful message.
    
    One correction to the assessment worth recording: **line 143, the R_HOME
    guard, was already at 2/2** — it was not part of the gap.
    
    ### Verification
    
    Two reviewers returned nine findings and reported **thirteen surviving
    mutants** against the first draft. All thirteen were independently
    reproduced on a pristine tree before being fixed, and all thirteen now
    die.
    
    **39 mutations in the final table: 38 killed, 1 equivalent, 0 real
    survivors.** The equivalent one is `frozenset` to `set` for
    `EXPECTED_CONFIG_KEYS` — reported rather than omitted.
    
    The repair also confirmed something worth stating about the measurement
    itself: `pytest`'s `importlib` import mode re-executes this module per
    test, and there is **exactly one** `<class
    filename="texera_run_python_worker.py">` entry in every report, on both
    sides and in the full-suite run. So the fresh execs attribute to the
    same source path rather than splitting the coverage entry — checked
    rather than assumed.
    
    Measured from `amber/` with an identical single-spec test-path scope on
    both sides, both with and without `--cov-branch` (the no-branch figure
    measured directly, not derived), per-line `hits` and
    `condition-coverage` read out of `coverage.xml` rather than from the
    summary percentage.
    
    Full pyamber suite: `5 failed, 1174 passed, 7 errors` against main's
    `1159 passed`. Failure sets sorted and diffed — **identical by
    identity**, 12 entries. `ruff check` and `ruff format --check` pass on
    CI's exact scope.
    
    No production file is touched; `git diff -- 'amber/src/main/*'` is
    empty.
    
    ### Any related issues, documentation, discussions?
    
    Closes #8046
    
    ### How was this PR tested?
    
    ```
    cd amber && python -m pytest -m "not integration" -q 
src/test/python/test_run_python_worker.py
    ```
    
    ```
    42 passed
    ```
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 5)
---
 amber/src/test/python/test_run_python_worker.py | 309 +++++++++++++++++++++++-
 1 file changed, 307 insertions(+), 2 deletions(-)

diff --git a/amber/src/test/python/test_run_python_worker.py 
b/amber/src/test/python/test_run_python_worker.py
index 7deec97d48..2c4280ac59 100644
--- a/amber/src/test/python/test_run_python_worker.py
+++ b/amber/src/test/python/test_run_python_worker.py
@@ -16,10 +16,18 @@
 # under the License.
 
 import base64
+import builtins
+import importlib.util
+import io
 import json
+import os
+import sys
+import types
+from pathlib import Path
 from unittest import mock
 
 import pytest
+from loguru import logger
 
 import texera_run_python_worker as entry
 
@@ -155,10 +163,15 @@ def test_parse_rejects_a_missing_key(missing_key):
 
 
 def test_parse_rejects_an_unexpected_key():
-    """An extra key (e.g. the JVM side added a field) fails instead of being 
ignored."""
+    """An extra key (e.g. the JVM side added a field) fails instead of being 
ignored,
+    and is reported on the `unexpected` side — that message is the whole 
diagnostic
+    an operator gets when the two sides drift, so naming the wrong side sends 
the
+    reader to the wrong end of the contract."""
     config = _full_config()
     config["someNewField"] = "value"
-    with pytest.raises(ValueError, match="key mismatch"):
+    with pytest.raises(
+        ValueError, match=r"missing=\[\], unexpected=\['someNewField'\]"
+    ):
         entry.parse_startup_config(_encode(config))
 
 
@@ -179,3 +192,295 @@ def test_parse_round_trips_a_base64_encoded_config():
     """The config is passed as Base64-encoded JSON; parsing decodes it back."""
     config = _full_config()
     assert entry.parse_startup_config(_encode(config)) == config
+
+
+ENTRY_MODULE_PATH = Path(entry.__file__)
+
+
+def _exec_entry_module(module_name: str):
+    """Execute the entry script afresh from its file under the given module 
name.
+
+    `exec_module` deliberately does not register the result in `sys.modules`, 
so a
+    run that aborts half-way through its imports — or one executed as 
`__main__` —
+    cannot disturb the already-imported `texera_run_python_worker` that the 
tests
+    above use. Loading by file path keeps the executed code objects anchored 
to the
+    real source file, so coverage still attributes the lines to it.
+    """
+    spec = importlib.util.spec_from_file_location(module_name, 
ENTRY_MODULE_PATH)
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)
+    return module
+
+
+def _exec_entry_module_with_failing_collaborator_imports(error):
+    """Re-run the entry script with its `core.*` imports raising `error`.
+
+    Only the `core` package is failed: the stdlib and loguru imports above the
+    try/except must still succeed, otherwise the failure would never reach the
+    handler under test.
+    """
+    real_import = builtins.__import__
+
+    def failing_import(name, globals=None, locals=None, fromlist=(), level=0):
+        if name.split(".")[0] == "core":
+            raise error
+        return real_import(name, globals, locals, fromlist, level)
+
+    with mock.patch.object(builtins, "__import__", failing_import):
+        return _exec_entry_module("texera_run_python_worker_reexec")
+
+
[email protected]
+def restored_loguru_sink():
+    """Undo `init_loguru_logger`'s process-global handler surgery.
+
+    `logger.remove()` stops every loguru handler for the remainder of the 
pytest
+    process, so a test that lets it run must hand a working stderr sink back 
to the
+    suites that follow. The stopped handler objects cannot be re-registered, 
so a
+    fresh sink is added instead. `LOGURU_LEVEL` is safe to reuse verbatim: 
loguru
+    validates it while configuring its own default handler at import time, so 
an
+    unusable value would already have failed long before this fixture runs.
+    """
+    yield
+    logger.remove()
+    logger.add(
+        sys.__stderr__ or sys.stderr, level=os.environ.get("LOGURU_LEVEL", 
"DEBUG")
+    )
+
+
+def test_missing_proto_package_exits_with_generation_guidance():
+    """The generated proto bindings are not checked in, so their absence is the
+    single most likely first-run failure; it must exit with the regeneration
+    recipe rather than a bare ImportError traceback."""
+    with pytest.raises(SystemExit) as excinfo:
+        _exec_entry_module_with_failing_collaborator_imports(
+            ModuleNotFoundError("No module named 'proto'", name="proto")
+        )
+
+    message = str(excinfo.value)
+    assert "amber/src/main/python/proto/" in message
+    assert "bin/python-proto-gen.sh" in message
+
+
+def test_missing_proto_submodule_exits_with_generation_guidance():
+    """A partially generated tree reports a submodule rather than `proto` 
itself,
+    which has to be recognised as the same failure."""
+    with pytest.raises(SystemExit) as excinfo:
+        _exec_entry_module_with_failing_collaborator_imports(
+            ModuleNotFoundError(
+                "No module named 'proto.edu'", name="proto.edu.uci.ics.amber"
+            )
+        )
+
+    assert "bin/python-proto-gen.sh" in str(excinfo.value)
+
+
[email protected](
+    "missing_name",
+    [
+        "numpy",
+        # A dependency whose name merely *begins with* "proto" — protobuf sits 
right
+        # next to the generated `proto/` package in this project — must not 
match.
+        "protobuf",
+        # `core` is the very package the try block imports: a broken 
PYTHONPATH or a
+        # worker spawned from the wrong cwd makes it unimportable, and blaming 
the
+        # proto bindings for that sends the operator off to regenerate 
bindings that
+        # are already present.
+        "core",
+        "core.models",
+    ],
+)
+def test_unrelated_missing_dependency_propagates(missing_name):
+    """A genuinely missing third-party dependency must not be misreported as
+    missing proto bindings: the original error has to reach the caller. The 
guard
+    has to be specific as well as complete — matching a name that merely starts
+    with or contains "proto" is as wrong as failing to match "proto." 
itself."""
+    with pytest.raises(ModuleNotFoundError) as excinfo:
+        _exec_entry_module_with_failing_collaborator_imports(
+            ModuleNotFoundError(f"No module named {missing_name!r}", 
name=missing_name)
+        )
+
+    assert excinfo.value.name == missing_name
+
+
+def test_missing_dependency_without_a_name_propagates():
+    """`ModuleNotFoundError.name` is optional, so the proto check must 
tolerate a
+    `None` name instead of failing with an AttributeError of its own."""
+    with pytest.raises(ModuleNotFoundError) as excinfo:
+        _exec_entry_module_with_failing_collaborator_imports(
+            ModuleNotFoundError("the import machinery reported no module name")
+        )
+
+    assert excinfo.value.name is None
+
+
[email protected](
+    ("requested_level", "info_expected"),
+    # Two rows, and a probe *between* DEBUG(10) and WARNING(30), are what make 
the
+    # level parameter observable. A single row bounded only by debug and 
warning is
+    # satisfied by every hardcoded constant in {INFO, SUCCESS, WARNING}, 
because
+    # loguru puts INFO(20) and SUCCESS(25) inside that window.
+    [("WARNING", False), ("INFO", True)],
+)
+def test_init_loguru_logger_replaces_handlers_at_the_requested_level(
+    monkeypatch, restored_loguru_sink, requested_level, info_expected
+):
+    """Configuring the logger means *replacing* loguru's handlers: any sink 
that
+    was already registered (loguru installs one by default) must be gone, and 
the
+    new stderr sink must honour the level it was *asked* for rather than a 
level of
+    its own choosing."""
+    pre_existing_records = []
+    logger.add(pre_existing_records.append, level="DEBUG")
+    stderr_sink = io.StringIO()
+    monkeypatch.setattr(sys, "stderr", stderr_sink)
+
+    entry.init_loguru_logger(requested_level)
+
+    logger.debug("below-every-requested-level")
+    logger.info("between-the-requested-levels")
+    logger.warning("at-or-above-every-requested-level")
+
+    emitted = stderr_sink.getvalue()
+    assert "at-or-above-every-requested-level" in emitted
+    assert "below-every-requested-level" not in emitted
+    assert ("between-the-requested-levels" in emitted) is info_expected
+    assert pre_existing_records == []
+
+
+def test_script_entry_point_starts_the_worker_from_argv(restored_loguru_sink):
+    """Run as a script — the way PythonWorkflowWorker on the JVM side spawns 
it —
+    the module must start a worker from the config in argv[1]."""
+    python_worker = mock.MagicMock()
+    worker_module = types.ModuleType("core.python_worker")
+    worker_module.PythonWorker = python_worker
+    storage_module = types.ModuleType("core.storage.storage_config")
+    storage_module.StorageConfig = mock.MagicMock()
+    # The third element exists only so that index 1 and index -1 differ, 
making the
+    # argv *index* observable. The JVM side passes exactly one argument, so 
nothing
+    # is asserted about trailing arguments beyond their not being what gets 
parsed.
+    argv = [
+        "src/main/python/texera_run_python_worker.py",
+        _encode(_full_config()),
+        "trailing-argument-the-entry-point-must-ignore",
+    ]
+
+    with (
+        mock.patch.dict(
+            sys.modules,
+            {
+                "core.python_worker": worker_module,
+                "core.storage.storage_config": storage_module,
+            },
+        ),
+        mock.patch.object(sys, "argv", argv),
+    ):
+        _exec_entry_module("__main__")
+
+    python_worker.assert_called_once_with(
+        worker_id="worker-1", host="localhost", output_port=5005
+    )
+    python_worker.return_value.run.assert_called_once()
+
+
+def test_parse_names_the_missing_key_on_the_missing_side():
+    """The companion of test_parse_rejects_an_unexpected_key: a key the JVM 
stopped
+    sending has to be reported as *missing*. The 19-way parametrized test 
above can
+    only afford a loose match, so the side of the diagnostic is fenced here."""
+    config = _full_config()
+    del config["s3Region"]
+    with pytest.raises(ValueError, match=r"missing=\['s3Region'\], 
unexpected=\[\]"):
+        entry.parse_startup_config(_encode(config))
+
+
+def _encode_raw_utf8(config) -> str:
+    """Encode a config the way Jackson actually does: with raw, unescaped 
UTF-8.
+
+    `_encode` cannot model the real producer here. `json.dumps` defaults to
+    `ensure_ascii=True`, which escapes every non-ASCII character, so any 
payload it
+    builds is pure ASCII. PythonWorkflowWorker on the JVM side does
+    `objectMapper.writeValueAsString(...)` then
+    `Base64.getEncoder.encodeToString(json.getBytes(StandardCharsets.UTF_8))`, 
and
+    Jackson emits non-ASCII characters literally. `ensure_ascii=False` is 
therefore
+    the entire point of this helper - do not "simplify" it back to `_encode`.
+    """
+    return base64.b64encode(
+        json.dumps(config, ensure_ascii=False).encode("utf-8")
+    ).decode("ascii")
+
+
+def test_parse_decodes_non_ascii_values_as_utf8():
+    """Config values arrive as raw UTF-8 bytes, so they must be decoded as 
UTF-8.
+
+    A non-ASCII value is entirely reachable in production - a Windows profile
+    directory under a non-ASCII user name is the obvious case - and decoding 
the
+    payload as ASCII would kill every worker on such a host at startup, before 
a
+    single log line.
+    """
+    non_ascii_path = "/tmp/\u0444\u0430\u0439\u043b\u044b-\u6570\u636e"
+    config = _full_config()
+    config["icebergFileStorageDirectoryPath"] = non_ascii_path
+
+    parsed = entry.parse_startup_config(_encode_raw_utf8(config))
+
+    assert parsed["icebergFileStorageDirectoryPath"] == non_ascii_path
+
+
+def test_main_forwards_the_configured_logger_level():
+    """`loggerLevel` carries the JVM's UdfConfig.pythonLogStreamHandlerLevel, 
and it
+    is the one named field no other test observes reaching its destination: 
the three
+    main() tests replace init_loguru_logger with a mock and never look at the 
call.
+    The level used here differs from the sample config's on purpose, so no 
hardcoded
+    constant at the call site can satisfy the assertion."""
+    assert _full_config()["loggerLevel"] != "WARNING"
+    config = _full_config()
+    config["loggerLevel"] = "WARNING"
+    storage_patch, worker_patch, _unused_logger_patch = 
_patched_collaborators()
+    with (
+        storage_patch,
+        worker_patch,
+        mock.patch.object(entry, "init_loguru_logger") as init_logger,
+    ):
+        entry.main(_encode(config))
+
+    init_logger.assert_called_once_with("WARNING")
+
+
+def test_main_keeps_the_rest_catalog_uri_and_warehouse_name_in_order():
+    """Fence the one adjacent pair of StorageConfig.initialize arguments the 
sample
+    config cannot tell apart: `icebergRestCatalogUri` and
+    `icebergRestCatalogWarehouseName` are both "" there, so a swap of the two 
would
+    satisfy assert_called_once_with in either order. On a REST catalog both 
fields
+    are non-empty, and swapping them points the catalog URI at a warehouse 
name.
+
+    The values are overridden locally rather than in `_full_config()` so the 
shared
+    fixture, and the two full-mapping assertions built on it, stay untouched.
+    """
+    config = _full_config()
+    config["icebergRestCatalogUri"] = "http://rest-catalog:8181";
+    config["icebergRestCatalogWarehouseName"] = "warehouse-name"
+    storage_patch, worker_patch, logger_patch = _patched_collaborators()
+    with storage_patch as storage_config, worker_patch, logger_patch:
+        entry.main(_encode(config))
+
+    positional = storage_config.initialize.call_args.args
+    assert positional[4] == "http://rest-catalog:8181";
+    assert positional[5] == "warehouse-name"
+
+
+def test_main_leaves_r_home_unset_when_r_path_is_blank(monkeypatch):
+    """The false arm of the R_HOME guard: a non-R worker must not be handed an 
empty
+    R_HOME. An empty value is worse than an absent one for rpy2's R discovery, 
which
+    is exactly what the guard exists to prevent. Every other main() test runs 
the
+    false arm too, but none of them looks at the environment."""
+    # Load-bearing, not decorative: test_main_sets_r_home_when_r_path_present 
lets
+    # production write R_HOME directly, and monkeypatch.delenv records nothing 
to
+    # restore when the variable was absent to begin with - so R_HOME leaks out 
of
+    # that test and this one would otherwise depend on collection order.
+    monkeypatch.delenv("R_HOME", raising=False)
+    config = _full_config()
+    assert config["rPath"] == ""
+    storage_patch, worker_patch, logger_patch = _patched_collaborators()
+    with storage_patch, worker_patch, logger_patch:
+        entry.main(_encode(config))
+
+    assert "R_HOME" not in os.environ

Reply via email to