This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 855bb4e21c8 fix(gsheets): align service-account validation and
serialize upload dates (#44695)
855bb4e21c8 is described below
commit 855bb4e21c8492775eb8bdd0ac7fe165c2dca513
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Thu Oct 1 12:50:52 2026 +1000
fix(gsheets): align service-account validation and serialize upload dates
(#44695)
---
docs/admin_docs/configuration/google-sheets.mdx | 49 +++++
superset/db_engine_specs/gsheets.py | 81 +++++--
tests/unit_tests/db_engine_specs/test_gsheets.py | 259 ++++++++++++++++++++++-
3 files changed, 374 insertions(+), 15 deletions(-)
diff --git a/docs/admin_docs/configuration/google-sheets.mdx
b/docs/admin_docs/configuration/google-sheets.mdx
new file mode 100644
index 00000000000..0f888f9e314
--- /dev/null
+++ b/docs/admin_docs/configuration/google-sheets.mdx
@@ -0,0 +1,49 @@
+---
+title: Google Sheets credentials
+---
+
+{/*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements. See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership. The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied. See the License for the
+specific language governing permissions and limitations
+under the License.
+*/}
+
+## Service-account connections
+
+When configuring Google Sheets with service-account JSON in the database
dialog,
+share private spreadsheets with the service account's `client_email`.
+Both catalog validation and queries use the service account, not the Google
+identity of the administrator adding the spreadsheet. This applies when
creating
+a connection and when editing its catalog.
+
+The dialog's stored `impersonate_user` flag does not enable domain-wide
delegation
+for this service-account credential path. A spreadsheet shared only with the
+administrator cannot be validated on that basis, even if the service account
has
+domain-wide delegation enabled. Creating an empty connection and editing it
later
+does not change the identity used to access the spreadsheet.
+
+## Upload values
+
+Date and datetime upload values are sent as ISO strings rather than numeric
+nanosecond counts. Timezone-aware datetimes are converted to UTC, then uploaded
+without a timezone suffix so Google Sheets can parse them as datetime cells
+with `USER_ENTERED` input. For example, `2024-03-01 00:30:00+02:00` is
uploaded as
+`2024-02-29 22:30:00`. The original timezone is not retained; interpret these
cell
+values as UTC. Naive datetimes keep their original clock time.
+
+Nullable integer columns retain integer values in the upload payload, and
missing
+values become empty cells. Time-only values use `HH:MM:SS[.ffffff]` strings.
+Durations are sent as signed `H:MM:SS[.ffffff]` strings, with hours including
days.
diff --git a/superset/db_engine_specs/gsheets.py
b/superset/db_engine_specs/gsheets.py
index b1edb0cceb0..2eb210b46ce 100644
--- a/superset/db_engine_specs/gsheets.py
+++ b/superset/db_engine_specs/gsheets.py
@@ -19,10 +19,12 @@ from __future__ import annotations
import logging
import re
-from datetime import datetime
+from datetime import date, datetime, time, timedelta, UTC
+from decimal import Decimal
from re import Pattern
from typing import Any, TYPE_CHECKING, TypedDict
+import numpy as np
import pandas as pd
from apispec import APISpec
from apispec.ext.marshmallow import MarshmallowPlugin
@@ -64,6 +66,56 @@ SYNTAX_ERROR_REGEX = re.compile('SQLError: near
"(?P<server_error>.*?)": syntax
ma_plugin = MarshmallowPlugin()
+def _to_python_value(value: Any) -> Any:
+ """
+ Convert numpy and pandas scalars into their Python equivalents.
+ """
+ if isinstance(value, np.datetime64):
+ # ``.item()`` returns an int for nanosecond precision; go through
pandas.
+ value = pd.Timestamp(value)
+ elif isinstance(value, np.timedelta64):
+ # Durations also return an int from ``.item()`` at nanosecond
precision.
+ value = pd.Timedelta(value)
+ elif isinstance(value, np.generic):
+ value = value.item()
+ if value is pd.NaT:
+ return None
+ if isinstance(value, pd.Timestamp):
+ return value.to_pydatetime()
+ if isinstance(value, pd.Timedelta):
+ return value.to_pytimedelta()
+ return value
+
+
+def to_json_value(value: Any) -> Any:
+ """
+ Convert a dataframe cell into a JSON value the Sheets API parses back.
+
+ Dates and timestamps become ISO strings (``USER_ENTERED`` input parses them
+ as dates). Aware timestamps are normalized to UTC with the offset removed;
+ naive timestamps retain their clock time. Durations become signed
+ ``H:MM:SS[.ffffff]`` strings with total hours (including days), and numpy
+ scalars become Python scalars.
+ """
+ value = _to_python_value(value)
+ if isinstance(value, datetime):
+ if value.utcoffset() is not None:
+ value = value.astimezone(UTC).replace(tzinfo=None)
+ return value.isoformat(sep=" ")
+ if isinstance(value, (date, time)):
+ return value.isoformat()
+ if isinstance(value, timedelta):
+ sign = "-" if value < timedelta(0) else ""
+ value = abs(value)
+ hours = value.days * 24 + value.seconds // 3600
+ minutes, seconds = divmod(value.seconds % 3600, 60)
+ fraction = f".{value.microseconds:06d}" if value.microseconds else ""
+ return f"{sign}{hours}:{minutes:02d}:{seconds:02d}{fraction}"
+ if isinstance(value, Decimal):
+ return str(value)
+ return value
+
+
class GSheetsParametersSchema(Schema):
catalog = fields.Dict()
service_account_info = EncryptedString(
@@ -93,7 +145,7 @@ DELEGATION_KEY = "domain_wide_delegation"
class GSheetsParametersType(TypedDict, total=False):
- service_account_info: str
+ service_account_info: str | dict[str, Any]
catalog: dict[str, str] | None
oauth2_client_info: dict[str, str] | None
@@ -102,6 +154,7 @@ class GSheetsPropertiesType(TypedDict, total=False):
parameters: GSheetsParametersType
catalog: dict[str, str]
masked_encrypted_extra: str
+ impersonate_user: bool
class GSheetsEngineSpec(ShillelaghEngineSpec):
@@ -461,19 +514,18 @@ class GSheetsEngineSpec(ShillelaghEngineSpec):
)
return errors
- # We need a subject in case domain wide delegation is set, otherwise
the
- # check will fail. This means that the admin will be able to add sheets
- # that only they have access, even if later users are not able to
access
- # them.
- subject = g.user.email if g.user else None
-
+ # Service-account queries use connect_args.adapter_kwargs, which
replaces
+ # the dialect's URL-derived adapter_kwargs, so they run as the service
+ # account unless the secure extra opts into domain-wide delegation.
+ # Validate as the same service account even when the modal has stored
+ # impersonate_user=True; a delegated subject breaks non-DWD
credentials.
engine = create_engine(
"gsheets://",
connect_args={
"adapter_kwargs": {
"gsheetsapi": {
"service_account_info": encrypted_credentials,
- "subject": subject,
+ "subject": None,
}
}
},
@@ -641,12 +693,17 @@ class GSheetsEngineSpec(ShillelaghEngineSpec):
spreadsheet_url = payload["spreadsheetUrl"]
# insert data
- data = df.fillna("").values.tolist()
- data.insert(0, df.columns.values.tolist())
+ normalized_df = df.astype(object).where(df.notna(), None)
+ # Convert cells outside pandas to avoid inferring floats for nullable
ints.
+ values = [
+ [to_json_value(value) if value is not None else "" for value in
row]
+ for row in normalized_df.itertuples(index=False, name=None)
+ ]
+ values.insert(0, df.columns.values.tolist())
body = {
"range": range_,
"majorDimension": "ROWS",
- "values": data,
+ "values": values,
}
url = (
"https://sheets.googleapis.com/v4/spreadsheets/"
diff --git a/tests/unit_tests/db_engine_specs/test_gsheets.py
b/tests/unit_tests/db_engine_specs/test_gsheets.py
index 52f724c1e33..abc220c3294 100644
--- a/tests/unit_tests/db_engine_specs/test_gsheets.py
+++ b/tests/unit_tests/db_engine_specs/test_gsheets.py
@@ -23,11 +23,12 @@
from __future__ import annotations
-from datetime import datetime
+from datetime import datetime, time, timedelta, timezone, UTC
from typing import Any, TYPE_CHECKING
from unittest.mock import MagicMock
from urllib.parse import parse_qs, urlparse
+import numpy as np
import pandas as pd
import pytest
import sqlalchemy
@@ -200,6 +201,7 @@ def test_validate_parameters_catalog(
]
properties: GSheetsPropertiesType = {
+ "impersonate_user": True,
"parameters": {"service_account_info": "", "catalog": None},
"catalog": {
"private_sheet": "https://docs.google.com/spreadsheets/d/1/edit",
@@ -268,7 +270,7 @@ def test_validate_parameters_catalog(
"adapter_kwargs": {
"gsheetsapi": {
"service_account_info": {},
- "subject": "[email protected]",
+ "subject": None,
}
}
},
@@ -342,7 +344,7 @@ def test_validate_parameters_catalog_and_credentials(
"adapter_kwargs": {
"gsheetsapi": {
"service_account_info": {},
- "subject": "[email protected]",
+ "subject": None,
}
}
},
@@ -1274,6 +1276,257 @@ def test_convert_dttm(
assert_convert_dttm(GSheetsEngineSpec, target_type, expected_result, dttm)
+def test_upload_dates(mocker: MockerFixture) -> None:
+ """
+ Test that date and numpy values are uploaded as JSON values.
+ """
+ from datetime import date
+ from decimal import Decimal
+
+ import numpy as np
+
+ from superset.db_engine_specs.gsheets import GSheetsEngineSpec
+
+ mocker.patch("superset.db_engine_specs.gsheets.db")
+ get_adapter_for_table_name = mocker.patch(
+ "shillelagh.backends.apsw.dialects.base.get_adapter_for_table_name"
+ )
+ session = get_adapter_for_table_name()._get_session()
+ session.post().json.return_value = {
+ "spreadsheetId": 1,
+ "spreadsheetUrl": "https://docs.example.org",
+ "sheets": [{"properties": {"title": "sample_data"}}],
+ }
+
+ database = mocker.MagicMock()
+ database.get_extra.return_value = {}
+
+ df = pd.DataFrame(
+ {
+ "i": np.array([1, 2], dtype="int64"),
+ "f": [1.5, np.nan],
+ "d": [date(2024, 2, 29), None],
+ "ts": pd.to_datetime(["2024-02-29 23:59:58", None]),
+ "dec": [Decimal("1.10"), None],
+ }
+ )
+ GSheetsEngineSpec.df_to_sql(database, Table("sample_data"), df, {})
+
+ body = session.post.call_args_list[-1].kwargs["json"]
+ assert json.loads(json.dumps(body["values"])) == [
+ ["i", "f", "d", "ts", "dec"],
+ [1, 1.5, "2024-02-29", "2024-02-29 23:59:58", "1.10"],
+ [2, "", "", "", ""],
+ ]
+
+
[email protected](
+ "column, expected",
+ [
+ (
+ pd.Series(pd.to_datetime(["2024-03-01 00:30:00.123456+02:00",
None])),
+ ["2024-02-29 22:30:00.123456", ""],
+ ),
+ (
+ pd.Series(pd.to_datetime(["2024-02-29 23:30:00-05:00", None])),
+ ["2024-03-01 04:30:00", ""],
+ ),
+ (
+ pd.Series([1, None, 9007199254740993], dtype="Int64"),
+ [1, "", 9007199254740993],
+ ),
+ (
+ pd.Series([time(12, 34, 56, 123456), None, time(0, 0)]),
+ ["12:34:56.123456", "", "00:00:00"],
+ ),
+ (
+ pd.Series(pd.to_timedelta([5, 90_000_000_000, None], unit="ns")),
+ ["0:00:00", "0:01:30", ""],
+ ),
+ ],
+ ids=["positive-offset", "negative-offset", "nullable-int", "time",
"ns-duration"],
+)
+def test_upload_cell_types(
+ mocker: MockerFixture,
+ column: pd.Series,
+ expected: list[str | int],
+) -> None:
+ """Serialize cells without offsets or pandas' integer-to-float
inference."""
+ from superset.db_engine_specs.gsheets import GSheetsEngineSpec
+
+ mocker.patch("superset.db_engine_specs.gsheets.db")
+ get_adapter = mocker.patch(
+ "shillelagh.backends.apsw.dialects.base.get_adapter_for_table_name"
+ )
+ session = get_adapter.return_value._get_session.return_value
+ session.post.return_value.json.return_value = {
+ "spreadsheetId": 1,
+ "spreadsheetUrl": "https://docs.example.org",
+ "sheets": [{"properties": {"title": "sample_data"}}],
+ }
+ database = mocker.MagicMock()
+ database.get_extra.return_value = {}
+ df = pd.DataFrame({"value": column})
+ original = df.copy(deep=True)
+
+ GSheetsEngineSpec.df_to_sql(database, Table("sample_data"), df, {})
+
+ request = session.post.call_args.kwargs
+ assert request["params"] == {"valueInputOption": "USER_ENTERED"}
+ values = json.loads(json.dumps(request["json"]["values"]))
+ assert values == [["value"], *[[value] for value in expected]]
+ # Numeric equality alone would accept 1.0 in place of 1.
+ assert [type(row[0]) for row in values[1:]] == [type(value) for value in
expected]
+ pd.testing.assert_frame_equal(df, original)
+
+
[email protected](
+ "value, expected",
+ [
+ (datetime(2024, 3, 1, 0, 30, tzinfo=UTC), "2024-03-01 00:30:00"),
+ (
+ datetime(2024, 3, 1, 0, 30, tzinfo=timezone(timedelta(hours=2))),
+ "2024-02-29 22:30:00",
+ ),
+ (pd.Timestamp("2024-03-01 00:30:00", tz="Asia/Kolkata"), "2024-02-29
19:00:00"),
+ (datetime(2024, 3, 1, 0, 30), "2024-03-01 00:30:00"),
+ ],
+)
+def test_to_json_value_datetime_utc(value: datetime, expected: str) -> None:
+ """Aware timestamps become naive UTC; naive timestamps retain their clock
time."""
+ from superset.db_engine_specs.gsheets import to_json_value
+
+ assert to_json_value(value) == expected
+
+
[email protected](
+ "value, expected",
+ [
+ (np.datetime64("2024-02-29T23:59:58", "us"), "2024-02-29 23:59:58"),
+ (np.datetime64("2024-02-29T23:59:58", "ns"), "2024-02-29 23:59:58"),
+ (np.datetime64("2024-02-29", "D"), "2024-02-29 00:00:00"),
+ (np.datetime64("NaT"), None),
+ (np.timedelta64(90, "s"), "0:01:30"),
+ (np.timedelta64(90_000_000_000, "ns"), "0:01:30"),
+ (np.timedelta64(5, "ns"), "0:00:00"),
+ (np.timedelta64("NaT", "ns"), None),
+ (pd.Timedelta(seconds=90), "0:01:30"),
+ (timedelta(hours=1), "1:00:00"),
+ (timedelta(days=2, seconds=61), "48:01:01"),
+ (timedelta(seconds=-90), "-0:01:30"),
+ (timedelta(days=-2), "-48:00:00"),
+ (timedelta(microseconds=1), "0:00:00.000001"),
+ (timedelta(microseconds=-1), "-0:00:00.000001"),
+ (pd.Timedelta(days=2, microseconds=123456), "48:00:00.123456"),
+ (np.int64(3), 3),
+ (time(12, 34, 56, 123456), "12:34:56.123456"),
+ ],
+)
+def test_to_json_value_numpy_dates_and_durations(value: Any, expected: Any) ->
None:
+ """
+ Test that numpy dates and durations become JSON-serializable values.
+ """
+ from superset.db_engine_specs.gsheets import to_json_value
+
+ result = to_json_value(value)
+ assert result == expected
+ json.dumps(result)
+
+
[email protected]("impersonate_user", [None, False, True])
[email protected](
+ "serialized_credentials", [False, True], ids=["edit", "create"]
+)
[email protected]("catalog_in_parameters", [False, True])
+def test_validate_parameters_service_account_subject(
+ mocker: MockerFixture,
+ impersonate_user: bool | None,
+ serialized_credentials: bool,
+ catalog_in_parameters: bool,
+) -> None:
+ """Create and edit validate as the service account, even with the modal
flag."""
+ from superset.db_engine_specs.gsheets import (
+ GSheetsEngineSpec,
+ GSheetsPropertiesType,
+ )
+
+ g = mocker.patch("superset.db_engine_specs.gsheets.g")
+ g.user.email = "[email protected]"
+ create_engine =
mocker.patch("superset.db_engine_specs.gsheets.create_engine")
+ mocker.patch.object(GSheetsEngineSpec, "register_engine_events")
+ credentials = {"client_email": "[email protected]", "private_key": "KEY"}
+ sheet_url = "https://docs.google.com/spreadsheets/d/1/edit"
+ properties: GSheetsPropertiesType = {
+ "parameters": {
+ "service_account_info": (
+ json.dumps(credentials) if serialized_credentials else
credentials
+ ),
+ },
+ }
+ if catalog_in_parameters:
+ properties["parameters"]["catalog"] = {"sheet": sheet_url}
+ else:
+ properties["catalog"] = {"sheet": sheet_url}
+ if impersonate_user is not None:
+ properties["impersonate_user"] = impersonate_user
+
+ assert GSheetsEngineSpec.validate_parameters(properties) == []
+
+ create_engine.assert_called_once_with(
+ "gsheets://",
+ connect_args={
+ "adapter_kwargs": {
+ "gsheetsapi": {"service_account_info": credentials, "subject":
None},
+ },
+ },
+ )
+ conn = create_engine.return_value.connect.return_value
+ assert str(conn.execute.call_args.args[0]) == (
+ 'SELECT * FROM "https://docs.google.com/spreadsheets/d/1/edit" LIMIT 1'
+ )
+ conn.execute.return_value.fetchall.assert_called_once()
+
+
[email protected]("impersonate_user", [False, True])
+def test_query_service_account_subject(
+ mocker: MockerFixture,
+ impersonate_user: bool,
+) -> None:
+ """Exercise SQLAlchemy's final DBAPI arguments, not just the URL
subject."""
+ from superset.models.core import Database
+
+ user = mocker.MagicMock(email="[email protected]")
+ mocker.patch("superset.models.core.get_username", return_value="admin")
+ mocker.patch(
+ "superset.extensions.security_manager.find_user",
+ return_value=user,
+ )
+ credentials = {"client_email": "[email protected]", "private_key": "KEY"}
+ catalog = {"sheet": "https://docs.google.com/spreadsheets/d/1/edit"}
+ database = Database(
+ database_name="sheets",
+ sqlalchemy_uri="gsheets://",
+ impersonate_user=impersonate_user,
+ encrypted_extra=json.dumps({"service_account_info": credentials}),
+ extra=json.dumps({"engine_params": {"catalog": catalog}}),
+ )
+ engine = database._get_sqla_engine()
+ connect = mocker.spy(engine.dialect.dbapi, "connect")
+ try:
+ # No network or Google credentials are needed for a literal query.
+ with engine.connect() as conn:
+ assert conn.exec_driver_sql("SELECT 1").scalar() == 1
+ adapter_kwargs =
connect.call_args.kwargs["adapter_kwargs"]["gsheetsapi"]
+ assert adapter_kwargs["service_account_info"] == credentials
+ assert adapter_kwargs["catalog"] == catalog
+ assert adapter_kwargs.get("subject") is None
+ # Without the delegation opt-in the subject is removed from the URL
too,
+ # since connect_args replaces the URL-derived adapter_kwargs.
+ assert "subject" not in engine.url.query
+ finally:
+ engine.dispose()
+
+
def _service_account_adapter_kwargs(
mocker: MockerFixture,
encrypted_extra: dict[str, Any],