fancy-google-analytics 0.3.4__tar.gz → 0.5.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.
Files changed (16) hide show
  1. {fancy_google_analytics-0.3.4 → fancy_google_analytics-0.5.0}/CHANGELOG.md +46 -0
  2. {fancy_google_analytics-0.3.4 → fancy_google_analytics-0.5.0}/PKG-INFO +10 -1
  3. {fancy_google_analytics-0.3.4 → fancy_google_analytics-0.5.0}/README.md +9 -0
  4. fancy_google_analytics-0.5.0/art/fancified.svg +26 -0
  5. {fancy_google_analytics-0.3.4 → fancy_google_analytics-0.5.0}/pyproject.toml +2 -2
  6. {fancy_google_analytics-0.3.4 → fancy_google_analytics-0.5.0}/src/fancy_google_analytics/__init__.py +1 -1
  7. {fancy_google_analytics-0.3.4 → fancy_google_analytics-0.5.0}/src/fancy_google_analytics/_runtime.py +206 -8
  8. {fancy_google_analytics-0.3.4 → fancy_google_analytics-0.5.0}/src/fancy_google_analytics/faker.py +12 -0
  9. {fancy_google_analytics-0.3.4 → fancy_google_analytics-0.5.0}/.gitignore +0 -0
  10. {fancy_google_analytics-0.3.4 → fancy_google_analytics-0.5.0}/LICENSE +0 -0
  11. {fancy_google_analytics-0.3.4 → fancy_google_analytics-0.5.0}/src/fancy_google_analytics/_fake.py +0 -0
  12. {fancy_google_analytics-0.3.4 → fancy_google_analytics-0.5.0}/src/fancy_google_analytics/actions/__init__.py +0 -0
  13. {fancy_google_analytics-0.3.4 → fancy_google_analytics-0.5.0}/src/fancy_google_analytics/actions/report_run.py +0 -0
  14. {fancy_google_analytics-0.3.4 → fancy_google_analytics-0.5.0}/src/fancy_google_analytics/py.typed +0 -0
  15. {fancy_google_analytics-0.3.4 → fancy_google_analytics-0.5.0}/src/fancy_google_analytics/service.py +0 -0
  16. {fancy_google_analytics-0.3.4 → fancy_google_analytics-0.5.0}/tests/test_faker.py +0 -0
@@ -8,6 +8,52 @@ 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.5.0] — 2026-11-08
12
+
13
+ ### Added
14
+
15
+ - **One Google consent grant, shared by every Google connector** (`oauth.grant:
16
+ "google"`), for an org-level Google connection: one OAuth client, one consent
17
+ and one refresh token per person, with each feature's scopes asked for when it
18
+ is first used. Declared in the manifest, the `-ui` package's OAuth constant,
19
+ the README and the registry index:
20
+ - `authorizeParams`: `access_type=offline` and `include_granted_scopes=true`.
21
+ A refresh token comes back only on the FIRST code exchange, so keep the stored
22
+ one when an exchange returns none.
23
+ - `revoke`: POST `https://oauth2.googleapis.com/revoke` with `token`, and
24
+ `scope: "grant"`. **Revoking removes the person's WHOLE Google grant, every
25
+ Google connector**, so a per-feature disconnect stops using that feature's
26
+ scopes locally and only a full Google disconnect revokes.
27
+ - `identity`: identify the account by the id_token's `sub` (request `openid`;
28
+ issuer `https://accounts.google.com` or `accounts.google.com`; audience is
29
+ the `clientId`), never by email, which Google says is neither unique nor
30
+ permanent.
31
+ `restrictedScopes` is empty: none of this connector's scopes is restricted.
32
+
33
+ Nothing a request sends has changed; this is what a host needs to run the
34
+ connection.
35
+
36
+ ## [0.4.0] — 2026-11-08
37
+
38
+ ### Added
39
+
40
+ - **Laravel integration, in this package** (`src/Laravel/`), auto-discovered
41
+ through composer's `extra.laravel`. Laravel is SUGGESTED, never required:
42
+ outside a Laravel app none of it loads.
43
+ - a service provider that merges `config/google-analytics.php` under
44
+ `connectors.google-analytics` (credentials from the environment, or per call for a
45
+ multi-account app);
46
+ - the `GoogleAnalytics` facade: `GoogleAnalytics::call('<operation>', [...])` runs any action;
47
+ - `GoogleAnalytics::fake()`, answered from the connector's faker, with
48
+ `assertSent` / `assertNotSent` / `assertNothingSent` / `assertSentCount`;
49
+ - its own Pest test of all of it against a booted Laravel app.
50
+
51
+ ### Changed
52
+
53
+ - **Tests: Pest `^5`**, and `laravel/framework` `^13` as a DEV-ONLY test
54
+ dependency (the framework this package integrates with; nothing ships in
55
+ `require`).
56
+
11
57
  ## [0.3.4] — 2026-09-12
