Skip to content

Commit 200201d

Browse files
Address PR review: robust guard test + preserve client.py re-exports
Follow-up to the lazy-thrift change addressing two review-bot findings. Medium (test robustness / CI false-positive): the guard test's child subprocess exited with code 1 on any failure, which is also Python's generic uncaught-exception code -- so an import failure was indistinguishable from "thrift was imported". This caused a false CI failure: in the "default deps" job (no pyarrow), importing `kernel.type_mapping` raises `ModuleNotFoundError: pyarrow` (exit 1), which the test misread as a thrift leak. It was also a false *pass* risk for the Thrift-backend counterpart test. The child now emits dedicated sentinel exit codes only after the import completes, captures stderr, and reports import failure separately; the parametrized test skips modules that can't import due to a missing optional dependency (rather than failing), while still detecting a genuine thrift leak. Low (back-compat): `client.py`'s `__getattr__` only re-exported `ThriftDatabricksClient`. Extend it to also lazily resolve the other names `client.py` historically exposed as importable (`ThriftResultSet`, `TOpenSessionResp`, `TSparkParameter`, `TOperationState`), so `from databricks.sql.client import <name>` keeps working without importing the `thrift` package at module load. Verified: importing `databricks.sql.client` still loads zero thrift modules; touching any re-export resolves correctly (and only then pulls thrift). Guard test passes with full deps (14/14) and correctly skips the kernel modules when pyarrow/kernel are absent. Signed-off-by: Vikrant Puppala <vikrant.puppala@databricks.com> Co-authored-by: Isaac Signed-off-by: Vikrant Puppala <vikrant.puppala@databricks.com>
1 parent 5ea26c2 commit 200201d

2 files changed

Lines changed: 106 additions & 23 deletions

File tree

src/databricks/sql/client.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -113,20 +113,29 @@
113113

114114

