Skip to content

Commit bd15a5b

Browse files
feat: add --pyformat option to use Python string formatting (#111)
* feat: add --pyformat option to use Python string formatting * use mock_credentials in magics tests * avoid typing issue in tests * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * avoid polluting global options with context tests * fix coverage --------- Co-authored-by: Owl Bot <gcf-owl-bot[bot]@users.noreply.github.com>
1 parent 3da6f07 commit bd15a5b

File tree

8 files changed

+592
-301
lines changed

8 files changed

+592
-301
lines changed

bigquery_magics/bigquery.py

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@
7777
serializable. The variable reference is indicated by a ``$`` before
7878
the variable name (ex. ``$my_dict_var``). See ``In[6]`` and ``In[7]``
7979
in the Examples section below.
80+
* ``--engine <engine>`` (Optional[line argument]):
81+
Set the execution engine, either 'pandas' (default) or 'bigframes'
82+
(experimental).
83+
* ``--pyformat`` (Optional[line argument]):
84+
Warning! Do not use with user-provided values.
85+
This doesn't escape values. Use --params instead for proper SQL escaping.
86+
This enables Python string formatting in the query text.
87+
Useful for values not supported by SQL query params such as table IDs.
8088
8189
* ``<query>`` (required, cell argument):
8290
SQL query to run. If the query does not contain any whitespace (aside
@@ -122,7 +130,7 @@
122130
import bigquery_magics._versions_helpers
123131
import bigquery_magics.config
124132
import bigquery_magics.graph_server as graph_server
125-
import bigquery_magics.line_arg_parser.exceptions
133+
import bigquery_magics.pyformat
126134
import bigquery_magics.version
127135

128136
try:
@@ -401,6 +409,17 @@ def _create_dataset_if_necessary(client, dataset_id):
401409
default=False,
402410
help=("Visualizes the query results as a graph"),
403411
)
412+
@magic_arguments.argument(
413+
"--pyformat",
414+
action="store_true",
415+
default=False,
416+
help=(
417+
"Warning! Do not use with user-provided values. "
418+
"This doesn't escape values. Use --params instead for proper SQL escaping. "
419+
"This enables Python string formatting in the query text. "
420+
"Useful for values not supported by SQL query params such as table IDs. "
421+
),
422+
)
404423
def _cell_magic(line, query):
405424
"""Underlying function for bigquery cell magic
406425
@@ -515,7 +534,6 @@ def _query_with_bigframes(query: str, params: List[Any], args: Any):
515534

516535
def _query_with_pandas(query: str, params: List[Any], args: Any):
517536
bq_client, bqstorage_client = _create_clients(args)
518-
519537
try:
520538
return _make_bq_query(
521539
query,
@@ -779,26 +797,30 @@ def _make_bq_query(
779797

780798
def _validate_and_resolve_query(query: str, args: Any) -> str:
781799
# Check if query is given as a reference to a variable.
782-
if not query.startswith("$"):
783-
return query
800+
if query.startswith("$"):
801+
query_var_name = query[1:]
784802

785-
query_var_name = query[1:]
803+
if not query_var_name:
804+
missing_msg = 'Missing query variable name, empty "$" is not allowed.'
805+
raise NameError(missing_msg)
786806

787-
if not query_var_name:
788-
missing_msg = 'Missing query variable name, empty "$" is not allowed.'
789-
raise NameError(missing_msg)
807+
if query_var_name.isidentifier():
808+
ip = get_ipython()
809+
query = ip.user_ns.get(query_var_name, ip) # ip serves as a sentinel
790810

791-
if query_var_name.isidentifier():
811+
if query is ip:
812+
raise NameError(
813+
f"Unknown query, variable {query_var_name} does not exist."
814+
)
815+
elif not isinstance(query, (str, bytes)):
816+
raise TypeError(
817+
f"Query variable {query_var_name} must be a string "
818+
"or a bytes-like value."
819+
)
820+
821+
if args.pyformat:
792822
ip = get_ipython()
793-
query = ip.user_ns.get(query_var_name, ip) # ip serves as a sentinel
794-
795-
if query is ip:
796-
raise NameError(f"Unknown query, variable {query_var_name} does not exist.")
797-
elif not isinstance(query, (str, bytes)):
798-
raise TypeError(
799-
f"Query variable {query_var_name} must be a string "
800-
"or a bytes-like value."
801-
)
823+
query = bigquery_magics.pyformat.pyformat(query, ip.user_ns)
802824
return query
803825

804826

bigquery_magics/pyformat.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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+
"""Helpers for the --pyformat feature."""
16+
17+
from __future__ import annotations
18+
19+
import numbers
20+
import string
21+
from typing import Any
22+
23+
24+
def _field_to_template_value(name: str, value: Any) -> Any:
25+
"""Convert value to something embeddable in a SQL string.
26+
27+
Does **not** escape strings.
28+
"""
29+
_validate_type(name, value)
30+
# TODO(tswast): convert DataFrame objects to gbq tables or literals subquery.
31+
return value
32+
33+
34+
def _validate_type(name: str, value: Any):
35+
"""Raises TypeError if value is unsupported."""
36+
if not isinstance(value, (str, numbers.Real)):
37+
raise TypeError(
38+
f"{name} has unsupported type: {type(value)}. "
39+
"Only str, int, float are supported."
40+
)
41+
42+
43+
def _parse_fields(sql_template: str) -> list[str]:
44+
return [
45+
field_name
46+
for _, field_name, _, _ in string.Formatter().parse(sql_template)
47+
if field_name is not None
48+
]
49+
50+
51+
def pyformat(sql_template: str, user_ns: dict) -> str:
52+
"""Unsafe Python-style string formatting of SQL string.
53+
54+
Only some data types supported.
55+
56+
Warning: strings are **not** escaped. This allows them to be used in
57+
contexts such as table identifiers, where normal query parameters are not
58+
supported.
59+
60+
Args:
61+
sql_template (str):
62+
SQL string with 0+ {var_name}-style format options.
63+
user_ns (dict):
64+
IPython variable namespace to use for formatting.
65+
66+
Raises:
67+
TypeError: if a referenced variable is not of a supported type.
68+
KeyError: if a referenced variable is not found.
69+
"""
70+
fields = _parse_fields(sql_template)
71+
72+
format_kwargs = {}
73+
for name in fields:
74+
value = user_ns[name]
75+
format_kwargs[name] = _field_to_template_value(name, value)
76+
77+
return sql_template.format(**format_kwargs)

tests/system/test_bigquery.py

Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -16,38 +16,17 @@
1616

1717
import re
1818

19+
from IPython.testing import globalipapp
20+
from IPython.utils import io
21+
import pandas
1922
import psutil
20-
import pytest
2123

22-
IPython = pytest.importorskip("IPython")
23-
io = pytest.importorskip("IPython.utils.io")
24-
pandas = pytest.importorskip("pandas")
25-
tools = pytest.importorskip("IPython.testing.tools")
26-
interactiveshell = pytest.importorskip("IPython.terminal.interactiveshell")
2724

28-
29-
@pytest.fixture(scope="session")
30-
def ipython():
31-
config = tools.default_config()
32-
config.TerminalInteractiveShell.simple_prompt = True
33-
shell = interactiveshell.TerminalInteractiveShell.instance(config=config)
34-
return shell
35-
36-
37-
@pytest.fixture()
38-
def ipython_interactive(ipython):
39-
"""Activate IPython's builtin hooks
40-
41-
for the duration of the test scope.
42-
"""
43-
with ipython.builtin_trap:
44-
yield ipython
45-
46-
47-
def test_bigquery_magic(ipython_interactive):
48-
ip = IPython.get_ipython()
25+
def test_bigquery_magic():
26+
globalipapp.start_ipython()
27+
ip = globalipapp.get_ipython()
4928
current_process = psutil.Process()
50-
conn_count_start = len(current_process.connections())
29+
conn_count_start = len(current_process.net_connections())
5130

5231
ip.extension_manager.load_extension("bigquery_magics")
5332
sql = """
@@ -64,7 +43,7 @@ def test_bigquery_magic(ipython_interactive):
6443
with io.capture_output() as captured:
6544
result = ip.run_cell_magic("bigquery", "--use_rest_api", sql)
6645

67-
conn_count_end = len(current_process.connections())
46+
conn_count_end = len(current_process.net_connections())
6847

6948
lines = re.split("\n|\r", captured.stdout)
7049
# Removes blanks & terminal code (result of display clearing)

tests/unit/bigquery/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
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+
# https://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.

tests/unit/bigquery/conftest.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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+
# https://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+
from unittest import mock
16+
17+
import google.auth.credentials
18+
import pytest
19+
import test_utils.imports # google-cloud-testutils
20+
21+
import bigquery_magics
22+
23+
24+
@pytest.fixture()
25+
def ipython_ns_cleanup():
26+
"""A helper to clean up user namespace after the test
27+
28+
for the duration of the test scope.
29+
"""
30+
names_to_clean = [] # pairs (IPython_instance, name_to_clean)
31+
32+
yield names_to_clean
33+
34+
for ip, name in names_to_clean:
35+
if name in ip.user_ns:
36+
del ip.user_ns[name]
37+
38+
39+
@pytest.fixture(scope="session")
40+
def missing_bq_storage():
41+
"""Provide a patcher that can make the bigquery storage import to fail."""
42+
43+
def fail_if(name, globals, locals, fromlist, level):
44+
# NOTE: *very* simplified, assuming a straightforward absolute import
45+
return "bigquery_storage" in name or (
46+
fromlist is not None and "bigquery_storage" in fromlist
47+
)
48+
49+
return test_utils.imports.maybe_fail_import(predicate=fail_if)
50+
51+
52+
@pytest.fixture(scope="session")
53+
def missing_grpcio_lib():
54+
"""Provide a patcher that can make the gapic library import to fail."""
55+
56+
def fail_if(name, globals, locals, fromlist, level):
57+
# NOTE: *very* simplified, assuming a straightforward absolute import
58+
return "gapic_v1" in name or (fromlist is not None and "gapic_v1" in fromlist)
59+
60+
return test_utils.imports.maybe_fail_import(predicate=fail_if)
61+
62+
63+
@pytest.fixture
64+
def mock_credentials(monkeypatch):
65+
credentials = mock.create_autospec(
66+
google.auth.credentials.Credentials, instance=True
67+
)
68+
69+
# Set up the context with monkeypatch so that it's reset for subsequent
70+
# tests.
71+
monkeypatch.setattr(bigquery_magics.context, "_project", "test-project")
72+
monkeypatch.setattr(bigquery_magics.context, "_credentials", credentials)
73+
74+
75+
@pytest.fixture
76+
def set_bigframes_engine_in_context(monkeypatch):
77+
monkeypatch.setattr(bigquery_magics.context, "engine", "bigframes")

0 commit comments

Comments
 (0)