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.
@@ -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
+ ]
@@ -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)."""
@@ -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
@@ -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
@@ -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,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: repost-client
3
+ Version: 0.1.0
4
+ Summary: Repost webhook client runtime — typed senders generated from your .repost schema
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: httpx>=0.27
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any