Skip to content

Commit 3a681e0

Browse files
authored
feat: allow cell magic body to be a $variable (#1053)
* feat: allow cell magic body to be a $variable * Fix missing indefinitive article in error msg * Adjust test assertion to error message change * Refactor logic for extracting query variable * Explicitly warn about missing query variable name * Thest the query "variable" is not identifier case
1 parent 7052054 commit 3a681e0

File tree

2 files changed

+161
-1
lines changed

2 files changed

+161
-1
lines changed

google/cloud/bigquery/magics/magics.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,29 @@ def _cell_magic(line, query):
596596
_handle_error(error, args.destination_var)
597597
return
598598

599+
# Check if query is given as a reference to a variable.
600+
if query.startswith("$"):
601+
query_var_name = query[1:]
602+
603+
if not query_var_name:
604+
missing_msg = 'Missing query variable name, empty "$" is not allowed.'
605+
raise NameError(missing_msg)
606+
607+
if query_var_name.isidentifier():
608+
ip = IPython.get_ipython()
609+
query = ip.user_ns.get(query_var_name, ip) # ip serves as a sentinel
610+
611+
if query is ip:
612+
raise NameError(
613+
f"Unknown query, variable {query_var_name} does not exist."
614+
)
615+
else:
616+
if not isinstance(query, (str, bytes)):
617+
raise TypeError(
618+
f"Query variable {query_var_name} must be a string "
619+
"or a bytes-like value."
620+
)
621+
599622
# Any query that does not contain whitespace (aside from leading and trailing whitespace)
600623
# is assumed to be a table id
601624
if not re.search(r"\s", query):

tests/unit/test_magics.py

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -584,7 +584,7 @@ def test_bigquery_magic_does_not_clear_display_in_verbose_mode():
584584

585585

586586
@pytest.mark.usefixtures("ipython_interactive")
587-
def test_bigquery_magic_clears_display_in_verbose_mode():
587+
def test_bigquery_magic_clears_display_in_non_verbose_mode():
588588
ip = IPython.get_ipython()
589589
ip.extension_manager.load_extension("google.cloud.bigquery")
590590
magics.context.credentials = mock.create_autospec(
@@ -1710,6 +1710,143 @@ def test_bigquery_magic_with_improperly_formatted_params():
17101710
ip.run_cell_magic("bigquery", "--params {17}", sql)
17111711

17121712

1713+
@pytest.mark.parametrize(
1714+
"raw_sql", ("SELECT answer AS 42", " \t SELECT answer AS 42 \t ")
1715+
)
1716+
@pytest.mark.usefixtures("ipython_interactive")
1717+
@pytest.mark.skipif(pandas is None, reason="Requires `pandas`")
1718+
def test_bigquery_magic_valid_query_in_existing_variable(ipython_ns_cleanup, raw_sql):
1719+
ip = IPython.get_ipython()
1720+
ip.extension_manager.load_extension("google.cloud.bigquery")
1721+
magics.context.credentials = mock.create_autospec(
1722+
google.auth.credentials.Credentials, instance=True
1723+
)
1724+
1725+
ipython_ns_cleanup.append((ip, "custom_query"))
1726+
ipython_ns_cleanup.append((ip, "query_results_df"))
1727+
1728+
run_query_patch = mock.patch(
1729+
"google.cloud.bigquery.magics.magics._run_query", autospec=True
1730+
)
1731+
query_job_mock = mock.create_autospec(
1732+
google.cloud.bigquery.job.QueryJob, instance=True
1733+
)
1734+
mock_result = pandas.DataFrame([42], columns=["answer"])
1735+
query_job_mock.to_dataframe.return_value = mock_result
1736+
1737+
ip.user_ns["custom_query"] = raw_sql
1738+
cell_body = "$custom_query" # Referring to an existing variable name (custom_query)
1739+
assert "query_results_df" not in ip.user_ns
1740+
1741+
with run_query_patch as run_query_mock:
1742+
run_query_mock.return_value = query_job_mock
1743+
1744+
ip.run_cell_magic("bigquery", "query_results_df", cell_body)
1745+
1746+
run_query_mock.assert_called_once_with(mock.ANY, raw_sql, mock.ANY)
1747+
1748+
assert "query_results_df" in ip.user_ns # verify that the variable exists
1749+
df = ip.user_ns["query_results_df"]
1750+
assert len(df) == len(mock_result) # verify row count
1751+
assert list(df) == list(mock_result) # verify column names
1752+
assert list(df["answer"]) == [42]
1753+
1754+
1755+
@pytest.mark.usefixtures("ipython_interactive")
1756+
@pytest.mark.skipif(pandas is None, reason="Requires `pandas`")
1757+
def test_bigquery_magic_nonexisting_query_variable():
1758+
ip = IPython.get_ipython()
1759+
ip.extension_manager.load_extension("google.cloud.bigquery")
1760+
magics.context.credentials = mock.create_autospec(
1761+
google.auth.credentials.Credentials, instance=True
1762+
)
1763+
1764+
run_query_patch = mock.patch(
1765+
"google.cloud.bigquery.magics.magics._run_query", autospec=True
1766+
)
1767+
1768+
ip.user_ns.pop("custom_query", None) # Make sure the variable does NOT exist.
1769+
cell_body = "$custom_query" # Referring to a non-existing variable name.
1770+
1771+
with pytest.raises(
1772+
NameError, match=r".*custom_query does not exist.*"
1773+
), run_query_patch as run_query_mock:
1774+
ip.run_cell_magic("bigquery", "", cell_body)
1775+
1776+
run_query_mock.assert_not_called()
1777+
1778+
1779+
@pytest.mark.usefixtures("ipython_interactive")
1780+
@pytest.mark.skipif(pandas is None, reason="Requires `pandas`")
1781+
def test_bigquery_magic_empty_query_variable_name():
1782+
ip = IPython.get_ipython()
1783+
ip.extension_manager.load_extension("google.cloud.bigquery")
1784+
magics.context.credentials = mock.create_autospec(
1785+
google.auth.credentials.Credentials, instance=True
1786+
)
1787+
1788+
run_query_patch = mock.patch(
1789+
"google.cloud.bigquery.magics.magics._run_query", autospec=True
1790+
)
1791+
cell_body = "$" # Not referring to any variable (name omitted).
1792+
1793+
with pytest.raises(
1794+
NameError, match=r"(?i).*missing query variable name.*"
1795+
), run_query_patch as run_query_mock:
1796+
ip.run_cell_magic("bigquery", "", cell_body)
1797+
1798+
run_query_mock.assert_not_called()
1799+
1800+
1801+
@pytest.mark.usefixtures("ipython_interactive")
1802+
@pytest.mark.skipif(pandas is None, reason="Requires `pandas`")
1803+
def test_bigquery_magic_query_variable_non_string(ipython_ns_cleanup):
1804+
ip = IPython.get_ipython()
1805+
ip.extension_manager.load_extension("google.cloud.bigquery")
1806+
magics.context.credentials = mock.create_autospec(
1807+
google.auth.credentials.Credentials, instance=True
1808+
)
1809+
1810+
run_query_patch = mock.patch(
1811+
"google.cloud.bigquery.magics.magics._run_query", autospec=True
1812+
)
1813+
1814+
ipython_ns_cleanup.append((ip, "custom_query"))
1815+
1816+
ip.user_ns["custom_query"] = object()
1817+
cell_body = "$custom_query" # Referring to a non-string variable.
1818+
1819+
with pytest.raises(
1820+
TypeError, match=r".*must be a string or a bytes-like.*"
1821+
), run_query_patch as run_query_mock:
1822+
ip.run_cell_magic("bigquery", "", cell_body)
1823+
1824+
run_query_mock.assert_not_called()
1825+
1826+
1827+
@pytest.mark.usefixtures("ipython_interactive")
1828+
@pytest.mark.skipif(pandas is None, reason="Requires `pandas`")
1829+
def test_bigquery_magic_query_variable_not_identifier():
1830+
ip = IPython.get_ipython()
1831+
ip.extension_manager.load_extension("google.cloud.bigquery")
1832+
magics.context.credentials = mock.create_autospec(
1833+
google.auth.credentials.Credentials, instance=True
1834+
)
1835+
1836+
cell_body = "$123foo" # 123foo is not valid Python identifier
1837+
1838+
with io.capture_output() as captured_io:
1839+
ip.run_cell_magic("bigquery", "", cell_body)
1840+
1841+
# If "$" prefixes a string that is not a Python identifier, we do not treat such
1842+
# cell_body as a variable reference and just treat is as any other cell body input.
1843+
# If at the same time the cell body does not contain any whitespace, it is
1844+
# considered a table name, thus we expect an error that the table ID is not valid.
1845+
output = captured_io.stderr
1846+
assert "ERROR:" in output
1847+
assert "must be a fully-qualified ID" in output
1848+
1849+
17131850
@pytest.mark.usefixtures("ipython_interactive")
17141851
@pytest.mark.skipif(pandas is None, reason="Requires `pandas`")
17151852
def test_bigquery_magic_with_invalid_multiple_option_values():

0 commit comments

Comments
 (0)