repost-client 0.1.0__py3-none-any.whl
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.
- repost_client/__init__.py +57 -0
- repost_client/_cuid2.py +25 -0
- repost_client/_generators.py +59 -0
- repost_client/_sentinel.py +35 -0
- repost_client/_types.py +117 -0
- repost_client/errors.py +12 -0
- repost_client/py.typed +0 -0
- repost_client/serialize.py +293 -0
- repost_client/transport.py +270 -0
- repost_client/version.py +43 -0
- repost_client/webhooks.py +128 -0
- repost_client-0.1.0.dist-info/METADATA +6 -0
- repost_client-0.1.0.dist-info/RECORD +14 -0
- repost_client-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Repost webhook client runtime — typed senders generated from your
|
|
2
|
+
``.repost`` schema.
|
|
3
|
+
|
|
4
|
+
Generated clients are data + types only; every behavior (serialization,
|
|
5
|
+
envelopes, retries, idempotency) lives here, pinned by
|
|
6
|
+
``sdk/conformance/CONTRACT.md`` and the ``sdk/conformance`` vectors.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ._sentinel import UNSET, UnsetType
|
|
12
|
+
from ._types import (
|
|
13
|
+
DefaultSpec,
|
|
14
|
+
EventDescriptor,
|
|
15
|
+
FieldDescriptor,
|
|
16
|
+
Generators,
|
|
17
|
+
ModelDescriptor,
|
|
18
|
+
SchemaDescriptor,
|
|
19
|
+
SendResult,
|
|
20
|
+
)
|
|
21
|
+
from .errors import DescriptorVersionError
|
|
22
|
+
from .serialize import serialize_model
|
|
23
|
+
from .transport import (
|
|
24
|
+
Envelope,
|
|
25
|
+
HTTPTransportOptions,
|
|
26
|
+
PublishError,
|
|
27
|
+
SendRequest,
|
|
28
|
+
Transport,
|
|
29
|
+
http_transport,
|
|
30
|
+
)
|
|
31
|
+
from .version import DESCRIPTOR_FORMAT_VERSION, assert_descriptor_version
|
|
32
|
+
from .webhooks import ClientOptions, StubTransport, send
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"DESCRIPTOR_FORMAT_VERSION",
|
|
36
|
+
"UNSET",
|
|
37
|
+
"ClientOptions",
|
|
38
|
+
"DefaultSpec",
|
|
39
|
+
"DescriptorVersionError",
|
|
40
|
+
"Envelope",
|
|
41
|
+
"EventDescriptor",
|
|
42
|
+
"FieldDescriptor",
|
|
43
|
+
"Generators",
|
|
44
|
+
"HTTPTransportOptions",
|
|
45
|
+
"ModelDescriptor",
|
|
46
|
+
"PublishError",
|
|
47
|
+
"SchemaDescriptor",
|
|
48
|
+
"SendRequest",
|
|
49
|
+
"SendResult",
|
|
50
|
+
"StubTransport",
|
|
51
|
+
"Transport",
|
|
52
|
+
"UnsetType",
|
|
53
|
+
"assert_descriptor_version",
|
|
54
|
+
"http_transport",
|
|
55
|
+
"send",
|
|
56
|
+
"serialize_model",
|
|
57
|
+
]
|
repost_client/_cuid2.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Vendored cuid2-shaped id generator.
|
|
2
|
+
|
|
3
|
+
Generates ids matching the documented cuid2 *shape* — 24 characters, a
|
|
4
|
+
lowercase letter first, the rest lowercase base36 — from :mod:`secrets`
|
|
5
|
+
entropy. Shape only, not the reference algorithm's session counters and
|
|
6
|
+
fingerprints; vendored so the runtime carries zero id-generation
|
|
7
|
+
dependencies (SDK distribution decision §3).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import secrets
|
|
13
|
+
|
|
14
|
+
LENGTH = 24
|
|
15
|
+
"""The number of characters in a generated id."""
|
|
16
|
+
|
|
17
|
+
_LETTERS = "abcdefghijklmnopqrstuvwxyz"
|
|
18
|
+
_BASE36 = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def cuid() -> str:
|
|
22
|
+
"""Return a cuid2-shaped id: 24 chars, lowercase letter first, base36."""
|
|
23
|
+
first = secrets.choice(_LETTERS)
|
|
24
|
+
rest = "".join(secrets.choice(_BASE36) for _ in range(LENGTH - 1))
|
|
25
|
+
return first + rest
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Default clock and id generators, plus the resolution helper that merges
|
|
2
|
+
a user :class:`~repost_client.Generators` over them."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import uuid as _uuid
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
|
|
11
|
+
from . import _cuid2
|
|
12
|
+
from ._types import Generators
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def format_timestamp(value: datetime) -> str:
|
|
16
|
+
"""Format a datetime as the contract's timestamp canon.
|
|
17
|
+
|
|
18
|
+
``YYYY-MM-DDTHH:MM:SS.mmmZ`` — millisecond precision, ``Z`` suffix, in
|
|
19
|
+
UTC (the JS ``toISOString()`` shape). Aware datetimes are converted to
|
|
20
|
+
UTC; naive datetimes are assumed UTC.
|
|
21
|
+
"""
|
|
22
|
+
if value.tzinfo is None:
|
|
23
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
24
|
+
value = value.astimezone(timezone.utc)
|
|
25
|
+
return value.strftime("%Y-%m-%dT%H:%M:%S.") + f"{value.microsecond // 1000:03d}Z"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def default_now() -> str:
|
|
29
|
+
"""The default clock: the current UTC time in the timestamp canon."""
|
|
30
|
+
return format_timestamp(datetime.now(timezone.utc))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def default_uuid() -> str:
|
|
34
|
+
"""The default UUID source: a random UUIDv4."""
|
|
35
|
+
return str(_uuid.uuid4())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def default_cuid() -> str:
|
|
39
|
+
"""The default cuid source: the vendored cuid2-shape generator."""
|
|
40
|
+
return _cuid2.cuid()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class ResolvedGenerators:
|
|
45
|
+
"""A :class:`~repost_client.Generators` with every seam filled in."""
|
|
46
|
+
|
|
47
|
+
now: Callable[[], str]
|
|
48
|
+
uuid: Callable[[], str]
|
|
49
|
+
cuid: Callable[[], str]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_generators(generators: Generators | None) -> ResolvedGenerators:
|
|
53
|
+
"""Merge a user ``Generators`` over the defaults (``None`` = default)."""
|
|
54
|
+
g = generators or Generators()
|
|
55
|
+
return ResolvedGenerators(
|
|
56
|
+
now=g.now or default_now,
|
|
57
|
+
uuid=g.uuid or default_uuid,
|
|
58
|
+
cuid=g.cuid or default_cuid,
|
|
59
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""The ``UNSET`` sentinel: Python's tri-state for optional fields.
|
|
2
|
+
|
|
3
|
+
An attribute equal to :data:`UNSET` is *absent* (omitted from the wire, or
|
|
4
|
+
replaced by the field's ``@default``); ``None`` is an explicit JSON ``null``
|
|
5
|
+
(contract §2.3 — explicit null is not absence).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UnsetType:
|
|
14
|
+
"""The type of the :data:`UNSET` sentinel (a falsy singleton).
|
|
15
|
+
|
|
16
|
+
Generated dataclasses annotate optional fields as
|
|
17
|
+
``T | None | UnsetType`` with ``UNSET`` as the default.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
_instance: UnsetType | None = None
|
|
21
|
+
|
|
22
|
+
def __new__(cls) -> UnsetType:
|
|
23
|
+
if cls._instance is None:
|
|
24
|
+
cls._instance = super().__new__(cls)
|
|
25
|
+
return cls._instance
|
|
26
|
+
|
|
27
|
+
def __repr__(self) -> str:
|
|
28
|
+
return "UNSET"
|
|
29
|
+
|
|
30
|
+
def __bool__(self) -> bool:
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
UNSET: Final[UnsetType] = UnsetType()
|
|
35
|
+
"""Marks an attribute as *not provided* — omit it (or inject its default)."""
|
repost_client/_types.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Descriptor and result types shared across the runtime (contract §1).
|
|
2
|
+
|
|
3
|
+
Generated clients embed these descriptors as plain data; the runtime reads
|
|
4
|
+
them to serialize, envelope, and send. All descriptor dataclasses are frozen
|
|
5
|
+
— they are immutable schema facts, not builders.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _empty_enum_descriptors() -> dict[str, dict[str, str]]:
|
|
16
|
+
return {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class DefaultSpec:
|
|
21
|
+
"""An ``@default`` injection spec, applied when a field is absent.
|
|
22
|
+
|
|
23
|
+
``kind`` is one of ``"literal"`` (inject ``value`` verbatim), ``"now"``
|
|
24
|
+
(ISO-8601 timestamp from the clock), ``"uuid"``, or ``"cuid"`` (from the
|
|
25
|
+
id generators).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
kind: str
|
|
29
|
+
value: object = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class FieldDescriptor:
|
|
34
|
+
"""One field's serialization descriptor. Field order is wire key order.
|
|
35
|
+
|
|
36
|
+
``name`` is the DSL identifier (the SDK-input property); ``wire`` is the
|
|
37
|
+
``@map``'d wire name when it differs; ``list`` marks list arity (elements
|
|
38
|
+
serialized one by one); ``model`` names a nested payload model to recurse
|
|
39
|
+
into; ``enum`` maps member name → wire value; ``default`` is the
|
|
40
|
+
``@default`` injection when the field is absent from the input.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
schema_name: str | None = None
|
|
44
|
+
wire_name: str | None = None
|
|
45
|
+
scalar_kind: Literal["string", "boolean", "int64", "float64", "datetime", "json", "enum", "model"] | str | None = None
|
|
46
|
+
descriptor_id: str | None = None
|
|
47
|
+
required_in_input: bool = False
|
|
48
|
+
nullable_in_input: bool = True
|
|
49
|
+
list: bool = False
|
|
50
|
+
default: DefaultSpec | None = None
|
|
51
|
+
|
|
52
|
+
# Immutable descriptor-format-1 compatibility fields. Generated v2 code
|
|
53
|
+
# never sets them; the frozen v1 vector runner still does.
|
|
54
|
+
name: str | None = None
|
|
55
|
+
wire: str | None = None
|
|
56
|
+
model: str | None = None
|
|
57
|
+
enum: dict[str, str] | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class ModelDescriptor:
|
|
62
|
+
"""A payload model's serialization descriptor: its fields, in
|
|
63
|
+
declaration order."""
|
|
64
|
+
|
|
65
|
+
fields: tuple[FieldDescriptor, ...]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class EventDescriptor:
|
|
70
|
+
"""One catalog member's binding: wire event ``type`` (e.g.
|
|
71
|
+
``"book.created"``) plus the payload ``model``'s key in the model map."""
|
|
72
|
+
|
|
73
|
+
type: str
|
|
74
|
+
model: str
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class SchemaDescriptor:
|
|
79
|
+
"""Everything the runtime needs from a generated client.
|
|
80
|
+
|
|
81
|
+
``descriptor_format_version`` is the version the client was emitted for
|
|
82
|
+
— generated code asserts it against the runtime at construction via
|
|
83
|
+
:func:`repost_client.assert_descriptor_version`. ``webhooks`` is
|
|
84
|
+
``camelCase(Catalog) → member → event binding``.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
descriptor_format_version: int
|
|
88
|
+
models: dict[str, ModelDescriptor]
|
|
89
|
+
webhooks: dict[str, dict[str, EventDescriptor]]
|
|
90
|
+
enums: dict[str, dict[str, str]] = field(default_factory=_empty_enum_descriptors)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class SendResult:
|
|
95
|
+
"""The result of a webhook send, as returned by Repost's publish API
|
|
96
|
+
(202): the assigned message id (``msg_…``), the wire event type, the
|
|
97
|
+
receiving tenant, and the envelope's ISO-8601 timestamp."""
|
|
98
|
+
|
|
99
|
+
id: str
|
|
100
|
+
type: str
|
|
101
|
+
customer_id: str
|
|
102
|
+
timestamp: str
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class Generators:
|
|
107
|
+
"""Injectable clock and id sources (the deterministic-test seam).
|
|
108
|
+
|
|
109
|
+
Used by the envelope timestamp and ``@default(now()/uuid()/cuid())``
|
|
110
|
+
injection. ``None`` fields fall back to the real implementations —
|
|
111
|
+
override them for deterministic tests (the conformance suite's
|
|
112
|
+
requirement: every runtime exposes these seams).
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
now: Callable[[], str] | None = None
|
|
116
|
+
uuid: Callable[[], str] | None = None
|
|
117
|
+
cuid: Callable[[], str] | None = None
|
repost_client/errors.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Runtime error types (the transport's ``PublishError`` lives in
|
|
2
|
+
:mod:`repost_client.transport`)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DescriptorVersionError(Exception):
|
|
8
|
+
"""A generated client's descriptor format does not match this runtime.
|
|
9
|
+
|
|
10
|
+
Raised by :func:`repost_client.assert_descriptor_version` at client
|
|
11
|
+
construction — never silently at send time.
|
|
12
|
+
"""
|
repost_client/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Descriptor-driven serialization (contract §2, pinned byte-for-byte by
|
|
2
|
+
``sdk/conformance/vectors``).
|
|
3
|
+
|
|
4
|
+
Fields emit in declaration order under their ``@map``'d wire names, absent
|
|
5
|
+
fields with an ``@default`` are injected, absent optional fields are
|
|
6
|
+
omitted, nested models recurse, and enum members serialize to their
|
|
7
|
+
(possibly ``@map``'d) wire values.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Callable, Mapping
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from typing import cast
|
|
17
|
+
|
|
18
|
+
from ._generators import ResolvedGenerators, format_timestamp, resolve_generators
|
|
19
|
+
from ._sentinel import UNSET
|
|
20
|
+
from ._types import DefaultSpec, FieldDescriptor, Generators, ModelDescriptor, SchemaDescriptor
|
|
21
|
+
|
|
22
|
+
_Getter = Callable[[str], "tuple[object, bool]"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def serialize_model(
|
|
26
|
+
models: dict[str, ModelDescriptor],
|
|
27
|
+
model_name: str,
|
|
28
|
+
data: object,
|
|
29
|
+
generators: Generators | None = None,
|
|
30
|
+
enums: dict[str, dict[str, str]] | None = None,
|
|
31
|
+
) -> dict[str, object]:
|
|
32
|
+
"""Serialize a send input into its wire payload, driven by the model
|
|
33
|
+
descriptors.
|
|
34
|
+
|
|
35
|
+
The returned ``dict`` is built in field declaration order (Python dicts
|
|
36
|
+
preserve insertion order — the wire is an ordered JSON object).
|
|
37
|
+
|
|
38
|
+
``data`` is either a mapping keyed by schema field names (a missing key
|
|
39
|
+
is *absent*; an explicit ``None`` value is JSON ``null``), or an object
|
|
40
|
+
carrying ``__repost_fields__`` — a ``dict`` mapping schema field name →
|
|
41
|
+
attribute name, as generated dataclasses do (an attribute equal to
|
|
42
|
+
:data:`~repost_client.UNSET` is absent). ``datetime`` values serialize
|
|
43
|
+
to the contract's timestamp canon; ``(str, Enum)`` members serialize as
|
|
44
|
+
their string value through the enum wire map.
|
|
45
|
+
|
|
46
|
+
``generators`` overrides the clock and id sources used by
|
|
47
|
+
``@default(now()/uuid()/cuid())`` injection (the conformance seam).
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
KeyError: when ``model_name`` has no descriptor in ``models``.
|
|
51
|
+
TypeError: when ``data`` is neither accepted input form.
|
|
52
|
+
"""
|
|
53
|
+
enum_descriptors = enums or {}
|
|
54
|
+
_validate_model_descriptors(models, enum_descriptors)
|
|
55
|
+
return _serialize_model(models, model_name, data, resolve_generators(generators), enum_descriptors, "$")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _serialize_model(
|
|
59
|
+
models: dict[str, ModelDescriptor],
|
|
60
|
+
model_name: str,
|
|
61
|
+
data: object,
|
|
62
|
+
generators: ResolvedGenerators,
|
|
63
|
+
enums: dict[str, dict[str, str]],
|
|
64
|
+
path: str,
|
|
65
|
+
) -> dict[str, object]:
|
|
66
|
+
descriptor = models.get(model_name)
|
|
67
|
+
if descriptor is None:
|
|
68
|
+
raise KeyError(f"repost-client: no serialization descriptor for model '{model_name}'")
|
|
69
|
+
get = _input_getter(model_name, data)
|
|
70
|
+
|
|
71
|
+
payload: dict[str, object] = {}
|
|
72
|
+
for field in descriptor.fields:
|
|
73
|
+
name = field.schema_name if field.schema_name is not None else field.name
|
|
74
|
+
if name is None:
|
|
75
|
+
raise ValueError("repost-client: [INVALID_DESCRIPTOR] $: field name is missing")
|
|
76
|
+
field_path = _append_path(path, name)
|
|
77
|
+
_validate_descriptor(field, field_path)
|
|
78
|
+
value, present = get(name)
|
|
79
|
+
if not present:
|
|
80
|
+
if field.default is not None:
|
|
81
|
+
value = _resolve_default(field.default, generators)
|
|
82
|
+
elif field.schema_name is not None and field.required_in_input:
|
|
83
|
+
_validation_error("REQUIRED_FIELD", field_path, "required field is absent")
|
|
84
|
+
else:
|
|
85
|
+
continue
|
|
86
|
+
if value is None and field.schema_name is not None and not field.nullable_in_input:
|
|
87
|
+
_validation_error("NULL_NOT_ALLOWED", field_path, "null is not allowed")
|
|
88
|
+
wire_name = field.wire_name if field.wire_name is not None else field.wire or name
|
|
89
|
+
payload[wire_name] = _serialize_value(models, field, value, generators, enums, field_path)
|
|
90
|
+
return payload
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _input_getter(model_name: str, data: object) -> _Getter:
|
|
94
|
+
"""Normalize the two accepted input forms into a presence-aware lookup."""
|
|
95
|
+
if isinstance(data, Mapping):
|
|
96
|
+
mapping = cast(Mapping[str, object], data)
|
|
97
|
+
|
|
98
|
+
def get_from_mapping(name: str) -> tuple[object, bool]:
|
|
99
|
+
if name not in mapping:
|
|
100
|
+
return None, False
|
|
101
|
+
value = mapping[name]
|
|
102
|
+
if value is UNSET:
|
|
103
|
+
return None, False
|
|
104
|
+
return value, True
|
|
105
|
+
|
|
106
|
+
return get_from_mapping
|
|
107
|
+
|
|
108
|
+
fields_map = getattr(data, "__repost_fields__", None)
|
|
109
|
+
if isinstance(fields_map, dict):
|
|
110
|
+
attributes = cast("dict[str, str]", fields_map)
|
|
111
|
+
|
|
112
|
+
def get_from_object(name: str) -> tuple[object, bool]:
|
|
113
|
+
attribute = attributes.get(name)
|
|
114
|
+
if attribute is None:
|
|
115
|
+
return None, False
|
|
116
|
+
value = getattr(data, attribute, UNSET)
|
|
117
|
+
if value is UNSET:
|
|
118
|
+
return None, False
|
|
119
|
+
return value, True
|
|
120
|
+
|
|
121
|
+
return get_from_object
|
|
122
|
+
|
|
123
|
+
raise TypeError(
|
|
124
|
+
f"repost-client: expected a mapping or an object with __repost_fields__ "
|
|
125
|
+
f"for model '{model_name}', got {_describe(data)}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _describe(data: object) -> str:
|
|
130
|
+
return "null" if data is None else type(data).__name__
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _serialize_value(
|
|
134
|
+
models: dict[str, ModelDescriptor],
|
|
135
|
+
field: FieldDescriptor,
|
|
136
|
+
value: object,
|
|
137
|
+
generators: ResolvedGenerators,
|
|
138
|
+
enums: dict[str, dict[str, str]],
|
|
139
|
+
path: str,
|
|
140
|
+
) -> object:
|
|
141
|
+
"""Apply list arity, then per-item model recursion or enum mapping."""
|
|
142
|
+
if field.list and isinstance(value, (list, tuple)):
|
|
143
|
+
items = cast("list[object] | tuple[object, ...]", value)
|
|
144
|
+
serialized: list[object] = []
|
|
145
|
+
for index, item in enumerate(items):
|
|
146
|
+
item_path = f"{path}[{index}]"
|
|
147
|
+
if item is None and field.schema_name is not None:
|
|
148
|
+
_validation_error("NULL_LIST_ELEMENT", item_path, "null list elements are not allowed")
|
|
149
|
+
serialized.append(_serialize_item(models, field, item, generators, enums, item_path))
|
|
150
|
+
return serialized
|
|
151
|
+
if field.list and field.schema_name is not None and value is not None:
|
|
152
|
+
_validation_error("TYPE_MISMATCH", path, "expected a list")
|
|
153
|
+
return _serialize_item(models, field, value, generators, enums, path)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _serialize_item(
|
|
157
|
+
models: dict[str, ModelDescriptor],
|
|
158
|
+
field: FieldDescriptor,
|
|
159
|
+
value: object,
|
|
160
|
+
generators: ResolvedGenerators,
|
|
161
|
+
enums: dict[str, dict[str, str]],
|
|
162
|
+
path: str,
|
|
163
|
+
) -> object:
|
|
164
|
+
if value is None:
|
|
165
|
+
return None
|
|
166
|
+
model = field.descriptor_id if field.scalar_kind == "model" else field.model
|
|
167
|
+
if model is not None:
|
|
168
|
+
return _serialize_model(models, model, value, generators, enums, path)
|
|
169
|
+
enum_values = enums.get(field.descriptor_id or "") if field.scalar_kind == "enum" else field.enum
|
|
170
|
+
if enum_values is not None:
|
|
171
|
+
if isinstance(value, str):
|
|
172
|
+
# Generated enums are `class X(str, Enum)` whose VALUE is the
|
|
173
|
+
# member name — str subclasses, so the wire map keys match by
|
|
174
|
+
# string equality. Never `str(value)`: that renders "X.MEMBER".
|
|
175
|
+
wire = enum_values.get(value)
|
|
176
|
+
if wire is not None:
|
|
177
|
+
return wire
|
|
178
|
+
return str.__str__(value) # unrecognized member passes through verbatim
|
|
179
|
+
return value
|
|
180
|
+
if field.schema_name is not None:
|
|
181
|
+
_validate_scalar(field.scalar_kind, value, path)
|
|
182
|
+
if isinstance(value, datetime):
|
|
183
|
+
# Generated DateTime fields: format to the contract's timestamp
|
|
184
|
+
# canon in UTC (mirrors the Go runtime's time.Time support).
|
|
185
|
+
return format_timestamp(value)
|
|
186
|
+
return value
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
_SCALAR_KINDS = {"string", "boolean", "int64", "float64", "datetime", "json", "enum", "model"}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _validate_descriptor(field: FieldDescriptor, path: str) -> None:
|
|
193
|
+
if field.schema_name is None:
|
|
194
|
+
return
|
|
195
|
+
if field.scalar_kind not in _SCALAR_KINDS:
|
|
196
|
+
_validation_error("UNKNOWN_SCALAR_KIND", path, f"unknown scalar kind '{field.scalar_kind}'")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _validate_scalar(kind: str | None, value: object, path: str) -> None:
|
|
200
|
+
if kind in {"float64", "int64"} and isinstance(value, float):
|
|
201
|
+
import math
|
|
202
|
+
|
|
203
|
+
if not math.isfinite(value):
|
|
204
|
+
_validation_error("NON_FINITE_NUMBER", path, "non-finite numbers are not allowed")
|
|
205
|
+
if kind == "json":
|
|
206
|
+
_validate_json(value, path)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _validate_json(value: object, path: str) -> None:
|
|
210
|
+
_validate_json_at(value, path, set())
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _validate_json_at(value: object, path: str, active_ancestors: set[int]) -> None:
|
|
214
|
+
if isinstance(value, float):
|
|
215
|
+
import math
|
|
216
|
+
|
|
217
|
+
if not math.isfinite(value):
|
|
218
|
+
_validation_error("NON_FINITE_NUMBER", path, "non-finite numbers are not allowed")
|
|
219
|
+
if not isinstance(value, (list, tuple, Mapping)):
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
identity = id(cast(object, value))
|
|
223
|
+
if identity in active_ancestors:
|
|
224
|
+
_validation_error("INVALID_JSON", path, "cyclic JSON values are not allowed")
|
|
225
|
+
active_ancestors.add(identity)
|
|
226
|
+
try:
|
|
227
|
+
if isinstance(value, (list, tuple)):
|
|
228
|
+
items = cast("list[object] | tuple[object, ...]", value)
|
|
229
|
+
for index, item in enumerate(items):
|
|
230
|
+
_validate_json_at(item, f"{path}[{index}]", active_ancestors)
|
|
231
|
+
else:
|
|
232
|
+
mapping = cast("Mapping[object, object]", value)
|
|
233
|
+
for _, item in sorted(mapping.items(), key=lambda pair: str(pair[0])):
|
|
234
|
+
_validate_json_at(item, f"{path}[{{*}}]", active_ancestors)
|
|
235
|
+
finally:
|
|
236
|
+
active_ancestors.remove(identity)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _validate_model_descriptors(
|
|
240
|
+
models: dict[str, ModelDescriptor],
|
|
241
|
+
enums: dict[str, dict[str, str]],
|
|
242
|
+
) -> None:
|
|
243
|
+
for model_name in sorted(models):
|
|
244
|
+
model = models[model_name]
|
|
245
|
+
for index, field in enumerate(model.fields):
|
|
246
|
+
if field.schema_name is None:
|
|
247
|
+
continue
|
|
248
|
+
_validate_descriptor(field, _append_path("$", field.schema_name))
|
|
249
|
+
if field.scalar_kind not in {"enum", "model"}:
|
|
250
|
+
continue
|
|
251
|
+
registry = enums if field.scalar_kind == "enum" else models
|
|
252
|
+
if field.descriptor_id is None or field.descriptor_id not in registry:
|
|
253
|
+
reference_path = f"{_append_path('$.models', model_name)}.fields[{index}].descriptorId"
|
|
254
|
+
_validation_error(
|
|
255
|
+
"INVALID_DESCRIPTOR_REFERENCE",
|
|
256
|
+
reference_path,
|
|
257
|
+
f"{field.scalar_kind} descriptor '{field.descriptor_id or ''}' does not exist",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def validate_schema_descriptor(schema: SchemaDescriptor) -> None:
|
|
262
|
+
_validate_model_descriptors(schema.models, schema.enums)
|
|
263
|
+
for group in sorted(schema.webhooks):
|
|
264
|
+
members = schema.webhooks[group]
|
|
265
|
+
for member in sorted(members):
|
|
266
|
+
event = members[member]
|
|
267
|
+
if event.model not in schema.models:
|
|
268
|
+
path = f"{_append_path(_append_path('$.webhooks', group), member)}.model"
|
|
269
|
+
_validation_error(
|
|
270
|
+
"INVALID_DESCRIPTOR_REFERENCE",
|
|
271
|
+
path,
|
|
272
|
+
f"model descriptor '{event.model}' does not exist",
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _append_path(path: str, key: str) -> str:
|
|
277
|
+
if re.fullmatch(r"[A-Za-z_$][A-Za-z0-9_$]*", key):
|
|
278
|
+
return f"{path}.{key}"
|
|
279
|
+
return f"{path}[{json.dumps(key, ensure_ascii=False)}]"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _validation_error(code: str, path: str, message: str) -> None:
|
|
283
|
+
raise ValueError(f"repost-client: [{code}] {path}: {message}.")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _resolve_default(spec: DefaultSpec, generators: ResolvedGenerators) -> object:
|
|
287
|
+
if spec.kind == "now":
|
|
288
|
+
return generators.now()
|
|
289
|
+
if spec.kind == "uuid":
|
|
290
|
+
return generators.uuid()
|
|
291
|
+
if spec.kind == "cuid":
|
|
292
|
+
return generators.cuid()
|
|
293
|
+
return spec.value # "literal"
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""The HTTP transport against Repost's publish API (contract §5), built on
|
|
2
|
+
httpx.
|
|
3
|
+
|
|
4
|
+
One idempotency key is minted per send and reused across internal retries,
|
|
5
|
+
so retrying is always safe: the server deduplicates. Retryable outcomes are
|
|
6
|
+
transport errors, per-attempt timeouts, 5xx, 429, and 409 (a concurrent
|
|
7
|
+
duplicate still in flight); ``Retry-After`` is honored when it exceeds the
|
|
8
|
+
backoff, capped at 60s.
|
|
9
|
+
|
|
10
|
+
Sync only for now; the :class:`Transport` protocol is designed so an
|
|
11
|
+
``AsyncTransport`` twin can be added later without changing this surface.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import random
|
|
18
|
+
import time
|
|
19
|
+
import uuid as _uuid
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Protocol, cast
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
from ._types import SendResult
|
|
27
|
+
|
|
28
|
+
DEFAULT_API_URL = "https://api.repost.sh"
|
|
29
|
+
_RETRY_AFTER_CAP_SECONDS = 60.0
|
|
30
|
+
# The publish API's body cap (publish-api handler.go MaxBodyBytes): 1 MiB.
|
|
31
|
+
MAX_PAYLOAD_BYTES = 1 << 20
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PublishError(Exception):
|
|
35
|
+
"""A publish rejected or failed by the Repost API.
|
|
36
|
+
|
|
37
|
+
``status`` is the HTTP status of the failing response (``None`` when the
|
|
38
|
+
failure produced no status — network error, timeout, body cap); ``body``
|
|
39
|
+
is the parsed JSON error body when one was returned.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
status: int | None
|
|
43
|
+
body: object
|
|
44
|
+
|
|
45
|
+
def __init__(self, message: str, status: int | None = None, body: object = None) -> None:
|
|
46
|
+
super().__init__(message)
|
|
47
|
+
self.status = status
|
|
48
|
+
self.body = body
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Envelope:
|
|
53
|
+
"""The Standard Webhooks envelope the runtime constructs for a send:
|
|
54
|
+
wire event ``type``, ISO-8601 ``timestamp``, serialized ``data``."""
|
|
55
|
+
|
|
56
|
+
type: str
|
|
57
|
+
timestamp: str
|
|
58
|
+
data: dict[str, object]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class SendRequest:
|
|
63
|
+
"""Everything a transport needs to deliver one webhook publish.
|
|
64
|
+
|
|
65
|
+
``token`` is the publish API key (sent as the bearer token); ``api_url``
|
|
66
|
+
is the base URL (``None`` means the transport default);
|
|
67
|
+
``customer_id`` is the receiving tenant; ``idempotency_key`` is the
|
|
68
|
+
caller-owned key (``None`` means the transport mints one per send,
|
|
69
|
+
deduplicating internal retries only).
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
token: str
|
|
73
|
+
api_url: str | None
|
|
74
|
+
customer_id: str
|
|
75
|
+
idempotency_key: str | None
|
|
76
|
+
envelope: Envelope
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Transport(Protocol):
|
|
80
|
+
"""The seam through which a send reaches Repost. The HTTP transport is
|
|
81
|
+
the default; :class:`repost_client.StubTransport` serves tests."""
|
|
82
|
+
|
|
83
|
+
def send(self, request: SendRequest) -> SendResult:
|
|
84
|
+
"""Deliver one publish and return the API's result."""
|
|
85
|
+
...
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class HTTPTransportOptions:
|
|
90
|
+
"""Configuration for :func:`http_transport`. Defaults are production
|
|
91
|
+
values; ``sleep`` and ``rand`` are the deterministic-test seams.
|
|
92
|
+
|
|
93
|
+
``client`` is an injectable :class:`httpx.Client` (e.g. wrapping
|
|
94
|
+
``httpx.MockTransport``); ``max_retries`` counts retries after the
|
|
95
|
+
initial attempt; ``base_delay`` is the backoff base in seconds, doubled
|
|
96
|
+
per attempt with jitter; ``timeout`` is the per-attempt timeout in
|
|
97
|
+
seconds (fresh budget each retry); ``rand`` returns jitter in [0, 1).
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
client: httpx.Client | None = None
|
|
101
|
+
max_retries: int = 3
|
|
102
|
+
base_delay: float = 0.25
|
|
103
|
+
timeout: float = 30.0
|
|
104
|
+
sleep: Callable[[float], None] = time.sleep
|
|
105
|
+
rand: Callable[[], float] = random.random
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class _AttemptFailure:
|
|
110
|
+
"""One failed attempt: the typed error, whether it may be retried, and
|
|
111
|
+
any Retry-After hint (seconds)."""
|
|
112
|
+
|
|
113
|
+
error: PublishError
|
|
114
|
+
retryable: bool
|
|
115
|
+
retry_after: float | None = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def http_transport(options: HTTPTransportOptions | None = None) -> Transport:
|
|
119
|
+
"""Build the HTTP transport against Repost's publish API."""
|
|
120
|
+
return _HTTPTransport(options or HTTPTransportOptions())
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _HTTPTransport:
|
|
124
|
+
"""`Transport` implementation over httpx (see the module docstring)."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, options: HTTPTransportOptions) -> None:
|
|
127
|
+
self._client = options.client or httpx.Client()
|
|
128
|
+
self._max_retries = options.max_retries
|
|
129
|
+
self._base_delay = options.base_delay
|
|
130
|
+
self._timeout = options.timeout
|
|
131
|
+
self._sleep = options.sleep
|
|
132
|
+
self._rand = options.rand
|
|
133
|
+
|
|
134
|
+
def send(self, request: SendRequest) -> SendResult:
|
|
135
|
+
"""Deliver one publish, retrying per the contract's retry table."""
|
|
136
|
+
base = (request.api_url or DEFAULT_API_URL).rstrip("/")
|
|
137
|
+
url = base + "/v1/messages"
|
|
138
|
+
|
|
139
|
+
body = json.dumps(
|
|
140
|
+
{
|
|
141
|
+
"type": request.envelope.type,
|
|
142
|
+
"customerId": request.customer_id,
|
|
143
|
+
"timestamp": request.envelope.timestamp,
|
|
144
|
+
"data": request.envelope.data,
|
|
145
|
+
},
|
|
146
|
+
separators=(",", ":"),
|
|
147
|
+
ensure_ascii=False,
|
|
148
|
+
).encode("utf-8")
|
|
149
|
+
|
|
150
|
+
# The API rejects oversized bodies with a 413; failing before the
|
|
151
|
+
# first attempt gives the caller the byte count instead of burned
|
|
152
|
+
# retries.
|
|
153
|
+
if len(body) > MAX_PAYLOAD_BYTES:
|
|
154
|
+
raise PublishError(
|
|
155
|
+
f"repost-client: publish body is {len(body)} bytes, "
|
|
156
|
+
f"over the API's 1 MiB ({MAX_PAYLOAD_BYTES}-byte) limit"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# ONE idempotency key per send, reused across every retry.
|
|
160
|
+
idempotency_key = request.idempotency_key or str(_uuid.uuid4())
|
|
161
|
+
|
|
162
|
+
last_error = PublishError("repost-client: publish failed before any attempt")
|
|
163
|
+
retry_after: float | None = None
|
|
164
|
+
for attempt in range(self._max_retries + 1):
|
|
165
|
+
if attempt > 0:
|
|
166
|
+
self._sleep(self._retry_delay(attempt, retry_after))
|
|
167
|
+
result, failure = self._attempt(url, request.token, idempotency_key, body)
|
|
168
|
+
if failure is None:
|
|
169
|
+
assert result is not None
|
|
170
|
+
return result
|
|
171
|
+
last_error = failure.error
|
|
172
|
+
retry_after = failure.retry_after
|
|
173
|
+
if not failure.retryable:
|
|
174
|
+
raise last_error
|
|
175
|
+
raise last_error
|
|
176
|
+
|
|
177
|
+
def _attempt(
|
|
178
|
+
self, url: str, token: str, idempotency_key: str, body: bytes
|
|
179
|
+
) -> tuple[SendResult | None, _AttemptFailure | None]:
|
|
180
|
+
"""Perform one HTTP attempt under a fresh per-attempt timeout."""
|
|
181
|
+
try:
|
|
182
|
+
response = self._client.request(
|
|
183
|
+
"POST",
|
|
184
|
+
url,
|
|
185
|
+
content=body,
|
|
186
|
+
headers={
|
|
187
|
+
"authorization": f"Bearer {token}",
|
|
188
|
+
"content-type": "application/json",
|
|
189
|
+
"idempotency-key": idempotency_key,
|
|
190
|
+
},
|
|
191
|
+
timeout=self._timeout,
|
|
192
|
+
)
|
|
193
|
+
except httpx.TimeoutException:
|
|
194
|
+
return None, _AttemptFailure(
|
|
195
|
+
PublishError(f"repost-client: publish attempt timed out after {self._timeout}s"),
|
|
196
|
+
retryable=True,
|
|
197
|
+
)
|
|
198
|
+
except httpx.HTTPError as error:
|
|
199
|
+
return None, _AttemptFailure(
|
|
200
|
+
PublishError(f"repost-client: publish request failed: {error}"),
|
|
201
|
+
retryable=True,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if 200 <= response.status_code < 300:
|
|
205
|
+
return self._decode_success(response)
|
|
206
|
+
|
|
207
|
+
parsed: object
|
|
208
|
+
try:
|
|
209
|
+
parsed = response.json()
|
|
210
|
+
except ValueError:
|
|
211
|
+
parsed = None
|
|
212
|
+
return None, _AttemptFailure(
|
|
213
|
+
PublishError(
|
|
214
|
+
f"repost-client: publish failed with status {response.status_code}",
|
|
215
|
+
status=response.status_code,
|
|
216
|
+
body=parsed,
|
|
217
|
+
),
|
|
218
|
+
retryable=response.status_code >= 500 or response.status_code in (429, 409),
|
|
219
|
+
retry_after=_parse_retry_after(response.headers.get("retry-after")),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def _decode_success(
|
|
223
|
+
self, response: httpx.Response
|
|
224
|
+
) -> tuple[SendResult | None, _AttemptFailure | None]:
|
|
225
|
+
"""Parse a 2xx body as ``{id, type, customerId, timestamp}``."""
|
|
226
|
+
try:
|
|
227
|
+
parsed = response.json()
|
|
228
|
+
except ValueError as error:
|
|
229
|
+
return None, _AttemptFailure(
|
|
230
|
+
PublishError(f"repost-client: publish request failed: decoding response: {error}"),
|
|
231
|
+
retryable=True,
|
|
232
|
+
)
|
|
233
|
+
if not isinstance(parsed, dict):
|
|
234
|
+
return None, _AttemptFailure(
|
|
235
|
+
PublishError(
|
|
236
|
+
"repost-client: publish request failed: decoding response: expected a JSON object"
|
|
237
|
+
),
|
|
238
|
+
retryable=True,
|
|
239
|
+
)
|
|
240
|
+
fields = cast("dict[str, object]", parsed)
|
|
241
|
+
return (
|
|
242
|
+
SendResult(
|
|
243
|
+
id=str(fields.get("id", "")),
|
|
244
|
+
type=str(fields.get("type", "")),
|
|
245
|
+
customer_id=str(fields.get("customerId", "")),
|
|
246
|
+
timestamp=str(fields.get("timestamp", "")),
|
|
247
|
+
),
|
|
248
|
+
None,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def _retry_delay(self, attempt: int, retry_after: float | None) -> float:
|
|
252
|
+
"""The pause before retry ``attempt`` (1-based): exponential backoff
|
|
253
|
+
with jitter, overridden by a larger Retry-After capped at 60s."""
|
|
254
|
+
backoff = self._base_delay * (2 ** (attempt - 1)) * (0.5 + 0.5 * self._rand())
|
|
255
|
+
if retry_after is not None and retry_after > backoff:
|
|
256
|
+
return min(retry_after, _RETRY_AFTER_CAP_SECONDS)
|
|
257
|
+
return backoff
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _parse_retry_after(header: str | None) -> float | None:
|
|
261
|
+
"""Parse an integer-seconds ``Retry-After`` header value."""
|
|
262
|
+
if not header:
|
|
263
|
+
return None
|
|
264
|
+
try:
|
|
265
|
+
seconds = float(header)
|
|
266
|
+
except ValueError:
|
|
267
|
+
return None
|
|
268
|
+
if seconds < 0:
|
|
269
|
+
return None
|
|
270
|
+
return seconds
|
repost_client/version.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""The descriptor-format version handshake (contract §1).
|
|
2
|
+
|
|
3
|
+
Generated clients declare the version they were emitted for and call
|
|
4
|
+
:func:`assert_descriptor_version` at client construction; a mismatch is
|
|
5
|
+
refused with the concrete fix per direction.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .errors import DescriptorVersionError
|
|
11
|
+
|
|
12
|
+
DESCRIPTOR_FORMAT_VERSION = 2
|
|
13
|
+
"""The descriptor-format version this runtime implements.
|
|
14
|
+
|
|
15
|
+
Bumped only on a wire-breaking change to the descriptor contract (see
|
|
16
|
+
``sdk/conformance/CONTRACT.md`` and the conformance-vector freeze policy).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def assert_descriptor_version(declared: int) -> None:
|
|
21
|
+
"""Refuse a generated client whose descriptor format this runtime does
|
|
22
|
+
not implement.
|
|
23
|
+
|
|
24
|
+
Generated code calls this at client construction (never at send time).
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
DescriptorVersionError: when ``declared`` differs from
|
|
28
|
+
:data:`DESCRIPTOR_FORMAT_VERSION`, naming the fix — upgrade the
|
|
29
|
+
runtime when the client is newer, regenerate when it is older.
|
|
30
|
+
"""
|
|
31
|
+
if declared == DESCRIPTOR_FORMAT_VERSION:
|
|
32
|
+
return
|
|
33
|
+
if declared > DESCRIPTOR_FORMAT_VERSION:
|
|
34
|
+
raise DescriptorVersionError(
|
|
35
|
+
f"repost-client: [UNSUPPORTED_DESCRIPTOR_VERSION] $: the generated client declares descriptor format {declared}, "
|
|
36
|
+
f"but this runtime implements {DESCRIPTOR_FORMAT_VERSION}. "
|
|
37
|
+
"Upgrade the repost-client package."
|
|
38
|
+
)
|
|
39
|
+
raise DescriptorVersionError(
|
|
40
|
+
f"repost-client: the generated client declares descriptor format {declared}, "
|
|
41
|
+
f"but this runtime implements {DESCRIPTOR_FORMAT_VERSION}. "
|
|
42
|
+
"Re-run `repost schema generate` with the current CLI."
|
|
43
|
+
)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""The send path a generated client's typed methods call into (contract §4).
|
|
2
|
+
|
|
3
|
+
Configuration is lazy: auth and base URL are resolved (and enforced) at send
|
|
4
|
+
time, never at construction. Generated clients also check the descriptor
|
|
5
|
+
version at construction, while this public send entry point repeats the full
|
|
6
|
+
version-and-shape handshake before configuration or other side effects.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from ._generators import resolve_generators
|
|
15
|
+
from ._types import Generators, SchemaDescriptor, SendResult
|
|
16
|
+
from .serialize import serialize_model, validate_schema_descriptor
|
|
17
|
+
from .transport import Envelope, HTTPTransportOptions, SendRequest, Transport, http_transport
|
|
18
|
+
from .version import assert_descriptor_version
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ClientOptions:
|
|
23
|
+
"""Options a generated client is constructed with. Nothing is validated
|
|
24
|
+
until a send.
|
|
25
|
+
|
|
26
|
+
``api_key`` is the environment's publish API key (``None`` means
|
|
27
|
+
``REPOST_SEND_API_KEY``, then legacy ``REPOST_TOKEN``, read at send
|
|
28
|
+
time); ``api_url`` is the API base URL (``None`` means
|
|
29
|
+
``REPOST_API_URL``, then the transport default); ``transport`` overrides
|
|
30
|
+
how sends reach Repost (:class:`StubTransport` for no-network tests);
|
|
31
|
+
``generators`` are the injectable clock and id seams.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
api_key: str | None = None
|
|
35
|
+
api_url: str | None = None
|
|
36
|
+
transport: Transport | None = None
|
|
37
|
+
generators: Generators | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# The shared production transport, created on first use so sends without an
|
|
41
|
+
# explicit transport reuse one connection pool.
|
|
42
|
+
_default_transport: Transport | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_default_transport() -> Transport:
|
|
46
|
+
global _default_transport
|
|
47
|
+
if _default_transport is None:
|
|
48
|
+
_default_transport = http_transport(HTTPTransportOptions())
|
|
49
|
+
return _default_transport
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def send(
|
|
53
|
+
options: ClientOptions,
|
|
54
|
+
schema: SchemaDescriptor,
|
|
55
|
+
group: str,
|
|
56
|
+
member: str,
|
|
57
|
+
*,
|
|
58
|
+
customer_id: str,
|
|
59
|
+
data: object,
|
|
60
|
+
idempotency_key: str | None = None,
|
|
61
|
+
) -> SendResult:
|
|
62
|
+
"""Publish one webhook event; generated typed send methods call this.
|
|
63
|
+
|
|
64
|
+
Resolves auth lazily (``options.api_key`` → ``REPOST_SEND_API_KEY`` →
|
|
65
|
+
legacy ``REPOST_TOKEN``) and the base URL (``options.api_url`` →
|
|
66
|
+
``REPOST_API_URL``), serializes ``data`` through the model descriptors,
|
|
67
|
+
builds the Standard Webhooks envelope ``{type, timestamp, data}``, and
|
|
68
|
+
dispatches through the transport seam.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
KeyError: for an unknown ``group`` or ``member``.
|
|
72
|
+
RuntimeError: when no API key is configured.
|
|
73
|
+
"""
|
|
74
|
+
assert_descriptor_version(schema.descriptor_format_version)
|
|
75
|
+
validate_schema_descriptor(schema)
|
|
76
|
+
|
|
77
|
+
members = schema.webhooks.get(group)
|
|
78
|
+
if members is None:
|
|
79
|
+
raise KeyError(f"repost-client: unknown webhook group '{group}'")
|
|
80
|
+
event = members.get(member)
|
|
81
|
+
if event is None:
|
|
82
|
+
raise KeyError(f"repost-client: unknown webhook member '{member}' in group '{group}'")
|
|
83
|
+
|
|
84
|
+
# Lazy configuration: first non-empty wins — the api_key option,
|
|
85
|
+
# REPOST_SEND_API_KEY (the standard .env variable), legacy REPOST_TOKEN.
|
|
86
|
+
api_key = options.api_key or os.environ.get("REPOST_SEND_API_KEY") or os.environ.get("REPOST_TOKEN")
|
|
87
|
+
if not api_key:
|
|
88
|
+
raise RuntimeError(
|
|
89
|
+
"repost-client: missing API key: set ClientOptions.api_key or the "
|
|
90
|
+
"REPOST_SEND_API_KEY environment variable (scaffolded into .env by "
|
|
91
|
+
"`repost schema init`; create a key with your environment in the dashboard)"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
api_url = options.api_url or os.environ.get("REPOST_API_URL") or None
|
|
95
|
+
|
|
96
|
+
resolved = resolve_generators(options.generators)
|
|
97
|
+
operation_now = resolved.now()
|
|
98
|
+
operation_generators = Generators(now=lambda: operation_now, uuid=resolved.uuid, cuid=resolved.cuid)
|
|
99
|
+
envelope = Envelope(
|
|
100
|
+
type=event.type,
|
|
101
|
+
timestamp=operation_now,
|
|
102
|
+
data=serialize_model(schema.models, event.model, data, operation_generators, schema.enums),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
transport = options.transport or _get_default_transport()
|
|
106
|
+
return transport.send(
|
|
107
|
+
SendRequest(
|
|
108
|
+
token=api_key,
|
|
109
|
+
api_url=api_url,
|
|
110
|
+
customer_id=customer_id,
|
|
111
|
+
idempotency_key=idempotency_key,
|
|
112
|
+
envelope=envelope,
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class StubTransport:
|
|
118
|
+
"""No-network transport for tests: echoes the envelope identity as a
|
|
119
|
+
typed :class:`~repost_client.SendResult` with a fixed stub message id."""
|
|
120
|
+
|
|
121
|
+
def send(self, request: SendRequest) -> SendResult:
|
|
122
|
+
"""Implement :class:`~repost_client.Transport` without the network."""
|
|
123
|
+
return SendResult(
|
|
124
|
+
id="msg_stub",
|
|
125
|
+
type=request.envelope.type,
|
|
126
|
+
customer_id=request.customer_id,
|
|
127
|
+
timestamp=request.envelope.timestamp,
|
|
128
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
repost_client/__init__.py,sha256=wQi8s395uRpMSoz01cwKgCijn7nwfYrKKcxiIULxHZU,1365
|
|
2
|
+
repost_client/_cuid2.py,sha256=9P0uw-C2DCwzAKTfN2NA29cwspliN6pxqutLgvMSFlg,810
|
|
3
|
+
repost_client/_generators.py,sha256=yKKYeAiDQ4ca_F6pbeSDXEia-S-JWqUEJdknNopG1x0,1814
|
|
4
|
+
repost_client/_sentinel.py,sha256=iHKahBjDeEZMYyKTdkvXZM8KdVCA3K3n-IOw3bVFiZM,963
|
|
5
|
+
repost_client/_types.py,sha256=5-Fspw15pZVwq04OIG1G8LG7oMZy9CMGI_nVVMnl4V8,3805
|
|
6
|
+
repost_client/errors.py,sha256=hpgNIU7xQozT9VHb7AZvtIytybeyqNlHiC8yVculy7Y,387
|
|
7
|
+
repost_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
repost_client/serialize.py,sha256=N15W22_R83AdsBDcpJ9RoCQkMpbpaMWu5L1MCpVt7T8,11637
|
|
9
|
+
repost_client/transport.py,sha256=aiBGIPnBVasJs8ODaEF8J6XX_kjiTPcVJ_nyVZSrwKg,9584
|
|
10
|
+
repost_client/version.py,sha256=osefP5ogxEz4S2G1_WUKs9pT_dfC-u9c1XcelOtYjJ0,1695
|
|
11
|
+
repost_client/webhooks.py,sha256=4MUxhvfscejwXNAx98cY_rpu4al7hP7uq6U0PrHIKzg,4819
|
|
12
|
+
repost_client-0.1.0.dist-info/METADATA,sha256=8gcJs1qjpp84JqH1Mxic_xFNjLe2CyOY7GMK5Y6iipk,200
|
|
13
|
+
repost_client-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
14
|
+
repost_client-0.1.0.dist-info/RECORD,,
|