fancy-google-sheets 0.3.4__tar.gz → 0.4.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.
- {fancy_google_sheets-0.3.4 → fancy_google_sheets-0.4.0}/CHANGELOG.md +12 -0
- {fancy_google_sheets-0.3.4 → fancy_google_sheets-0.4.0}/PKG-INFO +29 -1
- {fancy_google_sheets-0.3.4 → fancy_google_sheets-0.4.0}/README.md +28 -0
- fancy_google_sheets-0.4.0/art/fancified.svg +26 -0
- {fancy_google_sheets-0.3.4 → fancy_google_sheets-0.4.0}/pyproject.toml +2 -2
- {fancy_google_sheets-0.3.4 → fancy_google_sheets-0.4.0}/src/fancy_google_sheets/__init__.py +5 -1
- {fancy_google_sheets-0.3.4 → fancy_google_sheets-0.4.0}/src/fancy_google_sheets/_runtime.py +159 -6
- {fancy_google_sheets-0.3.4 → fancy_google_sheets-0.4.0}/src/fancy_google_sheets/actions/__init__.py +4 -0
- fancy_google_sheets-0.4.0/src/fancy_google_sheets/actions/values_get.py +92 -0
- fancy_google_sheets-0.4.0/src/fancy_google_sheets/actions/values_update.py +129 -0
- {fancy_google_sheets-0.3.4 → fancy_google_sheets-0.4.0}/src/fancy_google_sheets/faker.py +65 -0
- {fancy_google_sheets-0.3.4 → fancy_google_sheets-0.4.0}/tests/test_faker.py +39 -0
- {fancy_google_sheets-0.3.4 → fancy_google_sheets-0.4.0}/.gitignore +0 -0
- {fancy_google_sheets-0.3.4 → fancy_google_sheets-0.4.0}/LICENSE +0 -0
- {fancy_google_sheets-0.3.4 → fancy_google_sheets-0.4.0}/src/fancy_google_sheets/_fake.py +0 -0
- {fancy_google_sheets-0.3.4 → fancy_google_sheets-0.4.0}/src/fancy_google_sheets/actions/row_append.py +0 -0
- {fancy_google_sheets-0.3.4 → fancy_google_sheets-0.4.0}/src/fancy_google_sheets/py.typed +0 -0
- {fancy_google_sheets-0.3.4 → fancy_google_sheets-0.4.0}/src/fancy_google_sheets/service.py +0 -0
|
@@ -8,6 +8,18 @@ The four packages share one version, because they are generated from one
|
|
|
8
8
|
`provider/` definition and a version that meant something different in each
|
|
9
9
|
would be a version nobody could reason about.
|
|
10
10
|
|
|
11
|
+
## [0.4.0] — 2026-10-01
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`values_get`** and **`values_update`** — read a range, and overwrite
|
|
16
|
+
one. No scope change: this connector already holds the full
|
|
17
|
+
`spreadsheets` scope `row_append` needed. `values_update` OVERWRITES
|
|
18
|
+
the cells inside the given range; it does not push existing rows down
|
|
19
|
+
or search for the next empty row the way `row_append` (`values.append`)
|
|
20
|
+
does — the two sound alike and behave differently on data already
|
|
21
|
+
there, said plainly in both actions' own descriptions.
|
|
22
|
+
|
|
11
23
|
## [0.3.4] — 2026-09-12
|
|
12
24
|
|
|
13
25
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: fancy-google-sheets
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Google Sheets for Python — the service descriptor, its faker, its webhook verification, and one function per operation. Plain HTTP; no vendor SDK.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Fancy-Friends/google-sheets
|
|
6
6
|
Project-URL: Issues, https://github.com/Fancy-Friends/google-sheets/issues
|
|
@@ -22,6 +22,8 @@ Description-Content-Type: text/markdown
|
|
|
22
22
|
|
|
23
23
|
# Google Sheets
|
|
24
24
|
|
|
25
|
+
[](https://particle.academy)
|
|
26
|
+
|
|
25
27
|
Google Sheets for [fancy-flow][flow] — as **four imported, versioned packages**, one
|
|
26
28
|
per runtime. Not vendored source: a copy cannot be upgraded, and third-party APIs
|
|
27
29
|
change.
|
|
@@ -101,6 +103,32 @@ Append a row to a Google Sheet.
|
|
|
101
103
|
| `valueInputOption` | no | USER_ENTERED parses the cells the way typing them would -- so =SUM(A1:A2) becomes a formula and 1/2/26 becomes a date. RAW stores exactly the characters given. |
|
|
102
104
|
| `insertDataOption` | no | INSERT_ROWS pushes existing rows down. OVERWRITE writes into them. |
|
|
103
105
|
|
|
106
|
+
#### `values_get` — Read Google Sheets values
|
|
107
|
+
|
|
108
|
+
Read the cell values of a range.
|
|
109
|
+
|
|
110
|
+
`GET /v4/spreadsheets/{spreadsheetId}/values/{range}` · reads only — safe to replay
|
|
111
|
+
|
|
112
|
+
| Input | Required | What it is |
|
|
113
|
+
|---|---|---|
|
|
114
|
+
| `spreadsheetId` | yes | The long id from the sheet's URL: docs.google.com/spreadsheets/d/THIS_PART/edit. |
|
|
115
|
+
| `range` | yes | In A1 notation, the same shape row_append accepts. |
|
|
116
|
+
| `valueRenderOption` | no | How to render values |
|
|
117
|
+
| `majorDimension` | no | Whether the outer array of the result is rows or columns. |
|
|
118
|
+
|
|
119
|
+
#### `values_update` — Write Google Sheets values
|
|
120
|
+
|
|
121
|
+
Overwrite the cell values of a range. Existing values in that range are replaced, not pushed down.
|
|
122
|
+
|
|
123
|
+
`PUT /v4/spreadsheets/{spreadsheetId}/values/{range}` · idempotent — safe to replay
|
|
124
|
+
|
|
125
|
+
| Input | Required | What it is |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| `spreadsheetId` | yes | Spreadsheet ID |
|
|
128
|
+
| `range` | yes | In A1 notation. The values written fill this range from its top-left cell -- more rows or columns than the range covers are written anyway, following Google's own documented behavior; fewer leave the rest of the range untouched. |
|
|
129
|
+
| `values` | yes | The cells to write, comma separated, left to right, one row. For more than one row, call this action once per row. |
|
|
130
|
+
| `valueInputOption` | yes | USER_ENTERED parses the cells the way typing them would -- so =SUM(A1:A2) becomes a formula. RAW stores exactly the characters given. |
|
|
131
|
+
|
|
104
132
|
## Run it before you have credentials
|
|
105
133
|
|
|
106
134
|
Every operation ships a **faker**, whether or not Google Sheets has a sandbox. Set a
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Google Sheets
|
|
2
2
|
|
|
3
|
+
[](https://particle.academy)
|
|
4
|
+
|
|
3
5
|
Google Sheets for [fancy-flow][flow] — as **four imported, versioned packages**, one
|
|
4
6
|
per runtime. Not vendored source: a copy cannot be upgraded, and third-party APIs
|
|
5
7
|
change.
|
|
@@ -79,6 +81,32 @@ Append a row to a Google Sheet.
|
|
|
79
81
|
| `valueInputOption` | no | USER_ENTERED parses the cells the way typing them would -- so =SUM(A1:A2) becomes a formula and 1/2/26 becomes a date. RAW stores exactly the characters given. |
|
|
80
82
|
| `insertDataOption` | no | INSERT_ROWS pushes existing rows down. OVERWRITE writes into them. |
|
|
81
83
|
|
|
84
|
+
#### `values_get` — Read Google Sheets values
|
|
85
|
+
|
|
86
|
+
Read the cell values of a range.
|
|
87
|
+
|
|
88
|
+
`GET /v4/spreadsheets/{spreadsheetId}/values/{range}` · reads only — safe to replay
|
|
89
|
+
|
|
90
|
+
| Input | Required | What it is |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| `spreadsheetId` | yes | The long id from the sheet's URL: docs.google.com/spreadsheets/d/THIS_PART/edit. |
|
|
93
|
+
| `range` | yes | In A1 notation, the same shape row_append accepts. |
|
|
94
|
+
| `valueRenderOption` | no | How to render values |
|
|
95
|
+
| `majorDimension` | no | Whether the outer array of the result is rows or columns. |
|
|
96
|
+
|
|
97
|
+
#### `values_update` — Write Google Sheets values
|
|
98
|
+
|
|
99
|
+
Overwrite the cell values of a range. Existing values in that range are replaced, not pushed down.
|
|
100
|
+
|
|
101
|
+
`PUT /v4/spreadsheets/{spreadsheetId}/values/{range}` · idempotent — safe to replay
|
|
102
|
+
|
|
103
|
+
| Input | Required | What it is |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `spreadsheetId` | yes | Spreadsheet ID |
|
|
106
|
+
| `range` | yes | In A1 notation. The values written fill this range from its top-left cell -- more rows or columns than the range covers are written anyway, following Google's own documented behavior; fewer leave the rest of the range untouched. |
|
|
107
|
+
| `values` | yes | The cells to write, comma separated, left to right, one row. For more than one row, call this action once per row. |
|
|
108
|
+
| `valueInputOption` | yes | USER_ENTERED parses the cells the way typing them would -- so =SUM(A1:A2) becomes a formula. RAW stores exactly the characters given. |
|
|
109
|
+
|
|
82
110
|
## Run it before you have credentials
|
|
83
111
|
|
|
84
112
|
Every operation ships a **faker**, whether or not Google Sheets has a sandbox. Set a
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="28" role="img" aria-label="Fancified — built with Fancy UI">
|
|
2
|
+
<title>Fancified · built with Fancy UI</title>
|
|
3
|
+
<defs>
|
|
4
|
+
<linearGradient id="fcdBg" x1="0" y1="0" x2="1" y2="1">
|
|
5
|
+
<stop offset="0" stop-color="#6d28d9"/>
|
|
6
|
+
<stop offset=".5" stop-color="#9333ea"/>
|
|
7
|
+
<stop offset="1" stop-color="#d946ef"/>
|
|
8
|
+
</linearGradient>
|
|
9
|
+
<linearGradient id="fcdGloss" x1="0" y1="0" x2="0" y2="1">
|
|
10
|
+
<stop offset="0" stop-color="#ffffff" stop-opacity=".30"/>
|
|
11
|
+
<stop offset=".55" stop-color="#ffffff" stop-opacity="0"/>
|
|
12
|
+
</linearGradient>
|
|
13
|
+
<clipPath id="fcdClip"><rect width="128" height="28" rx="7"/></clipPath>
|
|
14
|
+
</defs>
|
|
15
|
+
<g clip-path="url(#fcdClip)">
|
|
16
|
+
<rect width="128" height="28" fill="url(#fcdBg)"/>
|
|
17
|
+
<rect width="34" height="28" fill="#3b0764" fill-opacity=".5"/>
|
|
18
|
+
<rect width="128" height="13" fill="url(#fcdGloss)"/>
|
|
19
|
+
<g fill="#fff">
|
|
20
|
+
<path transform="translate(17 14)" d="M0 -7 C.6 -2.2 2.2 -.6 7 0 C2.2 .6 .6 2.2 0 7 C-.6 2.2 -2.2 .6 -7 0 C-2.2 -.6 -.6 -2.2 0 -7 Z"/>
|
|
21
|
+
<path transform="translate(25.5 7.5) scale(.34)" fill-opacity=".85" d="M0 -7 C.6 -2.2 2.2 -.6 7 0 C2.2 .6 .6 2.2 0 7 C-.6 2.2 -2.2 .6 -7 0 C-2.2 -.6 -.6 -2.2 0 -7 Z"/>
|
|
22
|
+
</g>
|
|
23
|
+
<text x="81" y="18.5" fill="#ffffff" text-anchor="middle" font-family="'Segoe UI',Verdana,'DejaVu Sans',sans-serif" font-size="12" font-weight="700" letter-spacing=".3">Fancified</text>
|
|
24
|
+
</g>
|
|
25
|
+
<rect x=".5" y=".5" width="127" height="27" rx="6.5" fill="none" stroke="#ffffff" stroke-opacity=".22"/>
|
|
26
|
+
</svg>
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fancy-google-sheets"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Google Sheets for Python — the service descriptor, its faker, its webhook verification, and one function per operation. Plain HTTP; no vendor SDK."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
# 3.11 matches fancy-flow-py's floor: 3.10 reaches end of life on 2026-10-31,
|
|
@@ -49,7 +49,7 @@ dev = [{ include-group = "test" }, { include-group = "lint" }, { include-group =
|
|
|
49
49
|
packages = ["src/fancy_google_sheets"]
|
|
50
50
|
|
|
51
51
|
[tool.hatch.build.targets.sdist]
|
|
52
|
-
include = ["/src", "/tests", "/README.md", "/CHANGELOG.md", "/LICENSE"]
|
|
52
|
+
include = ["/src", "/tests", "/art", "/README.md", "/CHANGELOG.md", "/LICENSE"]
|
|
53
53
|
|
|
54
54
|
[tool.pytest.ini_options]
|
|
55
55
|
testpaths = ["tests"]
|
|
@@ -18,10 +18,12 @@ from __future__ import annotations
|
|
|
18
18
|
|
|
19
19
|
from ._fake import FakeValues
|
|
20
20
|
from .actions.row_append import row_append
|
|
21
|
+
from .actions.values_get import values_get
|
|
22
|
+
from .actions.values_update import values_update
|
|
21
23
|
from .faker import respond
|
|
22
24
|
from .service import BASE_URLS, CONNECTOR_API_VERSION, REQUIRES, SANDBOX, SERVICE, TITLE, descriptor
|
|
23
25
|
|
|
24
|
-
__version__ = "0.
|
|
26
|
+
__version__ = "0.4.0"
|
|
25
27
|
|
|
26
28
|
__all__ = [
|
|
27
29
|
"BASE_URLS",
|
|
@@ -34,4 +36,6 @@ __all__ = [
|
|
|
34
36
|
"descriptor",
|
|
35
37
|
"respond",
|
|
36
38
|
"row_append",
|
|
39
|
+
"values_get",
|
|
40
|
+
"values_update",
|
|
37
41
|
]
|
|
@@ -308,14 +308,17 @@ _ALGORITHMS = {"sha256": hashlib.sha256, "sha1": hashlib.sha1, "sha512": hashlib
|
|
|
308
308
|
def verify_hmac(
|
|
309
309
|
*,
|
|
310
310
|
raw: str,
|
|
311
|
-
signature: str | None,
|
|
311
|
+
signature: str | list[str] | None,
|
|
312
312
|
secret: str | None,
|
|
313
|
-
payload: Callable[
|
|
313
|
+
payload: Callable[..., str],
|
|
314
314
|
algorithm: str,
|
|
315
315
|
encoding: str = "hex",
|
|
316
316
|
tolerance: int | None = None,
|
|
317
317
|
timestamp: str | None = None,
|
|
318
318
|
now: int | None = None,
|
|
319
|
+
secret_encoding: str = "utf8", # noqa: S107 — how the secret is SPELLED, not one
|
|
320
|
+
secret_prefix: str | None = None,
|
|
321
|
+
id: str | None = None,
|
|
319
322
|
) -> Verification:
|
|
320
323
|
"""Verify one inbound delivery.
|
|
321
324
|
|
|
@@ -327,16 +330,36 @@ def verify_hmac(
|
|
|
327
330
|
``raw`` must be the body EXACTLY as received. Re-serialised JSON changes key
|
|
328
331
|
order and whitespace and produces a mismatch that looks precisely like a
|
|
329
332
|
wrong secret.
|
|
333
|
+
|
|
334
|
+
``secret_encoding`` says how the SECRET is spelled: ``utf8`` (the default,
|
|
335
|
+
every provider before Svix — the key is the text's bytes) or ``base64``
|
|
336
|
+
(the key is the DECODED bytes; Svix's ``whsec_<base64>``, half of whose
|
|
337
|
+
bytes are not UTF-8). ``secret_prefix`` is stripped first, and its absence
|
|
338
|
+
is a refusal, never a guess.
|
|
330
339
|
"""
|
|
331
340
|
if not secret:
|
|
332
341
|
return Verification(False, "no signing secret is configured for this connection")
|
|
333
|
-
|
|
342
|
+
|
|
343
|
+
# A LIST when the provider sends several — Stripe signs once per active
|
|
344
|
+
# secret while a secret is rolled, Svix's header "could be any number of
|
|
345
|
+
# signatures" — and the delivery is accepted when ANY matches. A first-only
|
|
346
|
+
# rule fails every delivery whose first signature came from the new secret.
|
|
347
|
+
offered = signature if isinstance(signature, list) else [signature]
|
|
348
|
+
candidates = [s for s in offered if isinstance(s, str) and s]
|
|
349
|
+
if not candidates:
|
|
334
350
|
return Verification(False, "the delivery carried no signature")
|
|
335
351
|
|
|
336
352
|
digest = _ALGORITHMS.get(algorithm)
|
|
337
353
|
if digest is None:
|
|
338
354
|
return Verification(False, f'unsupported signature algorithm "{algorithm}"')
|
|
339
355
|
|
|
356
|
+
# The key is checked BEFORE anything is signed, so a secret that cannot be
|
|
357
|
+
# a key is named as such rather than producing a mismatch that reads like a
|
|
358
|
+
# wrong secret.
|
|
359
|
+
key = _secret_key_bytes(secret, secret_encoding, secret_prefix)
|
|
360
|
+
if isinstance(key, Verification):
|
|
361
|
+
return key
|
|
362
|
+
|
|
340
363
|
if tolerance is not None:
|
|
341
364
|
if not timestamp:
|
|
342
365
|
return Verification(
|
|
@@ -355,16 +378,146 @@ def verify_hmac(
|
|
|
355
378
|
f"({abs(current - sent)}s old)",
|
|
356
379
|
)
|
|
357
380
|
|
|
358
|
-
|
|
381
|
+
# A payload that signs the delivery's ID (Svix) takes it as a third argument;
|
|
382
|
+
# the two-argument payloads every earlier scheme wrote are called as before.
|
|
383
|
+
signed = payload(raw, timestamp) if id is None else payload(raw, timestamp, id)
|
|
384
|
+
computed = hmac.new(key, signed.encode(), digest)
|
|
359
385
|
expected = computed.hexdigest() if encoding == "hex" else _b64(computed.digest())
|
|
360
386
|
|
|
361
|
-
# Constant time, so a signature cannot be discovered one
|
|
362
|
-
|
|
387
|
+
# Constant time per candidate, so a signature cannot be discovered one
|
|
388
|
+
# character at a time; which candidate matched is not a secret.
|
|
389
|
+
if not any(hmac.compare_digest(expected, candidate) for candidate in candidates):
|
|
363
390
|
return Verification(False, "the signature does not match")
|
|
364
391
|
|
|
365
392
|
return Verification(True)
|
|
366
393
|
|
|
367
394
|
|
|
395
|
+
def _secret_key_bytes(
|
|
396
|
+
secret: str,
|
|
397
|
+
secret_encoding: str = "utf8", # noqa: S107 — how the secret is SPELLED, not one
|
|
398
|
+
secret_prefix: str | None = None,
|
|
399
|
+
) -> bytes | Verification:
|
|
400
|
+
"""The HMAC key a scheme's secret spells, or the refusal it earns."""
|
|
401
|
+
import base64
|
|
402
|
+
import binascii
|
|
403
|
+
import json
|
|
404
|
+
import re
|
|
405
|
+
|
|
406
|
+
material = secret
|
|
407
|
+
if secret_prefix is not None:
|
|
408
|
+
if not material.startswith(secret_prefix):
|
|
409
|
+
return Verification(
|
|
410
|
+
False, f"signing secret does not start with {json.dumps(secret_prefix)}"
|
|
411
|
+
)
|
|
412
|
+
material = material[len(secret_prefix) :]
|
|
413
|
+
|
|
414
|
+
if secret_encoding == "base64": # noqa: S105 — a spelling, not a password
|
|
415
|
+
# Strict: a lenient decode of a KEY is a key nobody can reason about.
|
|
416
|
+
well_formed = re.fullmatch(r"[A-Za-z0-9+/]*={0,2}", material) is not None
|
|
417
|
+
if not material or len(material) % 4 != 0 or not well_formed:
|
|
418
|
+
return Verification(False, "signing secret is not valid base64")
|
|
419
|
+
try:
|
|
420
|
+
return base64.b64decode(material, validate=True)
|
|
421
|
+
except (binascii.Error, ValueError):
|
|
422
|
+
return Verification(False, "signing secret is not valid base64")
|
|
423
|
+
|
|
424
|
+
return material.encode()
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def verify_shared_token(
|
|
428
|
+
*,
|
|
429
|
+
raw: str,
|
|
430
|
+
headers: dict[str, str],
|
|
431
|
+
secret: str | None,
|
|
432
|
+
placement: str,
|
|
433
|
+
name: str,
|
|
434
|
+
) -> Verification:
|
|
435
|
+
"""Verify a delivery by a token the provider ECHOES rather than a signature it computes.
|
|
436
|
+
|
|
437
|
+
Google Calendar sends the channel's ``token`` back in ``X-Goog-Channel-Token``
|
|
438
|
+
on every notification — with an EMPTY body, so there is nothing to sign.
|
|
439
|
+
Microsoft Graph sends ``clientState`` inside every item of the
|
|
440
|
+
notification's ``value`` array. Same refusal-by-default as ``verify_hmac``,
|
|
441
|
+
same result shape, and a constant-time comparison: a token is a secret.
|
|
442
|
+
|
|
443
|
+
``placement`` is ``header`` (then ``name`` is the header) or ``body`` (then
|
|
444
|
+
``name`` is a dotted path into the JSON body; a segment ending in ``[]``
|
|
445
|
+
means EVERY element of that array, all of which must match — one wrong item
|
|
446
|
+
refuses the whole delivery). The reasons are the connector core's exact
|
|
447
|
+
strings, so a host logs the same words whichever runtime answered.
|
|
448
|
+
"""
|
|
449
|
+
if not secret:
|
|
450
|
+
return Verification(False, "no shared token configured for this trigger")
|
|
451
|
+
|
|
452
|
+
tokens: list[Any]
|
|
453
|
+
if placement == "header":
|
|
454
|
+
tokens = [next((v for k, v in headers.items() if k.lower() == name.lower()), None)]
|
|
455
|
+
else:
|
|
456
|
+
try:
|
|
457
|
+
parsed = json.loads(raw)
|
|
458
|
+
except ValueError:
|
|
459
|
+
return Verification(False, "delivery body is not JSON")
|
|
460
|
+
tokens = _read_tokens(parsed, name.split("."))
|
|
461
|
+
|
|
462
|
+
present = [t for t in tokens if t is not None and t != ""]
|
|
463
|
+
if not present:
|
|
464
|
+
return Verification(False, "delivery carried no token")
|
|
465
|
+
|
|
466
|
+
# Every element is compared, none is skipped: a batch is accepted as a whole
|
|
467
|
+
# or refused as a whole.
|
|
468
|
+
matched = len(present) == len(tokens)
|
|
469
|
+
for token in present:
|
|
470
|
+
matched = (isinstance(token, str) and hmac.compare_digest(secret, token)) and matched
|
|
471
|
+
|
|
472
|
+
return Verification(True) if matched else Verification(False, "token did not match")
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _read_tokens(value: Any, segments: list[str]) -> list[Any]:
|
|
476
|
+
"""Every value at a dotted path; a ``[]`` segment fans out over a list. Absent is None."""
|
|
477
|
+
if not segments:
|
|
478
|
+
return [value]
|
|
479
|
+
|
|
480
|
+
head, rest = segments[0], segments[1:]
|
|
481
|
+
each_element = head.endswith("[]")
|
|
482
|
+
key = head[:-2] if each_element else head
|
|
483
|
+
|
|
484
|
+
if not isinstance(value, dict) or key not in value:
|
|
485
|
+
return [None]
|
|
486
|
+
nxt = value[key]
|
|
487
|
+
|
|
488
|
+
if not each_element:
|
|
489
|
+
return _read_tokens(nxt, rest)
|
|
490
|
+
if not isinstance(nxt, list):
|
|
491
|
+
return [None]
|
|
492
|
+
|
|
493
|
+
found: list[Any] = []
|
|
494
|
+
for element in nxt:
|
|
495
|
+
found.extend(_read_tokens(element, rest))
|
|
496
|
+
|
|
497
|
+
return found
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def handshake_response(
|
|
501
|
+
param: str | None, query: dict[str, str | list[str]], content_type: str = "text/plain"
|
|
502
|
+
) -> dict[str, Any] | None:
|
|
503
|
+
"""Answer a provider's challenge — Graph POSTs ``?validationToken=…`` when a
|
|
504
|
+
subscription is created and refuses to create it unless the token comes
|
|
505
|
+
back, decoded, as ``text/plain``. The pure half: what to send, or None when
|
|
506
|
+
the request is not a challenge at all (no parameter, an empty one, or a
|
|
507
|
+
trigger that declares no handshake). ``query`` is already URL-decoded by
|
|
508
|
+
the framework.
|
|
509
|
+
"""
|
|
510
|
+
if param is None:
|
|
511
|
+
return None
|
|
512
|
+
|
|
513
|
+
raw = query.get(param)
|
|
514
|
+
token = raw[0] if isinstance(raw, list) and raw else raw
|
|
515
|
+
if not token or isinstance(token, list):
|
|
516
|
+
return None
|
|
517
|
+
|
|
518
|
+
return {"status": 200, "contentType": content_type, "body": token}
|
|
519
|
+
|
|
520
|
+
|
|
368
521
|
def _b64(raw: bytes) -> str:
|
|
369
522
|
import base64
|
|
370
523
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# GENERATED FILE — do not edit.
|
|
2
|
+
#
|
|
3
|
+
# Emitted from provider/actions/values-get.json by weaver's generator.
|
|
4
|
+
# A hand-edit here is destroyed by the next protocol sync, which is worse than
|
|
5
|
+
# being rejected, because it works until it silently does not. Fix
|
|
6
|
+
# provider/actions/values-get.json (or weaver's template/) and regenerate:
|
|
7
|
+
#
|
|
8
|
+
# npm run provider -- google_sheets
|
|
9
|
+
|
|
10
|
+
"""Read the cell values of a range.
|
|
11
|
+
|
|
12
|
+
GET /v4/spreadsheets/{spreadsheetId}/values/{range} —
|
|
13
|
+
https://developers.google.com/workspace/sheets/api/reference/rest/v4/spreadsheets.values/get
|
|
14
|
+
|
|
15
|
+
This describes the request. `call` resolves the connection, picks the
|
|
16
|
+
estate, and either calls Google Sheets or calls the faker.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any
|
|
22
|
+
from urllib.parse import quote
|
|
23
|
+
|
|
24
|
+
from .._runtime import CallResult, ConnectorConfigError, Mode, call
|
|
25
|
+
from ..service import descriptor
|
|
26
|
+
|
|
27
|
+
OPERATION = "values_get"
|
|
28
|
+
METHOD = "GET"
|
|
29
|
+
PATH = "/v4/spreadsheets/{spreadsheetId}/values/{range}"
|
|
30
|
+
SIDE_EFFECTS = "none"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def body(config: dict[str, Any]) -> dict[str, Any]:
|
|
34
|
+
"""Build the form body for one call, failing loudly and specifically."""
|
|
35
|
+
if config.get("spreadsheetId") is None or config.get("spreadsheetId") == "":
|
|
36
|
+
raise ConnectorConfigError(
|
|
37
|
+
"values_get: \"spreadsheetId\" is required (Spreadsheet ID)."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if config.get("range") is None or config.get("range") == "":
|
|
41
|
+
raise ConnectorConfigError(
|
|
42
|
+
"values_get: \"range\" is required (Range)."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
out: dict[str, Any] = {}
|
|
46
|
+
_value = config.get("valueRenderOption")
|
|
47
|
+
if _value is not None and _value != "":
|
|
48
|
+
out["valueRenderOption"] = str(_value)
|
|
49
|
+
_value = config.get("majorDimension")
|
|
50
|
+
if _value is not None and _value != "":
|
|
51
|
+
out["majorDimension"] = str(_value)
|
|
52
|
+
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def path(config: dict[str, Any]) -> str:
|
|
58
|
+
"""The request path, with each config value URL-ENCODED into it.
|
|
59
|
+
|
|
60
|
+
`PATH` above is the TEMPLATE, which is what the descriptor advertises;
|
|
61
|
+
this is what a caller sends. A value interpolated raw changes WHICH URL is
|
|
62
|
+
called — a range like `Sheet1!A:B`, or a sheet named `Q1/Q2` — and the
|
|
63
|
+
provider answers 404 about the document rather than about the encoding.
|
|
64
|
+
"""
|
|
65
|
+
return (
|
|
66
|
+
"/v4/spreadsheets/"
|
|
67
|
+
+ quote(str(config.get("spreadsheetId") or ""), safe="")
|
|
68
|
+
+ "/values/"
|
|
69
|
+
+ quote(str(config.get("range") or ""), safe="")
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def values_get(
|
|
73
|
+
config: dict[str, Any],
|
|
74
|
+
*,
|
|
75
|
+
credentials: dict[str, str | None] | None = None,
|
|
76
|
+
mode: Mode = "auto",
|
|
77
|
+
connection_id: str | None = None,
|
|
78
|
+
attempts: int = 3,
|
|
79
|
+
) -> CallResult:
|
|
80
|
+
"""Read the cell values of a range."""
|
|
81
|
+
return call(
|
|
82
|
+
descriptor(),
|
|
83
|
+
operation=OPERATION,
|
|
84
|
+
method=METHOD,
|
|
85
|
+
path=PATH,
|
|
86
|
+
form=body(config),
|
|
87
|
+
config=config,
|
|
88
|
+
credentials=credentials,
|
|
89
|
+
mode=mode,
|
|
90
|
+
connection_id=connection_id,
|
|
91
|
+
attempts=attempts,
|
|
92
|
+
)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# GENERATED FILE — do not edit.
|
|
2
|
+
#
|
|
3
|
+
# Emitted from provider/actions/values-update.json by weaver's generator.
|
|
4
|
+
# A hand-edit here is destroyed by the next protocol sync, which is worse than
|
|
5
|
+
# being rejected, because it works until it silently does not. Fix
|
|
6
|
+
# provider/actions/values-update.json (or weaver's template/) and regenerate:
|
|
7
|
+
#
|
|
8
|
+
# npm run provider -- google_sheets
|
|
9
|
+
|
|
10
|
+
"""Overwrite the cell values of a range. Existing values in that range are
|
|
11
|
+
replaced, not pushed down.
|
|
12
|
+
|
|
13
|
+
PUT /v4/spreadsheets/{spreadsheetId}/values/{range} —
|
|
14
|
+
https://developers.google.com/workspace/sheets/api/reference/rest/v4/spreadsheets.values/update
|
|
15
|
+
|
|
16
|
+
This describes the request. `call` resolves the connection, picks the
|
|
17
|
+
estate, and either calls Google Sheets or calls the faker.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
from urllib.parse import quote
|
|
24
|
+
|
|
25
|
+
from .._runtime import CallResult, ConnectorConfigError, Mode, call
|
|
26
|
+
from ..service import descriptor
|
|
27
|
+
|
|
28
|
+
OPERATION = "values_update"
|
|
29
|
+
METHOD = "PUT"
|
|
30
|
+
PATH = "/v4/spreadsheets/{spreadsheetId}/values/{range}"
|
|
31
|
+
SIDE_EFFECTS = "idempotent"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def body(config: dict[str, Any]) -> dict[str, Any]:
|
|
35
|
+
"""Build the JSON body for one call, failing loudly and specifically."""
|
|
36
|
+
if config.get("spreadsheetId") is None or config.get("spreadsheetId") == "":
|
|
37
|
+
raise ConnectorConfigError(
|
|
38
|
+
"values_update: \"spreadsheetId\" is required (Spreadsheet ID)."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if config.get("range") is None or config.get("range") == "":
|
|
42
|
+
raise ConnectorConfigError(
|
|
43
|
+
"values_update: \"range\" is required (Range)."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if config.get("values") is None or config.get("values") == "":
|
|
47
|
+
raise ConnectorConfigError(
|
|
48
|
+
"values_update: \"values\" is required (Row values)."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if config.get("valueInputOption") is None or config.get("valueInputOption") == "":
|
|
52
|
+
raise ConnectorConfigError(
|
|
53
|
+
"values_update: \"valueInputOption\" is required (How to interpret the values)."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
out: dict[str, Any] = {}
|
|
57
|
+
_value = config.get("values")
|
|
58
|
+
out["values"] = [_values_list(config.get("values"))]
|
|
59
|
+
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def path(config: dict[str, Any]) -> str:
|
|
65
|
+
"""The request path, with each config value URL-ENCODED into it.
|
|
66
|
+
|
|
67
|
+
`PATH` above is the TEMPLATE, which is what the descriptor advertises;
|
|
68
|
+
this is what a caller sends. A value interpolated raw changes WHICH URL is
|
|
69
|
+
called — a range like `Sheet1!A:B`, or a sheet named `Q1/Q2` — and the
|
|
70
|
+
provider answers 404 about the document rather than about the encoding.
|
|
71
|
+
"""
|
|
72
|
+
return (
|
|
73
|
+
"/v4/spreadsheets/"
|
|
74
|
+
+ quote(str(config.get("spreadsheetId") or ""), safe="")
|
|
75
|
+
+ "/values/"
|
|
76
|
+
+ quote(str(config.get("range") or ""), safe="")
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def query(config: dict[str, Any]) -> dict[str, Any]:
|
|
81
|
+
"""The QUERY parameters, which are not the same as the body.
|
|
82
|
+
|
|
83
|
+
A POST can carry both. Sent in the body instead, a parameter like Sheets'
|
|
84
|
+
`valueInputOption` is IGNORED and the provider falls back to its own
|
|
85
|
+
default — so the request succeeds and quietly does the wrong thing.
|
|
86
|
+
"""
|
|
87
|
+
out: dict[str, Any] = {}
|
|
88
|
+
_value = config.get("valueInputOption")
|
|
89
|
+
if _value is None or _value == "":
|
|
90
|
+
raise ConnectorConfigError("values_update: \"valueInputOption\" is required.")
|
|
91
|
+
|
|
92
|
+
out["valueInputOption"] = str(_value)
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
def values_update(
|
|
96
|
+
config: dict[str, Any],
|
|
97
|
+
*,
|
|
98
|
+
credentials: dict[str, str | None] | None = None,
|
|
99
|
+
mode: Mode = "auto",
|
|
100
|
+
connection_id: str | None = None,
|
|
101
|
+
attempts: int = 3,
|
|
102
|
+
) -> CallResult:
|
|
103
|
+
"""Overwrite the cell values of a range. Existing values in that range are replaced, not pushed
|
|
104
|
+
down.
|
|
105
|
+
"""
|
|
106
|
+
return call(
|
|
107
|
+
descriptor(),
|
|
108
|
+
operation=OPERATION,
|
|
109
|
+
method=METHOD,
|
|
110
|
+
path=PATH,
|
|
111
|
+
json_body=body(config),
|
|
112
|
+
config=config,
|
|
113
|
+
credentials=credentials,
|
|
114
|
+
mode=mode,
|
|
115
|
+
connection_id=connection_id,
|
|
116
|
+
attempts=attempts,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _values_list(value: Any) -> list[str]:
|
|
121
|
+
"""One value, a ","-separated string, or a list — all end up a list."""
|
|
122
|
+
if isinstance(value, list):
|
|
123
|
+
items = [str(item) for item in value]
|
|
124
|
+
elif isinstance(value, str):
|
|
125
|
+
items = value.split(",")
|
|
126
|
+
else:
|
|
127
|
+
return []
|
|
128
|
+
|
|
129
|
+
return [item.strip() for item in items if item.strip()]
|
|
@@ -24,6 +24,18 @@ from typing import Any
|
|
|
24
24
|
from ._fake import FakeValues
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
def _as_number(value: Any) -> float | None:
|
|
28
|
+
"""The coercion an `"as": "integer" | "number"` config binding uses: a value
|
|
29
|
+
that IS a number, never int(float(...))'s uncaught ValueError on one that
|
|
30
|
+
merely looks like text (a text field's auto-generated example, before an
|
|
31
|
+
author has typed a real one).
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
return float(value)
|
|
35
|
+
except (TypeError, ValueError):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
27
39
|
def _row_append(config: dict[str, Any], fake: FakeValues) -> Any:
|
|
28
40
|
bound_spreadsheetid = (
|
|
29
41
|
str(_v)
|
|
@@ -44,6 +56,53 @@ def _row_append(config: dict[str, Any], fake: FakeValues) -> Any:
|
|
|
44
56
|
}
|
|
45
57
|
|
|
46
58
|
|
|
59
|
+
def _values_get(config: dict[str, Any], fake: FakeValues) -> Any:
|
|
60
|
+
return {
|
|
61
|
+
"range": (
|
|
62
|
+
str(_v)
|
|
63
|
+
if (_v := config.get("range")) is not None and _v != ""
|
|
64
|
+
else "Sheet1!A1:C2"
|
|
65
|
+
),
|
|
66
|
+
"majorDimension": (
|
|
67
|
+
str(_v)
|
|
68
|
+
if (_v := config.get("majorDimension")) is not None and _v != ""
|
|
69
|
+
else "ROWS"
|
|
70
|
+
),
|
|
71
|
+
"values": [
|
|
72
|
+
[
|
|
73
|
+
"Name",
|
|
74
|
+
"Email",
|
|
75
|
+
"Status",
|
|
76
|
+
],
|
|
77
|
+
[
|
|
78
|
+
"Ada Lovelace",
|
|
79
|
+
"ada@example.test",
|
|
80
|
+
"Active",
|
|
81
|
+
],
|
|
82
|
+
],
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _values_update(config: dict[str, Any], fake: FakeValues) -> Any:
|
|
87
|
+
bound_spreadsheetid = (
|
|
88
|
+
str(_v)
|
|
89
|
+
if (_v := config.get("spreadsheetId")) is not None and _v != ""
|
|
90
|
+
else fake.id("1Sheet")
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
"spreadsheetId": bound_spreadsheetid,
|
|
95
|
+
"updatedRange": (
|
|
96
|
+
str(_v)
|
|
97
|
+
if (_v := config.get("range")) is not None and _v != ""
|
|
98
|
+
else "Sheet1!A1"
|
|
99
|
+
),
|
|
100
|
+
"updatedRows": 1,
|
|
101
|
+
"updatedColumns": 3,
|
|
102
|
+
"updatedCells": 3,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
47
106
|
def respond(operation: str, request: dict[str, Any]) -> Any:
|
|
48
107
|
"""Dispatch to the fixture for one operation."""
|
|
49
108
|
config: dict[str, Any] = request.get("config") or {}
|
|
@@ -52,6 +111,12 @@ def respond(operation: str, request: dict[str, Any]) -> Any:
|
|
|
52
111
|
if operation == "row_append":
|
|
53
112
|
return _row_append(config, fake)
|
|
54
113
|
|
|
114
|
+
if operation == "values_get":
|
|
115
|
+
return _values_get(config, fake)
|
|
116
|
+
|
|
117
|
+
if operation == "values_update":
|
|
118
|
+
return _values_update(config, fake)
|
|
119
|
+
|
|
55
120
|
# A faker asked for an operation it has no shape for must SAY so. Making
|
|
56
121
|
# something up would produce a green run whose output silently has none of
|
|
57
122
|
# the fields the author is about to reference.
|
|
@@ -40,6 +40,45 @@ def test_row_append_fakes_the_published_shape() -> None:
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
def test_values_get_fakes_the_published_shape() -> None:
|
|
44
|
+
config = {}
|
|
45
|
+
fake = FakeValues(seed_for_call("google_sheets", "values_get", config))
|
|
46
|
+
|
|
47
|
+
faked = respond("values_get", {"config": config, "fake": fake})
|
|
48
|
+
|
|
49
|
+
assert faked == {
|
|
50
|
+
"range": "Sheet1!A1:C2",
|
|
51
|
+
"majorDimension": "ROWS",
|
|
52
|
+
"values": [
|
|
53
|
+
[
|
|
54
|
+
"Name",
|
|
55
|
+
"Email",
|
|
56
|
+
"Status",
|
|
57
|
+
],
|
|
58
|
+
[
|
|
59
|
+
"Ada Lovelace",
|
|
60
|
+
"ada@example.test",
|
|
61
|
+
"Active",
|
|
62
|
+
],
|
|
63
|
+
],
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_values_update_fakes_the_published_shape() -> None:
|
|
68
|
+
config = {}
|
|
69
|
+
fake = FakeValues(seed_for_call("google_sheets", "values_update", config))
|
|
70
|
+
|
|
71
|
+
faked = respond("values_update", {"config": config, "fake": fake})
|
|
72
|
+
|
|
73
|
+
assert faked == {
|
|
74
|
+
"spreadsheetId": "1Sheet_fake_64ecbb208290",
|
|
75
|
+
"updatedRange": "Sheet1!A1",
|
|
76
|
+
"updatedRows": 1,
|
|
77
|
+
"updatedColumns": 3,
|
|
78
|
+
"updatedCells": 3,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
43
82
|
def test_an_operation_with_no_fixture_raises_rather_than_inventing_a_shape() -> None:
|
|
44
83
|
fake = FakeValues(seed_for_call("google_sheets", "no_such_operation", {}))
|
|
45
84
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|