gsheets-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gsheets_mcp-0.1.0/.gitignore +11 -0
- gsheets_mcp-0.1.0/LICENSE +21 -0
- gsheets_mcp-0.1.0/PKG-INFO +17 -0
- gsheets_mcp-0.1.0/README.md +67 -0
- gsheets_mcp-0.1.0/pyproject.toml +32 -0
- gsheets_mcp-0.1.0/run_server.py +13 -0
- gsheets_mcp-0.1.0/src/__init__.py +1 -0
- gsheets_mcp-0.1.0/src/server.py +238 -0
- gsheets_mcp-0.1.0/src/sheets_client.py +354 -0
- gsheets_mcp-0.1.0/tests/test_sheets_client.py +267 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Henry Chien
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gsheets-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Standalone MCP server for Google Sheets operations
|
|
5
|
+
Author: Henry Chien
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: google-api-python-client>=2.0.0
|
|
15
|
+
Requires-Dist: google-auth-httplib2>=0.1.0
|
|
16
|
+
Requires-Dist: google-auth-oauthlib>=1.0.0
|
|
17
|
+
Requires-Dist: mcp>=1.0.0
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# gsheets-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for Google Sheets operations.
|
|
4
|
+
|
|
5
|
+
## Tools
|
|
6
|
+
|
|
7
|
+
| Tool | Description |
|
|
8
|
+
|------|-------------|
|
|
9
|
+
| `gsheet_list_tabs` | List tabs in a spreadsheet by name or ID |
|
|
10
|
+
| `gsheet_read_range` | Read values from a range |
|
|
11
|
+
| `gsheet_update_range` | Update a range with user-entered values |
|
|
12
|
+
| `gsheet_append_rows` | Append rows to a range |
|
|
13
|
+
| `gsheet_create` | Create a new spreadsheet |
|
|
14
|
+
| `gsheet_search` | Search Drive for spreadsheets by name |
|
|
15
|
+
| `gsheet_clear_range` | Clear all values in a range |
|
|
16
|
+
| `gsheet_touch_range` | Rewrite formulas in a range to trigger recalculation |
|
|
17
|
+
|
|
18
|
+
## Setup
|
|
19
|
+
|
|
20
|
+
### Prerequisites
|
|
21
|
+
- Python 3.10+
|
|
22
|
+
- Google account with Sheets and Drive API access
|
|
23
|
+
- OAuth desktop client credentials from Google Cloud Console
|
|
24
|
+
|
|
25
|
+
### Installation
|
|
26
|
+
```bash
|
|
27
|
+
git clone https://github.com/<your-user>/gsheets-mcp.git
|
|
28
|
+
cd gsheets-mcp
|
|
29
|
+
python3 -m venv venv
|
|
30
|
+
source venv/bin/activate
|
|
31
|
+
pip install -e .
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Authentication
|
|
35
|
+
1. Save your OAuth desktop credentials file as `drive_credentials.json` in the repository root.
|
|
36
|
+
2. Run an auth bootstrap once:
|
|
37
|
+
```bash
|
|
38
|
+
source venv/bin/activate
|
|
39
|
+
python -c "from src import sheets_client; sheets_client.authenticate()"
|
|
40
|
+
```
|
|
41
|
+
3. Complete browser consent. A local `token.pickle` cache will be created.
|
|
42
|
+
|
|
43
|
+
### Claude Code Configuration
|
|
44
|
+
Add to `~/.claude.json`:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"mcpServers": {
|
|
49
|
+
"gsheets-mcp": {
|
|
50
|
+
"type": "stdio",
|
|
51
|
+
"command": "/path/to/gsheets-mcp/venv/bin/python",
|
|
52
|
+
"args": ["/path/to/gsheets-mcp/run_server.py"]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Development
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
source venv/bin/activate
|
|
62
|
+
pytest
|
|
63
|
+
python run_server.py
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
MIT
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "gsheets-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Standalone MCP server for Google Sheets operations"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Henry Chien" },
|
|
9
|
+
]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"License :: OSI Approved :: MIT License",
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Programming Language :: Python :: 3.10",
|
|
14
|
+
"Programming Language :: Python :: 3.11",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"mcp>=1.0.0",
|
|
19
|
+
"google-api-python-client>=2.0.0",
|
|
20
|
+
"google-auth-oauthlib>=1.0.0",
|
|
21
|
+
"google-auth-httplib2>=0.1.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
gsheets-mcp = "src.server:main"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["hatchling"]
|
|
29
|
+
build-backend = "hatchling.build"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Entry point to run the gsheets-mcp server."""
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
# Add parent to path so 'src' package is importable
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
9
|
+
|
|
10
|
+
from src.server import mcp
|
|
11
|
+
|
|
12
|
+
if __name__ == "__main__":
|
|
13
|
+
mcp.run()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""gsheets-mcp package."""
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Standalone MCP server for Google Sheets operations."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import FastMCP
|
|
6
|
+
|
|
7
|
+
from . import sheets_client
|
|
8
|
+
|
|
9
|
+
mcp = FastMCP(
|
|
10
|
+
"gsheets-mcp",
|
|
11
|
+
instructions=(
|
|
12
|
+
"Google Sheets tools for tab listing, range reads/writes, search, "
|
|
13
|
+
"sheet creation, range clearing, and range touch/recalc."
|
|
14
|
+
),
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _json_error(operation: str, error: Exception) -> str:
|
|
19
|
+
"""Return standardized JSON error payload for Sheets tools."""
|
|
20
|
+
return json.dumps(
|
|
21
|
+
{
|
|
22
|
+
"status": "error",
|
|
23
|
+
"error": str(error),
|
|
24
|
+
"operation": operation,
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _validate_render_options(
|
|
30
|
+
value_render_option: str,
|
|
31
|
+
date_time_render_option: str,
|
|
32
|
+
) -> None:
|
|
33
|
+
if value_render_option not in sheets_client.VALID_VALUE_RENDER_OPTIONS:
|
|
34
|
+
raise ValueError(
|
|
35
|
+
f"Invalid value_render_option '{value_render_option}'. "
|
|
36
|
+
f"Must be one of: {sorted(sheets_client.VALID_VALUE_RENDER_OPTIONS)}"
|
|
37
|
+
)
|
|
38
|
+
if date_time_render_option not in sheets_client.VALID_DATETIME_RENDER_OPTIONS:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"Invalid date_time_render_option '{date_time_render_option}'. "
|
|
41
|
+
f"Must be one of: {sorted(sheets_client.VALID_DATETIME_RENDER_OPTIONS)}"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@mcp.tool()
|
|
46
|
+
def gsheet_list_tabs(spreadsheet: str) -> str:
|
|
47
|
+
"""List tabs in a Google Sheets spreadsheet by name or spreadsheet ID."""
|
|
48
|
+
try:
|
|
49
|
+
spreadsheet_id, title = sheets_client.resolve_spreadsheet_id(spreadsheet)
|
|
50
|
+
sheets_service = sheets_client.get_sheets_service()
|
|
51
|
+
tabs = sheets_client.list_sheet_tabs(sheets_service, spreadsheet_id)
|
|
52
|
+
return json.dumps(
|
|
53
|
+
{
|
|
54
|
+
"status": "ok",
|
|
55
|
+
"spreadsheet_id": spreadsheet_id,
|
|
56
|
+
"title": title,
|
|
57
|
+
"tabs": tabs,
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
return _json_error("gsheet_list_tabs", exc)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@mcp.tool()
|
|
65
|
+
def gsheet_read_range(
|
|
66
|
+
spreadsheet: str,
|
|
67
|
+
cell_range: str,
|
|
68
|
+
value_render_option: str = "FORMATTED_VALUE",
|
|
69
|
+
date_time_render_option: str = "FORMATTED_STRING",
|
|
70
|
+
) -> str:
|
|
71
|
+
"""Read values from a range in a Google Sheets spreadsheet."""
|
|
72
|
+
try:
|
|
73
|
+
_validate_render_options(value_render_option, date_time_render_option)
|
|
74
|
+
spreadsheet_id, _ = sheets_client.resolve_spreadsheet_id(spreadsheet)
|
|
75
|
+
sheets_service = sheets_client.get_sheets_service()
|
|
76
|
+
values = sheets_client.read_sheet_range(
|
|
77
|
+
sheets_service,
|
|
78
|
+
spreadsheet_id,
|
|
79
|
+
cell_range,
|
|
80
|
+
value_render_option=value_render_option,
|
|
81
|
+
date_time_render_option=date_time_render_option,
|
|
82
|
+
)
|
|
83
|
+
return json.dumps(
|
|
84
|
+
{
|
|
85
|
+
"status": "ok",
|
|
86
|
+
"spreadsheet_id": spreadsheet_id,
|
|
87
|
+
"range": cell_range,
|
|
88
|
+
"value_render_option": value_render_option,
|
|
89
|
+
"date_time_render_option": date_time_render_option,
|
|
90
|
+
"values": values,
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
return _json_error("gsheet_read_range", exc)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@mcp.tool()
|
|
98
|
+
def gsheet_update_range(spreadsheet: str, cell_range: str, values: list[list]) -> str:
|
|
99
|
+
"""Update a range in a Google Sheets spreadsheet using USER_ENTERED values."""
|
|
100
|
+
try:
|
|
101
|
+
spreadsheet_id, _ = sheets_client.resolve_spreadsheet_id(spreadsheet)
|
|
102
|
+
sheets_service = sheets_client.get_sheets_service()
|
|
103
|
+
update_result = sheets_client.update_sheet_range(
|
|
104
|
+
sheets_service,
|
|
105
|
+
spreadsheet_id,
|
|
106
|
+
cell_range,
|
|
107
|
+
values,
|
|
108
|
+
value_input_option="USER_ENTERED",
|
|
109
|
+
)
|
|
110
|
+
return json.dumps(
|
|
111
|
+
{
|
|
112
|
+
"status": "ok",
|
|
113
|
+
"updatedRange": update_result.get("updatedRange", ""),
|
|
114
|
+
"updatedCells": update_result.get("updatedCells", 0),
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
return _json_error("gsheet_update_range", exc)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@mcp.tool()
|
|
122
|
+
def gsheet_append_rows(spreadsheet: str, cell_range: str, values: list[list]) -> str:
|
|
123
|
+
"""Append rows to a range in a Google Sheets spreadsheet."""
|
|
124
|
+
try:
|
|
125
|
+
spreadsheet_id, _ = sheets_client.resolve_spreadsheet_id(spreadsheet)
|
|
126
|
+
sheets_service = sheets_client.get_sheets_service()
|
|
127
|
+
append_result = sheets_client.append_sheet_rows(
|
|
128
|
+
sheets_service,
|
|
129
|
+
spreadsheet_id,
|
|
130
|
+
cell_range,
|
|
131
|
+
values,
|
|
132
|
+
value_input_option="USER_ENTERED",
|
|
133
|
+
insert_data_option="INSERT_ROWS",
|
|
134
|
+
)
|
|
135
|
+
return json.dumps(
|
|
136
|
+
{
|
|
137
|
+
"status": "ok",
|
|
138
|
+
"updatedRange": append_result.get("updatedRange", ""),
|
|
139
|
+
"updatedCells": append_result.get("updatedCells", 0),
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
return _json_error("gsheet_append_rows", exc)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@mcp.tool()
|
|
147
|
+
def gsheet_create(title: str) -> str:
|
|
148
|
+
"""Create a new Google Sheets spreadsheet."""
|
|
149
|
+
try:
|
|
150
|
+
sheets_service = sheets_client.get_sheets_service()
|
|
151
|
+
spreadsheet_id, url = sheets_client.create_spreadsheet(sheets_service, title)
|
|
152
|
+
return json.dumps(
|
|
153
|
+
{
|
|
154
|
+
"status": "ok",
|
|
155
|
+
"spreadsheet_id": spreadsheet_id,
|
|
156
|
+
"url": url,
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
return _json_error("gsheet_create", exc)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@mcp.tool()
|
|
164
|
+
def gsheet_search(query: str, max_results: int = 10) -> str:
|
|
165
|
+
"""Search Google Drive for spreadsheets by name."""
|
|
166
|
+
try:
|
|
167
|
+
if max_results <= 0:
|
|
168
|
+
raise ValueError("max_results must be > 0")
|
|
169
|
+
drive_service = sheets_client.authenticate()
|
|
170
|
+
files = sheets_client.search_spreadsheets(
|
|
171
|
+
drive_service,
|
|
172
|
+
query=query,
|
|
173
|
+
max_results=max_results,
|
|
174
|
+
)
|
|
175
|
+
return json.dumps(
|
|
176
|
+
{
|
|
177
|
+
"status": "ok",
|
|
178
|
+
"query": query,
|
|
179
|
+
"results": files,
|
|
180
|
+
"count": len(files),
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
return _json_error("gsheet_search", exc)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@mcp.tool()
|
|
188
|
+
def gsheet_clear_range(spreadsheet: str, cell_range: str) -> str:
|
|
189
|
+
"""Clear all values in a range without deleting cells."""
|
|
190
|
+
try:
|
|
191
|
+
spreadsheet_id, _ = sheets_client.resolve_spreadsheet_id(spreadsheet)
|
|
192
|
+
sheets_service = sheets_client.get_sheets_service()
|
|
193
|
+
clear_result = sheets_client.clear_sheet_range(
|
|
194
|
+
sheets_service,
|
|
195
|
+
spreadsheet_id,
|
|
196
|
+
cell_range,
|
|
197
|
+
)
|
|
198
|
+
return json.dumps(
|
|
199
|
+
{
|
|
200
|
+
"status": "ok",
|
|
201
|
+
"spreadsheet_id": spreadsheet_id,
|
|
202
|
+
"clearedRange": clear_result.get("clearedRange", cell_range),
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
except Exception as exc:
|
|
206
|
+
return _json_error("gsheet_clear_range", exc)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@mcp.tool()
|
|
210
|
+
def gsheet_touch_range(spreadsheet: str, cell_range: str) -> str:
|
|
211
|
+
"""Touch a range to force recalculation of custom functions. Reads formulas, clears the range, then rewrites them."""
|
|
212
|
+
try:
|
|
213
|
+
spreadsheet_id, _ = sheets_client.resolve_spreadsheet_id(spreadsheet)
|
|
214
|
+
sheets_service = sheets_client.get_sheets_service()
|
|
215
|
+
touch_result = sheets_client.touch_sheet_range(
|
|
216
|
+
sheets_service,
|
|
217
|
+
spreadsheet_id,
|
|
218
|
+
cell_range,
|
|
219
|
+
)
|
|
220
|
+
return json.dumps(
|
|
221
|
+
{
|
|
222
|
+
"status": "ok",
|
|
223
|
+
"spreadsheet_id": spreadsheet_id,
|
|
224
|
+
"touchedRange": touch_result.get("touchedRange", cell_range),
|
|
225
|
+
"touchedCells": touch_result.get("touchedCells", 0),
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
except Exception as exc:
|
|
229
|
+
return _json_error("gsheet_touch_range", exc)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def main() -> None:
|
|
233
|
+
"""Run the MCP server."""
|
|
234
|
+
mcp.run()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
if __name__ == "__main__":
|
|
238
|
+
main()
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""Google Sheets and Drive helpers for gsheets-mcp."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import pickle
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from google.auth.transport.requests import Request
|
|
9
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
10
|
+
from googleapiclient.discovery import build
|
|
11
|
+
from googleapiclient.errors import HttpError
|
|
12
|
+
|
|
13
|
+
SCOPES = [
|
|
14
|
+
"https://www.googleapis.com/auth/drive.readonly",
|
|
15
|
+
"https://www.googleapis.com/auth/spreadsheets",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
GOOGLE_SHEET_MIME = "application/vnd.google-apps.spreadsheet"
|
|
19
|
+
SPREADSHEET_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{20,}$")
|
|
20
|
+
|
|
21
|
+
VALID_VALUE_RENDER_OPTIONS = {
|
|
22
|
+
"FORMATTED_VALUE",
|
|
23
|
+
"UNFORMATTED_VALUE",
|
|
24
|
+
"FORMULA",
|
|
25
|
+
}
|
|
26
|
+
VALID_DATETIME_RENDER_OPTIONS = {
|
|
27
|
+
"SERIAL_NUMBER",
|
|
28
|
+
"FORMATTED_STRING",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
BASE_DIR = Path(__file__).parent.parent
|
|
32
|
+
CREDENTIALS_FILE = BASE_DIR / "drive_credentials.json"
|
|
33
|
+
TOKEN_FILE = BASE_DIR / "token.pickle"
|
|
34
|
+
|
|
35
|
+
_cached_creds = None
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _get_missing_scopes(creds) -> list[str]:
|
|
40
|
+
"""Return required scopes that are missing from credentials."""
|
|
41
|
+
granted = set()
|
|
42
|
+
if getattr(creds, "scopes", None):
|
|
43
|
+
granted.update(creds.scopes)
|
|
44
|
+
if getattr(creds, "granted_scopes", None):
|
|
45
|
+
granted.update(creds.granted_scopes)
|
|
46
|
+
return [scope for scope in SCOPES if scope not in granted]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _get_credentials():
|
|
50
|
+
"""Load, refresh, or create OAuth credentials with required scopes."""
|
|
51
|
+
global _cached_creds
|
|
52
|
+
|
|
53
|
+
creds = _cached_creds
|
|
54
|
+
if creds is None and TOKEN_FILE.exists():
|
|
55
|
+
with open(TOKEN_FILE, "rb") as token_file:
|
|
56
|
+
creds = pickle.load(token_file)
|
|
57
|
+
|
|
58
|
+
missing_scopes = _get_missing_scopes(creds) if creds else []
|
|
59
|
+
if missing_scopes:
|
|
60
|
+
creds = None
|
|
61
|
+
_cached_creds = None
|
|
62
|
+
if TOKEN_FILE.exists():
|
|
63
|
+
TOKEN_FILE.unlink()
|
|
64
|
+
|
|
65
|
+
should_save_token = False
|
|
66
|
+
if not creds or not creds.valid:
|
|
67
|
+
if creds and creds.expired and creds.refresh_token:
|
|
68
|
+
creds.refresh(Request())
|
|
69
|
+
should_save_token = True
|
|
70
|
+
else:
|
|
71
|
+
if not CREDENTIALS_FILE.exists():
|
|
72
|
+
raise FileNotFoundError(
|
|
73
|
+
f"Credentials file not found at {CREDENTIALS_FILE}. "
|
|
74
|
+
"Please copy your drive_credentials.json to the gsheets-mcp folder."
|
|
75
|
+
)
|
|
76
|
+
flow = InstalledAppFlow.from_client_secrets_file(
|
|
77
|
+
str(CREDENTIALS_FILE), SCOPES
|
|
78
|
+
)
|
|
79
|
+
creds = flow.run_local_server(port=0)
|
|
80
|
+
should_save_token = True
|
|
81
|
+
|
|
82
|
+
if should_save_token:
|
|
83
|
+
with open(TOKEN_FILE, "wb") as token_file:
|
|
84
|
+
pickle.dump(creds, token_file)
|
|
85
|
+
|
|
86
|
+
_cached_creds = creds
|
|
87
|
+
return creds
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def authenticate():
|
|
91
|
+
"""Authenticate with Google Drive API and return a service object."""
|
|
92
|
+
creds = _get_credentials()
|
|
93
|
+
return build("drive", "v3", credentials=creds)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_sheets_service():
|
|
97
|
+
"""Authenticate with Google Sheets API and return a service object."""
|
|
98
|
+
creds = _get_credentials()
|
|
99
|
+
return build("sheets", "v4", credentials=creds)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def resolve_spreadsheet_id(name_or_id: str) -> tuple[str, str]:
|
|
103
|
+
"""Resolve a spreadsheet by ID or exact name and return (id, title)."""
|
|
104
|
+
drive_service = authenticate()
|
|
105
|
+
|
|
106
|
+
if SPREADSHEET_ID_PATTERN.match(name_or_id):
|
|
107
|
+
try:
|
|
108
|
+
file_info = drive_service.files().get(
|
|
109
|
+
fileId=name_or_id,
|
|
110
|
+
supportsAllDrives=True,
|
|
111
|
+
fields="id, name, mimeType",
|
|
112
|
+
).execute()
|
|
113
|
+
if file_info.get("mimeType") == GOOGLE_SHEET_MIME:
|
|
114
|
+
return file_info["id"], file_info["name"]
|
|
115
|
+
except HttpError as exc:
|
|
116
|
+
if getattr(exc, "resp", None) is None or exc.resp.status != 404:
|
|
117
|
+
raise
|
|
118
|
+
|
|
119
|
+
escaped_name = name_or_id.replace("'", "\\'")
|
|
120
|
+
query = (
|
|
121
|
+
f"name = '{escaped_name}' and "
|
|
122
|
+
f"mimeType = '{GOOGLE_SHEET_MIME}' and "
|
|
123
|
+
"trashed = false"
|
|
124
|
+
)
|
|
125
|
+
results = drive_service.files().list(
|
|
126
|
+
q=query,
|
|
127
|
+
supportsAllDrives=True,
|
|
128
|
+
includeItemsFromAllDrives=True,
|
|
129
|
+
fields="files(id, name, modifiedTime, webViewLink, parents)",
|
|
130
|
+
).execute()
|
|
131
|
+
files = results.get("files", [])
|
|
132
|
+
|
|
133
|
+
if not files:
|
|
134
|
+
raise ValueError(f"Spreadsheet not found: {name_or_id}")
|
|
135
|
+
|
|
136
|
+
if len(files) == 1:
|
|
137
|
+
file_info = files[0]
|
|
138
|
+
return file_info["id"], file_info["name"]
|
|
139
|
+
|
|
140
|
+
parent_ids: set[str] = set()
|
|
141
|
+
for file_info in files:
|
|
142
|
+
for parent_id in file_info.get("parents", []):
|
|
143
|
+
parent_ids.add(parent_id)
|
|
144
|
+
|
|
145
|
+
parent_names: dict[str, str] = {}
|
|
146
|
+
for parent_id in parent_ids:
|
|
147
|
+
try:
|
|
148
|
+
parent_info = drive_service.files().get(
|
|
149
|
+
fileId=parent_id,
|
|
150
|
+
supportsAllDrives=True,
|
|
151
|
+
fields="id, name",
|
|
152
|
+
).execute()
|
|
153
|
+
parent_names[parent_id] = parent_info.get("name", "")
|
|
154
|
+
except HttpError:
|
|
155
|
+
parent_names[parent_id] = ""
|
|
156
|
+
|
|
157
|
+
candidates = [
|
|
158
|
+
"Multiple spreadsheets found. Use spreadsheet ID instead. Candidates:"
|
|
159
|
+
]
|
|
160
|
+
for file_info in files:
|
|
161
|
+
parent_name = ""
|
|
162
|
+
parent_list = file_info.get("parents", [])
|
|
163
|
+
if parent_list:
|
|
164
|
+
parent_name = parent_names.get(parent_list[0], "")
|
|
165
|
+
candidates.append(
|
|
166
|
+
f"- id: {file_info.get('id', '')}, "
|
|
167
|
+
f"name: {file_info.get('name', '')}, "
|
|
168
|
+
f"modifiedTime: {file_info.get('modifiedTime', '')}, "
|
|
169
|
+
f"webViewLink: {file_info.get('webViewLink', '')}, "
|
|
170
|
+
f"parent: {parent_name}"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
raise ValueError("\n".join(candidates))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def list_sheet_tabs(sheets_service, spreadsheet_id: str) -> list[dict]:
|
|
177
|
+
"""List tabs in a spreadsheet."""
|
|
178
|
+
spreadsheet = sheets_service.spreadsheets().get(
|
|
179
|
+
spreadsheetId=spreadsheet_id,
|
|
180
|
+
fields="sheets(properties(title,index,gridProperties(rowCount,columnCount)))",
|
|
181
|
+
).execute()
|
|
182
|
+
|
|
183
|
+
tabs = []
|
|
184
|
+
for sheet in spreadsheet.get("sheets", []):
|
|
185
|
+
props = sheet.get("properties", {})
|
|
186
|
+
grid = props.get("gridProperties", {})
|
|
187
|
+
tabs.append(
|
|
188
|
+
{
|
|
189
|
+
"title": props.get("title", ""),
|
|
190
|
+
"index": props.get("index", 0),
|
|
191
|
+
"rowCount": grid.get("rowCount", 0),
|
|
192
|
+
"columnCount": grid.get("columnCount", 0),
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
return tabs
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def read_sheet_range(
|
|
199
|
+
sheets_service,
|
|
200
|
+
spreadsheet_id: str,
|
|
201
|
+
range_a1: str,
|
|
202
|
+
value_render_option: str = "FORMATTED_VALUE",
|
|
203
|
+
date_time_render_option: str = "FORMATTED_STRING",
|
|
204
|
+
) -> list[list]:
|
|
205
|
+
"""Read values from a spreadsheet range with render option controls."""
|
|
206
|
+
if value_render_option not in VALID_VALUE_RENDER_OPTIONS:
|
|
207
|
+
raise ValueError(
|
|
208
|
+
f"Invalid value_render_option '{value_render_option}'. "
|
|
209
|
+
f"Must be one of: {sorted(VALID_VALUE_RENDER_OPTIONS)}"
|
|
210
|
+
)
|
|
211
|
+
if date_time_render_option not in VALID_DATETIME_RENDER_OPTIONS:
|
|
212
|
+
raise ValueError(
|
|
213
|
+
f"Invalid date_time_render_option '{date_time_render_option}'. "
|
|
214
|
+
f"Must be one of: {sorted(VALID_DATETIME_RENDER_OPTIONS)}"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
result = sheets_service.spreadsheets().values().get(
|
|
218
|
+
spreadsheetId=spreadsheet_id,
|
|
219
|
+
range=range_a1,
|
|
220
|
+
valueRenderOption=value_render_option,
|
|
221
|
+
dateTimeRenderOption=date_time_render_option,
|
|
222
|
+
).execute()
|
|
223
|
+
return result.get("values", [])
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def update_sheet_range(
|
|
227
|
+
sheets_service,
|
|
228
|
+
spreadsheet_id: str,
|
|
229
|
+
range_a1: str,
|
|
230
|
+
values: list[list],
|
|
231
|
+
value_input_option: str = "USER_ENTERED",
|
|
232
|
+
) -> dict:
|
|
233
|
+
"""Update a spreadsheet range with values."""
|
|
234
|
+
if not isinstance(values, list) or not values or any(
|
|
235
|
+
not isinstance(row, list) for row in values
|
|
236
|
+
):
|
|
237
|
+
raise ValueError("values must be a non-empty list of lists")
|
|
238
|
+
|
|
239
|
+
result = sheets_service.spreadsheets().values().update(
|
|
240
|
+
spreadsheetId=spreadsheet_id,
|
|
241
|
+
range=range_a1,
|
|
242
|
+
valueInputOption=value_input_option,
|
|
243
|
+
body={"values": values},
|
|
244
|
+
).execute()
|
|
245
|
+
return {
|
|
246
|
+
"updatedCells": result.get("updatedCells", 0),
|
|
247
|
+
"updatedRange": result.get("updatedRange", ""),
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def append_sheet_rows(
|
|
252
|
+
sheets_service,
|
|
253
|
+
spreadsheet_id: str,
|
|
254
|
+
range_a1: str,
|
|
255
|
+
values: list[list],
|
|
256
|
+
value_input_option: str = "USER_ENTERED",
|
|
257
|
+
insert_data_option: str = "INSERT_ROWS",
|
|
258
|
+
) -> dict:
|
|
259
|
+
"""Append rows to a spreadsheet range."""
|
|
260
|
+
if not isinstance(values, list) or not values or any(
|
|
261
|
+
not isinstance(row, list) for row in values
|
|
262
|
+
):
|
|
263
|
+
raise ValueError("values must be a non-empty list of lists")
|
|
264
|
+
|
|
265
|
+
result = sheets_service.spreadsheets().values().append(
|
|
266
|
+
spreadsheetId=spreadsheet_id,
|
|
267
|
+
range=range_a1,
|
|
268
|
+
valueInputOption=value_input_option,
|
|
269
|
+
insertDataOption=insert_data_option,
|
|
270
|
+
body={"values": values},
|
|
271
|
+
).execute()
|
|
272
|
+
updates = result.get("updates", {})
|
|
273
|
+
return {
|
|
274
|
+
"updatedCells": updates.get("updatedCells", 0),
|
|
275
|
+
"updatedRange": updates.get("updatedRange", ""),
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def create_spreadsheet(sheets_service, title: str) -> tuple[str, str]:
|
|
280
|
+
"""Create a new spreadsheet and return (spreadsheet_id, spreadsheet_url)."""
|
|
281
|
+
result = sheets_service.spreadsheets().create(
|
|
282
|
+
body={"properties": {"title": title}},
|
|
283
|
+
fields="spreadsheetId,spreadsheetUrl",
|
|
284
|
+
).execute()
|
|
285
|
+
return result.get("spreadsheetId", ""), result.get("spreadsheetUrl", "")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def search_spreadsheets(
|
|
289
|
+
drive_service,
|
|
290
|
+
query: str,
|
|
291
|
+
max_results: int = 10,
|
|
292
|
+
) -> list[dict]:
|
|
293
|
+
"""Search Google Drive for spreadsheets by name."""
|
|
294
|
+
escaped_query = query.replace("'", "\\'")
|
|
295
|
+
search_query = (
|
|
296
|
+
f"name contains '{escaped_query}' and "
|
|
297
|
+
f"mimeType = '{GOOGLE_SHEET_MIME}' and "
|
|
298
|
+
"trashed = false"
|
|
299
|
+
)
|
|
300
|
+
results = drive_service.files().list(
|
|
301
|
+
q=search_query,
|
|
302
|
+
supportsAllDrives=True,
|
|
303
|
+
includeItemsFromAllDrives=True,
|
|
304
|
+
fields="files(id, name, modifiedTime, webViewLink)",
|
|
305
|
+
pageSize=max_results,
|
|
306
|
+
).execute()
|
|
307
|
+
return results.get("files", [])
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def clear_sheet_range(sheets_service, spreadsheet_id: str, range_a1: str) -> dict:
|
|
311
|
+
"""Clear all values in a spreadsheet range."""
|
|
312
|
+
result = sheets_service.spreadsheets().values().clear(
|
|
313
|
+
spreadsheetId=spreadsheet_id,
|
|
314
|
+
range=range_a1,
|
|
315
|
+
body={},
|
|
316
|
+
).execute()
|
|
317
|
+
return {
|
|
318
|
+
"clearedRange": result.get("clearedRange", range_a1),
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def touch_sheet_range(sheets_service, spreadsheet_id: str, range_a1: str) -> dict:
|
|
323
|
+
"""Force custom-function recalculation by clearing and rewriting a range."""
|
|
324
|
+
original_values = read_sheet_range(
|
|
325
|
+
sheets_service,
|
|
326
|
+
spreadsheet_id,
|
|
327
|
+
range_a1,
|
|
328
|
+
value_render_option="FORMULA",
|
|
329
|
+
)
|
|
330
|
+
if not original_values:
|
|
331
|
+
return {"touchedRange": range_a1, "touchedCells": 0}
|
|
332
|
+
|
|
333
|
+
clear_sheet_range(sheets_service, spreadsheet_id, range_a1)
|
|
334
|
+
try:
|
|
335
|
+
updated = update_sheet_range(
|
|
336
|
+
sheets_service,
|
|
337
|
+
spreadsheet_id,
|
|
338
|
+
range_a1,
|
|
339
|
+
original_values,
|
|
340
|
+
value_input_option="USER_ENTERED",
|
|
341
|
+
)
|
|
342
|
+
except Exception:
|
|
343
|
+
logger.warning(
|
|
344
|
+
"touch_sheet_range update failed after clear; spreadsheet_id=%s range=%s original_values=%r",
|
|
345
|
+
spreadsheet_id,
|
|
346
|
+
range_a1,
|
|
347
|
+
original_values,
|
|
348
|
+
)
|
|
349
|
+
raise
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
"touchedRange": updated.get("updatedRange", range_a1),
|
|
353
|
+
"touchedCells": updated.get("updatedCells", 0),
|
|
354
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Unit tests for gsheets-mcp Sheets client helpers."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
from unittest.mock import MagicMock
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
11
|
+
|
|
12
|
+
from src import sheets_client
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _http_404_error() -> Exception:
|
|
16
|
+
return sheets_client.HttpError(
|
|
17
|
+
SimpleNamespace(status=404, reason="Not Found"),
|
|
18
|
+
b"Not Found",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _files_list_call_args(service_mock) -> dict:
|
|
23
|
+
return service_mock.files.return_value.list.call_args.kwargs
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_value_render_option_validation() -> None:
|
|
27
|
+
sheets_service = MagicMock()
|
|
28
|
+
|
|
29
|
+
with pytest.raises(ValueError) as exc_info:
|
|
30
|
+
sheets_client.read_sheet_range(
|
|
31
|
+
sheets_service,
|
|
32
|
+
spreadsheet_id="sheet-123",
|
|
33
|
+
range_a1="Sheet1!A1:A2",
|
|
34
|
+
value_render_option="BAD_VALUE",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
msg = str(exc_info.value)
|
|
38
|
+
assert "Invalid value_render_option 'BAD_VALUE'" in msg
|
|
39
|
+
assert "FORMULA" in msg
|
|
40
|
+
assert "FORMATTED_VALUE" in msg
|
|
41
|
+
assert "UNFORMATTED_VALUE" in msg
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_date_time_render_option_validation() -> None:
|
|
45
|
+
sheets_service = MagicMock()
|
|
46
|
+
|
|
47
|
+
with pytest.raises(ValueError) as exc_info:
|
|
48
|
+
sheets_client.read_sheet_range(
|
|
49
|
+
sheets_service,
|
|
50
|
+
spreadsheet_id="sheet-123",
|
|
51
|
+
range_a1="Sheet1!A1:A2",
|
|
52
|
+
date_time_render_option="BAD_DATE",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
msg = str(exc_info.value)
|
|
56
|
+
assert "Invalid date_time_render_option 'BAD_DATE'" in msg
|
|
57
|
+
assert "FORMATTED_STRING" in msg
|
|
58
|
+
assert "SERIAL_NUMBER" in msg
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_resolve_spreadsheet_id_by_pattern(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
62
|
+
drive_service = MagicMock()
|
|
63
|
+
drive_service.files.return_value.get.return_value.execute.return_value = {
|
|
64
|
+
"id": "abc1234567890XYZ___123",
|
|
65
|
+
"name": "My Sheet",
|
|
66
|
+
"mimeType": sheets_client.GOOGLE_SHEET_MIME,
|
|
67
|
+
}
|
|
68
|
+
monkeypatch.setattr(sheets_client, "authenticate", lambda: drive_service)
|
|
69
|
+
|
|
70
|
+
spreadsheet_id, title = sheets_client.resolve_spreadsheet_id(
|
|
71
|
+
"abc1234567890XYZ___123"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
assert spreadsheet_id == "abc1234567890XYZ___123"
|
|
75
|
+
assert title == "My Sheet"
|
|
76
|
+
drive_service.files.return_value.get.assert_called_once()
|
|
77
|
+
drive_service.files.return_value.list.assert_not_called()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_resolve_spreadsheet_id_by_name(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
81
|
+
drive_service = MagicMock()
|
|
82
|
+
drive_service.files.return_value.get.return_value.execute.side_effect = _http_404_error()
|
|
83
|
+
drive_service.files.return_value.list.return_value.execute.return_value = {
|
|
84
|
+
"files": [{"id": "sheet-1", "name": "Comps"}]
|
|
85
|
+
}
|
|
86
|
+
monkeypatch.setattr(sheets_client, "authenticate", lambda: drive_service)
|
|
87
|
+
|
|
88
|
+
spreadsheet_id, title = sheets_client.resolve_spreadsheet_id("Comps")
|
|
89
|
+
|
|
90
|
+
assert spreadsheet_id == "sheet-1"
|
|
91
|
+
assert title == "Comps"
|
|
92
|
+
list_kwargs = _files_list_call_args(drive_service)
|
|
93
|
+
assert list_kwargs["supportsAllDrives"] is True
|
|
94
|
+
assert list_kwargs["includeItemsFromAllDrives"] is True
|
|
95
|
+
assert "name = 'Comps'" in list_kwargs["q"]
|
|
96
|
+
assert sheets_client.GOOGLE_SHEET_MIME in list_kwargs["q"]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_resolve_spreadsheet_ambiguous(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
100
|
+
drive_service = MagicMock()
|
|
101
|
+
|
|
102
|
+
def fake_get(*, fileId, **kwargs):
|
|
103
|
+
req = MagicMock()
|
|
104
|
+
if fileId in {"parent-1", "parent-2"}:
|
|
105
|
+
req.execute.return_value = {"id": fileId, "name": f"Folder {fileId[-1]}"}
|
|
106
|
+
else:
|
|
107
|
+
req.execute.side_effect = _http_404_error()
|
|
108
|
+
return req
|
|
109
|
+
|
|
110
|
+
drive_service.files.return_value.get.side_effect = fake_get
|
|
111
|
+
drive_service.files.return_value.list.return_value.execute.return_value = {
|
|
112
|
+
"files": [
|
|
113
|
+
{
|
|
114
|
+
"id": "sheet-a",
|
|
115
|
+
"name": "Comps",
|
|
116
|
+
"modifiedTime": "2026-02-21T00:00:00Z",
|
|
117
|
+
"webViewLink": "https://docs.google.com/spreadsheets/d/sheet-a",
|
|
118
|
+
"parents": ["parent-1"],
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"id": "sheet-b",
|
|
122
|
+
"name": "Comps",
|
|
123
|
+
"modifiedTime": "2026-02-22T00:00:00Z",
|
|
124
|
+
"webViewLink": "https://docs.google.com/spreadsheets/d/sheet-b",
|
|
125
|
+
"parents": ["parent-2"],
|
|
126
|
+
},
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
monkeypatch.setattr(sheets_client, "authenticate", lambda: drive_service)
|
|
130
|
+
|
|
131
|
+
with pytest.raises(ValueError) as exc_info:
|
|
132
|
+
sheets_client.resolve_spreadsheet_id("Comps")
|
|
133
|
+
|
|
134
|
+
msg = str(exc_info.value)
|
|
135
|
+
assert "Multiple spreadsheets found" in msg
|
|
136
|
+
assert "sheet-a" in msg
|
|
137
|
+
assert "sheet-b" in msg
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_search_spreadsheets_filters_mime() -> None:
|
|
141
|
+
drive_service = MagicMock()
|
|
142
|
+
drive_service.files.return_value.list.return_value.execute.return_value = {
|
|
143
|
+
"files": []
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
sheets_client.search_spreadsheets(drive_service, query="Comp", max_results=5)
|
|
147
|
+
|
|
148
|
+
list_kwargs = _files_list_call_args(drive_service)
|
|
149
|
+
assert sheets_client.GOOGLE_SHEET_MIME in list_kwargs["q"]
|
|
150
|
+
assert "trashed = false" in list_kwargs["q"]
|
|
151
|
+
assert list_kwargs["pageSize"] == 5
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_search_spreadsheets_shared_drives() -> None:
|
|
155
|
+
drive_service = MagicMock()
|
|
156
|
+
drive_service.files.return_value.list.return_value.execute.return_value = {
|
|
157
|
+
"files": []
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
sheets_client.search_spreadsheets(drive_service, query="Comp")
|
|
161
|
+
|
|
162
|
+
list_kwargs = _files_list_call_args(drive_service)
|
|
163
|
+
assert list_kwargs["supportsAllDrives"] is True
|
|
164
|
+
assert list_kwargs["includeItemsFromAllDrives"] is True
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_search_spreadsheets_escapes_apostrophes() -> None:
|
|
168
|
+
drive_service = MagicMock()
|
|
169
|
+
drive_service.files.return_value.list.return_value.execute.return_value = {
|
|
170
|
+
"files": []
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
sheets_client.search_spreadsheets(drive_service, query="O'Reilly")
|
|
174
|
+
|
|
175
|
+
list_kwargs = _files_list_call_args(drive_service)
|
|
176
|
+
assert "O\\'Reilly" in list_kwargs["q"]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_clear_range_calls_api() -> None:
|
|
180
|
+
sheets_service = MagicMock()
|
|
181
|
+
clear_execute = (
|
|
182
|
+
sheets_service.spreadsheets.return_value.values.return_value.clear.return_value.execute
|
|
183
|
+
)
|
|
184
|
+
clear_execute.return_value = {"clearedRange": "Sheet1!A1:C10"}
|
|
185
|
+
|
|
186
|
+
result = sheets_client.clear_sheet_range(
|
|
187
|
+
sheets_service,
|
|
188
|
+
spreadsheet_id="sheet-123",
|
|
189
|
+
range_a1="Sheet1!A1:C10",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
sheets_service.spreadsheets.return_value.values.return_value.clear.assert_called_once_with(
|
|
193
|
+
spreadsheetId="sheet-123",
|
|
194
|
+
range="Sheet1!A1:C10",
|
|
195
|
+
body={},
|
|
196
|
+
)
|
|
197
|
+
assert result["clearedRange"] == "Sheet1!A1:C10"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_touch_sheet_range_reads_clears_and_rewrites_in_order() -> None:
|
|
201
|
+
sheets_service = MagicMock()
|
|
202
|
+
values_api = sheets_service.spreadsheets.return_value.values.return_value
|
|
203
|
+
call_order: list[str] = []
|
|
204
|
+
|
|
205
|
+
def _record_read():
|
|
206
|
+
call_order.append("read")
|
|
207
|
+
return {"values": [["=SF(\"AAPL\",\"income\",\"revenue\")", 42], ["foo", "bar"]]}
|
|
208
|
+
|
|
209
|
+
def _record_clear():
|
|
210
|
+
call_order.append("clear")
|
|
211
|
+
return {"clearedRange": "Sheet1!A1:B2"}
|
|
212
|
+
|
|
213
|
+
def _record_update():
|
|
214
|
+
call_order.append("update")
|
|
215
|
+
return {"updatedRange": "Sheet1!A1:B2", "updatedCells": 4}
|
|
216
|
+
|
|
217
|
+
values_api.get.return_value.execute.side_effect = _record_read
|
|
218
|
+
values_api.clear.return_value.execute.side_effect = _record_clear
|
|
219
|
+
values_api.update.return_value.execute.side_effect = _record_update
|
|
220
|
+
|
|
221
|
+
result = sheets_client.touch_sheet_range(
|
|
222
|
+
sheets_service,
|
|
223
|
+
spreadsheet_id="sheet-123",
|
|
224
|
+
range_a1="Sheet1!A1:B2",
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
assert call_order == ["read", "clear", "update"]
|
|
228
|
+
values_api.get.assert_called_once_with(
|
|
229
|
+
spreadsheetId="sheet-123",
|
|
230
|
+
range="Sheet1!A1:B2",
|
|
231
|
+
valueRenderOption="FORMULA",
|
|
232
|
+
dateTimeRenderOption="FORMATTED_STRING",
|
|
233
|
+
)
|
|
234
|
+
values_api.clear.assert_called_once_with(
|
|
235
|
+
spreadsheetId="sheet-123",
|
|
236
|
+
range="Sheet1!A1:B2",
|
|
237
|
+
body={},
|
|
238
|
+
)
|
|
239
|
+
values_api.update.assert_called_once_with(
|
|
240
|
+
spreadsheetId="sheet-123",
|
|
241
|
+
range="Sheet1!A1:B2",
|
|
242
|
+
valueInputOption="USER_ENTERED",
|
|
243
|
+
body={"values": [["=SF(\"AAPL\",\"income\",\"revenue\")", 42], ["foo", "bar"]]},
|
|
244
|
+
)
|
|
245
|
+
assert result == {"touchedRange": "Sheet1!A1:B2", "touchedCells": 4}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_touch_sheet_range_empty_range_returns_without_writes() -> None:
|
|
249
|
+
sheets_service = MagicMock()
|
|
250
|
+
values_api = sheets_service.spreadsheets.return_value.values.return_value
|
|
251
|
+
values_api.get.return_value.execute.return_value = {"values": []}
|
|
252
|
+
|
|
253
|
+
result = sheets_client.touch_sheet_range(
|
|
254
|
+
sheets_service,
|
|
255
|
+
spreadsheet_id="sheet-123",
|
|
256
|
+
range_a1="Sheet1!A1:B2",
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
values_api.get.assert_called_once_with(
|
|
260
|
+
spreadsheetId="sheet-123",
|
|
261
|
+
range="Sheet1!A1:B2",
|
|
262
|
+
valueRenderOption="FORMULA",
|
|
263
|
+
dateTimeRenderOption="FORMATTED_STRING",
|
|
264
|
+
)
|
|
265
|
+
values_api.clear.assert_not_called()
|
|
266
|
+
values_api.update.assert_not_called()
|
|
267
|
+
assert result == {"touchedRange": "Sheet1!A1:B2", "touchedCells": 0}
|