Skip to content

Commit 0dd163f

Browse files
authored
feat: instrument vscode, jupyter and 3p plugin usage (#115)
* feat: instrument vscode, jupyter and 3p plugin usage * add more test coverage * improve code coverage
1 parent 232bac8 commit 0dd163f

File tree

3 files changed

+305
-3
lines changed

3 files changed

+305
-3
lines changed

bigquery_magics/bigquery.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@
126126
from google.cloud.bigquery.job import QueryJobConfig
127127
import pandas
128128

129+
from bigquery_magics import environment
129130
from bigquery_magics import line_arg_parser as lap
130131
import bigquery_magics._versions_helpers
131132
import bigquery_magics.config
@@ -143,10 +144,27 @@
143144
except ImportError:
144145
bpd = None
145146

146-
USER_AGENT = f"ipython-{IPython.__version__} bigquery-magics/{bigquery_magics.version.__version__}"
147147
context = bigquery_magics.config.context
148148

149149

150+
def _get_user_agent():
151+
identities = [
152+
f"ipython-{IPython.__version__}",
153+
f"bigquery-magics/{bigquery_magics.version.__version__}",
154+
]
155+
156+
if environment.is_vscode():
157+
identities.append("vscode")
158+
if environment.is_vscode_google_cloud_code_extension_installed():
159+
identities.append(environment.GOOGLE_CLOUD_CODE_EXTENSION_NAME)
160+
elif environment.is_jupyter():
161+
identities.append("jupyter")
162+
if environment.is_jupyter_bigquery_plugin_installed():
163+
identities.append(environment.BIGQUERY_JUPYTER_PLUGIN_NAME)
164+
165+
return " ".join(identities)
166+
167+
150168
def _handle_error(error, destination_var=None):
151169
"""Process a query execution error.
152170
@@ -558,7 +576,7 @@ def _create_clients(args: Any) -> Tuple[bigquery.Client, Any]:
558576
project=args.project or context.project,
559577
credentials=context.credentials,
560578
default_query_job_config=context.default_query_job_config,
561-
client_info=client_info.ClientInfo(user_agent=USER_AGENT),
579+
client_info=client_info.ClientInfo(user_agent=_get_user_agent()),
562580
client_options=bigquery_client_options,
563581
location=args.location,
564582
)
@@ -885,7 +903,7 @@ def _make_bqstorage_client(client, client_options):
885903

886904
return client._ensure_bqstorage_client(
887905
client_options=client_options,
888-
client_info=gapic_client_info.ClientInfo(user_agent=USER_AGENT),
906+
client_info=gapic_client_info.ClientInfo(user_agent=_get_user_agent()),
889907
)
890908

891909

bigquery_magics/environment.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
16+
import importlib
17+
import json
18+
import os
19+
import pathlib
20+
21+
Path = pathlib.Path
22+
23+
24+
# The identifier for GCP VS Code extension
25+
# https://cloud.google.com/code/docs/vscode/install
26+
GOOGLE_CLOUD_CODE_EXTENSION_NAME = "googlecloudtools.cloudcode"
27+
28+
29+
# The identifier for BigQuery Jupyter notebook plugin
30+
# https://cloud.google.com/bigquery/docs/jupyterlab-plugin
31+
BIGQUERY_JUPYTER_PLUGIN_NAME = "bigquery_jupyter_plugin"
32+
33+
34+
def _is_vscode_extension_installed(extension_id: str) -> bool:
35+
"""
36+
Checks if a given Visual Studio Code extension is installed.
37+
38+
Args:
39+
extension_id: The ID of the extension (e.g., "ms-python.python").
40+
41+
Returns:
42+
True if the extension is installed, False otherwise.
43+
"""
44+
try:
45+
# Determine the user's VS Code extensions directory.
46+
user_home = Path.home()
47+
vscode_extensions_dir = user_home / ".vscode" / "extensions"
48+
49+
# Check if the extensions directory exists.
50+
if not vscode_extensions_dir.exists():
51+
return False
52+
53+
# Iterate through the subdirectories in the extensions directory.
54+
extension_dirs = filter(
55+
lambda p: p.is_dir() and p.name.startswith(extension_id + "-"),
56+
vscode_extensions_dir.iterdir(),
57+
)
58+
for extension_dir in extension_dirs:
59+
# As a more robust check, the manifest file must exist.
60+
manifest_path = extension_dir / "package.json"
61+
if not manifest_path.exists() or not manifest_path.is_file():
62+
continue
63+
64+
# Finally, the manifest file must be a valid json
65+
with open(manifest_path, "r", encoding="utf-8") as f:
66+
json.load(f)
67+
68+
return True
69+
except Exception:
70+
pass
71+
72+
return False
73+
74+
75+
def _is_package_installed(package_name: str) -> bool:
76+
"""
77+
Checks if a Python package is installed.
78+
79+
Args:
80+
package_name: The name of the package to check (e.g., "requests", "numpy").
81+
82+
Returns:
83+
True if the package is installed, False otherwise.
84+
"""
85+
try:
86+
importlib.import_module(package_name)
87+
return True
88+
except Exception:
89+
return False
90+
91+
92+
def is_vscode() -> bool:
93+
return os.getenv("VSCODE_PID") is not None
94+
95+
96+
def is_jupyter() -> bool:
97+
return os.getenv("JPY_PARENT_PID") is not None
98+
99+
100+
def is_vscode_google_cloud_code_extension_installed() -> bool:
101+
return _is_vscode_extension_installed(GOOGLE_CLOUD_CODE_EXTENSION_NAME)
102+
103+
104+
def is_jupyter_bigquery_plugin_installed() -> bool:
105+
return _is_package_installed(BIGQUERY_JUPYTER_PLUGIN_NAME)

tests/unit/bigquery/test_bigquery.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@
1616
import contextlib
1717
import copy
1818
import json
19+
import os
20+
import pathlib
1921
import re
2022
import sys
23+
import tempfile
2124
from unittest import mock
2225
import warnings
2326

@@ -59,6 +62,8 @@
5962
except ImportError:
6063
gpd = None
6164

65+
Path = pathlib.Path
66+
6267

6368
def make_connection(*args):
6469
# TODO(tswast): Remove this in favor of a mock google.cloud.bigquery.Client
@@ -929,6 +934,180 @@ def test_bigquery_magic_default_connection_user_agent():
929934
)
930935

931936

937+
def test_bigquery_magic_default_connection_user_agent_vscode():
938+
globalipapp.start_ipython()
939+
ip = globalipapp.get_ipython()
940+
ip.extension_manager.load_extension("bigquery_magics")
941+
bigquery_magics.context._connection = None
942+
943+
credentials_mock = mock.create_autospec(
944+
google.auth.credentials.Credentials, instance=True
945+
)
946+
default_patch = mock.patch(
947+
"google.auth.default", return_value=(credentials_mock, "general-project")
948+
)
949+
run_query_patch = mock.patch("bigquery_magics.bigquery._run_query", autospec=True)
950+
conn_patch = mock.patch("google.cloud.bigquery.client.Connection", autospec=True)
951+
env_patch = mock.patch.dict(os.environ, {"VSCODE_PID": "1234"}, clear=True)
952+
953+
with conn_patch as conn, run_query_patch, default_patch, env_patch:
954+
ip.run_cell_magic("bigquery", "", "SELECT 17 as num")
955+
956+
client_info_arg = conn.call_args[1].get("client_info")
957+
assert client_info_arg is not None
958+
assert (
959+
client_info_arg.user_agent
960+
== f"ipython-{IPython.__version__} bigquery-magics/{bigquery_magics.__version__} vscode"
961+
)
962+
963+
964+
@pytest.mark.parametrize(
965+
(
966+
"install_dir_exists",
967+
"manifest_exists",
968+
"manifest_valid",
969+
"expect_extension_user_agent",
970+
),
971+
[
972+
pytest.param(False, False, False, False, id="no-install"),
973+
pytest.param(True, False, False, False, id="no-manifest"),
974+
pytest.param(True, True, False, False, id="invalid-manifest"),
975+
pytest.param(True, True, True, True, id="good-install"),
976+
],
977+
)
978+
def test_bigquery_magic_default_connection_user_agent_vscode_extension(
979+
install_dir_exists, manifest_exists, manifest_valid, expect_extension_user_agent
980+
):
981+
globalipapp.start_ipython()
982+
ip = globalipapp.get_ipython()
983+
ip.extension_manager.load_extension("bigquery_magics")
984+
bigquery_magics.context._connection = None
985+
986+
credentials_mock = mock.create_autospec(
987+
google.auth.credentials.Credentials, instance=True
988+
)
989+
default_patch = mock.patch(
990+
"google.auth.default", return_value=(credentials_mock, "general-project")
991+
)
992+
run_query_patch = mock.patch("bigquery_magics.bigquery._run_query", autospec=True)
993+
conn_patch = mock.patch("google.cloud.bigquery.client.Connection", autospec=True)
994+
env_patch = mock.patch.dict(os.environ, {"VSCODE_PID": "1234"}, clear=True)
995+
996+
with tempfile.TemporaryDirectory() as tmpdir:
997+
user_home = Path(tmpdir)
998+
extension_dir = (
999+
user_home / ".vscode" / "extensions" / "googlecloudtools.cloudcode-0.12"
1000+
)
1001+
extension_config = extension_dir / "package.json"
1002+
1003+
# originally extension config does not exist
1004+
assert not extension_config.exists()
1005+
1006+
# simulate extension installation by creating extension config on disk
1007+
if install_dir_exists:
1008+
extension_dir.mkdir(parents=True)
1009+
1010+
if manifest_exists:
1011+
if manifest_valid:
1012+
with open(extension_config, "w") as f:
1013+
f.write("{}")
1014+
else:
1015+
extension_config.touch()
1016+
1017+
home_dir_patch = mock.patch("pathlib.Path.home", return_value=user_home)
1018+
1019+
with conn_patch as conn, (
1020+
run_query_patch
1021+
), default_patch, env_patch, home_dir_patch:
1022+
ip.run_cell_magic("bigquery", "", "SELECT 17 as num")
1023+
1024+
expected_user_agents = [
1025+
f"ipython-{IPython.__version__}",
1026+
f"bigquery-magics/{bigquery_magics.__version__}",
1027+
"vscode",
1028+
]
1029+
if expect_extension_user_agent:
1030+
expected_user_agents.append("googlecloudtools.cloudcode")
1031+
expected_user_agent = " ".join(expected_user_agents)
1032+
1033+
client_info_arg = conn.call_args[1].get("client_info")
1034+
assert client_info_arg is not None
1035+
assert client_info_arg.user_agent == expected_user_agent
1036+
1037+
1038+
def test_bigquery_magic_default_connection_user_agent_jupyter():
1039+
globalipapp.start_ipython()
1040+
ip = globalipapp.get_ipython()
1041+
ip.extension_manager.load_extension("bigquery_magics")
1042+
bigquery_magics.context._connection = None
1043+
1044+
credentials_mock = mock.create_autospec(
1045+
google.auth.credentials.Credentials, instance=True
1046+
)
1047+
default_patch = mock.patch(
1048+
"google.auth.default", return_value=(credentials_mock, "general-project")
1049+
)
1050+
run_query_patch = mock.patch("bigquery_magics.bigquery._run_query", autospec=True)
1051+
conn_patch = mock.patch("google.cloud.bigquery.client.Connection", autospec=True)
1052+
env_patch = mock.patch.dict(os.environ, {"JPY_PARENT_PID": "1234"}, clear=True)
1053+
1054+
with conn_patch as conn, run_query_patch, default_patch, env_patch:
1055+
ip.run_cell_magic("bigquery", "", "SELECT 17 as num")
1056+
1057+
client_info_arg = conn.call_args[1].get("client_info")
1058+
assert client_info_arg is not None
1059+
assert (
1060+
client_info_arg.user_agent
1061+
== f"ipython-{IPython.__version__} bigquery-magics/{bigquery_magics.__version__} jupyter"
1062+
)
1063+
1064+
1065+
def test_bigquery_magic_default_connection_user_agent_jupyter_plugin():
1066+
globalipapp.start_ipython()
1067+
ip = globalipapp.get_ipython()
1068+
ip.extension_manager.load_extension("bigquery_magics")
1069+
bigquery_magics.context._connection = None
1070+
1071+
credentials_mock = mock.create_autospec(
1072+
google.auth.credentials.Credentials, instance=True
1073+
)
1074+
default_patch = mock.patch(
1075+
"google.auth.default", return_value=(credentials_mock, "general-project")
1076+
)
1077+
run_query_patch = mock.patch("bigquery_magics.bigquery._run_query", autospec=True)
1078+
conn_patch = mock.patch("google.cloud.bigquery.client.Connection", autospec=True)
1079+
env_patch = mock.patch.dict(os.environ, {"JPY_PARENT_PID": "1234"}, clear=True)
1080+
1081+
def custom_import_module_side_effect(name, package=None):
1082+
if name == "bigquery_jupyter_plugin":
1083+
return mock.MagicMock()
1084+
else:
1085+
import importlib
1086+
1087+
return importlib.import_module(name, package)
1088+
1089+
assert isinstance(
1090+
custom_import_module_side_effect("bigquery_jupyter_plugin"), mock.MagicMock
1091+
)
1092+
assert custom_import_module_side_effect("bigquery_magics") is bigquery_magics
1093+
1094+
extension_import_patch = mock.patch(
1095+
"importlib.import_module", side_effect=custom_import_module_side_effect
1096+
)
1097+
1098+
with conn_patch as conn, (
1099+
run_query_patch
1100+
), default_patch, env_patch, extension_import_patch:
1101+
ip.run_cell_magic("bigquery", "", "SELECT 17 as num")
1102+
1103+
client_info_arg = conn.call_args[1].get("client_info")
1104+
assert client_info_arg is not None
1105+
assert (
1106+
client_info_arg.user_agent
1107+
== f"ipython-{IPython.__version__} bigquery-magics/{bigquery_magics.__version__} jupyter bigquery_jupyter_plugin"
1108+
)
1109+
1110+
9321111
def test_bigquery_magic_with_legacy_sql():
9331112
globalipapp.start_ipython()
9341113
ip = globalipapp.get_ipython()

0 commit comments

Comments
 (0)