fancy-google-docs 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.
@@ -8,6 +8,21 @@ 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
+ - **`text_insert`** — insert plain text into an existing document's body
16
+ (`documents.batchUpdate`, a single `insertText` request). No scope
17
+ change. Until now `document_create` could only make a BLANK document
18
+ with no way to ever put content in it.
19
+ - Deliberately NOT included: the general `batchUpdate` escape hatch (an
20
+ author-supplied array of Google's dozens of typed request kinds —
21
+ `deleteContentRange`, `replaceAllText`, `insertTable`, text styling,
22
+ ...). That needs an array-of-polymorphic-objects config shape nothing
23
+ in this estate has used before — a real second need, not a first
24
+ guess, same bar as everything in `vocabulary-deferred.md`.
25
+
11
26
  ## [0.3.4] — 2026-09-12
12
27
 
13
28
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fancy-google-docs
3
- Version: 0.3.4
3
+ Version: 0.4.0
4
4
  Summary: Google Docs 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-docs
6
6
  Project-URL: Issues, https://github.com/Fancy-Friends/google-docs/issues
@@ -22,6 +22,8 @@ Description-Content-Type: text/markdown
22
22
 
23
23
  # Google Docs
24
24
 
25
+ [![Fancified](art/fancified.svg)](https://particle.academy)
26
+
25
27
  Google Docs 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.
@@ -97,6 +99,18 @@ Create a blank Google Docs document.
97
99
  |---|---|---|
98
100
  | `title` | yes | The title of the new blank document. |
99
101
 
102
+ #### `text_insert` — Insert text into a Google Doc
103
+
104
+ Insert plain text at a position in an existing document's body.
105
+
106
+ `POST /v1/documents/{documentId}:batchUpdate` · **unsafe to replay** — a retried durable run does it TWICE
107
+
108
+ | Input | Required | What it is |
109
+ |---|---|---|
110
+ | `documentId` | yes | From document_create's data.documentId, or the id in the document's own URL. |
111
+ | `text` | yes | Inserted verbatim. A newline in it starts a new paragraph, carrying the style of whatever paragraph the insertion point sits in. |
112
+ | `index` | yes | A zero-based position in UTF-16 code units, but 0 itself is never valid -- Google's body content starts at index 1. 1 is the very beginning of a freshly created blank document. To append after existing content, read the document's own length first (not yet offered by this connector) and insert there instead. |
113
+
100
114
  ## Run it before you have credentials
101
115
 
102
116
  Every operation ships a **faker**, whether or not Google Docs has a sandbox. Set a
@@ -1,5 +1,7 @@
1
1
  # Google Docs
2
2
 
3
+ [![Fancified](art/fancified.svg)](https://particle.academy)
4
+
3
5
  Google Docs 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.
@@ -75,6 +77,18 @@ Create a blank Google Docs document.
75
77
  |---|---|---|
76
78
  | `title` | yes | The title of the new blank document. |
77
79
 
80
+ #### `text_insert` — Insert text into a Google Doc
81
+
82
+ Insert plain text at a position in an existing document's body.
83
+
84
+ `POST /v1/documents/{documentId}:batchUpdate` · **unsafe to replay** — a retried durable run does it TWICE
85
+
86
+ | Input | Required | What it is |
87
+ |---|---|---|
88
+ | `documentId` | yes | From document_create's data.documentId, or the id in the document's own URL. |
89
+ | `text` | yes | Inserted verbatim. A newline in it starts a new paragraph, carrying the style of whatever paragraph the insertion point sits in. |
90
+ | `index` | yes | A zero-based position in UTF-16 code units, but 0 itself is never valid -- Google's body content starts at index 1. 1 is the very beginning of a freshly created blank document. To append after existing content, read the document's own length first (not yet offered by this connector) and insert there instead. |
91
+
78
92
  ## Run it before you have credentials
79
93
 
80
94
  Every operation ships a **faker**, whether or not Google Docs 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-docs"
7
- version = "0.3.4"
7
+ version = "0.4.0"
8
8
  description = "Google Docs 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_docs"]
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,11 @@ from __future__ import annotations
18
18
 
19
19
  from ._fake import FakeValues
20
20
  from .actions.document_create import document_create
21
+ from .actions.text_insert import text_insert
21
22
  from .faker import respond
22
23
  from .service import BASE_URLS, CONNECTOR_API_VERSION, REQUIRES, SANDBOX, SERVICE, TITLE, descriptor
23
24
 
24
- __version__ = "0.3.4"
25
+ __version__ = "0.4.0"
25
26
 
26
27
  __all__ = [
27
28
  "BASE_URLS",
@@ -34,4 +35,5 @@ __all__ = [
34
35
  "descriptor",
35
36
  "document_create",
36
37
  "respond",
38
+ "text_insert",
37
39
  ]
@@ -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[[str, str | None], str],
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
- if not signature:
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
- computed = hmac.new(secret.encode(), payload(raw, timestamp).encode(), digest)
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 character at a time.
362
- if not hmac.compare_digest(expected, signature):
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
 
@@ -8,7 +8,9 @@
8
8
  # npm run provider -- google_docs
9
9
 
10
10
  from .document_create import document_create
11
+ from .text_insert import text_insert
11
12
 
12
13
  __all__ = [
13
14
  "document_create",
15
+ "text_insert",
14
16
  ]
@@ -0,0 +1,161 @@
1
+ # GENERATED FILE — do not edit.
2
+ #
3
+ # Emitted from provider/actions/text-insert.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/text-insert.json (or weaver's template/) and regenerate:
7
+ #
8
+ # npm run provider -- google_docs
9
+
10
+ """Insert plain text at a position in an existing document's body.
11
+
12
+ POST /v1/documents/{documentId}:batchUpdate —
13
+ https://developers.google.com/workspace/docs/api/reference/rest/v1/documents/batchUpdate
14
+
15
+ This describes the request. `call` resolves the connection, picks the
16
+ estate, and either calls Google Docs 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 = "text_insert"
28
+ METHOD = "POST"
29
+ PATH = "/v1/documents/{documentId}:batchUpdate"
30
+ SIDE_EFFECTS = "unsafe-to-replay"
31
+
32
+
33
+ def body(config: dict[str, Any]) -> dict[str, Any]:
34
+ """Build the JSON body for one call, failing loudly and specifically."""
35
+ if config.get("documentId") is None or config.get("documentId") == "":
36
+ raise ConnectorConfigError(
37
+ "text_insert: \"documentId\" is required (Document ID)."
38
+ )
39
+
40
+ if config.get("text") is None or config.get("text") == "":
41
+ raise ConnectorConfigError(
42
+ "text_insert: \"text\" is required (Text)."
43
+ )
44
+
45
+ index = config.get("index")
46
+ if index is not None and index != "":
47
+ try:
48
+ _n = float(index)
49
+ except (TypeError, ValueError):
50
+ _n = None
51
+ if _n is None or _n != int(_n) or _n < 1:
52
+ raise ConnectorConfigError(
53
+ "text_insert: \"index\" must be a integer, got "
54
+ f"{index!r}."
55
+ )
56
+ else:
57
+ raise ConnectorConfigError(
58
+ "text_insert: \"index\" is required (Insert at index)."
59
+ )
60
+
61
+ out: dict[str, Any] = {}
62
+ _value = config.get("text")
63
+ if _value is None or _value == "":
64
+ raise ConnectorConfigError("text_insert: \"text\" is required.")
65
+
66
+ out["requests.0.insertText.text"] = str(_value)
67
+ _value = config.get("index")
68
+ if _value is None or _value == "":
69
+ raise ConnectorConfigError("text_insert: \"index\" is required.")
70
+
71
+ out["requests.0.insertText.location.index"] = int(float(_value))
72
+
73
+ return _nest_fields(out)
74
+
75
+
76
+
77
+ def path(config: dict[str, Any]) -> str:
78
+ """The request path, with each config value URL-ENCODED into it.
79
+
80
+ `PATH` above is the TEMPLATE, which is what the descriptor advertises;
81
+ this is what a caller sends. A value interpolated raw changes WHICH URL is
82
+ called — a range like `Sheet1!A:B`, or a sheet named `Q1/Q2` — and the
83
+ provider answers 404 about the document rather than about the encoding.
84
+ """
85
+ return (
86
+ "/v1/documents/"
87
+ + quote(str(config.get("documentId") or ""), safe="")
88
+ + ":batchUpdate"
89
+ )
90
+
91
+ def text_insert(
92
+ config: dict[str, Any],
93
+ *,
94
+ credentials: dict[str, str | None] | None = None,
95
+ mode: Mode = "auto",
96
+ connection_id: str | None = None,
97
+ attempts: int = 3,
98
+ ) -> CallResult:
99
+ """Insert plain text at a position in an existing document's body."""
100
+ return call(
101
+ descriptor(),
102
+ operation=OPERATION,
103
+ method=METHOD,
104
+ path=PATH,
105
+ json_body=body(config),
106
+ config=config,
107
+ credentials=credentials,
108
+ mode=mode,
109
+ connection_id=connection_id,
110
+ attempts=attempts,
111
+ )
112
+
113
+
114
+
115
+ def _nest_fields(flat: dict[str, Any]) -> dict[str, Any]:
116
+ """`{"properties.email": x}` -> `{"properties": {"email": x}}`.
117
+
118
+ A dotted `as` means NESTING, and only a JSON body can nest -- in a form body
119
+ that spelling already means a literal dotted key.
120
+ """
121
+ out: dict[str, Any] = {}
122
+
123
+ for path, value in flat.items():
124
+ parts = path.split(".")
125
+ node = out
126
+
127
+ for key in parts[:-1]:
128
+ found = node.get(key)
129
+ if not isinstance(found, dict):
130
+ found = {}
131
+ node[key] = found
132
+ node = found
133
+
134
+ node[parts[-1]] = value
135
+
136
+ # The ROOT is always an object -- a JSON body's top level is never a list
137
+ # -- so only its VALUES are converted. That also keeps the return type
138
+ # honest: `_listify` returns Any, and returning it directly is a
139
+ # no-any-return error under mypy --strict.
140
+ return {key: _listify(value) for key, value in out.items()}
141
+
142
+
143
+ def _listify(node: Any) -> Any:
144
+ """A mapping whose keys are 0, 1, 2 ... is an ARRAY, not an object.
145
+
146
+ `dateRanges.0.startDate` has to become `[{...}]`. PHP produced the list by
147
+ accident -- its integer-keyed arrays serialise as JSON arrays -- while
148
+ TypeScript and Python produced `{"0": {...}}`, which the provider refuses
149
+ as the wrong type. The parity suite is what caught the disagreement, and
150
+ converting at the END keeps the walk above simple.
151
+ """
152
+ if not isinstance(node, dict):
153
+ return node
154
+
155
+ walked = {key: _listify(value) for key, value in node.items()}
156
+ wanted = [str(index) for index in range(len(walked))]
157
+
158
+ if walked and list(walked.keys()) == wanted:
159
+ return [walked[key] for key in wanted]
160
+
161
+ return walked
@@ -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 _document_create(config: dict[str, Any], fake: FakeValues) -> Any:
28
40
  return {
29
41
  "documentId": fake.id("1Doc"),
@@ -35,6 +47,19 @@ def _document_create(config: dict[str, Any], fake: FakeValues) -> Any:
35
47
  }
36
48
 
37
49
 
50
+ def _text_insert(config: dict[str, Any], fake: FakeValues) -> Any:
51
+ return {
52
+ "documentId": (
53
+ str(_v)
54
+ if (_v := config.get("documentId")) is not None and _v != ""
55
+ else fake.id("1Doc")
56
+ ),
57
+ "replies": [
58
+ {},
59
+ ],
60
+ }
61
+
62
+
38
63
  def respond(operation: str, request: dict[str, Any]) -> Any:
39
64
  """Dispatch to the fixture for one operation."""
40
65
  config: dict[str, Any] = request.get("config") or {}
@@ -43,6 +68,9 @@ def respond(operation: str, request: dict[str, Any]) -> Any:
43
68
  if operation == "document_create":
44
69
  return _document_create(config, fake)
45
70
 
71
+ if operation == "text_insert":
72
+ return _text_insert(config, fake)
73
+
46
74
  # A faker asked for an operation it has no shape for must SAY so. Making
47
75
  # something up would produce a green run whose output silently has none of
48
76
  # the fields the author is about to reference.
@@ -33,6 +33,20 @@ def test_document_create_fakes_the_published_shape() -> None:
33
33
  }
34
34
 
35
35
 
36
+ def test_text_insert_fakes_the_published_shape() -> None:
37
+ config = {}
38
+ fake = FakeValues(seed_for_call("google_docs", "text_insert", config))
39
+
40
+ faked = respond("text_insert", {"config": config, "fake": fake})
41
+
42
+ assert faked == {
43
+ "documentId": "1Doc_fake_2a078907bfc7",
44
+ "replies": [
45
+ {},
46
+ ],
47
+ }
48
+
49
+
36
50
  def test_an_operation_with_no_fixture_raises_rather_than_inventing_a_shape() -> None:
37
51
  fake = FakeValues(seed_for_call("google_docs", "no_such_operation", {}))
38
52