arcade-google-sheets 2.0.0rc1__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.
- arcade_google_sheets-2.0.0rc1/.gitignore +175 -0
- arcade_google_sheets-2.0.0rc1/.pre-commit-config.yaml +18 -0
- arcade_google_sheets-2.0.0rc1/.ruff.toml +46 -0
- arcade_google_sheets-2.0.0rc1/LICENSE +21 -0
- arcade_google_sheets-2.0.0rc1/Makefile +55 -0
- arcade_google_sheets-2.0.0rc1/PKG-INFO +24 -0
- arcade_google_sheets-2.0.0rc1/arcade_google_sheets/__init__.py +7 -0
- arcade_google_sheets-2.0.0rc1/arcade_google_sheets/constants.py +2 -0
- arcade_google_sheets-2.0.0rc1/arcade_google_sheets/decorators.py +24 -0
- arcade_google_sheets-2.0.0rc1/arcade_google_sheets/enums.py +25 -0
- arcade_google_sheets-2.0.0rc1/arcade_google_sheets/file_picker.py +49 -0
- arcade_google_sheets-2.0.0rc1/arcade_google_sheets/models.py +241 -0
- arcade_google_sheets-2.0.0rc1/arcade_google_sheets/tools/__init__.py +4 -0
- arcade_google_sheets-2.0.0rc1/arcade_google_sheets/tools/read.py +42 -0
- arcade_google_sheets-2.0.0rc1/arcade_google_sheets/tools/write.py +114 -0
- arcade_google_sheets-2.0.0rc1/arcade_google_sheets/types.py +1 -0
- arcade_google_sheets-2.0.0rc1/arcade_google_sheets/utils.py +548 -0
- arcade_google_sheets-2.0.0rc1/evals/eval_google_sheets.py +169 -0
- arcade_google_sheets-2.0.0rc1/pyproject.toml +63 -0
- arcade_google_sheets-2.0.0rc1/tests/__init__.py +0 -0
- arcade_google_sheets-2.0.0rc1/tests/test_sheets_models.py +84 -0
- arcade_google_sheets-2.0.0rc1/tests/test_sheets_utils.py +542 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
.DS_Store
|
|
2
|
+
credentials.yaml
|
|
3
|
+
docker/credentials.yaml
|
|
4
|
+
|
|
5
|
+
*.lock
|
|
6
|
+
|
|
7
|
+
# example data
|
|
8
|
+
examples/data
|
|
9
|
+
scratch
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
docs/source
|
|
13
|
+
|
|
14
|
+
# From https://raw.githubusercontent.com/github/gitignore/main/Python.gitignore
|
|
15
|
+
|
|
16
|
+
# Byte-compiled / optimized / DLL files
|
|
17
|
+
__pycache__/
|
|
18
|
+
*.py[cod]
|
|
19
|
+
*$py.class
|
|
20
|
+
|
|
21
|
+
# C extensions
|
|
22
|
+
*.so
|
|
23
|
+
|
|
24
|
+
# Distribution / packaging
|
|
25
|
+
.Python
|
|
26
|
+
build/
|
|
27
|
+
develop-eggs/
|
|
28
|
+
dist/
|
|
29
|
+
downloads/
|
|
30
|
+
eggs/
|
|
31
|
+
.eggs/
|
|
32
|
+
lib/
|
|
33
|
+
lib64/
|
|
34
|
+
parts/
|
|
35
|
+
sdist/
|
|
36
|
+
var/
|
|
37
|
+
wheels/
|
|
38
|
+
share/python-wheels/
|
|
39
|
+
*.egg-info/
|
|
40
|
+
.installed.cfg
|
|
41
|
+
*.egg
|
|
42
|
+
MANIFEST
|
|
43
|
+
|
|
44
|
+
# PyInstaller
|
|
45
|
+
# Usually these files are written by a python script from a template
|
|
46
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
47
|
+
*.manifest
|
|
48
|
+
*.spec
|
|
49
|
+
|
|
50
|
+
# Installer logs
|
|
51
|
+
pip-log.txt
|
|
52
|
+
pip-delete-this-directory.txt
|
|
53
|
+
|
|
54
|
+
# Unit test / coverage reports
|
|
55
|
+
htmlcov/
|
|
56
|
+
.tox/
|
|
57
|
+
.nox/
|
|
58
|
+
.coverage
|
|
59
|
+
.coverage.*
|
|
60
|
+
.cache
|
|
61
|
+
nosetests.xml
|
|
62
|
+
coverage.xml
|
|
63
|
+
*.cover
|
|
64
|
+
*.py,cover
|
|
65
|
+
.hypothesis/
|
|
66
|
+
.pytest_cache/
|
|
67
|
+
cover/
|
|
68
|
+
|
|
69
|
+
# Translations
|
|
70
|
+
*.mo
|
|
71
|
+
*.pot
|
|
72
|
+
|
|
73
|
+
# Django stuff:
|
|
74
|
+
*.log
|
|
75
|
+
local_settings.py
|
|
76
|
+
db.sqlite3
|
|
77
|
+
db.sqlite3-journal
|
|
78
|
+
|
|
79
|
+
# Flask stuff:
|
|
80
|
+
instance/
|
|
81
|
+
.webassets-cache
|
|
82
|
+
|
|
83
|
+
# Scrapy stuff:
|
|
84
|
+
.scrapy
|
|
85
|
+
|
|
86
|
+
# Sphinx documentation
|
|
87
|
+
docs/_build/
|
|
88
|
+
|
|
89
|
+
# PyBuilder
|
|
90
|
+
.pybuilder/
|
|
91
|
+
target/
|
|
92
|
+
|
|
93
|
+
# Jupyter Notebook
|
|
94
|
+
.ipynb_checkpoints
|
|
95
|
+
|
|
96
|
+
# IPython
|
|
97
|
+
profile_default/
|
|
98
|
+
ipython_config.py
|
|
99
|
+
|
|
100
|
+
# pyenv
|
|
101
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
102
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
103
|
+
# .python-version
|
|
104
|
+
|
|
105
|
+
# pipenv
|
|
106
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
107
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
108
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
109
|
+
# install all needed dependencies.
|
|
110
|
+
#Pipfile.lock
|
|
111
|
+
|
|
112
|
+
# poetry
|
|
113
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
114
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
115
|
+
# commonly ignored for libraries.
|
|
116
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
117
|
+
poetry.lock
|
|
118
|
+
|
|
119
|
+
# pdm
|
|
120
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
121
|
+
#pdm.lock
|
|
122
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
123
|
+
# in version control.
|
|
124
|
+
# https://pdm.fming.dev/#use-with-ide
|
|
125
|
+
.pdm.toml
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# SageMath parsed files
|
|
135
|
+
*.sage.py
|
|
136
|
+
|
|
137
|
+
# Environments
|
|
138
|
+
.env
|
|
139
|
+
.venv
|
|
140
|
+
env/
|
|
141
|
+
venv/
|
|
142
|
+
ENV/
|
|
143
|
+
env.bak/
|
|
144
|
+
venv.bak/
|
|
145
|
+
|
|
146
|
+
# Spyder project settings
|
|
147
|
+
.spyderproject
|
|
148
|
+
.spyproject
|
|
149
|
+
|
|
150
|
+
# Rope project settings
|
|
151
|
+
.ropeproject
|
|
152
|
+
|
|
153
|
+
# mkdocs documentation
|
|
154
|
+
/site
|
|
155
|
+
|
|
156
|
+
# mypy
|
|
157
|
+
.mypy_cache/
|
|
158
|
+
.dmypy.json
|
|
159
|
+
dmypy.json
|
|
160
|
+
|
|
161
|
+
# Pyre type checker
|
|
162
|
+
.pyre/
|
|
163
|
+
|
|
164
|
+
# pytype static type analyzer
|
|
165
|
+
.pytype/
|
|
166
|
+
|
|
167
|
+
# Cython debug symbols
|
|
168
|
+
cython_debug/
|
|
169
|
+
|
|
170
|
+
# PyCharm
|
|
171
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
172
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
173
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
174
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
175
|
+
#.idea/
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
files: ^.*/google_sheets/.*
|
|
2
|
+
repos:
|
|
3
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
4
|
+
rev: "v4.4.0"
|
|
5
|
+
hooks:
|
|
6
|
+
- id: check-case-conflict
|
|
7
|
+
- id: check-merge-conflict
|
|
8
|
+
- id: check-toml
|
|
9
|
+
- id: check-yaml
|
|
10
|
+
- id: end-of-file-fixer
|
|
11
|
+
- id: trailing-whitespace
|
|
12
|
+
|
|
13
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
14
|
+
rev: v0.6.7
|
|
15
|
+
hooks:
|
|
16
|
+
- id: ruff
|
|
17
|
+
args: [--fix]
|
|
18
|
+
- id: ruff-format
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
target-version = "py310"
|
|
2
|
+
line-length = 100
|
|
3
|
+
fix = true
|
|
4
|
+
|
|
5
|
+
[lint]
|
|
6
|
+
select = [
|
|
7
|
+
# flake8-2020
|
|
8
|
+
"YTT",
|
|
9
|
+
# flake8-bandit
|
|
10
|
+
"S",
|
|
11
|
+
# flake8-bugbear
|
|
12
|
+
"B",
|
|
13
|
+
# flake8-builtins
|
|
14
|
+
"A",
|
|
15
|
+
# flake8-comprehensions
|
|
16
|
+
"C4",
|
|
17
|
+
# flake8-debugger
|
|
18
|
+
"T10",
|
|
19
|
+
# flake8-simplify
|
|
20
|
+
"SIM",
|
|
21
|
+
# isort
|
|
22
|
+
"I",
|
|
23
|
+
# mccabe
|
|
24
|
+
"C90",
|
|
25
|
+
# pycodestyle
|
|
26
|
+
"E", "W",
|
|
27
|
+
# pyflakes
|
|
28
|
+
"F",
|
|
29
|
+
# pygrep-hooks
|
|
30
|
+
"PGH",
|
|
31
|
+
# pyupgrade
|
|
32
|
+
"UP",
|
|
33
|
+
# ruff
|
|
34
|
+
"RUF",
|
|
35
|
+
# tryceratops
|
|
36
|
+
"TRY",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[lint.per-file-ignores]
|
|
40
|
+
"*" = ["TRY003", "B904"]
|
|
41
|
+
"**/tests/*" = ["S101", "E501"]
|
|
42
|
+
"**/evals/*" = ["S101", "E501"]
|
|
43
|
+
|
|
44
|
+
[format]
|
|
45
|
+
preview = true
|
|
46
|
+
skip-magic-trailing-comma = false
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Arcade
|
|
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,55 @@
|
|
|
1
|
+
.PHONY: help
|
|
2
|
+
|
|
3
|
+
help:
|
|
4
|
+
@echo "🛠️ github Commands:\n"
|
|
5
|
+
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
|
6
|
+
|
|
7
|
+
.PHONY: install
|
|
8
|
+
install: ## Install the uv environment and install all packages with dependencies
|
|
9
|
+
@echo "🚀 Creating virtual environment and installing all packages using uv"
|
|
10
|
+
@uv sync --active --all-extras --no-sources
|
|
11
|
+
@if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi
|
|
12
|
+
@echo "✅ All packages and dependencies installed via uv"
|
|
13
|
+
|
|
14
|
+
.PHONY: install-local
|
|
15
|
+
install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources
|
|
16
|
+
@echo "🚀 Creating virtual environment and installing all packages using uv"
|
|
17
|
+
@uv sync --active --all-extras
|
|
18
|
+
@if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi
|
|
19
|
+
@echo "✅ All packages and dependencies installed via uv"
|
|
20
|
+
|
|
21
|
+
.PHONY: build
|
|
22
|
+
build: clean-build ## Build wheel file using poetry
|
|
23
|
+
@echo "🚀 Creating wheel file"
|
|
24
|
+
uv build
|
|
25
|
+
|
|
26
|
+
.PHONY: clean-build
|
|
27
|
+
clean-build: ## clean build artifacts
|
|
28
|
+
@echo "🗑️ Cleaning dist directory"
|
|
29
|
+
rm -rf dist
|
|
30
|
+
|
|
31
|
+
.PHONY: test
|
|
32
|
+
test: ## Test the code with pytest
|
|
33
|
+
@echo "🚀 Testing code: Running pytest"
|
|
34
|
+
@uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
|
|
35
|
+
|
|
36
|
+
.PHONY: coverage
|
|
37
|
+
coverage: ## Generate coverage report
|
|
38
|
+
@echo "coverage report"
|
|
39
|
+
@uv run --no-sources coverage report
|
|
40
|
+
@echo "Generating coverage report"
|
|
41
|
+
@uv run --no-sources coverage html
|
|
42
|
+
|
|
43
|
+
.PHONY: bump-version
|
|
44
|
+
bump-version: ## Bump the version in the pyproject.toml file by a patch version
|
|
45
|
+
@echo "🚀 Bumping version in pyproject.toml"
|
|
46
|
+
uv version --no-sources --bump patch
|
|
47
|
+
|
|
48
|
+
.PHONY: check
|
|
49
|
+
check: ## Run code quality tools.
|
|
50
|
+
@if [ -f .pre-commit-config.yaml ]; then\
|
|
51
|
+
echo "🚀 Linting code: Running pre-commit";\
|
|
52
|
+
uv run --no-sources pre-commit run -a;\
|
|
53
|
+
fi
|
|
54
|
+
@echo "🚀 Static type checking: Running mypy"
|
|
55
|
+
@uv run --no-sources mypy --config-file=pyproject.toml
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: arcade_google_sheets
|
|
3
|
+
Version: 2.0.0rc1
|
|
4
|
+
Summary: Arcade.dev LLM tools for Google Sheets
|
|
5
|
+
Author-email: Arcade <dev@arcade.dev>
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: arcade-tdk<3.0.0,>=2.0.0
|
|
9
|
+
Requires-Dist: google-api-core<3.0.0,>=2.19.1
|
|
10
|
+
Requires-Dist: google-api-python-client<3.0.0,>=2.137.0
|
|
11
|
+
Requires-Dist: google-auth-httplib2<1.0.0,>=0.2.0
|
|
12
|
+
Requires-Dist: google-auth<3.0.0,>=2.32.0
|
|
13
|
+
Requires-Dist: googleapis-common-protos<2.0.0,>=1.63.2
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: arcade-ai[evals]<3.0.0,>=2.0.0rc1; extra == 'dev'
|
|
16
|
+
Requires-Dist: arcade-serve<3.0.0,>=2.0.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: mypy<1.6.0,>=1.5.1; extra == 'dev'
|
|
18
|
+
Requires-Dist: pre-commit<3.5.0,>=3.4.0; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest-asyncio<0.25.0,>=0.24.0; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest-cov<4.1.0,>=4.0.0; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest-mock<3.12.0,>=3.11.1; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest<8.4.0,>=8.3.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: ruff<0.8.0,>=0.7.4; extra == 'dev'
|
|
24
|
+
Requires-Dist: tox<4.12.0,>=4.11.1; extra == 'dev'
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from arcade_tdk import ToolContext
|
|
6
|
+
from googleapiclient.errors import HttpError
|
|
7
|
+
|
|
8
|
+
from arcade_google_sheets.file_picker import generate_google_file_picker_url
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def with_filepicker_fallback(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
12
|
+
""" """
|
|
13
|
+
|
|
14
|
+
@functools.wraps(func)
|
|
15
|
+
async def async_wrapper(context: ToolContext, *args: Any, **kwargs: Any) -> Any:
|
|
16
|
+
try:
|
|
17
|
+
return await func(context, *args, **kwargs)
|
|
18
|
+
except HttpError as e:
|
|
19
|
+
if e.status_code in [403, 404]:
|
|
20
|
+
file_picker_response = generate_google_file_picker_url(context)
|
|
21
|
+
return file_picker_response
|
|
22
|
+
raise
|
|
23
|
+
|
|
24
|
+
return async_wrapper
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CellErrorType(str, Enum):
|
|
5
|
+
"""The type of error in a cell
|
|
6
|
+
|
|
7
|
+
Implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#ErrorType
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
ERROR_TYPE_UNSPECIFIED = "ERROR_TYPE_UNSPECIFIED" # The default error type, do not use this.
|
|
11
|
+
ERROR = "ERROR" # Corresponds to the #ERROR! error.
|
|
12
|
+
NULL_VALUE = "NULL_VALUE" # Corresponds to the #NULL! error.
|
|
13
|
+
DIVIDE_BY_ZERO = "DIVIDE_BY_ZERO" # Corresponds to the #DIV/0 error.
|
|
14
|
+
VALUE = "VALUE" # Corresponds to the #VALUE! error.
|
|
15
|
+
REF = "REF" # Corresponds to the #REF! error.
|
|
16
|
+
NAME = "NAME" # Corresponds to the #NAME? error.
|
|
17
|
+
NUM = "NUM" # Corresponds to the #NUM! error.
|
|
18
|
+
N_A = "N_A" # Corresponds to the #N/A error.
|
|
19
|
+
LOADING = "LOADING" # Corresponds to the Loading... state.
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NumberFormatType(str, Enum):
|
|
23
|
+
NUMBER = "NUMBER"
|
|
24
|
+
PERCENT = "PERCENT"
|
|
25
|
+
CURRENCY = "CURRENCY"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from arcade_tdk import ToolContext, ToolMetadataKey
|
|
5
|
+
from arcade_tdk.errors import ToolExecutionError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def generate_google_file_picker_url(context: ToolContext) -> dict:
|
|
9
|
+
"""Generate a Google File Picker URL for user-driven file selection and authorization.
|
|
10
|
+
|
|
11
|
+
Generates a URL that directs the end-user to a Google File Picker interface where
|
|
12
|
+
where they can select or upload Google Drive files. Users can grant permission to access their
|
|
13
|
+
Drive files, providing a secure and authorized way to interact with their files.
|
|
14
|
+
|
|
15
|
+
This is particularly useful when prior tools (e.g., those accessing or modifying
|
|
16
|
+
Google Docs, Google Sheets, etc.) encountered failures due to file non-existence
|
|
17
|
+
(Requested entity was not found) or permission errors. Once the user completes the file
|
|
18
|
+
picker flow, the prior tool can be retried.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
A dictionary containing the URL and instructions for the llm to instruct the user.
|
|
22
|
+
"""
|
|
23
|
+
client_id = context.get_metadata(ToolMetadataKey.CLIENT_ID)
|
|
24
|
+
client_id_parts = client_id.split("-")
|
|
25
|
+
if not client_id_parts:
|
|
26
|
+
raise ToolExecutionError(
|
|
27
|
+
message="Invalid Google Client ID",
|
|
28
|
+
developer_message=f"Google Client ID '{client_id}' is not valid",
|
|
29
|
+
)
|
|
30
|
+
app_id = client_id_parts[0]
|
|
31
|
+
cloud_coordinator_url = context.get_metadata(ToolMetadataKey.COORDINATOR_URL).strip("/")
|
|
32
|
+
|
|
33
|
+
config = {
|
|
34
|
+
"auth": {
|
|
35
|
+
"client_id": client_id,
|
|
36
|
+
"app_id": app_id,
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
config_json = json.dumps(config)
|
|
40
|
+
config_base64 = base64.urlsafe_b64encode(config_json.encode("utf-8")).decode("utf-8")
|
|
41
|
+
url = f"{cloud_coordinator_url}/google/drive_picker?config={config_base64}"
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
"url": url,
|
|
45
|
+
"llm_instructions": (
|
|
46
|
+
"Instruct the user to click the following link to open the Google Drive File Picker. "
|
|
47
|
+
f"This will allow them to select files and grant access permissions: {url}"
|
|
48
|
+
),
|
|
49
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, field_validator, model_validator
|
|
5
|
+
|
|
6
|
+
from arcade_google_sheets.enums import CellErrorType, NumberFormatType
|
|
7
|
+
from arcade_google_sheets.types import CellValue
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CellErrorValue(BaseModel):
|
|
11
|
+
"""An error in a cell
|
|
12
|
+
|
|
13
|
+
Implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#ErrorValue
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
type: CellErrorType
|
|
17
|
+
message: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CellExtendedValue(BaseModel):
|
|
21
|
+
"""The kinds of value that a cell in a spreadsheet can have
|
|
22
|
+
|
|
23
|
+
Implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#ExtendedValue
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
numberValue: float | None = None
|
|
27
|
+
stringValue: str | None = None
|
|
28
|
+
boolValue: bool | None = None
|
|
29
|
+
formulaValue: str | None = None
|
|
30
|
+
errorValue: Optional["CellErrorValue"] = None
|
|
31
|
+
|
|
32
|
+
@model_validator(mode="after")
|
|
33
|
+
def check_exactly_one_value(cls, instance): # type: ignore[no-untyped-def]
|
|
34
|
+
provided = [v for v in instance.__dict__.values() if v is not None]
|
|
35
|
+
if len(provided) != 1:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"Exactly one of numberValue, stringValue, boolValue, "
|
|
38
|
+
"formulaValue, or errorValue must be set."
|
|
39
|
+
)
|
|
40
|
+
return instance
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class NumberFormat(BaseModel):
|
|
44
|
+
"""The format of a number
|
|
45
|
+
|
|
46
|
+
Implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#NumberFormat
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
pattern: str
|
|
50
|
+
type: NumberFormatType
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CellFormat(BaseModel):
|
|
54
|
+
"""The format of a cell
|
|
55
|
+
|
|
56
|
+
Partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#CellFormat
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
numberFormat: NumberFormat
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CellData(BaseModel):
|
|
63
|
+
"""Data about a specific cell
|
|
64
|
+
|
|
65
|
+
A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#CellData
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
userEnteredValue: CellExtendedValue
|
|
69
|
+
userEnteredFormat: CellFormat | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class RowData(BaseModel):
|
|
73
|
+
"""Data about each cellin a row
|
|
74
|
+
|
|
75
|
+
A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#RowData
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
values: list[CellData]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class GridData(BaseModel):
|
|
82
|
+
"""Data in the grid
|
|
83
|
+
|
|
84
|
+
A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#GridData
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
startRow: int
|
|
88
|
+
startColumn: int
|
|
89
|
+
rowData: list[RowData]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class GridProperties(BaseModel):
|
|
93
|
+
"""Properties of a grid
|
|
94
|
+
|
|
95
|
+
A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#GridProperties
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
rowCount: int
|
|
99
|
+
columnCount: int
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class SheetProperties(BaseModel):
|
|
103
|
+
"""Properties of a Sheet
|
|
104
|
+
|
|
105
|
+
A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#SheetProperties
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
sheetId: int
|
|
109
|
+
title: str
|
|
110
|
+
gridProperties: GridProperties | None = None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Sheet(BaseModel):
|
|
114
|
+
"""A Sheet in a spreadsheet
|
|
115
|
+
|
|
116
|
+
A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#Sheet
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
properties: SheetProperties
|
|
120
|
+
data: list[GridData] | None = None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class SpreadsheetProperties(BaseModel):
|
|
124
|
+
"""Properties of a spreadsheet
|
|
125
|
+
|
|
126
|
+
A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets#SpreadsheetProperties
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
title: str
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class Spreadsheet(BaseModel):
|
|
133
|
+
"""A spreadsheet
|
|
134
|
+
|
|
135
|
+
A partial implementation of https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
properties: SpreadsheetProperties
|
|
139
|
+
sheets: list[Sheet]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class SheetDataInput(BaseModel):
|
|
143
|
+
"""
|
|
144
|
+
SheetDataInput models the cell data of a spreadsheet in a custom format.
|
|
145
|
+
|
|
146
|
+
It is a dictionary mapping row numbers (as ints) to dictionaries that map
|
|
147
|
+
column letters (as uppercase strings) to cell values (int, float, str, or bool).
|
|
148
|
+
|
|
149
|
+
This model enforces that:
|
|
150
|
+
- The outer keys are convertible to int.
|
|
151
|
+
- The inner keys are alphabetic strings (normalized to uppercase).
|
|
152
|
+
- All cell values are only of type int, float, str, or bool.
|
|
153
|
+
|
|
154
|
+
The model automatically serializes (via `json_data()`)
|
|
155
|
+
and validates the inner types.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
data: dict[int, dict[str, CellValue]]
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def _parse_json_if_string(cls, value): # type: ignore[no-untyped-def]
|
|
162
|
+
"""Parses the value if it is a JSON string, otherwise returns it.
|
|
163
|
+
|
|
164
|
+
Helper method for when validating the `data` field.
|
|
165
|
+
"""
|
|
166
|
+
if isinstance(value, str):
|
|
167
|
+
try:
|
|
168
|
+
return json.loads(value)
|
|
169
|
+
except json.JSONDecodeError as e:
|
|
170
|
+
raise TypeError(f"Invalid JSON: {e}")
|
|
171
|
+
return value
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def _validate_row_key(cls, row_key) -> int: # type: ignore[no-untyped-def]
|
|
175
|
+
"""Converts the row key to an integer, raising an error if conversion fails.
|
|
176
|
+
|
|
177
|
+
Helper method for when validating the `data` field.
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
return int(row_key)
|
|
181
|
+
except (ValueError, TypeError):
|
|
182
|
+
raise TypeError(f"Row key '{row_key}' is not convertible to int.")
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def _validate_inner_cells(cls, cells, row_int: int) -> dict: # type: ignore[no-untyped-def]
|
|
186
|
+
"""Validates that 'cells' is a dict mapping column letters to valid cell values
|
|
187
|
+
and normalizes the keys.
|
|
188
|
+
|
|
189
|
+
Helper method for when validating the `data` field.
|
|
190
|
+
"""
|
|
191
|
+
if not isinstance(cells, dict):
|
|
192
|
+
raise TypeError(
|
|
193
|
+
f"Value for row '{row_int}' must be a dict mapping column letters to cell values."
|
|
194
|
+
)
|
|
195
|
+
new_inner = {}
|
|
196
|
+
for col_key, cell_value in cells.items():
|
|
197
|
+
if not isinstance(col_key, str):
|
|
198
|
+
raise TypeError(f"Column key '{col_key}' must be a string.")
|
|
199
|
+
col_string = col_key.upper()
|
|
200
|
+
if not col_string.isalpha():
|
|
201
|
+
raise TypeError(f"Column key '{col_key}' is invalid. Must be alphabetic.")
|
|
202
|
+
if not isinstance(cell_value, int | float | str | bool):
|
|
203
|
+
raise TypeError(
|
|
204
|
+
f"Cell value for {col_string}{row_int} must be an int, float, str, or bool."
|
|
205
|
+
)
|
|
206
|
+
new_inner[col_string] = cell_value
|
|
207
|
+
return new_inner
|
|
208
|
+
|
|
209
|
+
@field_validator("data", mode="before")
|
|
210
|
+
@classmethod
|
|
211
|
+
def validate_and_convert_keys(cls, value): # type: ignore[no-untyped-def]
|
|
212
|
+
"""
|
|
213
|
+
Validates data when SheetDataInput is instantiated and converts it to the correct format.
|
|
214
|
+
Uses private helper methods to parse JSON, validate row keys, and validate inner cell data.
|
|
215
|
+
"""
|
|
216
|
+
if value is None:
|
|
217
|
+
return {}
|
|
218
|
+
|
|
219
|
+
value = cls._parse_json_if_string(value)
|
|
220
|
+
if isinstance(value, dict):
|
|
221
|
+
new_value = {}
|
|
222
|
+
for row_key, cells in value.items():
|
|
223
|
+
row_int = cls._validate_row_key(row_key)
|
|
224
|
+
inner_cells = cls._validate_inner_cells(cells, row_int)
|
|
225
|
+
new_value[row_int] = inner_cells
|
|
226
|
+
return new_value
|
|
227
|
+
|
|
228
|
+
raise TypeError("data must be a dict or a valid JSON string representing a dict")
|
|
229
|
+
|
|
230
|
+
def json_data(self) -> str:
|
|
231
|
+
"""
|
|
232
|
+
Serialize the sheet data to a JSON string.
|
|
233
|
+
"""
|
|
234
|
+
return json.dumps(self.data)
|
|
235
|
+
|
|
236
|
+
@classmethod
|
|
237
|
+
def from_json(cls, json_str: str) -> "SheetDataInput":
|
|
238
|
+
"""
|
|
239
|
+
Create a SheetData instance from a JSON string.
|
|
240
|
+
"""
|
|
241
|
+
return cls.model_validate_json(json_str)
|