115115
def __getattr__(name: str) -> Any:
116-
"""Lazily resolve the Thrift backend class as a module attribute.
117-
118-
``client.py`` itself never instantiates ``ThriftDatabricksClient`` (the
119-
backend is chosen in ``Session.open``), but the name is exposed here as a
120-
module attribute so it can be resolved without importing the Apache Thrift
121-
``thrift`` package at module load -- which is what keeps the SEA/kernel
122-
connect path Thrift-free (see ``test_lazy_thrift_import``). It also
123-
preserves the long-standing test seam
124-
``patch("databricks.sql.client.ThriftDatabricksClient")``.
116+
"""Lazily resolve Thrift-related names that ``client.py`` used to expose as
117+
real top-level imports.
118+
119+
``client.py`` itself never instantiates these (the backend is chosen in
120+
``Session.open``), but they are resolved here as module attributes so
121+
``from databricks.sql.client import <name>`` keeps working for any existing
122+
caller -- and the ``patch("databricks.sql.client.ThriftDatabricksClient")``
123+
test seam is preserved -- without importing the Apache Thrift ``thrift``
124+
package at module load, which is what keeps the SEA/kernel connect path
125+
Thrift-free (see ``test_lazy_thrift_import``).
125126
"""
126127
if name == "ThriftDatabricksClient":
127128
from databricks.sql.backend.thrift_backend import ThriftDatabricksClient
128129

129130
return ThriftDatabricksClient
131+
if name == "ThriftResultSet":
132+
from databricks.sql.result_set import ThriftResultSet
133+
134+
return ThriftResultSet
135+
if name in ("TOpenSessionResp", "TSparkParameter", "TOperationState"):
136+
from databricks.sql.thrift_api.TCLIService import ttypes
137+
138+
return getattr(ttypes, name)
130139
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
131140

132141

tests/unit/test_lazy_thrift_import.py

Lines changed: 88 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,25 +21,76 @@
2121

2222
import pytest
2323

24+
# Sentinel exit codes emitted by the child *only after* the import completes,
25+
# so an interpreter crash / uncaught exception (Python's generic exit code 1)
26+
# can never be misread as a definitive "thrift (not) loaded" answer.
27+
_EXIT_IMPORTED_NO_THRIFT = 10
28+
_EXIT_IMPORTED_WITH_THRIFT = 11
29+
_EXIT_IMPORT_FAILED = 12
2430

25-
def _thrift_loaded_after_importing(module_name: str) -> bool:
26-
"""Import ``module_name`` in a clean subprocess and report whether the
27-
top-level ``thrift`` package ended up in ``sys.modules``."""
31+
32+
class _ImportProbeResult:
33+
"""Outcome of importing a module in a clean subprocess."""
34+
35+
def __init__(self, returncode: int, stderr: str):
36+
self.returncode = returncode
37+
self.stderr = stderr
38+
39+
@property
40+
def imported(self) -> bool:
41+
return self.returncode in (
42+
_EXIT_IMPORTED_NO_THRIFT,
43+
_EXIT_IMPORTED_WITH_THRIFT,
44+
)
45+
46+
@property
47+
def thrift_loaded(self) -> bool:
48+
return self.returncode == _EXIT_IMPORTED_WITH_THRIFT
49+
50+
@property
51+
def import_failed(self) -> bool:
52+
return self.returncode == _EXIT_IMPORT_FAILED
53+
54+
55+
def _probe_import(module_name: str) -> _ImportProbeResult:
56+
"""Import ``module_name`` in a clean subprocess and report, via a dedicated
57+
sentinel exit code, whether the top-level ``thrift`` package ended up in
58+
``sys.modules`` -- distinguishing that from an import failure (e.g. a
59+
missing optional dependency such as pyarrow), which is reported separately
60+
rather than being conflated with "thrift was imported"."""
2861
script = textwrap.dedent(
2962
f"""
3063
import sys
31-
import {module_name} # noqa: F401
32-
# Exit code 1 == thrift was imported, 0 == it was not.
33-
sys.exit(1 if "thrift" in sys.modules else 0)
64+
65+
try:
66+
import {module_name} # noqa: F401
67+
except BaseException:
68+
import traceback
69+
traceback.print_exc()
70+
sys.exit({_EXIT_IMPORT_FAILED})
71+
72+
sys.exit(
73+
{_EXIT_IMPORTED_WITH_THRIFT}
74+
if "thrift" in sys.modules
75+
else {_EXIT_IMPORTED_NO_THRIFT}
76+
)
3477
"""
3578
)
36-
result = subprocess.run([sys.executable, "-c", script])
37-
if result.returncode not in (0, 1):
79+
proc = subprocess.run(
80+
[sys.executable, "-c", script],
81+
capture_output=True,
82+
text=True,
83+
)
84+
if proc.returncode not in (
85+
_EXIT_IMPORTED_NO_THRIFT,
86+
_EXIT_IMPORTED_WITH_THRIFT,
87+
_EXIT_IMPORT_FAILED,
88+
):
3889
raise AssertionError(
39-
f"subprocess importing {module_name} failed with exit code "
40-
f"{result.returncode}"
90+
f"subprocess importing {module_name!r} exited with unexpected code "
91+
f"{proc.returncode}. stderr:\n{proc.stderr}"
4192
)
42-
return result.returncode == 1
93+
return _ImportProbeResult(proc.returncode, proc.stderr)
4394

4495

4596
# Modules on the connect()/execute() path that must stay Thrift-free so the
@@ -69,7 +120,21 @@ def test_module_does_not_import_thrift(module_name):
69120
This is what unblocks callers (e.g. Buck-based builds) that ship their own
70121
``thrift`` package and use only the SEA or kernel backend.
71122
"""
72-
assert not _thrift_loaded_after_importing(module_name), (
123+
result = _probe_import(module_name)
124+
125+
if result.import_failed:
126+
# A module that can't even be imported in this environment (typically a
127+
# missing *optional* dependency, e.g. pyarrow in the "default deps" CI
128+
# job) can't leak thrift. Skip rather than fail so this test stays
129+
# focused on the thrift invariant and doesn't double as an
130+
# optional-dependency presence check.
131+
pytest.skip(
132+
f"{module_name!r} could not be imported in this environment "
133+
f"(likely a missing optional dependency); import error:\n"
134+
f"{result.stderr}"
135+
)
136+
137+
assert not result.thrift_loaded, (
73138
f"Importing {module_name!r} pulled in the top-level 'thrift' package. "
74139
f"Something on this import chain grew a module-level "
75140
f"'from databricks.sql.thrift_api...' / 'import thrift' statement (or a "
@@ -83,8 +148,17 @@ def test_thrift_backend_still_imports_thrift():
83148
"""Sanity check the counterpart invariant: the Thrift backend legitimately
84149
depends on the Thrift runtime, so it must still import it. This guards
85150
against a future 'fix' that hides thrift so aggressively the Thrift path
86-
breaks."""
87-
assert _thrift_loaded_after_importing("databricks.sql.backend.thrift_backend"), (
151+
breaks.
152+
153+
A failure to import the module (as opposed to importing it without thrift)
154+
is surfaced explicitly rather than being treated as a pass."""
155+
result = _probe_import("databricks.sql.backend.thrift_backend")
156+
157+
assert result.imported, (
158+
"The Thrift backend could not be imported at all -- the Thrift code "
159+
f"path is broken. Import error:\n{result.stderr}"
160+
)
161+
assert result.thrift_loaded, (
88162
"The Thrift backend no longer imports the 'thrift' package; the Thrift "
89163
"code path is likely broken."
90164
)

0 commit comments

Comments
 (0)