Skip to content

Commit 4c12a13

Browse files
authored
fix: Use DataprocCommandRunner to run "pip install" (#187)
1 parent 252fe26 commit 4c12a13

2 files changed

Lines changed: 152 additions & 39 deletions

File tree

‎google/cloud/managed_spark_magics/magics.py‎

Lines changed: 73 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,20 @@
1414

1515
"""Managed Spark magic implementations."""
1616

17+
import re
1718
import shlex
1819
from IPython.core.magic import (Magics, magics_class, line_magic)
1920

2021

2122
@magics_class
2223
class ManagedSparkMagics(Magics):
2324

25+
PIP_INSTALL_FAILURE_MSG = "Pip install failed with non-zero exit code"
26+
DATAPROC_COMMAND_RUNNER = (
27+
"org.apache.spark.sql.artifact.DataprocCommandRunner"
28+
)
29+
PIP_INSTALL_COMMAND = "PipInstallPackages"
30+
2431
def __init__(
2532
self,
2633
shell,
@@ -34,47 +41,83 @@ def dpip(self, line):
3441
Custom magic to install pip packages as Spark Connect artifacts.
3542
Usage: %dpip install pandas numpy
3643
"""
44+
try:
45+
args = shlex.split(line)
46+
packages, session = self._check_preconditions(args)
47+
48+
print(f"Installing packages: {packages}")
49+
output = self._run_command(packages, session)
50+
51+
failure_match = re.search(self.PIP_INSTALL_FAILURE_MSG, output)
52+
if failure_match:
53+
raise RuntimeError(output)
54+
55+
print(output)
56+
print("Finished installing packages.")
57+
except Exception as e:
58+
raise RuntimeError(f"Failed to install packages: {e}") from e
59+
60+
def _check_preconditions(self, args):
3761
# Imported here rather than at module scope: google.cloud
3862
# .managed_spark_connect loads this extension from its own __init__,
3963
# so a module-level import back into it would be circular.
4064
from google.cloud.managed_spark_connect import ManagedSparkSession
4165

42-
try:
43-
args = shlex.split(line)
66+
if not args or args[0] != "install":
67+
raise RuntimeError("Usage: %dpip install <package1> <package2> ...")
4468

45-
if not args or args[0] != "install":
46-
raise RuntimeError(
47-
"Usage: %dpip install <package1> <package2> ..."
48-
)
69+
packages = args[1:] # remove `install`
4970

50-
packages = args[1:] # remove `install`
71+
if not packages:
72+
raise RuntimeError("Error: No packages specified.")
5173

52-
if not packages:
53-
raise RuntimeError("Error: No packages specified.")
74+
if any(pkg.startswith("-") for pkg in packages):
75+
raise RuntimeError("Error: Flags are not currently supported.")
5476

55-
if any(pkg.startswith("-") for pkg in packages):
56-
raise RuntimeError("Error: Flags are not currently supported.")
77+
sessions = [
78+
(key, value)
79+
for key, value in self.shell.user_ns.items()
80+
if isinstance(value, ManagedSparkSession)
81+
]
5782

58-
sessions = [
59-
(key, value)
60-
for key, value in self.shell.user_ns.items()
61-
if isinstance(value, ManagedSparkSession)
62-
]
83+
if not sessions:
84+
raise RuntimeError(
85+
"Error: No active Managed Spark Session found. Please create one first."
86+
)
87+
if len(sessions) > 1:
88+
raise RuntimeError(
89+
"Error: Found more than one active Managed Spark Sessions."
90+
)
6391

64-
if not sessions:
65-
raise RuntimeError(
66-
"Error: No active Managed Spark Session found. Please create one first."
67-
)
68-
if len(sessions) > 1:
69-
raise RuntimeError(
70-
"Error: Found more than one active Managed Spark Sessions."
71-
)
92+
((name, session),) = sessions
93+
print(f"Active session found: {name}")
94+
return packages, session
7295

73-
((name, session),) = sessions
74-
print(f"Active session found: {name}")
75-
print(f"Installing packages: {packages}")
76-
session.addArtifacts(*packages, pypi=True)
96+
def _run_command(self, packages, session):
97+
# Imported here rather than at module scope, like the package's other
98+
# pyspark imports: PySpark (and with it pyarrow) is an optional extra,
99+
# and this extension must still load without it.
100+
import pyarrow as pa
101+
import pyspark.sql.connect.proto as pb2
77102

78-
print("Finished installing packages.")
103+
command = pb2.Command()
104+
command.execute_external_command.runner = self.DATAPROC_COMMAND_RUNNER
105+
command.execute_external_command.command = self.PIP_INSTALL_COMMAND
106+
107+
for index, package in enumerate(packages):
108+
command.execute_external_command.options[str(index)] = package
109+
110+
_, properties, _ = session.client.execute_command(command)
111+
112+
try:
113+
binary_data = properties["sql_command_result"].local_relation.data
114+
115+
# decode the Arrow stream and return the output
116+
table = pa.ipc.RecordBatchStreamReader(binary_data).read_all()
117+
return "\n".join(table.column(0).to_pylist())
118+
except (KeyError, AttributeError) as e:
119+
raise RuntimeError(
120+
"Unexpected response structure: missing binary data."
121+
) from e
79122
except Exception as e:
80-
raise RuntimeError(f"Failed to install packages: {e}") from e
123+
raise RuntimeError(f"Error decoding Arrow data: {e}") from e

‎tests/unit/managed_spark_magics/test_magics.py‎

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
from contextlib import redirect_stdout
1818
from unittest import mock
1919

20+
import pyarrow as pa
21+
import pyspark.sql.connect.proto as pb2
2022
from google.cloud.managed_spark_connect import ManagedSparkSession
2123
from google.cloud.managed_spark_magics import ManagedSparkMagics
2224
from IPython.core.interactiveshell import InteractiveShell
@@ -31,6 +33,14 @@ def setUp(self):
3133
self.shell.config = Config()
3234
self.magics = ManagedSparkMagics(shell=self.shell)
3335

36+
def _create_mock_arrow_binary(self, lines: list[str]) -> bytes:
37+
schema = pa.schema([pa.field("output", pa.string())])
38+
table = pa.Table.from_arrays([lines], schema=schema)
39+
sink = pa.BufferOutputStream()
40+
with pa.ipc.RecordBatchStreamWriter(sink, table.schema) as writer:
41+
writer.write_table(table)
42+
return sink.getvalue()
43+
3444
def test_dpip_with_flags(self):
3545
with self.assertRaisesRegex(
3646
RuntimeError, "Error: Flags are not currently supported."
@@ -74,29 +84,89 @@ def test_dpip_no_packages_specified(self):
7484

7585
def test_dpip_install_packages_success(self):
7686
mock_session = mock.Mock(spec=ManagedSparkSession)
87+
mock_session.client = mock.Mock()
88+
89+
# Create a mock for the properties object
90+
properties = mock.Mock()
91+
92+
# Create a pyarrow table and serialize it
93+
binary_data = self._create_mock_arrow_binary(
94+
["Collecting pandas", "Successfully installed pandas"]
95+
)
96+
97+
# Set up the mock response structure
98+
properties.sql_command_result.local_relation.data = binary_data
99+
mock_session.client.execute_command.return_value = (
100+
None,
101+
{"sql_command_result": properties.sql_command_result},
102+
None,
103+
)
104+
77105
self.shell.user_ns["spark"] = mock_session
78106

79107
f = io.StringIO()
80108
with redirect_stdout(f):
81109
self.magics.dpip("install pandas numpy")
82110

83-
mock_session.addArtifacts.assert_called_once_with(
84-
"pandas", "numpy", pypi=True
111+
# Check that execute_command was called
112+
mock_session.client.execute_command.assert_called_once()
113+
call_args = mock_session.client.execute_command.call_args[0][0]
114+
self.assertIsInstance(call_args, pb2.Command)
115+
self.assertEqual(
116+
call_args.execute_external_command.command,
117+
ManagedSparkMagics.PIP_INSTALL_COMMAND,
118+
)
119+
self.assertEqual(
120+
call_args.execute_external_command.options["0"], "pandas"
121+
)
122+
self.assertEqual(
123+
call_args.execute_external_command.options["1"], "numpy"
85124
)
86-
self.assertEqual(mock_session.addArtifacts.call_count, 1)
87-
self.assertIn("Finished installing packages.", f.getvalue())
88125

89-
def test_dpip_add_artifacts_fails(self):
126+
output = f.getvalue()
127+
self.assertIn("Installing packages: ['pandas', 'numpy']", output)
128+
self.assertIn("Collecting pandas", output)
129+
self.assertIn("Successfully installed pandas", output)
130+
self.assertIn("Finished installing packages.", output)
131+
132+
def test_dpip_install_failure(self):
90133
mock_session = mock.Mock(spec=ManagedSparkSession)
91-
mock_session.addArtifacts.side_effect = Exception("Failed")
134+
mock_session.client = mock.Mock()
135+
136+
# Create a mock for the properties object with failure message
137+
properties = mock.Mock()
138+
binary_data = self._create_mock_arrow_binary(
139+
[
140+
ManagedSparkMagics.PIP_INSTALL_FAILURE_MSG,
141+
"ERROR: some pip error",
142+
]
143+
)
144+
145+
properties.sql_command_result.local_relation.data = binary_data
146+
mock_session.client.execute_command.return_value = (
147+
None,
148+
{"sql_command_result": properties.sql_command_result},
149+
None,
150+
)
151+
92152
self.shell.user_ns["spark"] = mock_session
93153

94154
with self.assertRaisesRegex(
95-
RuntimeError, "Failed to install packages: Failed"
155+
RuntimeError, "Failed to install packages: Pip install failed"
96156
):
97-
self.magics.dpip("install pandas")
157+
self.magics.dpip("install non-existent-package")
158+
159+
def test_dpip_unexpected_response(self):
160+
mock_session = mock.Mock(spec=ManagedSparkSession)
161+
mock_session.client = mock.Mock()
162+
# Return response without 'sql_command_result'
163+
mock_session.client.execute_command.return_value = (None, {}, None)
164+
self.shell.user_ns["spark"] = mock_session
98165

99-
mock_session.addArtifacts.assert_called_once_with("pandas", pypi=True)
166+
with self.assertRaisesRegex(
167+
RuntimeError, "Unexpected response structure: missing binary data"
168+
):
169+
self.magics.dpip("install pandas")
100170

101171

102172
if __name__ == "__main__":

0 commit comments

Comments
 (0)