maildeno 2.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.
maildeno/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """Official Python SDK for the Maildeno render API.
2
+
3
+ Quick start::
4
+
5
+ from maildeno import MaildenoClient
6
+
7
+ client = MaildenoClient(api_key="sk_live_...")
8
+ html = client.render_html("550e8400-e29b-41d4-a716-446655440000")
9
+ """
10
+
11
+ from importlib.metadata import PackageNotFoundError, version
12
+
13
+ from ._async_client import AsyncMaildenoClient
14
+ from ._client import MaildenoClient
15
+ from ._error import MaildenoError
16
+ from ._types import (
17
+ CacheConfig,
18
+ ContextValue,
19
+ DynamicData,
20
+ MergeTagGroup,
21
+ RenderResult,
22
+ RenderTarget,
23
+ SdkErrorCode,
24
+ TemplateJson,
25
+ ValidationIssue,
26
+ )
27
+
28
+ try:
29
+ __version__ = version("maildeno")
30
+ except PackageNotFoundError:
31
+ __version__ = "unknown"
32
+
33
+ __all__ = [
34
+ "AsyncMaildenoClient",
35
+ "CacheConfig",
36
+ "ContextValue",
37
+ "DynamicData",
38
+ "MaildenoClient",
39
+ "MaildenoError",
40
+ "MergeTagGroup",
41
+ "RenderResult",
42
+ "RenderTarget",
43
+ "SdkErrorCode",
44
+ "TemplateJson",
45
+ "ValidationIssue",
46
+ "__version__",
47
+ ]
@@ -0,0 +1,243 @@
1
+ """Asynchronous Maildeno client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import functools
7
+ import warnings
8
+ from types import TracebackType
9
+ from typing import List, Optional, Type
10
+
11
+ import httpx
12
+
13
+ from ._cache import build_cache
14
+ from ._minify import minify_output
15
+ from ._error import MaildenoError
16
+ from ._internal import (
17
+ DEFAULT_TIMEOUT,
18
+ TEMPLATE_PATH,
19
+ build_headers,
20
+ map_transport_error,
21
+ normalise_base_url,
22
+ normalise_dynamic_data,
23
+ parse_template_response,
24
+ raise_for_response,
25
+ )
26
+ from ._renderer import render_template
27
+ from ._types import CacheConfig, DynamicData, RenderResult, RenderTarget, TemplateJson
28
+
29
+
30
+ class AsyncMaildenoClient:
31
+ """Asynchronous Maildeno client. Mirrors :class:`MaildenoClient`.
32
+
33
+ Template JSON is fetched asynchronously, cached locally, and rendered
34
+ via the embedded Wasm engine in a thread-pool executor so the event
35
+ loop is never blocked.
36
+
37
+ Example::
38
+
39
+ import asyncio
40
+ from maildeno import AsyncMaildenoClient
41
+
42
+ async def main():
43
+ async with AsyncMaildenoClient(api_key="sk_live_...") as client:
44
+ html = await client.render_html("550e8400-...")
45
+ print(html)
46
+
47
+ asyncio.run(main())
48
+
49
+ For FastAPI / Starlette / aiohttp, instantiate one client per process
50
+ at startup and reuse it across all requests.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ api_key: str,
56
+ *,
57
+ base_url: Optional[str] = None,
58
+ timeout: float = DEFAULT_TIMEOUT,
59
+ cache: Optional[CacheConfig] = None,
60
+ http_client: Optional[httpx.AsyncClient] = None,
61
+ ) -> None:
62
+ if not api_key:
63
+ raise MaildenoError("INVALID_API_KEY", "api_key is required.")
64
+
65
+ self._api_key = api_key
66
+ self._base_url = normalise_base_url(base_url)
67
+ self._timeout = timeout
68
+ self._cache = build_cache(cache) # type: ignore[arg-type]
69
+
70
+ if http_client is not None:
71
+ self._http = http_client
72
+ self._owns_http = False
73
+ else:
74
+ self._http = httpx.AsyncClient(timeout=timeout)
75
+ self._owns_http = True
76
+
77
+ # ── Render ────────────────────────────────────────────────────────────────
78
+
79
+ async def render(
80
+ self,
81
+ *,
82
+ template_id: str,
83
+ target: RenderTarget = "html",
84
+ dynamic_data: Optional[DynamicData] = None,
85
+ ) -> RenderResult:
86
+ """Render a template to HTML, React Email TSX, or MJML.
87
+
88
+ The Wasm engine is synchronous; it runs in a thread-pool executor so
89
+ the event loop is never blocked.
90
+
91
+ :param template_id: UUID of the template (required).
92
+ :param target: ``"html"`` | ``"react-email"`` | ``"mjml"``.
93
+ :param dynamic_data: Merge tags + visibility context (fully optional).
94
+ :raises MaildenoError: On any API or render failure.
95
+ """
96
+ template, from_stale = await self._get_template(template_id, target)
97
+
98
+ norm_data: Optional[DynamicData] = None
99
+ if dynamic_data is not None:
100
+ raw = normalise_dynamic_data(dynamic_data)
101
+ if raw:
102
+ norm_data = raw # type: ignore[assignment]
103
+
104
+ # Run the synchronous Wasm render in a thread to avoid blocking the
105
+ # event loop. functools.partial binds the arguments cleanly.
106
+ loop = asyncio.get_event_loop()
107
+ raw_output: str = await loop.run_in_executor(
108
+ None,
109
+ functools.partial(render_template, template, target, norm_data),
110
+ )
111
+ output = minify_output(target, raw_output)
112
+
113
+ return RenderResult(
114
+ template_id=template_id,
115
+ target=target,
116
+ output=output,
117
+ from_stale_cache=from_stale,
118
+ )
119
+
120
+ async def render_html(
121
+ self,
122
+ template_id: str,
123
+ dynamic_data: Optional[DynamicData] = None,
124
+ ) -> str:
125
+ """Convenience: render to HTML, returning the output string directly."""
126
+ return (
127
+ await self.render(
128
+ template_id=template_id, target="html", dynamic_data=dynamic_data
129
+ )
130
+ ).output
131
+
132
+ async def render_react(
133
+ self,
134
+ template_id: str,
135
+ dynamic_data: Optional[DynamicData] = None,
136
+ ) -> str:
137
+ """Convenience: render to React Email TSX."""
138
+ return (
139
+ await self.render(
140
+ template_id=template_id, target="react-email", dynamic_data=dynamic_data
141
+ )
142
+ ).output
143
+
144
+ async def render_mjml(
145
+ self,
146
+ template_id: str,
147
+ dynamic_data: Optional[DynamicData] = None,
148
+ ) -> str:
149
+ """Convenience: render to MJML."""
150
+ return (
151
+ await self.render(
152
+ template_id=template_id, target="mjml", dynamic_data=dynamic_data
153
+ )
154
+ ).output
155
+
156
+ # ── Cache management ──────────────────────────────────────────────────────
157
+
158
+ def list_cached(self) -> List[str]:
159
+ """Return the IDs of all templates currently in the cache."""
160
+ return self._cache.list()
161
+
162
+ def delete_cached(self, template_id: str) -> None:
163
+ """Remove a single template from the cache."""
164
+ self._cache.invalidate(template_id)
165
+
166
+ def clear_cache(self) -> None:
167
+ """Remove all templates from the cache."""
168
+ self._cache.clear()
169
+
170
+ def invalidate(self, template_id: str) -> None:
171
+ """Deprecated — use :meth:`delete_cached` instead."""
172
+ warnings.warn(
173
+ "invalidate() is deprecated; use delete_cached() instead.",
174
+ DeprecationWarning,
175
+ stacklevel=2,
176
+ )
177
+ self.delete_cached(template_id)
178
+
179
+ # ── Lifecycle ─────────────────────────────────────────────────────────────
180
+
181
+ async def aclose(self) -> None:
182
+ """Close the underlying HTTP client.
183
+
184
+ Only closes the client if this :class:`AsyncMaildenoClient` created it.
185
+ """
186
+ if self._owns_http:
187
+ await self._http.aclose()
188
+
189
+ async def __aenter__(self) -> AsyncMaildenoClient:
190
+ return self
191
+
192
+ async def __aexit__(
193
+ self,
194
+ exc_type: Optional[Type[BaseException]],
195
+ exc: Optional[BaseException],
196
+ tb: Optional[TracebackType],
197
+ ) -> None:
198
+ await self.aclose()
199
+
200
+ # ── Template fetching ─────────────────────────────────────────────────────
201
+
202
+ async def _get_template(
203
+ self,
204
+ template_id: str,
205
+ target: RenderTarget,
206
+ ) -> tuple[TemplateJson, bool]:
207
+ """Return (template, from_stale_cache)."""
208
+ fresh = self._cache.get_fresh(template_id)
209
+ if fresh is not None:
210
+ return fresh, False
211
+
212
+ try:
213
+ template = await self._fetch_template(template_id, target)
214
+ self._cache.set(template_id, template)
215
+ return template, False
216
+ except MaildenoError:
217
+ stale = self._cache.get_fallback(template_id)
218
+ if stale is not None:
219
+ return stale, True
220
+ raise
221
+
222
+ async def _fetch_template(
223
+ self,
224
+ template_id: str,
225
+ target: RenderTarget,
226
+ ) -> TemplateJson:
227
+ url = f"{self._base_url}{TEMPLATE_PATH}/{template_id}?target={target}"
228
+ try:
229
+ response = await self._http.get(
230
+ url,
231
+ headers=build_headers(self._api_key),
232
+ timeout=self._timeout,
233
+ )
234
+ except httpx.HTTPError as exc:
235
+ raise map_transport_error(exc) from exc
236
+ except Exception as exc: # pragma: no cover
237
+ raise map_transport_error(exc) from exc
238
+
239
+ raise_for_response(response)
240
+ return parse_template_response(response.json())
241
+
242
+
243
+ __all__ = ["AsyncMaildenoClient"]
maildeno/_cache.py ADDED
@@ -0,0 +1,291 @@
1
+ """Template cache — memory and disk strategies.
2
+
3
+ Storage layout (disk mode):
4
+
5
+ {cache_dir}/
6
+ a7f4b181-a366-4944-a371-e7b941a3c5ab.json
7
+ 9ec0c043-e8a1-4a68-bbb3-92fbef1ea222.json
8
+
9
+ Each file contains::
10
+
11
+ {
12
+ "template_id": "...",
13
+ "fetched_at": 1717776000000,
14
+ "ttl": 300000,
15
+ "template": { ...TemplateJson... }
16
+ }
17
+
18
+ The file name is the UUID as-is. UUIDs contain only [0-9a-f-] which is safe
19
+ on every modern filesystem. Any other character is replaced with ``_`` as a
20
+ defensive guard — this never fires for real Maildeno template IDs.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import time
28
+ from abc import ABC, abstractmethod
29
+ from typing import Any, Dict, List, Optional
30
+
31
+ from ._types import TemplateJson
32
+
33
+
34
+ # ── Shared helpers ────────────────────────────────────────────────────────────
35
+
36
+ def _now_ms() -> int:
37
+ return int(time.time() * 1000)
38
+
39
+
40
+ def _is_stale(fetched_at: int, ttl: int) -> bool:
41
+ return _now_ms() - fetched_at > ttl
42
+
43
+
44
+ # ── Strategy interface ────────────────────────────────────────────────────────
45
+
46
+ class _CacheStore(ABC):
47
+ """All cache implementations must satisfy this interface."""
48
+
49
+ @abstractmethod
50
+ def get_fresh(self, template_id: str) -> Optional[TemplateJson]:
51
+ """Return the template only if it is within TTL, else None."""
52
+
53
+ @abstractmethod
54
+ def get_fallback(self, template_id: str) -> Optional[TemplateJson]:
55
+ """Return the template regardless of staleness, or None if absent."""
56
+
57
+ @abstractmethod
58
+ def set(self, template_id: str, template: TemplateJson) -> None:
59
+ """Store or overwrite a template entry."""
60
+
61
+ @abstractmethod
62
+ def invalidate(self, template_id: str) -> None:
63
+ """Remove a single entry. No-op if absent."""
64
+
65
+ @abstractmethod
66
+ def clear(self) -> None:
67
+ """Remove all entries."""
68
+
69
+ @abstractmethod
70
+ def list(self) -> List[str]:
71
+ """Return the IDs of all currently cached templates."""
72
+
73
+
74
+ # ── Memory store ──────────────────────────────────────────────────────────────
75
+
76
+ class _MemoryStore(_CacheStore):
77
+ """In-process dict cache with TTL + stale-on-error fallback.
78
+
79
+ Lost on process restart. Zero I/O. Every read is O(1).
80
+ Oldest entry evicted when ``max_entries`` is reached.
81
+ """
82
+
83
+ def __init__(self, ttl: int, max_entries: int) -> None:
84
+ self._ttl = ttl
85
+ self._max = max_entries
86
+ # {template_id: {"template": ..., "fetched_at": int, "ttl": int}}
87
+ self._store: Dict[str, Dict[str, Any]] = {}
88
+
89
+ def get_fresh(self, template_id: str) -> Optional[TemplateJson]:
90
+ entry = self._store.get(template_id)
91
+ if entry is None:
92
+ return None
93
+ if _is_stale(entry["fetched_at"], entry["ttl"]):
94
+ return None
95
+ return entry["template"] # type: ignore[return-value]
96
+
97
+ def get_fallback(self, template_id: str) -> Optional[TemplateJson]:
98
+ entry = self._store.get(template_id)
99
+ return entry["template"] if entry else None # type: ignore[return-value]
100
+
101
+ def set(self, template_id: str, template: TemplateJson) -> None:
102
+ if len(self._store) >= self._max and template_id not in self._store:
103
+ # Evict oldest entry
104
+ oldest = min(self._store, key=lambda k: self._store[k]["fetched_at"])
105
+ del self._store[oldest]
106
+ self._store[template_id] = {
107
+ "template": template,
108
+ "fetched_at": _now_ms(),
109
+ "ttl": self._ttl,
110
+ }
111
+
112
+ def invalidate(self, template_id: str) -> None:
113
+ self._store.pop(template_id, None)
114
+
115
+ def clear(self) -> None:
116
+ self._store.clear()
117
+
118
+ def list(self) -> List[str]:
119
+ return list(self._store.keys())
120
+
121
+
122
+ # ── Disk store ────────────────────────────────────────────────────────────────
123
+
124
+ class _DiskStore(_CacheStore):
125
+ """Persistent JSON-file cache.
126
+
127
+ Each template is stored as a single minified JSON file named after its
128
+ UUID. Survives process restarts. All reads/writes are synchronous — both
129
+ the sync and async clients call this from a thread (the async client uses
130
+ ``run_in_executor`` for the whole render pipeline).
131
+ """
132
+
133
+ def __init__(self, cache_dir: str, ttl: int, max_entries: int) -> None:
134
+ self._dir = cache_dir
135
+ self._ttl = ttl
136
+ self._max = max_entries
137
+
138
+ # ── Reads ──────────────────────────────────────────────────────────────
139
+
140
+ def get_fresh(self, template_id: str) -> Optional[TemplateJson]:
141
+ entry = self._read(template_id)
142
+ if entry is None:
143
+ return None
144
+ if _is_stale(entry["fetched_at"], entry["ttl"]):
145
+ return None
146
+ return entry["template"] # type: ignore[return-value]
147
+
148
+ def get_fallback(self, template_id: str) -> Optional[TemplateJson]:
149
+ entry = self._read(template_id)
150
+ return entry["template"] if entry else None # type: ignore[return-value]
151
+
152
+ # ── Writes ─────────────────────────────────────────────────────────────
153
+
154
+ def set(self, template_id: str, template: TemplateJson) -> None:
155
+ os.makedirs(self._dir, exist_ok=True)
156
+ self._enforce_limit()
157
+
158
+ entry = {
159
+ "template_id": template_id,
160
+ "fetched_at": _now_ms(),
161
+ "ttl": self._ttl,
162
+ "template": template,
163
+ }
164
+ final = self._path(template_id)
165
+ tmp = final + ".tmp"
166
+ with open(tmp, "w", encoding="utf-8") as fh:
167
+ # Minified — no indent. 58 KB → 22 KB.
168
+ json.dump(entry, fh, separators=(",", ":"))
169
+ os.replace(tmp, final) # atomic on POSIX; best-effort on Windows
170
+
171
+ def invalidate(self, template_id: str) -> None:
172
+ try:
173
+ os.unlink(self._path(template_id))
174
+ except FileNotFoundError:
175
+ pass
176
+
177
+ def clear(self) -> None:
178
+ try:
179
+ files = os.listdir(self._dir)
180
+ except FileNotFoundError:
181
+ return
182
+ for f in files:
183
+ if f.endswith(".json") and not f.endswith(".tmp"):
184
+ try:
185
+ os.unlink(os.path.join(self._dir, f))
186
+ except OSError:
187
+ pass
188
+
189
+ def list(self) -> List[str]:
190
+ try:
191
+ files = os.listdir(self._dir)
192
+ except FileNotFoundError:
193
+ return []
194
+ return [
195
+ f[:-5] # strip ".json"
196
+ for f in files
197
+ if f.endswith(".json") and not f.endswith(".tmp")
198
+ ]
199
+
200
+ # ── Internals ──────────────────────────────────────────────────────────
201
+
202
+ def _path(self, template_id: str) -> str:
203
+ safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in template_id)
204
+ return os.path.join(self._dir, f"{safe}.json")
205
+
206
+ def _read(self, template_id: str) -> Optional[Dict[str, Any]]:
207
+ try:
208
+ with open(self._path(template_id), encoding="utf-8") as fh:
209
+ return json.load(fh) # type: ignore[no-any-return]
210
+ except (FileNotFoundError, json.JSONDecodeError, KeyError):
211
+ return None
212
+
213
+ def _enforce_limit(self) -> None:
214
+ try:
215
+ files = [
216
+ f for f in os.listdir(self._dir)
217
+ if f.endswith(".json") and not f.endswith(".tmp")
218
+ ]
219
+ except FileNotFoundError:
220
+ return
221
+
222
+ if len(files) < self._max:
223
+ return
224
+
225
+ # Read fetchedAt from each file to find the oldest
226
+ entries = []
227
+ for f in files:
228
+ try:
229
+ with open(os.path.join(self._dir, f), encoding="utf-8") as fh:
230
+ data = json.load(fh)
231
+ entries.append((f, data.get("fetched_at", 0)))
232
+ except (OSError, json.JSONDecodeError):
233
+ entries.append((f, 0))
234
+
235
+ entries.sort(key=lambda x: x[1])
236
+ to_evict = entries[: len(entries) - self._max + 1]
237
+ for fname, _ in to_evict:
238
+ try:
239
+ os.unlink(os.path.join(self._dir, fname))
240
+ except OSError:
241
+ pass
242
+
243
+
244
+ # ── Public facade ─────────────────────────────────────────────────────────────
245
+
246
+ _DEFAULT_TTL = 300_000 # 5 minutes
247
+ _DEFAULT_MAX_ENTRIES = 50
248
+ _DEFAULT_CACHE_PATH = ".maildeno-cache"
249
+
250
+
251
+ class TemplateCache:
252
+ """Thin facade over the active ``_CacheStore``.
253
+
254
+ Both clients delegate every cache operation here so they never reference
255
+ ``_MemoryStore`` or ``_DiskStore`` directly.
256
+ """
257
+
258
+ def __init__(self, store: _CacheStore) -> None:
259
+ self._store = store
260
+
261
+ def get_fresh(self, template_id: str) -> Optional[TemplateJson]:
262
+ return self._store.get_fresh(template_id)
263
+
264
+ def get_fallback(self, template_id: str) -> Optional[TemplateJson]:
265
+ return self._store.get_fallback(template_id)
266
+
267
+ def set(self, template_id: str, template: TemplateJson) -> None:
268
+ self._store.set(template_id, template)
269
+
270
+ def invalidate(self, template_id: str) -> None:
271
+ self._store.invalidate(template_id)
272
+
273
+ def clear(self) -> None:
274
+ self._store.clear()
275
+
276
+ def list(self) -> List[str]:
277
+ return self._store.list()
278
+
279
+
280
+ def build_cache(config: Optional[Dict[str, Any]]) -> TemplateCache:
281
+ """Build a :class:`TemplateCache` from a raw config dict or ``None``."""
282
+ cfg = config or {}
283
+ ttl = int(cfg.get("ttl", _DEFAULT_TTL))
284
+ max_entries = int(cfg.get("max_entries", _DEFAULT_MAX_ENTRIES))
285
+
286
+ if cfg.get("type") == "disk":
287
+ raw_path = cfg.get("path", _DEFAULT_CACHE_PATH)
288
+ resolved = os.path.abspath(raw_path)
289
+ return TemplateCache(_DiskStore(resolved, ttl, max_entries))
290
+
291
+ return TemplateCache(_MemoryStore(ttl, max_entries))