fancy-buffer 0.1.2__tar.gz → 0.2.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_buffer-0.1.2 → fancy_buffer-0.2.0}/CHANGELOG.md +27 -0
- {fancy_buffer-0.1.2 → fancy_buffer-0.2.0}/PKG-INFO +3 -1
- {fancy_buffer-0.1.2 → fancy_buffer-0.2.0}/README.md +2 -0
- fancy_buffer-0.2.0/art/fancified.svg +26 -0
- {fancy_buffer-0.1.2 → fancy_buffer-0.2.0}/pyproject.toml +2 -2
- {fancy_buffer-0.1.2 → fancy_buffer-0.2.0}/src/fancy_buffer/__init__.py +1 -1
- {fancy_buffer-0.1.2 → fancy_buffer-0.2.0}/src/fancy_buffer/_runtime.py +206 -8
- {fancy_buffer-0.1.2 → fancy_buffer-0.2.0}/src/fancy_buffer/faker.py +12 -0
- {fancy_buffer-0.1.2 → fancy_buffer-0.2.0}/.gitignore +0 -0
- {fancy_buffer-0.1.2 → fancy_buffer-0.2.0}/LICENSE +0 -0
- {fancy_buffer-0.1.2 → fancy_buffer-0.2.0}/src/fancy_buffer/_fake.py +0 -0
- {fancy_buffer-0.1.2 → fancy_buffer-0.2.0}/src/fancy_buffer/actions/__init__.py +0 -0
- {fancy_buffer-0.1.2 → fancy_buffer-0.2.0}/src/fancy_buffer/actions/post_create.py +0 -0
- {fancy_buffer-0.1.2 → fancy_buffer-0.2.0}/src/fancy_buffer/py.typed +0 -0
- {fancy_buffer-0.1.2 → fancy_buffer-0.2.0}/src/fancy_buffer/service.py +0 -0
- {fancy_buffer-0.1.2 → fancy_buffer-0.2.0}/tests/test_faker.py +0 -0
|
@@ -4,6 +4,33 @@ All notable changes to `@particle-academy/buffer-ui`,
|
|
|
4
4
|
`@particle-academy/buffer-js`, `particle-academy/buffer-php` and
|
|
5
5
|
`fancy-buffer`.
|
|
6
6
|
|
|
7
|
+
## [0.2.0] — 2026-11-08
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Laravel integration, in this package** (`src/Laravel/`), auto-discovered
|
|
12
|
+
through composer's `extra.laravel`. Laravel is SUGGESTED, never required:
|
|
13
|
+
outside a Laravel app none of it loads.
|
|
14
|
+
- a service provider that merges `config/buffer.php` under
|
|
15
|
+
`connectors.buffer` (credentials from the environment, or per call for a
|
|
16
|
+
multi-account app);
|
|
17
|
+
- the `Buffer` facade: `Buffer::call('<operation>', [...])` runs any action;
|
|
18
|
+
- `Buffer::fake()`, answered from the connector's faker, with
|
|
19
|
+
`assertSent` / `assertNotSent` / `assertNothingSent` / `assertSentCount`;
|
|
20
|
+
- its own Pest test of all of it against a booted Laravel app.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **Tests: Pest `^5`**, and `laravel/framework` `^13` as a DEV-ONLY test
|
|
25
|
+
dependency (the framework this package integrates with; nothing ships in
|
|
26
|
+
`require`).
|
|
27
|
+
|
|
28
|
+
## [0.1.3] — 2026-09-12
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- **Requires `fancy-connector-core` ≥ 0.4.0** — `particle-academy/fancy-connector-core` for php, `@particle-academy/fancy-connector-core` for js. The flow executors now pass a provider's declared `idempotencyMaxLength` through to the core's key derivation, and a named argument an older core does not accept is a fatal rather than a no-op, so the floor moves with it. This connector declares no limit and passes nothing, so nothing else changes for it; the bump is the floor alone.
|
|
33
|
+
|
|
7
34
|
## [0.1.2] — 2026-09-11
|
|
8
35
|
|
|
9
36
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: fancy-buffer
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Buffer 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/buffer
|
|
6
6
|
Project-URL: Issues, https://github.com/Fancy-Friends/buffer/issues
|
|
@@ -22,6 +22,8 @@ Description-Content-Type: text/markdown
|
|
|
22
22
|
|
|
23
23
|
# Buffer
|
|
24
24
|
|
|
25
|
+
[](https://particle.academy)
|
|
26
|
+
|
|
25
27
|
Buffer 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.
|
|
@@ -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-buffer"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Buffer 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_buffer"]
|
|
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.post_create import post_create
|
|
|
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.
|
|
24
|
+
__version__ = "0.2.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[
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
362
|
-
|
|
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 _post_create(config: dict[str, Any], fake: FakeValues) -> Any:
|
|
28
40
|
return {
|
|
29
41
|
"data": {
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|