dex-connector-google-spreadsheet 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.
- dex_connector_google_spreadsheet-0.1.0/.gitignore +16 -0
- dex_connector_google_spreadsheet-0.1.0/LICENSE +21 -0
- dex_connector_google_spreadsheet-0.1.0/PKG-INFO +18 -0
- dex_connector_google_spreadsheet-0.1.0/README.md +5 -0
- dex_connector_google_spreadsheet-0.1.0/examples/upsert_contact/README.md +11 -0
- dex_connector_google_spreadsheet-0.1.0/examples/upsert_contact/__init__.py +3 -0
- dex_connector_google_spreadsheet-0.1.0/examples/upsert_contact/flow.py +83 -0
- dex_connector_google_spreadsheet-0.1.0/examples/upsert_contact/integration_test.py +115 -0
- dex_connector_google_spreadsheet-0.1.0/examples/upsert_contact/main.py +38 -0
- dex_connector_google_spreadsheet-0.1.0/pyproject.toml +41 -0
- dex_connector_google_spreadsheet-0.1.0/src/dex_connectors/google_spreadsheet/__init__.py +45 -0
- dex_connector_google_spreadsheet-0.1.0/src/dex_connectors/google_spreadsheet/_generated/__init__.py +2 -0
- dex_connector_google_spreadsheet-0.1.0/src/dex_connectors/google_spreadsheet/_generated/config.py +28 -0
- dex_connector_google_spreadsheet-0.1.0/src/dex_connectors/google_spreadsheet/_generated/connection.py +193 -0
- dex_connector_google_spreadsheet-0.1.0/src/dex_connectors/google_spreadsheet/_generated/definitions.py +80 -0
- dex_connector_google_spreadsheet-0.1.0/src/dex_connectors/google_spreadsheet/_generated/mock.py +218 -0
- dex_connector_google_spreadsheet-0.1.0/src/dex_connectors/google_spreadsheet/_generated/models.py +106 -0
- dex_connector_google_spreadsheet-0.1.0/src/dex_connectors/google_spreadsheet/_generated/refresh.py +9 -0
- dex_connector_google_spreadsheet-0.1.0/src/dex_connectors/google_spreadsheet/_generated/steps.py +196 -0
- dex_connector_google_spreadsheet-0.1.0/src/dex_connectors/google_spreadsheet/client.py +226 -0
- dex_connector_google_spreadsheet-0.1.0/src/dex_connectors/google_spreadsheet/connection.py +8 -0
- dex_connector_google_spreadsheet-0.1.0/src/dex_connectors/google_spreadsheet/dex-connector.json +135 -0
- dex_connector_google_spreadsheet-0.1.0/src/dex_connectors/google_spreadsheet/json_values.py +23 -0
- dex_connector_google_spreadsheet-0.1.0/src/dex_connectors/google_spreadsheet/models.py +10 -0
- dex_connector_google_spreadsheet-0.1.0/src/dex_connectors/google_spreadsheet/operations.py +851 -0
- dex_connector_google_spreadsheet-0.1.0/src/dex_connectors/google_spreadsheet/py.typed +0 -0
- dex_connector_google_spreadsheet-0.1.0/src/dex_connectors/google_spreadsheet/query_values.py +16 -0
- dex_connector_google_spreadsheet-0.1.0/src/dex_connectors/google_spreadsheet/spreadsheetmock/__init__.py +6 -0
- dex_connector_google_spreadsheet-0.1.0/src/dex_connectors/google_spreadsheet/steps.py +7 -0
- dex_connector_google_spreadsheet-0.1.0/tests/refresh_agreement_cases.json +530 -0
- dex_connector_google_spreadsheet-0.1.0/tests/test_conformance.py +43 -0
- dex_connector_google_spreadsheet-0.1.0/tests/test_json_values.py +15 -0
- dex_connector_google_spreadsheet-0.1.0/tests/test_provider_helpers.py +33 -0
- dex_connector_google_spreadsheet-0.1.0/tests/test_query_values.py +36 -0
- dex_connector_google_spreadsheet-0.1.0/tests/test_refresh_agreement.py +278 -0
- dex_connector_google_spreadsheet-0.1.0/uv.lock +934 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
.DS_Store
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
node_modules/
|
|
5
|
+
dist/
|
|
6
|
+
sdkpython/dist/dex_connectors_sdk-0.1.0-py3-none-any.whl
|
|
7
|
+
/.bin/dexcli
|
|
8
|
+
/.bin/dex-server-zd
|
|
9
|
+
/sdkpython/.dexcli-data/
|
|
10
|
+
site/.catalog.yaml
|
|
11
|
+
coverage/
|
|
12
|
+
*.out
|
|
13
|
+
*.test
|
|
14
|
+
go.work
|
|
15
|
+
go.work.sum
|
|
16
|
+
.idea/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Super Durable
|
|
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,18 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dex-connector-google-spreadsheet
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Dex-native Google Sheets connector
|
|
5
|
+
Project-URL: Source, https://github.com/superdurable/dex-connectors-library/tree/main/connectors/google/spreadsheet/python
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: dex-connectors-sdk==0.7.0
|
|
10
|
+
Requires-Dist: dex-python-sdk>=1.28.0
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# Google Sheets Python Connector
|
|
15
|
+
|
|
16
|
+
This package exposes `getValues`, `findRow`, and `upsertRow` as Dex Python Steps. Install `dex-connector-google-spreadsheet==0.1.0` and create a `google-sheets-oauth` project connection. See [the connector README](../README.md) for Google Cloud OAuth setup and the append safety model.
|
|
17
|
+
|
|
18
|
+
The `examples/upsert_contact` application reads or upserts a contact in a selected spreadsheet. Run its integration test with a local Dex development server; live Google authorization and provider behavior have not been verified for this port.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Google Sheets Python Connector
|
|
2
|
+
|
|
3
|
+
This package exposes `getValues`, `findRow`, and `upsertRow` as Dex Python Steps. Install `dex-connector-google-spreadsheet==0.1.0` and create a `google-sheets-oauth` project connection. See [the connector README](../README.md) for Google Cloud OAuth setup and the append safety model.
|
|
4
|
+
|
|
5
|
+
The `examples/upsert_contact` application reads or upserts a contact in a selected spreadsheet. Run its integration test with a local Dex development server; live Google authorization and provider behavior have not been verified for this port.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Upsert contact with Google Sheets
|
|
2
|
+
|
|
3
|
+
This Flow uses the `google-sheets-contacts` project connection, reads the `email`, `name`, and `status` columns, and upserts a contact by email. Configure the Google Sheets OAuth connection in Dex Web as described in the [connector README](../../../README.md), then start a local Dex server and run `uv run python -m examples.upsert_contact.main` from `connectors/google/spreadsheet/python`.
|
|
4
|
+
|
|
5
|
+
Start `GoogleSheetsUpsertContactPython` with a unique Flow ID and this JSON input:
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
{"spreadsheetId":"your-spreadsheet-id","sheetName":"Contacts","email":"ada@example.test","name":"Ada","status":"active"}
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Only `upserted` is wired. A conflict, provider rejection, invalid response, uncertain write, or local defect fails the Flow. `integration_test.py` verifies retry and routing through a real local Dex Worker and Client with an inert provider stand-in.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Upsert one contact through the Google Sheets connector and Dex."""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
import dex
|
|
8
|
+
from dex_connectors.sdk import MutationResult
|
|
9
|
+
|
|
10
|
+
from dex_connectors.google_spreadsheet import Connection, UpsertRowInput, UpsertRowOutput, new_upsert_row_step
|
|
11
|
+
|
|
12
|
+
CONNECTION_NAME = "google-sheets-contacts"
|
|
13
|
+
FLOW_TYPE = "GoogleSheetsUpsertContactPython"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class ContactInput:
|
|
18
|
+
"""Spreadsheet, tab, and contact values supplied to the Flow."""
|
|
19
|
+
|
|
20
|
+
spreadsheet_id: str = field(metadata={"dex_json_name": "spreadsheetId"})
|
|
21
|
+
sheet_name: str = field(metadata={"dex_json_name": "sheetName"})
|
|
22
|
+
email: str
|
|
23
|
+
name: str
|
|
24
|
+
status: str
|
|
25
|
+
|
|
26
|
+
def to_operation_input(self) -> UpsertRowInput:
|
|
27
|
+
"""Map a contact to the stable email-key row Mutation."""
|
|
28
|
+
return UpsertRowInput(
|
|
29
|
+
self.spreadsheet_id,
|
|
30
|
+
self.sheet_name,
|
|
31
|
+
"email",
|
|
32
|
+
self.email,
|
|
33
|
+
{"email": self.email, "name": self.name, "status": self.status},
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
contact_result = dex.Attribute("google-sheets-python-contact-result", UpsertRowOutput)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# dex:group group-id:google-sheets group-label:"Google Sheets"
|
|
41
|
+
# dex:explanation text:"Store the accepted contact row and complete the Flow."
|
|
42
|
+
class CompleteContact(dex.Step[MutationResult[UpsertRowOutput]]):
|
|
43
|
+
"""Persist the accepted row locator and complete the Flow."""
|
|
44
|
+
|
|
45
|
+
def execute(self, context: dex.Context, input: MutationResult[UpsertRowOutput]) -> dex.StepDecision:
|
|
46
|
+
"""Store only the non-secret provider result."""
|
|
47
|
+
contact_result.set(context, input.value)
|
|
48
|
+
return dex.graceful_complete(input.value)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ContactFlow(dex.Flow[ContactInput]):
|
|
52
|
+
"""Upsert a contact using one static Google Sheets connection."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, connection: Connection) -> None:
|
|
55
|
+
"""Bind the connector Mutation during Worker registration."""
|
|
56
|
+
if connection is None:
|
|
57
|
+
raise ValueError("Google Sheets connection is required")
|
|
58
|
+
self.complete = CompleteContact()
|
|
59
|
+
self.upsert = new_upsert_row_step(
|
|
60
|
+
step_type="UpsertContactRow",
|
|
61
|
+
step_input_type=ContactInput,
|
|
62
|
+
annotations={
|
|
63
|
+
"groupId": "google-sheets",
|
|
64
|
+
"groupLabel": "Google Sheets",
|
|
65
|
+
"explanation": "Upsert the contact in Google Sheets using email as the stable key.",
|
|
66
|
+
},
|
|
67
|
+
connection=connection,
|
|
68
|
+
connection_name=CONNECTION_NAME,
|
|
69
|
+
map_to_operation_input=ContactInput.to_operation_input,
|
|
70
|
+
upserted=self.complete,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def get_flow_type(self) -> str:
|
|
74
|
+
"""Keep the Python example Flow identity stable."""
|
|
75
|
+
return FLOW_TYPE
|
|
76
|
+
|
|
77
|
+
def get_steps(self) -> dex.StepList[ContactInput]:
|
|
78
|
+
"""Run the fenced provider Mutation and completion Step."""
|
|
79
|
+
return dex.StepList.start_step(self.upsert).other_steps(self.complete)
|
|
80
|
+
|
|
81
|
+
def get_persistence_schema(self) -> dex.PersistenceSchema:
|
|
82
|
+
"""Register the durable contact result Attribute."""
|
|
83
|
+
return dex.PersistenceSchema.of(contact_result)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Run the contact Flow through a real Dex Worker and Client."""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
import dex
|
|
13
|
+
import httpx
|
|
14
|
+
import pytest
|
|
15
|
+
from dex_connectors.sdk import ConnectionRef, SecretString, StaticCredentialProvider
|
|
16
|
+
|
|
17
|
+
from dex_connectors.google_spreadsheet import Client, Config, Credentials, UpsertRowOutput, new_connection
|
|
18
|
+
|
|
19
|
+
from .flow import CONNECTION_NAME, ContactFlow, ContactInput
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.mark.asyncio
|
|
23
|
+
async def test_contact_upsert_retries_once_and_fails_conflict_in_real_dex() -> None:
|
|
24
|
+
"""Verify durable retry, stable correlation key, and optional-branch failure."""
|
|
25
|
+
server_address = os.environ["DEX_FLOW_SERVICE_ADDRESS"]
|
|
26
|
+
writes: list[httpx.Request] = []
|
|
27
|
+
show_duplicate = False
|
|
28
|
+
|
|
29
|
+
def answer(request: httpx.Request) -> httpx.Response:
|
|
30
|
+
if request.method == "GET":
|
|
31
|
+
values = [["email", "name", "status"]]
|
|
32
|
+
if show_duplicate:
|
|
33
|
+
values.extend(
|
|
34
|
+
[
|
|
35
|
+
["ada@example.test", "Ada", "active"],
|
|
36
|
+
["ada@example.test", "Other", "active"],
|
|
37
|
+
]
|
|
38
|
+
)
|
|
39
|
+
return httpx.Response(
|
|
40
|
+
200,
|
|
41
|
+
json={
|
|
42
|
+
"range": "'Contacts'!A1:C1",
|
|
43
|
+
"majorDimension": "ROWS",
|
|
44
|
+
"values": values,
|
|
45
|
+
},
|
|
46
|
+
)
|
|
47
|
+
writes.append(request)
|
|
48
|
+
if len(writes) == 1:
|
|
49
|
+
return httpx.Response(503)
|
|
50
|
+
return httpx.Response(200, json={"updates": {"updatedRange": "'Contacts'!A2:C2"}})
|
|
51
|
+
|
|
52
|
+
reference = ConnectionRef("google", CONNECTION_NAME)
|
|
53
|
+
credentials = Credentials(
|
|
54
|
+
"client.apps.googleusercontent.com",
|
|
55
|
+
SecretString("secret"),
|
|
56
|
+
SecretString("inert-token"),
|
|
57
|
+
SecretString("refresh"),
|
|
58
|
+
)
|
|
59
|
+
async with httpx.AsyncClient(transport=httpx.MockTransport(answer)) as provider_transport:
|
|
60
|
+
client = Client(
|
|
61
|
+
Config(endpoint="http://127.0.0.1/v4"),
|
|
62
|
+
StaticCredentialProvider({reference: credentials}),
|
|
63
|
+
provider_transport,
|
|
64
|
+
)
|
|
65
|
+
connection = new_connection(client, reference)
|
|
66
|
+
flow = ContactFlow(connection)
|
|
67
|
+
registry = dex.Registry([flow], allow_async_handlers=True)
|
|
68
|
+
with tempfile.TemporaryDirectory(prefix="google-sheets-python-dex-") as temporary_directory:
|
|
69
|
+
cache = dex.open_blob_cache(
|
|
70
|
+
dex.BlobCacheConfig(str(Path(temporary_directory) / "blobs"), 1 << 24)
|
|
71
|
+
)
|
|
72
|
+
try:
|
|
73
|
+
worker = dex.AsyncWorker(registry, cache, dex.WorkerOptions(server_address=server_address))
|
|
74
|
+
worker_task = asyncio.create_task(worker.start())
|
|
75
|
+
try:
|
|
76
|
+
await worker.wait_pull_registered(timeout=15)
|
|
77
|
+
async with dex.AsyncClient(
|
|
78
|
+
registry, cache, dex.ClientOptions(server_address=server_address)
|
|
79
|
+
) as dex_client:
|
|
80
|
+
flow_id = f"sheets-python-{uuid4().hex}"
|
|
81
|
+
await dex_client.start_flow(
|
|
82
|
+
flow,
|
|
83
|
+
flow_id,
|
|
84
|
+
ContactInput("sheet-id", "Contacts", "ada@example.test", "Ada", "active"),
|
|
85
|
+
)
|
|
86
|
+
result = await asyncio.wait_for(
|
|
87
|
+
dex_client.wait_for_flow(flow_id, timedelta(seconds=25)), 35
|
|
88
|
+
)
|
|
89
|
+
assert result.status == dex.FlowStatus.COMPLETED, result.error_message
|
|
90
|
+
output = result.single_output(UpsertRowOutput)
|
|
91
|
+
assert output.action == "inserted" and output.row_number == 2
|
|
92
|
+
assert len(writes) == 2
|
|
93
|
+
assert (
|
|
94
|
+
writes[0].headers["X-Idempotency-Key"] == writes[1].headers["X-Idempotency-Key"]
|
|
95
|
+
)
|
|
96
|
+
assert writes[0].headers["X-Idempotency-Key"]
|
|
97
|
+
|
|
98
|
+
show_duplicate = True
|
|
99
|
+
conflict_id = f"sheets-python-{uuid4().hex}"
|
|
100
|
+
await dex_client.start_flow(
|
|
101
|
+
flow,
|
|
102
|
+
conflict_id,
|
|
103
|
+
ContactInput("sheet-id", "Contacts", "ada@example.test", "Ada", "active"),
|
|
104
|
+
)
|
|
105
|
+
conflict = await asyncio.wait_for(
|
|
106
|
+
dex_client.wait_for_flow(conflict_id, timedelta(seconds=20)), 30
|
|
107
|
+
)
|
|
108
|
+
assert conflict.status == dex.FlowStatus.FAILED
|
|
109
|
+
assert "conflict" in (conflict.error_message or "")
|
|
110
|
+
assert len(writes) == 2
|
|
111
|
+
finally:
|
|
112
|
+
await worker.stop()
|
|
113
|
+
await worker_task
|
|
114
|
+
finally:
|
|
115
|
+
cache.close()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Run the Google Sheets contact Worker with project OAuth configuration."""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import dex
|
|
11
|
+
from dex_connectors.sdk.projectconfig import load_from_environment
|
|
12
|
+
|
|
13
|
+
from dex_connectors.google_spreadsheet import new_project_connection
|
|
14
|
+
|
|
15
|
+
from .flow import CONNECTION_NAME, ContactFlow
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def main() -> None:
|
|
19
|
+
"""Load the project connection and serve the contact Flow."""
|
|
20
|
+
project = await load_from_environment()
|
|
21
|
+
connection = new_project_connection(project, CONNECTION_NAME)
|
|
22
|
+
cache_directory = Path(
|
|
23
|
+
os.environ.get("DEX_BLOB_CACHE_DIR", str(Path(tempfile.gettempdir()) / "dex-sheets-python-blobs"))
|
|
24
|
+
)
|
|
25
|
+
cache = dex.open_blob_cache(dex.BlobCacheConfig(str(cache_directory), 1 << 30))
|
|
26
|
+
try:
|
|
27
|
+
registry = dex.Registry([ContactFlow(connection)], allow_async_handlers=True)
|
|
28
|
+
address = os.environ.get("DEX_FLOW_SERVICE_ADDRESS", "localhost:8801")
|
|
29
|
+
async with dex.AsyncWorker(registry, cache, dex.WorkerOptions(server_address=address)) as worker:
|
|
30
|
+
await worker.start()
|
|
31
|
+
finally:
|
|
32
|
+
cache.close()
|
|
33
|
+
await connection.aclose()
|
|
34
|
+
await project.close()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dex-connector-google-spreadsheet"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Dex-native Google Sheets connector"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
dependencies = ["dex-connectors-sdk==0.7.0", "dex-python-sdk>=1.28.0", "httpx>=0.27"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Source = "https://github.com/superdurable/dex-connectors-library/tree/main/connectors/google/spreadsheet/python"
|
|
17
|
+
|
|
18
|
+
[dependency-groups]
|
|
19
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.24", "mypy>=1.13", "ruff>=0.7", "PyYAML>=6", "types-PyYAML>=6"]
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/dex_connectors"]
|
|
23
|
+
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
testpaths = ["tests"]
|
|
26
|
+
pythonpath = [".", "src"]
|
|
27
|
+
asyncio_mode = "auto"
|
|
28
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
29
|
+
|
|
30
|
+
[tool.mypy]
|
|
31
|
+
python_version = "3.11"
|
|
32
|
+
strict = true
|
|
33
|
+
mypy_path = "src"
|
|
34
|
+
explicit_package_bases = true
|
|
35
|
+
|
|
36
|
+
[tool.ruff]
|
|
37
|
+
target-version = "py311"
|
|
38
|
+
line-length = 110
|
|
39
|
+
|
|
40
|
+
[tool.ruff.lint]
|
|
41
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Dex-native Google Sheets range and row operations."""
|
|
4
|
+
|
|
5
|
+
from .client import Client as Client
|
|
6
|
+
from .client import Config as Config
|
|
7
|
+
from .client import Credentials as Credentials
|
|
8
|
+
from .connection import Connection as Connection
|
|
9
|
+
from .connection import new_connection as new_connection
|
|
10
|
+
from .connection import new_connection_with_operations as new_connection_with_operations
|
|
11
|
+
from .connection import new_project_connection as new_project_connection
|
|
12
|
+
from .models import FindRowInput as FindRowInput
|
|
13
|
+
from .models import FindRowOutput as FindRowOutput
|
|
14
|
+
from .models import GetValuesInput as GetValuesInput
|
|
15
|
+
from .models import GetValuesOutput as GetValuesOutput
|
|
16
|
+
from .models import UpsertRowInput as UpsertRowInput
|
|
17
|
+
from .models import UpsertRowOutput as UpsertRowOutput
|
|
18
|
+
from .operations import FindRowOperation as FindRowOperation
|
|
19
|
+
from .operations import GetValuesOperation as GetValuesOperation
|
|
20
|
+
from .operations import UpsertRowOperation as UpsertRowOperation
|
|
21
|
+
from .steps import new_find_row_step as new_find_row_step
|
|
22
|
+
from .steps import new_get_values_step as new_get_values_step
|
|
23
|
+
from .steps import new_upsert_row_step as new_upsert_row_step
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Client",
|
|
27
|
+
"Config",
|
|
28
|
+
"Credentials",
|
|
29
|
+
"Connection",
|
|
30
|
+
"FindRowInput",
|
|
31
|
+
"FindRowOperation",
|
|
32
|
+
"FindRowOutput",
|
|
33
|
+
"GetValuesInput",
|
|
34
|
+
"GetValuesOperation",
|
|
35
|
+
"GetValuesOutput",
|
|
36
|
+
"UpsertRowInput",
|
|
37
|
+
"UpsertRowOperation",
|
|
38
|
+
"UpsertRowOutput",
|
|
39
|
+
"new_connection",
|
|
40
|
+
"new_connection_with_operations",
|
|
41
|
+
"new_find_row_step",
|
|
42
|
+
"new_get_values_step",
|
|
43
|
+
"new_project_connection",
|
|
44
|
+
"new_upsert_row_step",
|
|
45
|
+
]
|
dex_connector_google_spreadsheet-0.1.0/src/dex_connectors/google_spreadsheet/_generated/config.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
# ruff: noqa
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from dex_connectors.sdk import SecretString
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class Config:
|
|
12
|
+
|
|
13
|
+
endpoint: str = "https://sheets.googleapis.com/v4"
|
|
14
|
+
max_response_bytes: int = 4194304
|
|
15
|
+
max_rows: int = 10000
|
|
16
|
+
|
|
17
|
+
setattr(Config, '__module__', 'dex_connectors.google_spreadsheet.client')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class Credentials:
|
|
22
|
+
|
|
23
|
+
oauth_client_id: str
|
|
24
|
+
oauth_client_secret: SecretString
|
|
25
|
+
access_token: SecretString
|
|
26
|
+
refresh_token: SecretString
|
|
27
|
+
|
|
28
|
+
setattr(Credentials, '__module__', 'dex_connectors.google_spreadsheet.client')
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
# ruff: noqa
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from dex_connectors.sdk import ConnectionRef, Mutation, Query, Call, SecretString, CredentialRefreshDriver
|
|
9
|
+
from dex_connectors.sdk.projectconfig import ConnectionKey, LoadedProject
|
|
10
|
+
from dex_connectors.sdk.projectconfig.provider import RefreshingCredentialProvider
|
|
11
|
+
from dex_connectors.sdk.oauthtoken import ManifestCredentialRefreshDriver
|
|
12
|
+
|
|
13
|
+
from ..client import Client, Config, Credentials
|
|
14
|
+
from .definitions import CONNECTOR_ID, PROVIDER, FIND_ROW_DEFINITION, GET_VALUES_DEFINITION, UPSERT_ROW_DEFINITION
|
|
15
|
+
from .refresh import AUTHORIZATION, CREDENTIAL_FIELDS
|
|
16
|
+
from ..models import FindRowInput, FindRowOutput, GetValuesInput, GetValuesOutput, UpsertRowInput, UpsertRowOutput
|
|
17
|
+
|
|
18
|
+
class Connection:
|
|
19
|
+
|
|
20
|
+
__slots__ = ('_get_values', '_find_row', '_upsert_row', '_client', 'reference')
|
|
21
|
+
|
|
22
|
+
def __init__(self,
|
|
23
|
+
reference: ConnectionRef,
|
|
24
|
+
get_values: Query[GetValuesInput, GetValuesOutput],
|
|
25
|
+
find_row: Query[FindRowInput, FindRowOutput],
|
|
26
|
+
upsert_row: Mutation[UpsertRowInput, UpsertRowOutput],
|
|
27
|
+
client: Client | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
reference.validate()
|
|
30
|
+
if reference.provider != PROVIDER or get_values is None or find_row is None or upsert_row is None:
|
|
31
|
+
raise ValueError("Google Sheets connection provider and operations are required")
|
|
32
|
+
if get_values.definition().operation != GET_VALUES_DEFINITION.operation:
|
|
33
|
+
raise ValueError("Google Sheets connection operation identity differs")
|
|
34
|
+
if find_row.definition().operation != FIND_ROW_DEFINITION.operation:
|
|
35
|
+
raise ValueError("Google Sheets connection operation identity differs")
|
|
36
|
+
if upsert_row.definition().operation != UPSERT_ROW_DEFINITION.operation:
|
|
37
|
+
raise ValueError("Google Sheets connection operation identity differs")
|
|
38
|
+
self.reference = reference
|
|
39
|
+
self._get_values = get_values
|
|
40
|
+
self._find_row = find_row
|
|
41
|
+
self._upsert_row = upsert_row
|
|
42
|
+
self._client = client
|
|
43
|
+
|
|
44
|
+
def get_values(self) -> Query[GetValuesInput, GetValuesOutput]:
|
|
45
|
+
return self._get_values
|
|
46
|
+
|
|
47
|
+
def find_row(self) -> Query[FindRowInput, FindRowOutput]:
|
|
48
|
+
return self._find_row
|
|
49
|
+
|
|
50
|
+
def upsert_row(self) -> Mutation[UpsertRowInput, UpsertRowOutput]:
|
|
51
|
+
return self._upsert_row
|
|
52
|
+
|
|
53
|
+
async def aclose(self) -> None:
|
|
54
|
+
if self._client is not None:
|
|
55
|
+
await self._client.aclose()
|
|
56
|
+
|
|
57
|
+
def __repr__(self) -> str:
|
|
58
|
+
return "google_spreadsheet.Connection([REDACTED])"
|
|
59
|
+
|
|
60
|
+
def __reduce__(self) -> tuple[object, ...]:
|
|
61
|
+
raise TypeError("Google Sheets connections cannot be serialized")
|
|
62
|
+
|
|
63
|
+
setattr(Connection, '__module__', 'dex_connectors.google_spreadsheet.connection')
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def new_connection(client: Client, reference: ConnectionRef) -> Connection:
|
|
67
|
+
if client is None:
|
|
68
|
+
raise ValueError("Google Sheets client is required")
|
|
69
|
+
return Connection(reference, client.get_values(), client.find_row(), client.upsert_row(), client)
|
|
70
|
+
|
|
71
|
+
setattr(new_connection, '__module__', 'dex_connectors.google_spreadsheet.connection')
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def new_connection_with_operations(
|
|
75
|
+
get_values: Query[GetValuesInput, GetValuesOutput],
|
|
76
|
+
find_row: Query[FindRowInput, FindRowOutput],
|
|
77
|
+
upsert_row: Mutation[UpsertRowInput, UpsertRowOutput],
|
|
78
|
+
reference: ConnectionRef,
|
|
79
|
+
) -> Connection:
|
|
80
|
+
return Connection(reference, get_values, find_row, upsert_row)
|
|
81
|
+
|
|
82
|
+
setattr(new_connection_with_operations, '__module__', 'dex_connectors.google_spreadsheet.connection')
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def decode_credentials(material: dict[str, Any], *, shared: bool = False) -> Credentials:
|
|
86
|
+
if not isinstance(material, dict):
|
|
87
|
+
raise ValueError("Google Sheets stored credential does not match the connector credential fields")
|
|
88
|
+
if set(material) - set(('oauth_client_id', 'oauth_client_secret', 'access_token', 'refresh_token')):
|
|
89
|
+
raise ValueError("Google Sheets stored credential does not match the connector credential fields")
|
|
90
|
+
is_shared = shared
|
|
91
|
+
oauth_client_id = material.get("oauth_client_id")
|
|
92
|
+
if oauth_client_id is not None and not (isinstance(oauth_client_id, str)):
|
|
93
|
+
raise ValueError("Google Sheets credential oauth_client_id has an invalid type")
|
|
94
|
+
oauth_client_secret = material.get("oauth_client_secret")
|
|
95
|
+
if oauth_client_secret is not None and not (isinstance(oauth_client_secret, str)):
|
|
96
|
+
raise ValueError("Google Sheets credential oauth_client_secret has an invalid type")
|
|
97
|
+
access_token = material.get("access_token")
|
|
98
|
+
if access_token is not None and not (isinstance(access_token, str)):
|
|
99
|
+
raise ValueError("Google Sheets credential access_token has an invalid type")
|
|
100
|
+
refresh_token = material.get("refresh_token")
|
|
101
|
+
if refresh_token is not None and not (isinstance(refresh_token, str)):
|
|
102
|
+
raise ValueError("Google Sheets credential refresh_token has an invalid type")
|
|
103
|
+
if not oauth_client_id:
|
|
104
|
+
raise ValueError("Google Sheets credential oauth_client_id is required")
|
|
105
|
+
if not oauth_client_secret and not is_shared:
|
|
106
|
+
raise ValueError("Google Sheets credential oauth_client_secret is required")
|
|
107
|
+
if not access_token:
|
|
108
|
+
raise ValueError("Google Sheets credential access_token is required")
|
|
109
|
+
if not refresh_token and not is_shared:
|
|
110
|
+
raise ValueError("Google Sheets credential refresh_token is required")
|
|
111
|
+
return Credentials(oauth_client_id, SecretString(oauth_client_secret or ''), SecretString(access_token), SecretString(refresh_token or ''))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def decode_shared_credentials(material: dict[str, Any]) -> Credentials:
|
|
115
|
+
return decode_credentials(material, shared=True)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def encode_credentials(credentials: Credentials) -> dict[str, Any]:
|
|
119
|
+
material: dict[str, Any] = {}
|
|
120
|
+
if credentials.oauth_client_id:
|
|
121
|
+
material["oauth_client_id"] = credentials.oauth_client_id
|
|
122
|
+
if credentials.oauth_client_secret.reveal():
|
|
123
|
+
material["oauth_client_secret"] = credentials.oauth_client_secret.reveal()
|
|
124
|
+
if credentials.access_token.reveal():
|
|
125
|
+
material["access_token"] = credentials.access_token.reveal()
|
|
126
|
+
if credentials.refresh_token.reveal():
|
|
127
|
+
material["refresh_token"] = credentials.refresh_token.reveal()
|
|
128
|
+
decode_credentials(material)
|
|
129
|
+
return material
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class _ProjectCredentialSource:
|
|
133
|
+
def __init__(self, project: LoadedProject, key: ConnectionKey, http_client: httpx.AsyncClient, settings: dict[str, Any]) -> None:
|
|
134
|
+
self.provider = RefreshingCredentialProvider(
|
|
135
|
+
project.connections, key, decode_credentials, encode_credentials, decode_shared_credentials
|
|
136
|
+
)
|
|
137
|
+
self.driver = ManifestCredentialRefreshDriver[Credentials]("Google Sheets", AUTHORIZATION, CREDENTIAL_FIELDS, http_client, settings=settings)
|
|
138
|
+
|
|
139
|
+
async def resolve(self, call: Call) -> Credentials:
|
|
140
|
+
return await self.provider.resolve_with_refresh(call, self.driver)
|
|
141
|
+
|
|
142
|
+
async def resolve_with_refresh(self, call: Call, driver: CredentialRefreshDriver[Credentials] | None = None) -> Credentials:
|
|
143
|
+
return await self.provider.resolve_with_refresh(call, driver or self.driver)
|
|
144
|
+
|
|
145
|
+
async def resolve_after_rejection(self, call: Call, driver: CredentialRefreshDriver[Credentials] | None = None) -> Credentials:
|
|
146
|
+
return await self.provider.resolve_after_rejection(call, driver or self.driver)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def new_project_connection(
|
|
150
|
+
project: LoadedProject, connection_name: str, http_client: httpx.AsyncClient | None = None
|
|
151
|
+
) -> Connection:
|
|
152
|
+
if project is None:
|
|
153
|
+
raise ValueError("Google Sheets connection requires loaded project configuration")
|
|
154
|
+
reference = ConnectionRef(PROVIDER, connection_name)
|
|
155
|
+
reference.validate()
|
|
156
|
+
key = ConnectionKey(CONNECTOR_ID, connection_name)
|
|
157
|
+
matching = [
|
|
158
|
+
entry
|
|
159
|
+
for entry in project.configuration.connections
|
|
160
|
+
if entry.connector_id == CONNECTOR_ID and entry.connection_name == connection_name
|
|
161
|
+
]
|
|
162
|
+
if len(matching) != 1:
|
|
163
|
+
raise ValueError("Google Sheets project connection is missing or duplicated")
|
|
164
|
+
if matching[0].provider != PROVIDER:
|
|
165
|
+
raise ValueError("Google Sheets project connection provider differs")
|
|
166
|
+
config = _decode_config(matching[0].configuration)
|
|
167
|
+
http_client = http_client or httpx.AsyncClient()
|
|
168
|
+
client = Client(config, _ProjectCredentialSource(project, key, http_client, matching[0].configuration), http_client)
|
|
169
|
+
return new_connection(client, reference)
|
|
170
|
+
|
|
171
|
+
setattr(new_project_connection, '__module__', 'dex_connectors.google_spreadsheet.connection')
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _decode_config(value: dict[str, Any]) -> Config:
|
|
175
|
+
if set(value) - {"endpoint", "maxResponseBytes", "maxRows"}:
|
|
176
|
+
raise ValueError("Google Sheets configuration has unknown fields")
|
|
177
|
+
config_arguments: dict[str, Any] = {}
|
|
178
|
+
if "endpoint" in value:
|
|
179
|
+
endpoint = value["endpoint"]
|
|
180
|
+
if not (isinstance(endpoint, str)):
|
|
181
|
+
raise ValueError("Google Sheets configuration endpoint has an invalid type")
|
|
182
|
+
config_arguments['endpoint'] = endpoint
|
|
183
|
+
if "maxResponseBytes" in value:
|
|
184
|
+
max_response_bytes = value["maxResponseBytes"]
|
|
185
|
+
if not (type(max_response_bytes) is int):
|
|
186
|
+
raise ValueError("Google Sheets configuration maxResponseBytes has an invalid type")
|
|
187
|
+
config_arguments['max_response_bytes'] = max_response_bytes
|
|
188
|
+
if "maxRows" in value:
|
|
189
|
+
max_rows = value["maxRows"]
|
|
190
|
+
if not (type(max_rows) is int):
|
|
191
|
+
raise ValueError("Google Sheets configuration maxRows has an invalid type")
|
|
192
|
+
config_arguments['max_rows'] = max_rows
|
|
193
|
+
return Config(**config_arguments)
|