Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions gsheets/sheets_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,84 @@ def _format_sheet_notes_section(
return f"\n\nCell notes in range '{range_label}':\n" + "\n".join(lines) + suffix


async def _fetch_cell_formulas(
service,
spreadsheet_id: str,
resolved_range: str,
) -> tuple[str, List[List[object]]]:
"""Fetch formula strings for cells in the given range.

Makes a second values().get() call with valueRenderOption="FORMULA" and
returns a formatted section listing any cells whose value starts with "=".
Cells containing plain values are silently skipped.

Returns an empty section and empty values list if the request fails.
"""
try:
result = await asyncio.to_thread(
service.spreadsheets()
.values()
.get(
spreadsheetId=spreadsheet_id,
range=resolved_range,
valueRenderOption="FORMULA",
)
.execute
)
except Exception as exc:
logger.warning(
"[read_sheet_values] Failed fetching formula values for range '%s': %s",
resolved_range,
exc,
)
return "", []

formula_values = result.get("values", [])
formulas: list[dict[str, str]] = []

sheet_name, range_part = _split_sheet_and_range(resolved_range)
start_part = range_part.split(":")[0] if ":" in range_part else range_part
start_col_idx, start_row_idx = _parse_a1_part(start_part)
base_col = start_col_idx if start_col_idx is not None else 0
base_row = start_row_idx if start_row_idx is not None else 0

for row_offset, formula_row in enumerate(formula_values):
for col_offset, cell_value in enumerate(formula_row):
if isinstance(cell_value, str) and cell_value.startswith("="):
abs_col = base_col + col_offset
abs_row = base_row + row_offset
cell_ref = f"{_index_to_column(abs_col)}{abs_row + 1}"
if sheet_name:
cell_ref = f"{_quote_sheet_title_for_a1(sheet_name)}!{cell_ref}"
formulas.append({"cell": cell_ref, "formula": cell_value})

return (
_format_sheet_formula_section(formulas=formulas, range_label=resolved_range),
formula_values,
)


def _format_sheet_formula_section(
*, formulas: list[dict[str, str]], range_label: str, max_details: int = 50
) -> str:
"""Format a list of formula cells into a human-readable section."""
if not formulas:
return ""

lines = []
for item in formulas[:max_details]:
cell = item.get("cell") or "(unknown cell)"
formula = item.get("formula") or "(empty formula)"
lines.append(f"- {cell}: {formula}")

suffix = (
f"\n... and {len(formulas) - max_details} more formula cells"
if len(formulas) > max_details
else ""
)
return f"\n\nFormula cells in range '{range_label}':\n" + "\n".join(lines) + suffix


async def _fetch_grid_metadata(
service,
spreadsheet_id: str,
Expand Down
40 changes: 35 additions & 5 deletions gsheets/sheets_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
_a1_range_for_values,
_build_boolean_rule,
_build_gradient_rule,
_fetch_cell_formulas,
_fetch_detailed_sheet_errors,
_fetch_grid_metadata,
_fetch_sheets_with_rules,
Expand Down Expand Up @@ -177,6 +178,7 @@ async def read_sheet_values(
range_name: str = "A1:Z1000",
include_hyperlinks: bool = False,
include_notes: bool = False,
include_formulas: bool = False,
) -> str:
"""
Reads values from a specific range in a Google Sheet.
Expand All @@ -189,6 +191,9 @@ async def read_sheet_values(
Defaults to False to avoid expensive includeGridData requests.
include_notes (bool): If True, also fetch cell notes for the range.
Defaults to False to avoid expensive includeGridData requests.
include_formulas (bool): If True, also fetch raw formula strings for cells that
contain formulas. Useful for identifying cross-sheet references before writing
back to a range. Defaults to False to avoid an extra API request.

Returns:
str: The formatted values from the specified range.
Expand All @@ -205,11 +210,7 @@ async def read_sheet_values(
)

values = result.get("values", [])
if not values:
return f"No data found in range '{range_name}' for {user_google_email}."

resolved_range = result.get("range", range_name)
detailed_range = _a1_range_for_values(resolved_range, values) or resolved_range

hyperlink_section, notes_section = await _fetch_grid_metadata(
service,
Expand All @@ -220,6 +221,29 @@ async def read_sheet_values(
include_notes=include_notes,
)

formula_section = ""
formula_values = []
if include_formulas:
formula_section, formula_values = await _fetch_cell_formulas(
service, spreadsheet_id, resolved_range
)

if not values and not formula_values:
return f"No data found in range '{range_name}' for {user_google_email}."

if not values:
logger.info(
"[read_sheet_values] Range '%s' has formula cells but no displayed values",
resolved_range,
)
return (
f"No displayed values found in range '{range_name}' in spreadsheet {spreadsheet_id} "
f"for {user_google_email}. The range contains formula cells."
+ formula_section
)

detailed_range = _a1_range_for_values(resolved_range, values) or resolved_range

detailed_errors_section = ""
if _values_contain_sheets_errors(values):
try:
Expand Down Expand Up @@ -250,7 +274,13 @@ async def read_sheet_values(
)

logger.info(f"Successfully read {len(values)} rows for {user_google_email}.")
return text_output + hyperlink_section + notes_section + detailed_errors_section
return (
text_output
+ hyperlink_section
+ notes_section
+ formula_section
+ detailed_errors_section
)


@server.tool()
Expand Down
57 changes: 57 additions & 0 deletions tests/gsheets/test_read_sheet_values.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Tests for formula-aware sheet reads."""

from unittest.mock import Mock

import pytest

from gsheets.sheets_tools import read_sheet_values


def _create_mock_service(*responses_or_errors):
"""Create a Sheets service mock for sequential values.get responses."""
mock_service = Mock()
mock_service.spreadsheets().values().get().execute = Mock(
side_effect=list(responses_or_errors)
)
return mock_service


async def _call_read_sheet_values(service, **overrides):
"""Call the undecorated implementation to keep auth out of unit tests."""
impl = read_sheet_values.__wrapped__.__wrapped__
return await impl(
service=service,
user_google_email="user@example.com",
spreadsheet_id="spreadsheet-123",
range_name="Sheet1!A1:A1",
**overrides,
)


@pytest.mark.asyncio
async def test_read_sheet_values_surfaces_formulas_when_display_values_are_blank():
service = _create_mock_service(
{"range": "Sheet1!A1:A1", "values": []},
{"range": "Sheet1!A1:A1", "values": [['=IF(TRUE, "", "")']]},
)

result = await _call_read_sheet_values(service, include_formulas=True)

assert "No data found" not in result
assert "The range contains formula cells." in result
assert "Formula cells in range 'Sheet1!A1:A1':" in result
assert '- Sheet1!A1: =IF(TRUE, "", "")' in result


@pytest.mark.asyncio
async def test_read_sheet_values_tolerates_formula_fetch_failures():
service = _create_mock_service(
{"range": "Sheet1!A1:A1", "values": [["1"]]},
RuntimeError("formula fetch failed"),
)

result = await _call_read_sheet_values(service, include_formulas=True)

assert "Successfully read 1 rows" in result
assert "Row 1: ['1']" in result
assert "Formula cells in range" not in result
Loading