epostix 1.0.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.
Files changed (40) hide show
  1. epostix/__init__.py +223 -0
  2. epostix/_client.py +253 -0
  3. epostix/_codec.py +248 -0
  4. epostix/_errors.py +335 -0
  5. epostix/_generated/__init__.py +19 -0
  6. epostix/_generated/codecs.py +1027 -0
  7. epostix/_generated/models.py +1268 -0
  8. epostix/_generated/operations.py +879 -0
  9. epostix/_generated/resources/__init__.py +90 -0
  10. epostix/_generated/resources/analytics.py +83 -0
  11. epostix/_generated/resources/api_keys.py +231 -0
  12. epostix/_generated/resources/attachments.py +15 -0
  13. epostix/_generated/resources/broadcasts.py +239 -0
  14. epostix/_generated/resources/contacts.py +296 -0
  15. epostix/_generated/resources/domains.py +193 -0
  16. epostix/_generated/resources/emails.py +346 -0
  17. epostix/_generated/resources/events.py +136 -0
  18. epostix/_generated/resources/inbound.py +169 -0
  19. epostix/_generated/resources/suppressions.py +186 -0
  20. epostix/_generated/resources/tags.py +119 -0
  21. epostix/_generated/resources/templates.py +202 -0
  22. epostix/_generated/resources/templates_versions.py +157 -0
  23. epostix/_generated/resources/webhooks.py +236 -0
  24. epostix/_generated/resources/webhooks_logs.py +150 -0
  25. epostix/_mailbox.py +23 -0
  26. epostix/_model.py +24 -0
  27. epostix/_pagination.py +293 -0
  28. epostix/_response.py +109 -0
  29. epostix/_retry.py +193 -0
  30. epostix/_transport.py +716 -0
  31. epostix/_types.py +20 -0
  32. epostix/attachments.py +152 -0
  33. epostix/batch.py +119 -0
  34. epostix/models.py +291 -0
  35. epostix/overrides.py +216 -0
  36. epostix/py.typed +0 -0
  37. epostix/webhooks.py +322 -0
  38. epostix-1.0.0.dist-info/METADATA +6 -0
  39. epostix-1.0.0.dist-info/RECORD +40 -0
  40. epostix-1.0.0.dist-info/WHEEL +4 -0