12
58
 
13
59
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fancy-google-analytics
3
- Version: 0.3.4
3
+ Version: 0.5.0
4
4
  Summary: Google Analytics 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-analytics
6
6
  Project-URL: Issues, https://github.com/Fancy-Friends/google-analytics/issues
@@ -22,6 +22,8 @@ Description-Content-Type: text/markdown
22
22
 
23
23
  # Google Analytics
24
24
 
25
+ [![Fancified](art/fancified.svg)](https://particle.academy)
26
+
25
27
  Google Analytics 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.
@@ -75,6 +77,13 @@ Google Analytics uses OAuth2 (authorization_code). The package DECLARES the exch
75
77
  - **Scopes** — `https://www.googleapis.com/auth/analytics.readonly`
76
78
  - **Access token lifetime** — 3600 seconds (1 hours). A host that never refreshes works all afternoon and is broken by morning.
77
79
 
80
+ **One consent grant, shared** (`grant: "google"`). Every connector declaring the same grant can run on ONE OAuth client, with one consent and one refresh token per person.
81
+
82
+ - **Authorize with** `access_type=offline`, `include_granted_scopes=true`. Ask for a feature's scopes when it is first used; the new token covers every scope granted before. A refresh token comes back only on the FIRST exchange, so keep the stored one when an exchange returns none.
83
+ - **Revoke** — POST https://oauth2.googleapis.com/revoke with `token`. **It removes the WHOLE grant**: every connector sharing it, not only this one. A per-feature disconnect stops using that feature's scopes locally; only a full disconnect revokes.
84
+ - **Identify the account by `sub`**: request `openid`, then verify the token response's id_token (issuer `https://accounts.google.com` or `accounts.google.com`, keys at https://www.googleapis.com/oauth2/v3/certs, audience = `clientId`). Never key a connection by email.
85
+ - **No restricted scopes** in this connector.
86
+
78
87
  The refresh tokens do **not** rotate: the same one is reusable, so a refresh may safely be retried and may run concurrently. Stated rather than assumed, because the opposite — a provider that spends the token and revokes the grant on a replay — looks identical until it happens.
79
88
 
80
89
  ### The estate
@@ -1,5 +1,7 @@
1
1
  # Google Analytics
2
2
 
3
+ [![Fancified](art/fancified.svg)](https://particle.academy)
4
+
3
5
  Google Analytics 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.
@@ -53,6 +55,13 @@ Google Analytics uses OAuth2 (authorization_code). The package DECLARES the exch
53
55
  - **Scopes** — `https://www.googleapis.com/auth/analytics.readonly`
54
56
  - **Access token lifetime** — 3600 seconds (1 hours). A host that never refreshes works all afternoon and is broken by morning.
55
57
 
58
+ **One consent grant, shared** (`grant: "google"`). Every connector declaring the same grant can run on ONE OAuth client, with one consent and one refresh token per person.
59
+
60
+ - **Authorize with** `access_type=offline`, `include_granted_scopes=true`. Ask for a feature's scopes when it is first used; the new token covers every scope granted before. A refresh token comes back only on the FIRST exchange, so keep the stored one when an exchange returns none.
61
+ - **Revoke** — POST https://oauth2.googleapis.com/revoke with `token`. **It removes the WHOLE grant**: every connector sharing it, not only this one. A per-feature disconnect stops using that feature's scopes locally; only a full disconnect revokes.
62
+ - **Identify the account by `sub`**: request `openid`, then verify the token response's id_token (issuer `https://accounts.google.com` or `accounts.google.com`, keys at https://www.googleapis.com/oauth2/v3/certs, audience = `clientId`). Never key a connection by email.
63
+ - **No restricted scopes** in this connector.
64
+
56
65
  The refresh tokens do **not** rotate: the same one is reusable, so a refresh may safely be retried and may run concurrently. Stated rather than assumed, because the opposite — a provider that spends the token and revokes the grant on a replay — looks identical until it happens.
57
66
 
58
67
  ### The estate
@@ -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-analytics"
7
- version = "0.3.4"
7
+ version = "0.5.0"
8
8
  description = "Google Analytics 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_analytics"]
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"]
@@ -21,7 +21,7 @@ from .actions.report_run import report_run
21
21
  from .faker import respond
22
22
  from .service import BASE_URLS, CONNECTOR_API_VERSION, REQUIRES, SANDBOX, SERVICE, TITLE, descriptor
23
23
 
24
- __version__ = "0.3.4"
24
+ __version__ = "0.5.0"
25
25
 
26
26
  __all__ = [
27
27
  "BASE_URLS",
@@ -308,14 +308,22 @@ _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,
322
+ timestamp_unit: str = "s",
323
+ signs_request_line: bool = False,
324
+ uri: str | None = None,
325
+ method: str | None = None,
326
+ url: str | None = None,
319
327
  ) -> Verification:
320
328
  """Verify one inbound delivery.
321
329
 
@@ -327,44 +335,234 @@ def verify_hmac(
327
335
  ``raw`` must be the body EXACTLY as received. Re-serialised JSON changes key
328
336
  order and whitespace and produces a mismatch that looks precisely like a
329
337
  wrong secret.
338
+
339
+ ``secret_encoding`` says how the SECRET is spelled: ``utf8`` (the default,
340
+ every provider before Svix — the key is the text's bytes) or ``base64``
341
+ (the key is the DECODED bytes; Svix's ``whsec_<base64>``, half of whose
342
+ bytes are not UTF-8). ``secret_prefix`` is stripped first, and its absence
343
+ is a refusal, never a guess.
330
344
  """
331
345
  if not secret:
332
346
  return Verification(False, "no signing secret is configured for this connection")
333
- if not signature:
347
+
348
+ # A LIST when the provider sends several — Stripe signs once per active
349
+ # secret while a secret is rolled, Svix's header "could be any number of
350
+ # signatures" — and the delivery is accepted when ANY matches. A first-only
351
+ # rule fails every delivery whose first signature came from the new secret.
352
+ offered = signature if isinstance(signature, list) else [signature]
353
+ candidates = [s for s in offered if isinstance(s, str) and s]
354
+ if not candidates:
334
355
  return Verification(False, "the delivery carried no signature")
335
356
 
336
357
  digest = _ALGORITHMS.get(algorithm)
337
358
  if digest is None:
338
359
  return Verification(False, f'unsupported signature algorithm "{algorithm}"')
339
360
 
361
+ # The key is checked BEFORE anything is signed, so a secret that cannot be
362
+ # a key is named as such rather than producing a mismatch that reads like a
363
+ # wrong secret.
364
+ key = _secret_key_bytes(secret, secret_encoding, secret_prefix)
365
+ if isinstance(key, Verification):
366
+ return key
367
+
340
368
  if tolerance is not None:
341
369
  if not timestamp:
342
370
  return Verification(
343
371
  False, "the delivery carried no timestamp, and this scheme signs one"
344
372
  )
345
373
  try:
346
- sent = int(float(timestamp))
374
+ sent = float(timestamp)
347
375
  except (TypeError, ValueError):
348
376
  return Verification(False, f'the delivery timestamp "{timestamp}" is not a number')
377
+ # Converted, never compared raw: HubSpot sends MILLISECONDS, and a
378
+ # millisecond value against a window in seconds refuses every delivery.
379
+ if timestamp_unit == "ms":
380
+ sent = sent / 1000
349
381
 
350
382
  current = int(time.time()) if now is None else now
351
383
  if abs(current - sent) > tolerance:
352
384
  return Verification(
353
385
  False,
354
386
  f"the delivery is outside the {tolerance}s replay window "
355
- f"({abs(current - sent)}s old)",
387
+ f"({int(abs(current - sent))}s old)",
356
388
  )
357
389
 
358
- computed = hmac.new(secret.encode(), payload(raw, timestamp).encode(), digest)
390
+ # A payload that signs the delivery's ID (Svix) takes it as a third argument;
391
+ # the two-argument payloads every earlier scheme wrote are called as before.
392
+ # HubSpot v3 signs the REQUEST LINE: refused by name without it, and the
393
+ # uri normalised here, once, by the scheme's rule.
394
+ if signs_request_line:
395
+ if not method or not url:
396
+ return Verification(False, "the delivery carried no request method or url")
397
+ line = {"method": method, "uri": normalise_signed_uri(url, uri) if uri else url}
398
+ signed = payload(raw, timestamp, id, line)
399
+ else:
400
+ signed = payload(raw, timestamp) if id is None else payload(raw, timestamp, id)
401
+ computed = hmac.new(key, signed.encode(), digest)
359
402
  expected = computed.hexdigest() if encoding == "hex" else _b64(computed.digest())
360
403
 
361
- # Constant time, so a signature cannot be discovered one character at a time.
362
- if not hmac.compare_digest(expected, signature):
404
+ # Constant time per candidate, so a signature cannot be discovered one
405
+ # character at a time; which candidate matched is not a secret.
406
+ if not any(hmac.compare_digest(expected, candidate) for candidate in candidates):
363
407
  return Verification(False, "the signature does not match")
364
408
 
365
409
  return Verification(True)
366
410
 
367
411
 
412
+ _HUBSPOT_V3_DECODE = {
413
+ "%3A": ":", "%2F": "/", "%3F": "?", "%40": "@", "%21": "!", "%24": "$",
414
+ "%27": "'", "%28": "(", "%29": ")", "%2A": "*", "%2C": ",", "%3B": ";",
415
+ }
416
+
417
+
418
+ def normalise_signed_uri(url: str, rule: str) -> str:
419
+ """The URI a provider signs, from the URL the request arrived on.
420
+
421
+ ``hubspot-v3``: only the QUERY portion is percent-decoded, and only for
422
+ twelve escapes (case-insensitive); never its leading ``?``; any
423
+ ``#fragment`` dropped. ``%26`` and ``%3D`` stay as sent. The twin of the
424
+ core's ``normaliseSignedUri``.
425
+ """
426
+ import re
427
+
428
+ if rule != "hubspot-v3":
429
+ raise ValueError(f"unknown signed-uri rule {rule!r}")
430
+ without_fragment = url.split("#", 1)[0]
431
+ base, sep, query = without_fragment.partition("?")
432
+ if not sep:
433
+ return without_fragment
434
+ decoded = re.sub(
435
+ r"%[0-9A-Fa-f]{2}", lambda m: _HUBSPOT_V3_DECODE.get(m.group(0).upper(), m.group(0)), query
436
+ )
437
+ return f"{base}?{decoded}"
438
+
439
+
440
+ def _secret_key_bytes(
441
+ secret: str,
442
+ secret_encoding: str = "utf8", # noqa: S107 — how the secret is SPELLED, not one
443
+ secret_prefix: str | None = None,
444
+ ) -> bytes | Verification:
445
+ """The HMAC key a scheme's secret spells, or the refusal it earns."""
446
+ import base64
447
+ import binascii
448
+ import json
449
+ import re
450
+
451
+ material = secret
452
+ if secret_prefix is not None:
453
+ if not material.startswith(secret_prefix):
454
+ return Verification(
455
+ False, f"signing secret does not start with {json.dumps(secret_prefix)}"
456
+ )
457
+ material = material[len(secret_prefix) :]
458
+
459
+ if secret_encoding == "base64": # noqa: S105 — a spelling, not a password
460
+ # Strict: a lenient decode of a KEY is a key nobody can reason about.
461
+ well_formed = re.fullmatch(r"[A-Za-z0-9+/]*={0,2}", material) is not None
462
+ if not material or len(material) % 4 != 0 or not well_formed:
463
+ return Verification(False, "signing secret is not valid base64")
464
+ try:
465
+ return base64.b64decode(material, validate=True)
466
+ except (binascii.Error, ValueError):
467
+ return Verification(False, "signing secret is not valid base64")
468
+
469
+ return material.encode()
470
+
471
+
472
+ def verify_shared_token(
473
+ *,
474
+ raw: str,
475
+ headers: dict[str, str],
476
+ secret: str | None,
477
+ placement: str,
478
+ name: str,
479
+ ) -> Verification:
480
+ """Verify a delivery by a token the provider ECHOES rather than a signature it computes.
481
+
482
+ Google Calendar sends the channel's ``token`` back in ``X-Goog-Channel-Token``
483
+ on every notification — with an EMPTY body, so there is nothing to sign.
484
+ Microsoft Graph sends ``clientState`` inside every item of the
485
+ notification's ``value`` array. Same refusal-by-default as ``verify_hmac``,
486
+ same result shape, and a constant-time comparison: a token is a secret.
487
+
488
+ ``placement`` is ``header`` (then ``name`` is the header) or ``body`` (then
489
+ ``name`` is a dotted path into the JSON body; a segment ending in ``[]``
490
+ means EVERY element of that array, all of which must match — one wrong item
491
+ refuses the whole delivery). The reasons are the connector core's exact
492
+ strings, so a host logs the same words whichever runtime answered.
493
+ """
494
+ if not secret:
495
+ return Verification(False, "no shared token configured for this trigger")
496
+
497
+ tokens: list[Any]
498
+ if placement == "header":
499
+ tokens = [next((v for k, v in headers.items() if k.lower() == name.lower()), None)]
500
+ else:
501
+ try:
502
+ parsed = json.loads(raw)
503
+ except ValueError:
504
+ return Verification(False, "delivery body is not JSON")
505
+ tokens = _read_tokens(parsed, name.split("."))
506
+
507
+ present = [t for t in tokens if t is not None and t != ""]
508
+ if not present:
509
+ return Verification(False, "delivery carried no token")
510
+
511
+ # Every element is compared, none is skipped: a batch is accepted as a whole
512
+ # or refused as a whole.
513
+ matched = len(present) == len(tokens)
514
+ for token in present:
515
+ matched = (isinstance(token, str) and hmac.compare_digest(secret, token)) and matched
516
+
517
+ return Verification(True) if matched else Verification(False, "token did not match")
518
+
519
+
520
+ def _read_tokens(value: Any, segments: list[str]) -> list[Any]:
521
+ """Every value at a dotted path; a ``[]`` segment fans out over a list. Absent is None."""
522
+ if not segments:
523
+ return [value]
524
+
525
+ head, rest = segments[0], segments[1:]
526
+ each_element = head.endswith("[]")
527
+ key = head[:-2] if each_element else head
528
+
529
+ if not isinstance(value, dict) or key not in value:
530
+ return [None]
531
+ nxt = value[key]
532
+
533
+ if not each_element:
534
+ return _read_tokens(nxt, rest)
535
+ if not isinstance(nxt, list):
536
+ return [None]
537
+
538
+ found: list[Any] = []
539
+ for element in nxt:
540
+ found.extend(_read_tokens(element, rest))
541
+
542
+ return found
543
+
544
+
545
+ def handshake_response(
546
+ param: str | None, query: dict[str, str | list[str]], content_type: str = "text/plain"
547
+ ) -> dict[str, Any] | None:
548
+ """Answer a provider's challenge — Graph POSTs ``?validationToken=…`` when a
549
+ subscription is created and refuses to create it unless the token comes
550
+ back, decoded, as ``text/plain``. The pure half: what to send, or None when
551
+ the request is not a challenge at all (no parameter, an empty one, or a
552
+ trigger that declares no handshake). ``query`` is already URL-decoded by
553
+ the framework.
554
+ """
555
+ if param is None:
556
+ return None
557
+
558
+ raw = query.get(param)
559
+ token = raw[0] if isinstance(raw, list) and raw else raw
560
+ if not token or isinstance(token, list):
561
+ return None
562
+
563
+ return {"status": 200, "contentType": content_type, "body": token}
564
+
565
+
368
566
  def _b64(raw: bytes) -> str:
369
567
  import base64
370
568
 
@@ -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 _report_run(config: dict[str, Any], fake: FakeValues) -> Any:
28
40
  return {
29
41
  "dimensionHeaders": [