From 9d30ccfa79fd34570a080d3a2bb90d516f2ee370 Mon Sep 17 00:00:00 2001 From: Andrew Ma <136692+ajma@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:19:52 -0700 Subject: [PATCH 01/10] feat: use vscode:// deep links for session URLs in VS Code When running inside VS Code, session detail links now point to the googlecloudtools.datacloud extension's session view instead of the Cloud Console, for the session-creation message, existing-session reattach message, and the notebook repr's session link. --- google/cloud/managed_spark_connect/session.py | 22 ++++- tests/unit/test_session.py | 99 +++++++++++++++++++ 2 files changed, 118 insertions(+), 3 deletions(-) diff --git a/google/cloud/managed_spark_connect/session.py b/google/cloud/managed_spark_connect/session.py index 76d2700..4caca36 100644 --- a/google/cloud/managed_spark_connect/session.py +++ b/google/cloud/managed_spark_connect/session.py @@ -71,6 +71,14 @@ "https://console.cloud.google.com/dataproc/interactive" ) +_VSCODE_SESSION_URI_BASE = "vscode://googlecloudtools.datacloud/dataproc/sessions" + + +def _build_session_details_url(region: str, project_id: str, session_id: str) -> str: + if environment.is_vscode(): + return f"{_VSCODE_SESSION_URI_BASE}/{session_id}?project={project_id}&location={region}" + return f"{_MANAGED_SPARK_SESSIONS_BASE_URL}/{region}/{session_id}?project={project_id}" + def _is_valid_label_value(value: str) -> bool: """ @@ -506,7 +514,9 @@ def _wait_for_session_available( ) def _display_session_link_on_creation(self, session_id): - session_url = f"{_MANAGED_SPARK_SESSIONS_BASE_URL}/{self._region}/{session_id}?project={self._project_id}" + session_url = _build_session_details_url( + self._region, self._project_id, session_id + ) plain_message = ( f"Creating Managed Spark Connect Session: {session_url}" ) @@ -573,8 +583,11 @@ def _get_exiting_active_session( session = ManagedSparkSession._default_session if session_response is not None: + session_url = _build_session_details_url( + self._region, self._project_id, s8s_session_id + ) print( - f"Using existing Managed Spark Session (configuration changes may not be applied): {_MANAGED_SPARK_SESSIONS_BASE_URL}/{self._region}/{s8s_session_id}?project={self._project_id}" + f"Using existing Managed Spark Session (configuration changes may not be applied): {session_url}" ) self._display_view_session_details_button(s8s_session_id) if session is None: @@ -1109,11 +1122,14 @@ def _repr_html_(self) -> str: s8s_session = f"{_MANAGED_SPARK_SESSIONS_BASE_URL}/{self._region}/{self._active_s8s_session_id}" ui = f"{s8s_session}/sparkApplications/applications" + session_url = _build_session_details_url( + self._region, self._project_id, self._active_s8s_session_id + ) return f"""

Spark Connect

-

Managed Spark Session

+

Managed Spark Session

Spark UI

""" diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py index a88bd7a..f82e026 100644 --- a/tests/unit/test_session.py +++ b/tests/unit/test_session.py @@ -1259,6 +1259,105 @@ def test_display_session_link_on_creation_not_colab_enterprise( self.assertIn("Creating Managed Spark Connect Session", html_output) self.assertIn("Managed Spark Session", html_output) + @mock.patch( + "IPython.core.interactiveshell.InteractiveShell.initialized", + return_value=True, + ) + @mock.patch("IPython.display.display") + def test_display_session_link_on_creation_vscode( + self, + mock_display, + _mock_ipy, + ): + mock.patch.dict( + os.environ, + { + "VSCODE_PID": "12345", + }, + ).start() + ManagedSparkSession.builder._display_session_link_on_creation( + "test_session" + ) + + mock_display.assert_called_once() + args, _ = mock_display.call_args + html_output = args[0].data + self.assertIn("Creating Managed Spark Connect Session", html_output) + self.assertIn( + "vscode://googlecloudtools.datacloud/dataproc/sessions/" + "test_session?project=test-project&location=test-region", + html_output, + ) + self.assertNotIn(_MANAGED_SPARK_SESSIONS_BASE_URL, html_output) + + @mock.patch.object(ManagedSparkSession, "getActiveSession") + @mock.patch( + "google.cloud.managed_spark_connect.session.get_active_s8s_session_response" + ) + def test_get_exiting_active_session_prints_vscode_url( + self, + mock_get_response, + mock_get_active_session, + ): + mock.patch.dict( + os.environ, + { + "VSCODE_PID": "12345", + }, + ).start() + mock_get_response.return_value = mock.Mock() + mock_get_active_session.return_value = mock.Mock() + ManagedSparkSession._active_s8s_session_id = "test_session" + self.addCleanup( + setattr, ManagedSparkSession, "_active_s8s_session_id", None + ) + + with mock.patch("builtins.print") as mock_print: + ManagedSparkSession.builder._get_exiting_active_session() + + printed = "\n".join( + str(call.args[0]) for call in mock_print.call_args_list + ) + self.assertIn( + "vscode://googlecloudtools.datacloud/dataproc/sessions/" + "test_session?project=test-project&location=test-region", + printed, + ) + self.assertNotIn(_MANAGED_SPARK_SESSIONS_BASE_URL, printed) + + def test_repr_html_uses_vscode_url_for_session_link(self): + mock.patch.dict( + os.environ, + { + "VSCODE_PID": "12345", + }, + ).start() + ManagedSparkSession._project_id = "test-project" + ManagedSparkSession._region = "test-region" + ManagedSparkSession._active_s8s_session_id = "test_session" + self.addCleanup( + setattr, ManagedSparkSession, "_project_id", None + ) + self.addCleanup(setattr, ManagedSparkSession, "_region", None) + self.addCleanup( + setattr, ManagedSparkSession, "_active_s8s_session_id", None + ) + + html = object.__new__(ManagedSparkSession)._repr_html_() + + self.assertIn( + '' + "Managed Spark Session", + html, + ) + self.assertIn( + f'' + "Spark UI", + html, + ) + def test_is_valid_label_value(self): # Valid label values self.assertTrue(_is_valid_label_value("valid-label-123")) From 18ed0d7c31ab08ef22d5150d9dac186c4a831bab Mon Sep 17 00:00:00 2001 From: Andrew Ma <136692+ajma@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:24:38 -0700 Subject: [PATCH 02/10] style: run pyink formatter --- google/cloud/managed_spark_connect/session.py | 8 ++++++-- tests/unit/test_session.py | 4 +--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/google/cloud/managed_spark_connect/session.py b/google/cloud/managed_spark_connect/session.py index 4caca36..b13c851 100644 --- a/google/cloud/managed_spark_connect/session.py +++ b/google/cloud/managed_spark_connect/session.py @@ -71,10 +71,14 @@ "https://console.cloud.google.com/dataproc/interactive" ) -_VSCODE_SESSION_URI_BASE = "vscode://googlecloudtools.datacloud/dataproc/sessions" +_VSCODE_SESSION_URI_BASE = ( + "vscode://googlecloudtools.datacloud/dataproc/sessions" +) -def _build_session_details_url(region: str, project_id: str, session_id: str) -> str: +def _build_session_details_url( + region: str, project_id: str, session_id: str +) -> str: if environment.is_vscode(): return f"{_VSCODE_SESSION_URI_BASE}/{session_id}?project={project_id}&location={region}" return f"{_MANAGED_SPARK_SESSIONS_BASE_URL}/{region}/{session_id}?project={project_id}" diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py index f82e026..02e8f9a 100644 --- a/tests/unit/test_session.py +++ b/tests/unit/test_session.py @@ -1335,9 +1335,7 @@ def test_repr_html_uses_vscode_url_for_session_link(self): ManagedSparkSession._project_id = "test-project" ManagedSparkSession._region = "test-region" ManagedSparkSession._active_s8s_session_id = "test_session" - self.addCleanup( - setattr, ManagedSparkSession, "_project_id", None - ) + self.addCleanup(setattr, ManagedSparkSession, "_project_id", None) self.addCleanup(setattr, ManagedSparkSession, "_region", None) self.addCleanup( setattr, ManagedSparkSession, "_active_s8s_session_id", None From 96ace49feb8df7abbbb45e21eb2e06f13cd457ca Mon Sep 17 00:00:00 2001 From: Andrew Ma <136692+ajma@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:30:47 -0700 Subject: [PATCH 03/10] refactor: build the Spark UI url fully in one place Address review feedback: the url was being assembled partly in _repr_html_ and partly inline in the ; build it in full where the other session-related urls are constructed. --- google/cloud/managed_spark_connect/session.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/google/cloud/managed_spark_connect/session.py b/google/cloud/managed_spark_connect/session.py index b13c851..2ce1e16 100644 --- a/google/cloud/managed_spark_connect/session.py +++ b/google/cloud/managed_spark_connect/session.py @@ -1124,17 +1124,20 @@ def _repr_html_(self) -> str:
No Active Managed Spark Session
""" - s8s_session = f"{_MANAGED_SPARK_SESSIONS_BASE_URL}/{self._region}/{self._active_s8s_session_id}" - ui = f"{s8s_session}/sparkApplications/applications" session_url = _build_session_details_url( self._region, self._project_id, self._active_s8s_session_id ) + ui = ( + f"{_MANAGED_SPARK_SESSIONS_BASE_URL}/{self._region}/" + f"{self._active_s8s_session_id}/sparkApplications/applications" + f"?project={self._project_id}" + ) return f"""

Spark Connect

Managed Spark Session

-

Spark UI

+

Spark UI

""" From ac1889dbba0630cd16b049f55a8a18c097e03e96 Mon Sep 17 00:00:00 2001 From: Andrew Ma <136692+ajma@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:38:48 -0700 Subject: [PATCH 04/10] fix: type _build_session_details_url params as Optional[str] region and project_id are always passed self._region/self._project_id, which are Optional[str] until a session connects. --- google/cloud/managed_spark_connect/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/google/cloud/managed_spark_connect/session.py b/google/cloud/managed_spark_connect/session.py index 2ce1e16..c074bf2 100644 --- a/google/cloud/managed_spark_connect/session.py +++ b/google/cloud/managed_spark_connect/session.py @@ -77,7 +77,7 @@ def _build_session_details_url( - region: str, project_id: str, session_id: str + region: Optional[str], project_id: Optional[str], session_id: str ) -> str: if environment.is_vscode(): return f"{_VSCODE_SESSION_URI_BASE}/{session_id}?project={project_id}&location={region}" From 7f3feb49fab03fb813cfa6fd14283d303efb8ad1 Mon Sep 17 00:00:00 2001 From: Andrew Ma <136692+ajma@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:45:19 -0700 Subject: [PATCH 05/10] style: rename repr_html ui var to ssui_url per review nit --- google/cloud/managed_spark_connect/session.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/google/cloud/managed_spark_connect/session.py b/google/cloud/managed_spark_connect/session.py index c074bf2..bb5d813 100644 --- a/google/cloud/managed_spark_connect/session.py +++ b/google/cloud/managed_spark_connect/session.py @@ -1127,7 +1127,7 @@ def _repr_html_(self) -> str: session_url = _build_session_details_url( self._region, self._project_id, self._active_s8s_session_id ) - ui = ( + ssui_url = ( f"{_MANAGED_SPARK_SESSIONS_BASE_URL}/{self._region}/" f"{self._active_s8s_session_id}/sparkApplications/applications" f"?project={self._project_id}" @@ -1137,7 +1137,7 @@ def _repr_html_(self) -> str:

Spark Connect

Managed Spark Session

-

Spark UI

+

Spark UI

""" From 728f3419f00c9b37d424d56f3c45ab0d4c4720e5 Mon Sep 17 00:00:00 2001 From: Andrew Ma <136692+ajma@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:47:57 -0700 Subject: [PATCH 06/10] fix: fall back to console url if the vscode extension isn't installed The vscode:// deep link only resolves if googlecloudtools.datacloud is installed; otherwise VS Code shows a generic, confusing error. Check via `code --list-extensions` and fall back to the Cloud Console url when it's not present or the check can't run. --- .../managed_spark_connect/environment.py | 30 +++++++++++ google/cloud/managed_spark_connect/session.py | 9 ++-- tests/unit/test_environment.py | 38 +++++++++++++ tests/unit/test_session.py | 53 ++++++++++++++++++- 4 files changed, 125 insertions(+), 5 deletions(-) diff --git a/google/cloud/managed_spark_connect/environment.py b/google/cloud/managed_spark_connect/environment.py index e19dd97..bfb8135 100644 --- a/google/cloud/managed_spark_connect/environment.py +++ b/google/cloud/managed_spark_connect/environment.py @@ -12,7 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +import functools import os +import subprocess import sys from typing import Callable, Tuple, List @@ -27,6 +29,34 @@ def is_vscode() -> bool: return os.getenv("VSCODE_PID") is not None +@functools.lru_cache(maxsize=1) +def _installed_vscode_extensions() -> frozenset: + try: + result = subprocess.run( + ["code", "--list-extensions"], + capture_output=True, + text=True, + timeout=5, + check=True, + ) + except (OSError, subprocess.SubprocessError): + return frozenset() + return frozenset( + line.strip().lower() + for line in result.stdout.splitlines() + if line.strip() + ) + + +def is_vscode_extension_installed(extension_id: str) -> bool: + """True if the given VS Code extension id is installed. + + Fails closed (returns False) if the `code` CLI is unavailable, times + out, or exits non-zero. + """ + return extension_id.lower() in _installed_vscode_extensions() + + def is_jupyter() -> bool: """True if running in a Jupyter environment.""" return os.getenv("JPY_PARENT_PID") is not None diff --git a/google/cloud/managed_spark_connect/session.py b/google/cloud/managed_spark_connect/session.py index bb5d813..258605a 100644 --- a/google/cloud/managed_spark_connect/session.py +++ b/google/cloud/managed_spark_connect/session.py @@ -71,15 +71,16 @@ "https://console.cloud.google.com/dataproc/interactive" ) -_VSCODE_SESSION_URI_BASE = ( - "vscode://googlecloudtools.datacloud/dataproc/sessions" -) +_VSCODE_EXTENSION_ID = "googlecloudtools.datacloud" +_VSCODE_SESSION_URI_BASE = f"vscode://{_VSCODE_EXTENSION_ID}/dataproc/sessions" def _build_session_details_url( region: Optional[str], project_id: Optional[str], session_id: str ) -> str: - if environment.is_vscode(): + if environment.is_vscode() and environment.is_vscode_extension_installed( + _VSCODE_EXTENSION_ID + ): return f"{_VSCODE_SESSION_URI_BASE}/{session_id}?project={project_id}&location={region}" return f"{_MANAGED_SPARK_SESSIONS_BASE_URL}/{region}/{session_id}?project={project_id}" diff --git a/tests/unit/test_environment.py b/tests/unit/test_environment.py index d8ef2d6..62c994c 100644 --- a/tests/unit/test_environment.py +++ b/tests/unit/test_environment.py @@ -14,6 +14,7 @@ import os import importlib +import subprocess import unittest from unittest import mock @@ -54,6 +55,43 @@ def test_is_vscode_false(self): os.environ.pop("VSCODE_PID", None) self.assertFalse(environment.is_vscode()) + @mock.patch("google.cloud.managed_spark_connect.environment.subprocess.run") + def test_is_vscode_extension_installed_true(self, mock_run): + mock_run.return_value = subprocess.CompletedProcess( + args=["code", "--list-extensions"], + returncode=0, + stdout="ms-python.python\ngooglecloudtools.datacloud\n", + ) + self.assertTrue( + environment.is_vscode_extension_installed( + "googlecloudtools.datacloud" + ) + ) + + @mock.patch("google.cloud.managed_spark_connect.environment.subprocess.run") + def test_is_vscode_extension_installed_false_not_listed(self, mock_run): + mock_run.return_value = subprocess.CompletedProcess( + args=["code", "--list-extensions"], + returncode=0, + stdout="ms-python.python\n", + ) + self.assertFalse( + environment.is_vscode_extension_installed( + "googlecloudtools.datacloud" + ) + ) + + @mock.patch("google.cloud.managed_spark_connect.environment.subprocess.run") + def test_is_vscode_extension_installed_false_code_cli_missing( + self, mock_run + ): + mock_run.side_effect = FileNotFoundError() + self.assertFalse( + environment.is_vscode_extension_installed( + "googlecloudtools.datacloud" + ) + ) + def test_is_jupyter_true(self): os.environ["JPY_PARENT_PID"] = "67890" self.assertTrue(environment.is_jupyter()) diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py index 02e8f9a..c74e8d4 100644 --- a/tests/unit/test_session.py +++ b/tests/unit/test_session.py @@ -1259,6 +1259,10 @@ def test_display_session_link_on_creation_not_colab_enterprise( self.assertIn("Creating Managed Spark Connect Session", html_output) self.assertIn("Managed Spark Session", html_output) + @mock.patch( + "google.cloud.managed_spark_connect.session.environment.is_vscode_extension_installed", + return_value=True, + ) @mock.patch( "IPython.core.interactiveshell.InteractiveShell.initialized", return_value=True, @@ -1268,6 +1272,7 @@ def test_display_session_link_on_creation_vscode( self, mock_display, _mock_ipy, + _mock_ext_installed, ): mock.patch.dict( os.environ, @@ -1290,6 +1295,45 @@ def test_display_session_link_on_creation_vscode( ) self.assertNotIn(_MANAGED_SPARK_SESSIONS_BASE_URL, html_output) + @mock.patch( + "google.cloud.managed_spark_connect.session.environment.is_vscode_extension_installed", + return_value=False, + ) + @mock.patch( + "IPython.core.interactiveshell.InteractiveShell.initialized", + return_value=True, + ) + @mock.patch("IPython.display.display") + def test_display_session_link_on_creation_vscode_extension_not_installed( + self, + mock_display, + _mock_ipy, + _mock_ext_installed, + ): + mock.patch.dict( + os.environ, + { + "VSCODE_PID": "12345", + }, + ).start() + ManagedSparkSession.builder._display_session_link_on_creation( + "test_session" + ) + + mock_display.assert_called_once() + args, _ = mock_display.call_args + html_output = args[0].data + self.assertIn( + f"{_MANAGED_SPARK_SESSIONS_BASE_URL}/test-region/" + "test_session?project=test-project", + html_output, + ) + self.assertNotIn("vscode://", html_output) + + @mock.patch( + "google.cloud.managed_spark_connect.session.environment.is_vscode_extension_installed", + return_value=True, + ) @mock.patch.object(ManagedSparkSession, "getActiveSession") @mock.patch( "google.cloud.managed_spark_connect.session.get_active_s8s_session_response" @@ -1298,6 +1342,7 @@ def test_get_exiting_active_session_prints_vscode_url( self, mock_get_response, mock_get_active_session, + _mock_ext_installed, ): mock.patch.dict( os.environ, @@ -1325,7 +1370,13 @@ def test_get_exiting_active_session_prints_vscode_url( ) self.assertNotIn(_MANAGED_SPARK_SESSIONS_BASE_URL, printed) - def test_repr_html_uses_vscode_url_for_session_link(self): + @mock.patch( + "google.cloud.managed_spark_connect.session.environment.is_vscode_extension_installed", + return_value=True, + ) + def test_repr_html_uses_vscode_url_for_session_link( + self, _mock_ext_installed + ): mock.patch.dict( os.environ, { From eb358255f2ebd6472a10cb2a1976306502d82c66 Mon Sep 17 00:00:00 2001 From: Andrew Ma <136692+ajma@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:35:25 -0700 Subject: [PATCH 07/10] fix: fall back to scanning extensions dirs when code CLI is unavailable code may not be on PATH even when VS Code and the extension are installed (e.g. macOS before running "Shell Command: Install 'code' command in PATH", or a remote/SSH session using .vscode-server). Scan the on-disk extensions directories as a fallback so the vscode:// link still shows up in those cases. --- .../managed_spark_connect/environment.py | 36 +++++++++++- tests/unit/test_environment.py | 57 ++++++++++++++++++- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/google/cloud/managed_spark_connect/environment.py b/google/cloud/managed_spark_connect/environment.py index bfb8135..2a0b8bd 100644 --- a/google/cloud/managed_spark_connect/environment.py +++ b/google/cloud/managed_spark_connect/environment.py @@ -48,13 +48,43 @@ def _installed_vscode_extensions() -> frozenset: ) +def _vscode_extensions_dirs() -> Tuple[str, ...]: + home = os.path.expanduser("~") + return ( + os.path.join(home, ".vscode", "extensions"), + os.path.join(home, ".vscode-server", "extensions"), + os.path.join(home, ".vscode-insiders", "extensions"), + os.path.join(home, ".vscode-server-insiders", "extensions"), + ) + + +@functools.lru_cache(maxsize=None) +def _is_extension_dir_present(extension_id: str) -> bool: + prefix = f"{extension_id}-" + for extensions_dir in _vscode_extensions_dirs(): + try: + entries = os.listdir(extensions_dir) + except OSError: + continue + if any(name.lower().startswith(prefix) for name in entries): + return True + return False + + def is_vscode_extension_installed(extension_id: str) -> bool: """True if the given VS Code extension id is installed. - Fails closed (returns False) if the `code` CLI is unavailable, times - out, or exits non-zero. + Checks via the `code` CLI first, falling back to scanning the + on-disk extensions directories directly, since `code` may not be on + PATH even when VS Code and the extension are installed (e.g. macOS + without "Shell Command: Install 'code' command in PATH" run, or a + remote/SSH session). Fails closed (returns False) if neither check + finds it. """ - return extension_id.lower() in _installed_vscode_extensions() + extension_id = extension_id.lower() + return extension_id in _installed_vscode_extensions() or ( + _is_extension_dir_present(extension_id) + ) def is_jupyter() -> bool: diff --git a/tests/unit/test_environment.py b/tests/unit/test_environment.py index 62c994c..ee380a2 100644 --- a/tests/unit/test_environment.py +++ b/tests/unit/test_environment.py @@ -55,43 +55,94 @@ def test_is_vscode_false(self): os.environ.pop("VSCODE_PID", None) self.assertFalse(environment.is_vscode()) + @mock.patch("google.cloud.managed_spark_connect.environment.os.listdir") @mock.patch("google.cloud.managed_spark_connect.environment.subprocess.run") - def test_is_vscode_extension_installed_true(self, mock_run): + def test_is_vscode_extension_installed_true_via_cli( + self, mock_run, mock_listdir + ): mock_run.return_value = subprocess.CompletedProcess( args=["code", "--list-extensions"], returncode=0, stdout="ms-python.python\ngooglecloudtools.datacloud\n", ) + mock_listdir.side_effect = OSError() self.assertTrue( environment.is_vscode_extension_installed( "googlecloudtools.datacloud" ) ) + @mock.patch("google.cloud.managed_spark_connect.environment.os.listdir") @mock.patch("google.cloud.managed_spark_connect.environment.subprocess.run") - def test_is_vscode_extension_installed_false_not_listed(self, mock_run): + def test_is_vscode_extension_installed_false_not_listed( + self, mock_run, mock_listdir + ): mock_run.return_value = subprocess.CompletedProcess( args=["code", "--list-extensions"], returncode=0, stdout="ms-python.python\n", ) + mock_listdir.side_effect = OSError() self.assertFalse( environment.is_vscode_extension_installed( "googlecloudtools.datacloud" ) ) + @mock.patch("google.cloud.managed_spark_connect.environment.os.listdir") @mock.patch("google.cloud.managed_spark_connect.environment.subprocess.run") def test_is_vscode_extension_installed_false_code_cli_missing( - self, mock_run + self, mock_run, mock_listdir ): mock_run.side_effect = FileNotFoundError() + mock_listdir.side_effect = OSError() self.assertFalse( environment.is_vscode_extension_installed( "googlecloudtools.datacloud" ) ) + @mock.patch("google.cloud.managed_spark_connect.environment.os.listdir") + @mock.patch("google.cloud.managed_spark_connect.environment.subprocess.run") + def test_is_vscode_extension_installed_true_via_disk_scan( + self, mock_run, mock_listdir + ): + mock_run.side_effect = FileNotFoundError() + + def fake_listdir(path): + if path.endswith(os.path.join(".vscode", "extensions")): + return [ + "ms-python.python-2024.1.0", + "googlecloudtools.datacloud-1.2.3", + ] + raise OSError() + + mock_listdir.side_effect = fake_listdir + self.assertTrue( + environment.is_vscode_extension_installed( + "googlecloudtools.datacloud" + ) + ) + + @mock.patch("google.cloud.managed_spark_connect.environment.os.listdir") + @mock.patch("google.cloud.managed_spark_connect.environment.subprocess.run") + def test_is_vscode_extension_installed_true_via_remote_extensions_dir( + self, mock_run, mock_listdir + ): + mock_run.side_effect = FileNotFoundError() + + def fake_listdir(path): + if path.endswith(os.path.join(".vscode-server", "extensions")): + return ["googlecloudtools.datacloud-1.2.3"] + raise OSError() + + mock_listdir.side_effect = fake_listdir + self.assertTrue( + environment.is_vscode_extension_installed( + "googlecloudtools.datacloud" + ) + ) + def test_is_jupyter_true(self): os.environ["JPY_PARENT_PID"] = "67890" self.assertTrue(environment.is_jupyter()) From 6bb6ffa9e34ffab6c47444e5983b2dea05a9f72e Mon Sep 17 00:00:00 2001 From: Andrew Ma <136692+ajma@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:08:48 -0700 Subject: [PATCH 08/10] fix: ignore the code CLI check on a vscode-server (Remote-SSH) backend On a remote backend, code runs through a client-forwarding shim whose --list-extensions behavior isn't reliable, so only the on-disk ~/.vscode-server/extensions scan is trusted there. --- .../managed_spark_connect/environment.py | 20 ++++++-- tests/unit/test_environment.py | 51 ++++++++++++++++--- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/google/cloud/managed_spark_connect/environment.py b/google/cloud/managed_spark_connect/environment.py index 2a0b8bd..eb9bdf2 100644 --- a/google/cloud/managed_spark_connect/environment.py +++ b/google/cloud/managed_spark_connect/environment.py @@ -71,17 +71,31 @@ def _is_extension_dir_present(extension_id: str) -> bool: return False +def _is_remote_vscode_server() -> bool: + """True if this process is running as a vscode-server backend + (Remote-SSH, Tunnels, Dev Containers), as opposed to local desktop + VS Code. + """ + return os.path.isdir( + os.path.join(os.path.expanduser("~"), ".vscode-server") + ) + + def is_vscode_extension_installed(extension_id: str) -> bool: """True if the given VS Code extension id is installed. Checks via the `code` CLI first, falling back to scanning the on-disk extensions directories directly, since `code` may not be on PATH even when VS Code and the extension are installed (e.g. macOS - without "Shell Command: Install 'code' command in PATH" run, or a - remote/SSH session). Fails closed (returns False) if neither check - finds it. + without "Shell Command: Install 'code' command in PATH" run). On a + vscode-server (Remote-SSH) backend, `code` runs through a + client-forwarding shim with unreliable `--list-extensions` behavior, + so only the disk scan is used there. Fails closed (returns False) + if nothing finds it. """ extension_id = extension_id.lower() + if _is_remote_vscode_server(): + return _is_extension_dir_present(extension_id) return extension_id in _installed_vscode_extensions() or ( _is_extension_dir_present(extension_id) ) diff --git a/tests/unit/test_environment.py b/tests/unit/test_environment.py index ee380a2..2a2ae2e 100644 --- a/tests/unit/test_environment.py +++ b/tests/unit/test_environment.py @@ -55,11 +55,13 @@ def test_is_vscode_false(self): os.environ.pop("VSCODE_PID", None) self.assertFalse(environment.is_vscode()) + @mock.patch("google.cloud.managed_spark_connect.environment.os.path.isdir") @mock.patch("google.cloud.managed_spark_connect.environment.os.listdir") @mock.patch("google.cloud.managed_spark_connect.environment.subprocess.run") def test_is_vscode_extension_installed_true_via_cli( - self, mock_run, mock_listdir + self, mock_run, mock_listdir, mock_isdir ): + mock_isdir.return_value = False mock_run.return_value = subprocess.CompletedProcess( args=["code", "--list-extensions"], returncode=0, @@ -72,11 +74,13 @@ def test_is_vscode_extension_installed_true_via_cli( ) ) + @mock.patch("google.cloud.managed_spark_connect.environment.os.path.isdir") @mock.patch("google.cloud.managed_spark_connect.environment.os.listdir") @mock.patch("google.cloud.managed_spark_connect.environment.subprocess.run") def test_is_vscode_extension_installed_false_not_listed( - self, mock_run, mock_listdir + self, mock_run, mock_listdir, mock_isdir ): + mock_isdir.return_value = False mock_run.return_value = subprocess.CompletedProcess( args=["code", "--list-extensions"], returncode=0, @@ -89,11 +93,13 @@ def test_is_vscode_extension_installed_false_not_listed( ) ) + @mock.patch("google.cloud.managed_spark_connect.environment.os.path.isdir") @mock.patch("google.cloud.managed_spark_connect.environment.os.listdir") @mock.patch("google.cloud.managed_spark_connect.environment.subprocess.run") def test_is_vscode_extension_installed_false_code_cli_missing( - self, mock_run, mock_listdir + self, mock_run, mock_listdir, mock_isdir ): + mock_isdir.return_value = False mock_run.side_effect = FileNotFoundError() mock_listdir.side_effect = OSError() self.assertFalse( @@ -102,11 +108,13 @@ def test_is_vscode_extension_installed_false_code_cli_missing( ) ) + @mock.patch("google.cloud.managed_spark_connect.environment.os.path.isdir") @mock.patch("google.cloud.managed_spark_connect.environment.os.listdir") @mock.patch("google.cloud.managed_spark_connect.environment.subprocess.run") def test_is_vscode_extension_installed_true_via_disk_scan( - self, mock_run, mock_listdir + self, mock_run, mock_listdir, mock_isdir ): + mock_isdir.return_value = False mock_run.side_effect = FileNotFoundError() def fake_listdir(path): @@ -124,12 +132,18 @@ def fake_listdir(path): ) ) + @mock.patch("google.cloud.managed_spark_connect.environment.os.path.isdir") @mock.patch("google.cloud.managed_spark_connect.environment.os.listdir") @mock.patch("google.cloud.managed_spark_connect.environment.subprocess.run") - def test_is_vscode_extension_installed_true_via_remote_extensions_dir( - self, mock_run, mock_listdir + def test_is_vscode_extension_installed_true_on_remote_server( + self, mock_run, mock_listdir, mock_isdir ): - mock_run.side_effect = FileNotFoundError() + mock_isdir.return_value = True + mock_run.return_value = subprocess.CompletedProcess( + args=["code", "--list-extensions"], + returncode=0, + stdout="", + ) def fake_listdir(path): if path.endswith(os.path.join(".vscode-server", "extensions")): @@ -143,6 +157,29 @@ def fake_listdir(path): ) ) + @mock.patch("google.cloud.managed_spark_connect.environment.os.path.isdir") + @mock.patch("google.cloud.managed_spark_connect.environment.os.listdir") + @mock.patch("google.cloud.managed_spark_connect.environment.subprocess.run") + def test_is_vscode_extension_installed_ignores_cli_on_remote_server( + self, mock_run, mock_listdir, mock_isdir + ): + # On a vscode-server backend, the `code` CLI runs through a + # client-forwarding shim with unreliable --list-extensions + # behavior, so its (misleading, here) positive result must be + # ignored in favor of the disk scan. + mock_isdir.return_value = True + mock_run.return_value = subprocess.CompletedProcess( + args=["code", "--list-extensions"], + returncode=0, + stdout="googlecloudtools.datacloud\n", + ) + mock_listdir.side_effect = OSError() + self.assertFalse( + environment.is_vscode_extension_installed( + "googlecloudtools.datacloud" + ) + ) + def test_is_jupyter_true(self): os.environ["JPY_PARENT_PID"] = "67890" self.assertTrue(environment.is_jupyter()) From 4eed13ddd65bcc8559c7c4860ee7dca79ee8a29d Mon Sep 17 00:00:00 2001 From: Andrew Ma <136692+ajma@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:49:37 -0700 Subject: [PATCH 09/10] refactor: always show both console and vscode links instead of detecting Reliably detecting whether the Data Analytics Kit extension is installed proved impractical across local VS Code, Remote-SSH, and forks. Simplify by always showing both the Cloud Console link and the vscode:// deep link whenever running inside VS Code, and let the extension's own URI handler be the point of failure if it's missing. --- .../managed_spark_connect/environment.py | 74 ---------- google/cloud/managed_spark_connect/session.py | 50 ++++--- tests/unit/test_environment.py | 126 ------------------ tests/unit/test_session.py | 85 +++++------- 4 files changed, 63 insertions(+), 272 deletions(-) diff --git a/google/cloud/managed_spark_connect/environment.py b/google/cloud/managed_spark_connect/environment.py index eb9bdf2..e19dd97 100644 --- a/google/cloud/managed_spark_connect/environment.py +++ b/google/cloud/managed_spark_connect/environment.py @@ -12,9 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -import functools import os -import subprocess import sys from typing import Callable, Tuple, List @@ -29,78 +27,6 @@ def is_vscode() -> bool: return os.getenv("VSCODE_PID") is not None -@functools.lru_cache(maxsize=1) -def _installed_vscode_extensions() -> frozenset: - try: - result = subprocess.run( - ["code", "--list-extensions"], - capture_output=True, - text=True, - timeout=5, - check=True, - ) - except (OSError, subprocess.SubprocessError): - return frozenset() - return frozenset( - line.strip().lower() - for line in result.stdout.splitlines() - if line.strip() - ) - - -def _vscode_extensions_dirs() -> Tuple[str, ...]: - home = os.path.expanduser("~") - return ( - os.path.join(home, ".vscode", "extensions"), - os.path.join(home, ".vscode-server", "extensions"), - os.path.join(home, ".vscode-insiders", "extensions"), - os.path.join(home, ".vscode-server-insiders", "extensions"), - ) - - -@functools.lru_cache(maxsize=None) -def _is_extension_dir_present(extension_id: str) -> bool: - prefix = f"{extension_id}-" - for extensions_dir in _vscode_extensions_dirs(): - try: - entries = os.listdir(extensions_dir) - except OSError: - continue - if any(name.lower().startswith(prefix) for name in entries): - return True - return False - - -def _is_remote_vscode_server() -> bool: - """True if this process is running as a vscode-server backend - (Remote-SSH, Tunnels, Dev Containers), as opposed to local desktop - VS Code. - """ - return os.path.isdir( - os.path.join(os.path.expanduser("~"), ".vscode-server") - ) - - -def is_vscode_extension_installed(extension_id: str) -> bool: - """True if the given VS Code extension id is installed. - - Checks via the `code` CLI first, falling back to scanning the - on-disk extensions directories directly, since `code` may not be on - PATH even when VS Code and the extension are installed (e.g. macOS - without "Shell Command: Install 'code' command in PATH" run). On a - vscode-server (Remote-SSH) backend, `code` runs through a - client-forwarding shim with unreliable `--list-extensions` behavior, - so only the disk scan is used there. Fails closed (returns False) - if nothing finds it. - """ - extension_id = extension_id.lower() - if _is_remote_vscode_server(): - return _is_extension_dir_present(extension_id) - return extension_id in _installed_vscode_extensions() or ( - _is_extension_dir_present(extension_id) - ) - - def is_jupyter() -> bool: """True if running in a Jupyter environment.""" return os.getenv("JPY_PARENT_PID") is not None diff --git a/google/cloud/managed_spark_connect/session.py b/google/cloud/managed_spark_connect/session.py index 258605a..807ec00 100644 --- a/google/cloud/managed_spark_connect/session.py +++ b/google/cloud/managed_spark_connect/session.py @@ -27,7 +27,7 @@ import tqdm from packaging import version from types import MethodType -from typing import Any, cast, ClassVar, Dict, Iterable, Optional, Union +from typing import Any, cast, ClassVar, Dict, Iterable, List, Optional, Tuple, Union from google.api_core import retry from google.api_core.client_options import ClientOptions @@ -71,18 +71,20 @@ "https://console.cloud.google.com/dataproc/interactive" ) -_VSCODE_EXTENSION_ID = "googlecloudtools.datacloud" -_VSCODE_SESSION_URI_BASE = f"vscode://{_VSCODE_EXTENSION_ID}/dataproc/sessions" +_VSCODE_SESSION_URI_BASE = ( + "vscode://googlecloudtools.datacloud/dataproc/sessions" +) -def _build_session_details_url( +def _build_session_details_links( region: Optional[str], project_id: Optional[str], session_id: str -) -> str: - if environment.is_vscode() and environment.is_vscode_extension_installed( - _VSCODE_EXTENSION_ID - ): - return f"{_VSCODE_SESSION_URI_BASE}/{session_id}?project={project_id}&location={region}" - return f"{_MANAGED_SPARK_SESSIONS_BASE_URL}/{region}/{session_id}?project={project_id}" +) -> List[Tuple[str, str]]: + console_url = f"{_MANAGED_SPARK_SESSIONS_BASE_URL}/{region}/{session_id}?project={project_id}" + links = [("Managed Spark Session", console_url)] + if environment.is_vscode(): + vscode_url = f"{_VSCODE_SESSION_URI_BASE}/{session_id}?project={project_id}&location={region}" + links.append(("Managed Spark Session (Data Analytics Kit)", vscode_url)) + return links def _is_valid_label_value(value: str) -> bool: @@ -519,11 +521,11 @@ def _wait_for_session_available( ) def _display_session_link_on_creation(self, session_id): - session_url = _build_session_details_url( + links = _build_session_details_links( self._region, self._project_id, session_id ) - plain_message = ( - f"Creating Managed Spark Connect Session: {session_url}" + plain_message = "Creating Managed Spark Connect Session:\n" + ( + "\n".join(f" {label}: {url}" for label, url in links) ) if environment.is_colab_enterprise(): html_element = f""" @@ -532,10 +534,14 @@ def _display_session_link_on_creation(self, session_id): """ else: + links_html = "\n".join( + f'

{label}

' + for label, url in links + ) html_element = f"""

Creating Managed Spark Connect Session

-

Managed Spark Session

+ {links_html}
""" self._output_element_or_message(plain_message, html_element) @@ -588,11 +594,15 @@ def _get_exiting_active_session( session = ManagedSparkSession._default_session if session_response is not None: - session_url = _build_session_details_url( + links = _build_session_details_links( self._region, self._project_id, s8s_session_id ) + links_message = "\n".join( + f" {label}: {url}" for label, url in links + ) print( - f"Using existing Managed Spark Session (configuration changes may not be applied): {session_url}" + "Using existing Managed Spark Session (configuration " + f"changes may not be applied):\n{links_message}" ) self._display_view_session_details_button(s8s_session_id) if session is None: @@ -1125,9 +1135,13 @@ def _repr_html_(self) -> str:
No Active Managed Spark Session
""" - session_url = _build_session_details_url( + session_links = _build_session_details_links( self._region, self._project_id, self._active_s8s_session_id ) + session_links_html = "\n".join( + f'

{label}

' + for label, url in session_links + ) ssui_url = ( f"{_MANAGED_SPARK_SESSIONS_BASE_URL}/{self._region}/" f"{self._active_s8s_session_id}/sparkApplications/applications" @@ -1137,7 +1151,7 @@ def _repr_html_(self) -> str:

Spark Connect

-

Managed Spark Session

+ {session_links_html}

Spark UI

""" diff --git a/tests/unit/test_environment.py b/tests/unit/test_environment.py index 2a2ae2e..d8ef2d6 100644 --- a/tests/unit/test_environment.py +++ b/tests/unit/test_environment.py @@ -14,7 +14,6 @@ import os import importlib -import subprocess import unittest from unittest import mock @@ -55,131 +54,6 @@ def test_is_vscode_false(self): os.environ.pop("VSCODE_PID", None) self.assertFalse(environment.is_vscode()) - @mock.patch("google.cloud.managed_spark_connect.environment.os.path.isdir") - @mock.patch("google.cloud.managed_spark_connect.environment.os.listdir") - @mock.patch("google.cloud.managed_spark_connect.environment.subprocess.run") - def test_is_vscode_extension_installed_true_via_cli( - self, mock_run, mock_listdir, mock_isdir - ): - mock_isdir.return_value = False - mock_run.return_value = subprocess.CompletedProcess( - args=["code", "--list-extensions"], - returncode=0, - stdout="ms-python.python\ngooglecloudtools.datacloud\n", - ) - mock_listdir.side_effect = OSError() - self.assertTrue( - environment.is_vscode_extension_installed( - "googlecloudtools.datacloud" - ) - ) - - @mock.patch("google.cloud.managed_spark_connect.environment.os.path.isdir") - @mock.patch("google.cloud.managed_spark_connect.environment.os.listdir") - @mock.patch("google.cloud.managed_spark_connect.environment.subprocess.run") - def test_is_vscode_extension_installed_false_not_listed( - self, mock_run, mock_listdir, mock_isdir - ): - mock_isdir.return_value = False - mock_run.return_value = subprocess.CompletedProcess( - args=["code", "--list-extensions"], - returncode=0, - stdout="ms-python.python\n", - ) - mock_listdir.side_effect = OSError() - self.assertFalse( - environment.is_vscode_extension_installed( - "googlecloudtools.datacloud" - ) - ) - - @mock.patch("google.cloud.managed_spark_connect.environment.os.path.isdir") - @mock.patch("google.cloud.managed_spark_connect.environment.os.listdir") - @mock.patch("google.cloud.managed_spark_connect.environment.subprocess.run") - def test_is_vscode_extension_installed_false_code_cli_missing( - self, mock_run, mock_listdir, mock_isdir - ): - mock_isdir.return_value = False - mock_run.side_effect = FileNotFoundError() - mock_listdir.side_effect = OSError() - self.assertFalse( - environment.is_vscode_extension_installed( - "googlecloudtools.datacloud" - ) - ) - - @mock.patch("google.cloud.managed_spark_connect.environment.os.path.isdir") - @mock.patch("google.cloud.managed_spark_connect.environment.os.listdir") - @mock.patch("google.cloud.managed_spark_connect.environment.subprocess.run") - def test_is_vscode_extension_installed_true_via_disk_scan( - self, mock_run, mock_listdir, mock_isdir - ): - mock_isdir.return_value = False - mock_run.side_effect = FileNotFoundError() - - def fake_listdir(path): - if path.endswith(os.path.join(".vscode", "extensions")): - return [ - "ms-python.python-2024.1.0", - "googlecloudtools.datacloud-1.2.3", - ] - raise OSError() - - mock_listdir.side_effect = fake_listdir - self.assertTrue( - environment.is_vscode_extension_installed( - "googlecloudtools.datacloud" - ) - ) - - @mock.patch("google.cloud.managed_spark_connect.environment.os.path.isdir") - @mock.patch("google.cloud.managed_spark_connect.environment.os.listdir") - @mock.patch("google.cloud.managed_spark_connect.environment.subprocess.run") - def test_is_vscode_extension_installed_true_on_remote_server( - self, mock_run, mock_listdir, mock_isdir - ): - mock_isdir.return_value = True - mock_run.return_value = subprocess.CompletedProcess( - args=["code", "--list-extensions"], - returncode=0, - stdout="", - ) - - def fake_listdir(path): - if path.endswith(os.path.join(".vscode-server", "extensions")): - return ["googlecloudtools.datacloud-1.2.3"] - raise OSError() - - mock_listdir.side_effect = fake_listdir - self.assertTrue( - environment.is_vscode_extension_installed( - "googlecloudtools.datacloud" - ) - ) - - @mock.patch("google.cloud.managed_spark_connect.environment.os.path.isdir") - @mock.patch("google.cloud.managed_spark_connect.environment.os.listdir") - @mock.patch("google.cloud.managed_spark_connect.environment.subprocess.run") - def test_is_vscode_extension_installed_ignores_cli_on_remote_server( - self, mock_run, mock_listdir, mock_isdir - ): - # On a vscode-server backend, the `code` CLI runs through a - # client-forwarding shim with unreliable --list-extensions - # behavior, so its (misleading, here) positive result must be - # ignored in favor of the disk scan. - mock_isdir.return_value = True - mock_run.return_value = subprocess.CompletedProcess( - args=["code", "--list-extensions"], - returncode=0, - stdout="googlecloudtools.datacloud\n", - ) - mock_listdir.side_effect = OSError() - self.assertFalse( - environment.is_vscode_extension_installed( - "googlecloudtools.datacloud" - ) - ) - def test_is_jupyter_true(self): os.environ["JPY_PARENT_PID"] = "67890" self.assertTrue(environment.is_jupyter()) diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py index c74e8d4..f9a905d 100644 --- a/tests/unit/test_session.py +++ b/tests/unit/test_session.py @@ -1259,10 +1259,6 @@ def test_display_session_link_on_creation_not_colab_enterprise( self.assertIn("Creating Managed Spark Connect Session", html_output) self.assertIn("Managed Spark Session", html_output) - @mock.patch( - "google.cloud.managed_spark_connect.session.environment.is_vscode_extension_installed", - return_value=True, - ) @mock.patch( "IPython.core.interactiveshell.InteractiveShell.initialized", return_value=True, @@ -1272,7 +1268,6 @@ def test_display_session_link_on_creation_vscode( self, mock_display, _mock_ipy, - _mock_ext_installed, ): mock.patch.dict( os.environ, @@ -1289,51 +1284,16 @@ def test_display_session_link_on_creation_vscode( html_output = args[0].data self.assertIn("Creating Managed Spark Connect Session", html_output) self.assertIn( - "vscode://googlecloudtools.datacloud/dataproc/sessions/" - "test_session?project=test-project&location=test-region", + f'Managed Spark Session', html_output, ) - self.assertNotIn(_MANAGED_SPARK_SESSIONS_BASE_URL, html_output) - - @mock.patch( - "google.cloud.managed_spark_connect.session.environment.is_vscode_extension_installed", - return_value=False, - ) - @mock.patch( - "IPython.core.interactiveshell.InteractiveShell.initialized", - return_value=True, - ) - @mock.patch("IPython.display.display") - def test_display_session_link_on_creation_vscode_extension_not_installed( - self, - mock_display, - _mock_ipy, - _mock_ext_installed, - ): - mock.patch.dict( - os.environ, - { - "VSCODE_PID": "12345", - }, - ).start() - ManagedSparkSession.builder._display_session_link_on_creation( - "test_session" - ) - - mock_display.assert_called_once() - args, _ = mock_display.call_args - html_output = args[0].data self.assertIn( - f"{_MANAGED_SPARK_SESSIONS_BASE_URL}/test-region/" - "test_session?project=test-project", + "vscode://googlecloudtools.datacloud/dataproc/sessions/" + "test_session?project=test-project&location=test-region", html_output, ) - self.assertNotIn("vscode://", html_output) - @mock.patch( - "google.cloud.managed_spark_connect.session.environment.is_vscode_extension_installed", - return_value=True, - ) @mock.patch.object(ManagedSparkSession, "getActiveSession") @mock.patch( "google.cloud.managed_spark_connect.session.get_active_s8s_session_response" @@ -1342,7 +1302,6 @@ def test_get_exiting_active_session_prints_vscode_url( self, mock_get_response, mock_get_active_session, - _mock_ext_installed, ): mock.patch.dict( os.environ, @@ -1363,20 +1322,18 @@ def test_get_exiting_active_session_prints_vscode_url( printed = "\n".join( str(call.args[0]) for call in mock_print.call_args_list ) + self.assertIn( + f"{_MANAGED_SPARK_SESSIONS_BASE_URL}/test-region/" + "test_session?project=test-project", + printed, + ) self.assertIn( "vscode://googlecloudtools.datacloud/dataproc/sessions/" "test_session?project=test-project&location=test-region", printed, ) - self.assertNotIn(_MANAGED_SPARK_SESSIONS_BASE_URL, printed) - @mock.patch( - "google.cloud.managed_spark_connect.session.environment.is_vscode_extension_installed", - return_value=True, - ) - def test_repr_html_uses_vscode_url_for_session_link( - self, _mock_ext_installed - ): + def test_repr_html_uses_vscode_url_for_session_link(self): mock.patch.dict( os.environ, { @@ -1394,10 +1351,15 @@ def test_repr_html_uses_vscode_url_for_session_link( html = object.__new__(ManagedSparkSession)._repr_html_() + self.assertIn( + f'Managed Spark Session', + html, + ) self.assertIn( '' - "Managed Spark Session", + "Managed Spark Session (Data Analytics Kit)", html, ) self.assertIn( @@ -1407,6 +1369,21 @@ def test_repr_html_uses_vscode_url_for_session_link( html, ) + def test_repr_html_no_vscode_link_when_not_in_vscode(self): + os.environ.pop("VSCODE_PID", None) + ManagedSparkSession._project_id = "test-project" + ManagedSparkSession._region = "test-region" + ManagedSparkSession._active_s8s_session_id = "test_session" + self.addCleanup(setattr, ManagedSparkSession, "_project_id", None) + self.addCleanup(setattr, ManagedSparkSession, "_region", None) + self.addCleanup( + setattr, ManagedSparkSession, "_active_s8s_session_id", None + ) + + html = object.__new__(ManagedSparkSession)._repr_html_() + + self.assertNotIn("vscode://", html) + def test_is_valid_label_value(self): # Valid label values self.assertTrue(_is_valid_label_value("valid-label-123")) From 4691d74efa012d18f72588f1c5cd88258a50ae92 Mon Sep 17 00:00:00 2001 From: Andrew Ma <136692+ajma@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:11:26 -0700 Subject: [PATCH 10/10] fix: label session links with their destination Rename the link labels to "Managed Spark Session (Cloud Console)" and "Managed Spark Session (Data Agent Kit)" so it's clear which link goes where. --- google/cloud/managed_spark_connect/session.py | 4 ++-- tests/unit/test_session.py | 11 ++++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/google/cloud/managed_spark_connect/session.py b/google/cloud/managed_spark_connect/session.py index 807ec00..4f76a49 100644 --- a/google/cloud/managed_spark_connect/session.py +++ b/google/cloud/managed_spark_connect/session.py @@ -80,10 +80,10 @@ def _build_session_details_links( region: Optional[str], project_id: Optional[str], session_id: str ) -> List[Tuple[str, str]]: console_url = f"{_MANAGED_SPARK_SESSIONS_BASE_URL}/{region}/{session_id}?project={project_id}" - links = [("Managed Spark Session", console_url)] + links = [("Managed Spark Session (Cloud Console)", console_url)] if environment.is_vscode(): vscode_url = f"{_VSCODE_SESSION_URI_BASE}/{session_id}?project={project_id}&location={region}" - links.append(("Managed Spark Session (Data Analytics Kit)", vscode_url)) + links.append(("Managed Spark Session (Data Agent Kit)", vscode_url)) return links diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py index f9a905d..dbd4811 100644 --- a/tests/unit/test_session.py +++ b/tests/unit/test_session.py @@ -1285,7 +1285,8 @@ def test_display_session_link_on_creation_vscode( self.assertIn("Creating Managed Spark Connect Session", html_output) self.assertIn( f'Managed Spark Session', + 'test_session?project=test-project">' + "Managed Spark Session (Cloud Console)", html_output, ) self.assertIn( @@ -1293,6 +1294,7 @@ def test_display_session_link_on_creation_vscode( "test_session?project=test-project&location=test-region", html_output, ) + self.assertIn("Managed Spark Session (Data Agent Kit)", html_output) @mock.patch.object(ManagedSparkSession, "getActiveSession") @mock.patch( @@ -1322,11 +1324,13 @@ def test_get_exiting_active_session_prints_vscode_url( printed = "\n".join( str(call.args[0]) for call in mock_print.call_args_list ) + self.assertIn("Managed Spark Session (Cloud Console)", printed) self.assertIn( f"{_MANAGED_SPARK_SESSIONS_BASE_URL}/test-region/" "test_session?project=test-project", printed, ) + self.assertIn("Managed Spark Session (Data Agent Kit)", printed) self.assertIn( "vscode://googlecloudtools.datacloud/dataproc/sessions/" "test_session?project=test-project&location=test-region", @@ -1353,13 +1357,14 @@ def test_repr_html_uses_vscode_url_for_session_link(self): self.assertIn( f'Managed Spark Session', + 'test_session?project=test-project">' + "Managed Spark Session (Cloud Console)", html, ) self.assertIn( '' - "Managed Spark Session (Data Analytics Kit)", + "Managed Spark Session (Data Agent Kit)", html, ) self.assertIn(