epostix/__init__.py ADDED
@@ -0,0 +1,223 @@
1
+ from __future__ import annotations
2
+
3
+ from . import attachments as attachments
4
+ from . import batch as batch
5
+ from . import models as models
6
+ from . import overrides as overrides
7
+ from . import webhooks as webhooks
8
+ from ._client import (
9
+ DEDUPLICATION_SCOPE as DEDUPLICATION_SCOPE,
10
+ )
11
+ from ._client import (
12
+ PRODUCTION_BASE_URL as PRODUCTION_BASE_URL,
13
+ )
14
+ from ._client import (
15
+ SANDBOX_DOMAIN as SANDBOX_DOMAIN,
16
+ )
17
+ from ._client import (
18
+ STAGING_BASE_URL as STAGING_BASE_URL,
19
+ )
20
+ from ._client import (
21
+ AsyncEpostix as AsyncEpostix,
22
+ )
23
+ from ._client import (
24
+ Epostix as Epostix,
25
+ )
26
+ from ._client import (
27
+ KeyEnvironment as KeyEnvironment,
28
+ )
29
+ from ._client import (
30
+ key_environment_of as key_environment_of,
31
+ )
32
+ from ._codec import (
33
+ EpostixDecodeError as EpostixDecodeError,
34
+ )
35
+ from ._codec import (
36
+ is_known_enum_value as is_known_enum_value,
37
+ )
38
+ from ._errors import (
39
+ EpostixAccountSuspendedError as EpostixAccountSuspendedError,
40
+ )
41
+ from ._errors import (
42
+ EpostixAPIError as EpostixAPIError,
43
+ )
44
+ from ._errors import (
45
+ EpostixAuthenticationError as EpostixAuthenticationError,
46
+ )
47
+ from ._errors import (
48
+ EpostixConfigurationError as EpostixConfigurationError,
49
+ )
50
+ from ._errors import (
51
+ EpostixConflictError as EpostixConflictError,
52
+ )
53
+ from ._errors import (
54
+ EpostixContentError as EpostixContentError,
55
+ )
56
+ from ._errors import (
57
+ EpostixError as EpostixError,
58
+ )
59
+ from ._errors import (
60
+ EpostixErrorType as EpostixErrorType,
61
+ )
62
+ from ._errors import (
63
+ EpostixIdempotencyConflictError as EpostixIdempotencyConflictError,
64
+ )
65
+ from ._errors import (
66
+ EpostixIdempotencyInProgressError as EpostixIdempotencyInProgressError,
67
+ )
68
+ from ._errors import (
69
+ EpostixInvalidRequestError as EpostixInvalidRequestError,
70
+ )
71
+ from ._errors import (
72
+ EpostixNotFoundError as EpostixNotFoundError,
73
+ )
74
+ from ._errors import (
75
+ EpostixOutcomeUnknownError as EpostixOutcomeUnknownError,
76
+ )
77
+ from ._errors import (
78
+ EpostixPaginationError as EpostixPaginationError,
79
+ )
80
+ from ._errors import (
81
+ EpostixPermissionError as EpostixPermissionError,
82
+ )
83
+ from ._errors import (
84
+ EpostixQuotaExceededError as EpostixQuotaExceededError,
85
+ )
86
+ from ._errors import (
87
+ EpostixRateLimitError as EpostixRateLimitError,
88
+ )
89
+ from ._errors import (
90
+ EpostixServerError as EpostixServerError,
91
+ )
92
+ from ._errors import (
93
+ EpostixTimeoutError as EpostixTimeoutError,
94
+ )
95
+ from ._errors import (
96
+ EpostixTransportError as EpostixTransportError,
97
+ )
98
+ from ._errors import (
99
+ EpostixValidationError as EpostixValidationError,
100
+ )
101
+ from ._errors import (
102
+ EpostixWebhookVerificationError as EpostixWebhookVerificationError,
103
+ )
104
+ from ._errors import (
105
+ FieldError as FieldError,
106
+ )
107
+ from ._errors import (
108
+ PaginationFailureReason as PaginationFailureReason,
109
+ )
110
+ from ._errors import (
111
+ QuotaPeriod as QuotaPeriod,
112
+ )
113
+ from ._errors import (
114
+ TimeoutPhase as TimeoutPhase,
115
+ )
116
+ from ._errors import (
117
+ WebhookFailureReason as WebhookFailureReason,
118
+ )
119
+ from ._mailbox import (
120
+ Mailbox as Mailbox,
121
+ )
122
+ from ._mailbox import (
123
+ addr as addr,
124
+ )
125
+ from ._mailbox import (
126
+ named as named,
127
+ )
128
+ from ._model import (
129
+ Model as Model,
130
+ )
131
+ from ._model import (
132
+ response_metadata_of as response_metadata_of,
133
+ )
134
+ from ._model import (
135
+ unknown_fields_of as unknown_fields_of,
136
+ )
137
+ from ._pagination import (
138
+ AsyncPaginator as AsyncPaginator,
139
+ )
140
+ from ._pagination import (
141
+ Collection as Collection,
142
+ )
143
+ from ._pagination import (
144
+ Page as Page,
145
+ )
146
+ from ._pagination import (
147
+ Paginator as Paginator,
148
+ )
149
+ from ._response import (
150
+ AttemptRecord as AttemptRecord,
151
+ )
152
+ from ._response import (
153
+ RateLimitSnapshot as RateLimitSnapshot,
154
+ )
155
+ from ._response import (
156
+ ResponseMetadata as ResponseMetadata,
157
+ )
158
+ from ._response import (
159
+ RetriesExhaustedReason as RetriesExhaustedReason,
160
+ )
161
+ from ._retry import (
162
+ BATCH_RETRY_POLICY as BATCH_RETRY_POLICY,
163
+ )
164
+ from ._retry import (
165
+ DEFAULT_RETRY_POLICY as DEFAULT_RETRY_POLICY,
166
+ )
167
+ from ._retry import (
168
+ DISABLED_RETRY_POLICY as DISABLED_RETRY_POLICY,
169
+ )
170
+ from ._retry import (
171
+ UPLOAD_RETRY_POLICY as UPLOAD_RETRY_POLICY,
172
+ )
173
+ from ._retry import (
174
+ MutationRetryMode as MutationRetryMode,
175
+ )
176
+ from ._retry import (
177
+ RetryClass as RetryClass,
178
+ )
179
+ from ._retry import (
180
+ RetryPolicy as RetryPolicy,
181
+ )
182
+ from ._transport import (
183
+ IDEMPOTENCY_KEY_MAXIMUM_LENGTH as IDEMPOTENCY_KEY_MAXIMUM_LENGTH,
184
+ )
185
+ from ._transport import (
186
+ IDEMPOTENCY_RETENTION_HOURS as IDEMPOTENCY_RETENTION_HOURS,
187
+ )
188
+ from ._transport import (
189
+ OperationSpec as OperationSpec,
190
+ )
191
+ from ._transport import (
192
+ RequestOptions as RequestOptions,
193
+ )
194
+ from .attachments import (
195
+ ATTACHMENT_DECODED_LIMIT_BYTES as ATTACHMENT_DECODED_LIMIT_BYTES,
196
+ )
197
+ from .attachments import (
198
+ REQUEST_BODY_LIMIT_BYTES as REQUEST_BODY_LIMIT_BYTES,
199
+ )
200
+ from .attachments import (
201
+ EpostixAttachmentTooLargeError as EpostixAttachmentTooLargeError,
202
+ )
203
+ from .attachments import (
204
+ EpostixRequestTooLargeError as EpostixRequestTooLargeError,
205
+ )
206
+ from ._types import (
207
+ NOT_GIVEN as NOT_GIVEN,
208
+ )
209
+ from ._types import (
210
+ JsonValue as JsonValue,
211
+ )
212
+ from ._types import (
213
+ NotGiven as NotGiven,
214
+ )
215
+
216
+ from .overrides import (
217
+ AsyncRawContent as AsyncRawContent,
218
+ )
219
+ from .overrides import (
220
+ RawContent as RawContent,
221
+ )
222
+
223
+ __version__ = "1.0.0"
epostix/_client.py ADDED
@@ -0,0 +1,253 @@
1
+ from __future__ import annotations
2
+
3
+ import random as _random
4
+ import time
5
+ from types import TracebackType
6
+ from typing import Callable, Literal, Mapping, TypeAlias
7
+ from urllib.parse import urlparse
8
+
9
+ import httpx
10
+
11
+ from ._errors import EpostixConfigurationError
12
+ from ._generated.resources import build_async_resources, build_resources
13
+ from ._generated.resources.analytics import AnalyticsResource, AsyncAnalyticsResource
14
+ from ._generated.resources.api_keys import ApiKeysResource, AsyncApiKeysResource
15
+ from ._generated.resources.broadcasts import AsyncBroadcastsResource, BroadcastsResource
16
+ from ._generated.resources.contacts import AsyncContactsResource, ContactsResource
17
+ from ._generated.resources.domains import AsyncDomainsResource, DomainsResource
18
+ from ._generated.resources.emails import AsyncEmailsResource, EmailsResource
19
+ from ._generated.resources.events import AsyncEventsResource, EventsResource
20
+ from ._generated.resources.suppressions import AsyncSuppressionsResource, SuppressionsResource
21
+ from ._generated.resources.tags import AsyncTagsResource, TagsResource
22
+ from ._generated.resources.templates import AsyncTemplatesResource, TemplatesResource
23
+ from ._generated.resources.webhooks import AsyncWebhooksResource, WebhooksResource
24
+ from ._retry import DEFAULT_RETRY_POLICY, RetryPolicy
25
+ from ._transport import (
26
+ AsyncTransport,
27
+ TransportConfig,
28
+ Transport,
29
+ build_async_http_client,
30
+ build_http_client,
31
+ )
32
+ from .overrides import (
33
+ AsyncAttachmentsResource,
34
+ AsyncInboundResource,
35
+ AttachmentsResource,
36
+ InboundResource,
37
+ )
38
+
39
+ PRODUCTION_BASE_URL = "https://api.epostix.com/v1"
40
+ STAGING_BASE_URL = "https://api.staging.epostix.com/v1"
41
+ SANDBOX_DOMAIN = "epostix.dev"
42
+ DEDUPLICATION_SCOPE = "api_key + environment + method + path + key, 24h"
43
+ USER_AGENT = "epostix-python/1.0.0"
44
+
45
+ KeyEnvironment: TypeAlias = Literal["live", "test", "unrecognized"]
46
+
47
+
48
+ def key_environment_of(api_key: str) -> KeyEnvironment:
49
+ if api_key.startswith("tix_live_"):
50
+ return "live"
51
+
52
+ if api_key.startswith("tix_test_"):
53
+ return "test"
54
+
55
+ return "unrecognized"
56
+
57
+
58
+ def _build_config(
59
+ api_key: str,
60
+ base_url: str,
61
+ retry_policy: RetryPolicy,
62
+ default_headers: Mapping[str, str] | None,
63
+ user_agent_suffix: str | None,
64
+ automatic_idempotency_keys: bool,
65
+ random: Callable[[], float] | None,
66
+ now: Callable[[], float] | None,
67
+ ) -> TransportConfig:
68
+ if not api_key or not api_key.strip():
69
+ raise EpostixConfigurationError("an API key is required")
70
+
71
+ if api_key.startswith("Bearer "):
72
+ raise EpostixConfigurationError(
73
+ "pass the API key alone; the client adds the Bearer prefix itself"
74
+ )
75
+
76
+ parsed = urlparse(base_url)
77
+ if parsed.scheme not in ("http", "https") or not parsed.netloc:
78
+ raise EpostixConfigurationError(f"base_url is not a valid URL: {base_url}")
79
+
80
+ return TransportConfig(
81
+ api_key=api_key,
82
+ base_url=base_url.rstrip("/"),
83
+ retry_policy=retry_policy,
84
+ user_agent=USER_AGENT + (f" {user_agent_suffix}" if user_agent_suffix else ""),
85
+ automatic_idempotency_keys=automatic_idempotency_keys,
86
+ default_headers=dict(default_headers or {}),
87
+ random=random or _random.random,
88
+ now=now or time.monotonic,
89
+ )
90
+
91
+
92
+ class Epostix:
93
+ def __init__(
94
+ self,
95
+ api_key: str,
96
+ *,
97
+ base_url: str = PRODUCTION_BASE_URL,
98
+ retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY,
99
+ http_client: httpx.Client | None = None,
100
+ default_headers: Mapping[str, str] | None = None,
101
+ user_agent_suffix: str | None = None,
102
+ automatic_idempotency_keys: bool = True,
103
+ random: Callable[[], float] | None = None,
104
+ now: Callable[[], float] | None = None,
105
+ ) -> None:
106
+ config = _build_config(
107
+ api_key,
108
+ base_url,
109
+ retry_policy,
110
+ default_headers,
111
+ user_agent_suffix,
112
+ automatic_idempotency_keys,
113
+ random,
114
+ now,
115
+ )
116
+
117
+ owns_client = http_client is None
118
+ client = http_client if http_client is not None else build_http_client(retry_policy)
119
+
120
+ self._transport = Transport(config, client, owns_client)
121
+ resources = build_resources(self._transport)
122
+
123
+ self.analytics: AnalyticsResource = resources.analytics
124
+ self.api_keys: ApiKeysResource = resources.api_keys
125
+ self.attachments = AttachmentsResource(self._transport)
126
+ self.broadcasts: BroadcastsResource = resources.broadcasts
127
+ self.contacts: ContactsResource = resources.contacts
128
+ self.domains: DomainsResource = resources.domains
129
+ self.emails: EmailsResource = resources.emails
130
+ self.events: EventsResource = resources.events
131
+ self.inbound = InboundResource(self._transport)
132
+ self.suppressions: SuppressionsResource = resources.suppressions
133
+ self.tags: TagsResource = resources.tags
134
+ self.templates: TemplatesResource = resources.templates
135
+ self.webhooks: WebhooksResource = resources.webhooks
136
+
137
+ @property
138
+ def key_environment(self) -> KeyEnvironment:
139
+ return key_environment_of(self._transport.config.api_key)
140
+
141
+ @property
142
+ def deduplication_scope(self) -> str:
143
+ return DEDUPLICATION_SCOPE
144
+
145
+ def with_api_key(self, api_key: str) -> Epostix:
146
+ config = self._transport.config
147
+
148
+ return Epostix(
149
+ api_key,
150
+ base_url=config.base_url,
151
+ retry_policy=config.retry_policy,
152
+ http_client=self._transport.http_client,
153
+ default_headers=config.default_headers,
154
+ automatic_idempotency_keys=config.automatic_idempotency_keys,
155
+ random=config.random,
156
+ now=config.now,
157
+ )
158
+
159
+ def close(self) -> None:
160
+ self._transport.close()
161
+
162
+ def __enter__(self) -> Epostix:
163
+ return self
164
+
165
+ def __exit__(
166
+ self,
167
+ exc_type: type[BaseException] | None,
168
+ exc: BaseException | None,
169
+ traceback: TracebackType | None,
170
+ ) -> None:
171
+ self.close()
172
+
173
+
174
+ class AsyncEpostix:
175
+ def __init__(
176
+ self,
177
+ api_key: str,
178
+ *,
179
+ base_url: str = PRODUCTION_BASE_URL,
180
+ retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY,
181
+ http_client: httpx.AsyncClient | None = None,
182
+ default_headers: Mapping[str, str] | None = None,
183
+ user_agent_suffix: str | None = None,
184
+ automatic_idempotency_keys: bool = True,
185
+ random: Callable[[], float] | None = None,
186
+ now: Callable[[], float] | None = None,
187
+ ) -> None:
188
+ config = _build_config(
189
+ api_key,
190
+ base_url,
191
+ retry_policy,
192
+ default_headers,
193
+ user_agent_suffix,
194
+ automatic_idempotency_keys,
195
+ random,
196
+ now,
197
+ )
198
+
199
+ owns_client = http_client is None
200
+ client = http_client if http_client is not None else build_async_http_client(retry_policy)
201
+
202
+ self._transport = AsyncTransport(config, client, owns_client)
203
+ resources = build_async_resources(self._transport)
204
+
205
+ self.analytics: AsyncAnalyticsResource = resources.analytics
206
+ self.api_keys: AsyncApiKeysResource = resources.api_keys
207
+ self.attachments = AsyncAttachmentsResource(self._transport)
208
+ self.broadcasts: AsyncBroadcastsResource = resources.broadcasts
209
+ self.contacts: AsyncContactsResource = resources.contacts
210
+ self.domains: AsyncDomainsResource = resources.domains
211
+ self.emails: AsyncEmailsResource = resources.emails
212
+ self.events: AsyncEventsResource = resources.events
213
+ self.inbound = AsyncInboundResource(self._transport)
214
+ self.suppressions: AsyncSuppressionsResource = resources.suppressions
215
+ self.tags: AsyncTagsResource = resources.tags
216
+ self.templates: AsyncTemplatesResource = resources.templates
217
+ self.webhooks: AsyncWebhooksResource = resources.webhooks
218
+
219
+ @property
220
+ def key_environment(self) -> KeyEnvironment:
221
+ return key_environment_of(self._transport.config.api_key)
222
+
223
+ @property
224
+ def deduplication_scope(self) -> str:
225
+ return DEDUPLICATION_SCOPE
226
+
227
+ def with_api_key(self, api_key: str) -> AsyncEpostix:
228
+ config = self._transport.config
229
+
230
+ return AsyncEpostix(
231
+ api_key,
232
+ base_url=config.base_url,
233
+ retry_policy=config.retry_policy,
234
+ http_client=self._transport.http_client,
235
+ default_headers=config.default_headers,
236
+ automatic_idempotency_keys=config.automatic_idempotency_keys,
237
+ random=config.random,
238
+ now=config.now,
239
+ )
240
+
241
+ async def aclose(self) -> None:
242
+ await self._transport.aclose()
243
+
244
+ async def __aenter__(self) -> AsyncEpostix:
245
+ return self
246
+
247
+ async def __aexit__(
248
+ self,
249
+ exc_type: type[BaseException] | None,
250
+ exc: BaseException | None,
251
+ traceback: TracebackType | None,
252
+ ) -> None:
253
+ await self.aclose()
epostix/_codec.py ADDED
@@ -0,0 +1,248 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
6
+ from enum import Enum
7
+ from typing import Any, Callable, Sequence
8
+
9
+ from ._errors import EpostixConfigurationError, EpostixError
10
+ from ._mailbox import Mailbox
11
+ from ._model import Model
12
+ from ._types import NOT_GIVEN, NotGiven
13
+
14
+
15
+ class EpostixDecodeError(EpostixError):
16
+ pass
17
+
18
+
19
+ class Codec(ABC):
20
+ @abstractmethod
21
+ def encode(self, value: Any) -> Any: ...
22
+
23
+ @abstractmethod
24
+ def decode(self, value: Any) -> Any: ...
25
+
26
+
27
+ class _Passthrough(Codec):
28
+ def encode(self, value: Any) -> Any:
29
+ return value
30
+
31
+ def decode(self, value: Any) -> Any:
32
+ return value
33
+
34
+
35
+ def format_datetime(value: datetime) -> str:
36
+ text = value.isoformat()
37
+
38
+ if text.endswith("+00:00"):
39
+ return text[:-6] + "Z"
40
+
41
+ return text
42
+
43
+
44
+ class _DateTime(Codec):
45
+ def encode(self, value: Any) -> Any:
46
+ if isinstance(value, datetime):
47
+ return format_datetime(value)
48
+
49
+ return value
50
+
51
+ def decode(self, value: Any) -> Any:
52
+ if isinstance(value, str):
53
+ try:
54
+ return datetime.fromisoformat(value)
55
+ except ValueError:
56
+ return value
57
+
58
+ return value
59
+
60
+
61
+ PASSTHROUGH: Codec = _Passthrough()
62
+ DATETIME: Codec = _DateTime()
63
+
64
+
65
+ class ArrayCodec(Codec):
66
+ def __init__(self, inner: Callable[[], Codec]) -> None:
67
+ self._inner = inner
68
+
69
+ def encode(self, value: Any) -> Any:
70
+ if isinstance(value, (list, tuple)):
71
+ inner = self._inner()
72
+ return [inner.encode(item) for item in value]
73
+
74
+ return value
75
+
76
+ def decode(self, value: Any) -> Any:
77
+ if isinstance(value, list):
78
+ inner = self._inner()
79
+ return [inner.decode(item) for item in value]
80
+
81
+ return value
82
+
83
+
84
+ class MapCodec(Codec):
85
+ def __init__(self, inner: Callable[[], Codec]) -> None:
86
+ self._inner = inner
87
+
88
+ def encode(self, value: Any) -> Any:
89
+ if isinstance(value, dict):
90
+ inner = self._inner()
91
+ return {key: inner.encode(item) for key, item in value.items()}
92
+
93
+ return value
94
+
95
+ def decode(self, value: Any) -> Any:
96
+ if isinstance(value, dict):
97
+ inner = self._inner()
98
+ return {key: inner.decode(item) for key, item in value.items()}
99
+
100
+ return value
101
+
102
+
103
+ class EnumCodec(Codec):
104
+ def __init__(self, enum_class: type[Enum]) -> None:
105
+ self._enum_class = enum_class
106
+
107
+ def encode(self, value: Any) -> Any:
108
+ if isinstance(value, Enum):
109
+ return value.value
110
+
111
+ return value
112
+
113
+ def decode(self, value: Any) -> Any:
114
+ if not isinstance(value, str):
115
+ return value
116
+
117
+ try:
118
+ return self._enum_class(value)
119
+ except ValueError:
120
+ return value
121
+
122
+
123
+ def is_known_enum_value(enum_class: type[Enum], value: str) -> bool:
124
+ try:
125
+ enum_class(value)
126
+ except ValueError:
127
+ return False
128
+
129
+ return True
130
+
131
+
132
+ class ScalarOrArrayCodec(Codec):
133
+ def encode(self, value: Any) -> Any:
134
+ if isinstance(value, str):
135
+ return [value]
136
+
137
+ if isinstance(value, (list, tuple)):
138
+ return list(value)
139
+
140
+ return value
141
+
142
+ def decode(self, value: Any) -> Any:
143
+ return value
144
+
145
+
146
+ class MailboxCodec(Codec):
147
+ def encode(self, value: Any) -> Any:
148
+ if isinstance(value, Mailbox):
149
+ if not value.name:
150
+ return value.email
151
+
152
+ return {"email": value.email, "name": value.name}
153
+
154
+ return value
155
+
156
+ def decode(self, value: Any) -> Any:
157
+ if isinstance(value, dict):
158
+ return Mailbox(email=str(value.get("email", "")), name=value.get("name"))
159
+
160
+ return value
161
+
162
+
163
+ @dataclass(frozen=True)
164
+ class FieldSpec:
165
+ wire: str
166
+ name: str
167
+ codec: Callable[[], Codec]
168
+ required: bool
169
+ nullable: bool
170
+
171
+
172
+ class ModelCodec(Codec):
173
+ def __init__(self, model_class: type[Model], fields: Sequence[FieldSpec]) -> None:
174
+ self._model_class = model_class
175
+ self._fields = fields
176
+ self._known = frozenset(spec.wire for spec in fields)
177
+
178
+ def encode(self, value: Any) -> Any:
179
+ if not isinstance(value, self._model_class):
180
+ return value
181
+
182
+ out: dict[str, Any] = {}
183
+
184
+ for spec in self._fields:
185
+ raw = getattr(value, spec.name, NOT_GIVEN)
186
+
187
+ if isinstance(raw, NotGiven):
188
+ continue
189
+
190
+ out[spec.wire] = None if raw is None else spec.codec().encode(raw)
191
+
192
+ return out
193
+
194
+ def decode(self, value: Any) -> Any:
195
+ if not isinstance(value, dict):
196
+ return value
197
+
198
+ instance = self._model_class.__new__(self._model_class)
199
+
200
+ for spec in self._fields:
201
+ if spec.wire in value:
202
+ raw = value[spec.wire]
203
+ setattr(
204
+ instance,
205
+ spec.name,
206
+ None if raw is None else spec.codec().decode(raw),
207
+ )
208
+ else:
209
+ setattr(instance, spec.name, NOT_GIVEN)
210
+
211
+ instance.unknown_fields = {
212
+ key: item for key, item in value.items() if key not in self._known
213
+ }
214
+ instance.response_metadata = None
215
+
216
+ return instance
217
+
218
+
219
+ @dataclass(frozen=True)
220
+ class VariantSpec:
221
+ discriminator: str
222
+ model_class: type[Model]
223
+ codec: Callable[[], Codec]
224
+
225
+
226
+ class VariantCodec(Codec):
227
+ def __init__(self, variants: Sequence[VariantSpec], name: str) -> None:
228
+ self._variants = variants
229
+ self._name = name
230
+
231
+ def encode(self, value: Any) -> Any:
232
+ for variant in self._variants:
233
+ if isinstance(value, variant.model_class):
234
+ return variant.codec().encode(value)
235
+
236
+ raise EpostixConfigurationError(f"value does not match any {self._name} variant")
237
+
238
+ def decode(self, value: Any) -> Any:
239
+ if not isinstance(value, dict):
240
+ return value
241
+
242
+ for variant in self._variants:
243
+ if variant.discriminator in value:
244
+ return variant.codec().decode(value)
245
+
246
+ raise EpostixDecodeError(
247
+ f"the server returned a {self._name} shape this version does not recognise"
248
+ )