webhooks-cc 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.
- webhooks_cc/__init__.py +144 -0
- webhooks_cc/_crypto.py +255 -0
- webhooks_cc/_sentinel.py +16 -0
- webhooks_cc/_sse.py +45 -0
- webhooks_cc/client.py +1287 -0
- webhooks_cc/durations.py +56 -0
- webhooks_cc/email.py +78 -0
- webhooks_cc/errors.py +99 -0
- webhooks_cc/matchers.py +242 -0
- webhooks_cc/models.py +403 -0
- webhooks_cc/py.typed +0 -0
- webhooks_cc/pytest_plugin.py +43 -0
- webhooks_cc/templates.py +2483 -0
- webhooks_cc/testing.py +135 -0
- webhooks_cc/verify.py +1297 -0
- webhooks_cc-0.1.0.dist-info/METADATA +154 -0
- webhooks_cc-0.1.0.dist-info/RECORD +20 -0
- webhooks_cc-0.1.0.dist-info/WHEEL +4 -0
- webhooks_cc-0.1.0.dist-info/entry_points.txt +2 -0
- webhooks_cc-0.1.0.dist-info/licenses/LICENSE +21 -0
webhooks_cc/__init__.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""webhooks.cc SDK for Python.
|
|
2
|
+
|
|
3
|
+
Capture webhooks and email in tests, wait for them, send signed webhooks to
|
|
4
|
+
your own handlers, and verify provider and forwarding signatures.
|
|
5
|
+
|
|
6
|
+
.. code-block:: python
|
|
7
|
+
|
|
8
|
+
from webhooks_cc import WebhooksCC, match_body_path
|
|
9
|
+
|
|
10
|
+
with WebhooksCC() as client: # reads WHK_API_KEY
|
|
11
|
+
endpoint = client.endpoints.create(name="checkout test")
|
|
12
|
+
request = client.requests.wait_for(
|
|
13
|
+
endpoint.slug, timeout="30s", match=match_body_path("type", "invoice.paid")
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
Every signature check for each provider is in :mod:`webhooks_cc.verify`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
from .client import Emails, Endpoints, Forwarding, Requests, RetryOptions, WebhooksCC
|
|
22
|
+
from .durations import Duration, parse_duration
|
|
23
|
+
from .email import (
|
|
24
|
+
DEFAULT_EMAIL_DOMAIN,
|
|
25
|
+
email_address,
|
|
26
|
+
extract_code,
|
|
27
|
+
extract_link,
|
|
28
|
+
is_valid_email_tag,
|
|
29
|
+
)
|
|
30
|
+
from .errors import (
|
|
31
|
+
NotFoundError,
|
|
32
|
+
RateLimitError,
|
|
33
|
+
TimeoutError,
|
|
34
|
+
UnauthorizedError,
|
|
35
|
+
WebhooksCCError,
|
|
36
|
+
WebhookVerificationError,
|
|
37
|
+
)
|
|
38
|
+
from .matchers import (
|
|
39
|
+
Matcher,
|
|
40
|
+
is_email_request,
|
|
41
|
+
match_all,
|
|
42
|
+
match_any,
|
|
43
|
+
match_body_path,
|
|
44
|
+
match_body_subset,
|
|
45
|
+
match_content_type,
|
|
46
|
+
match_email,
|
|
47
|
+
match_header,
|
|
48
|
+
match_method,
|
|
49
|
+
match_path,
|
|
50
|
+
match_query_param,
|
|
51
|
+
match_unverified,
|
|
52
|
+
match_verified,
|
|
53
|
+
)
|
|
54
|
+
from .models import (
|
|
55
|
+
BuiltRequest,
|
|
56
|
+
EmailAddress,
|
|
57
|
+
EmailAttachment,
|
|
58
|
+
EmailCapture,
|
|
59
|
+
EmailExtracts,
|
|
60
|
+
Endpoint,
|
|
61
|
+
ExtractedLink,
|
|
62
|
+
Request,
|
|
63
|
+
RequestPage,
|
|
64
|
+
SendTestEmailResult,
|
|
65
|
+
TeamShare,
|
|
66
|
+
UsageInfo,
|
|
67
|
+
)
|
|
68
|
+
from .templates import (
|
|
69
|
+
TEMPLATE_METADATA,
|
|
70
|
+
TEMPLATE_PROVIDERS,
|
|
71
|
+
VERIFY_PROVIDERS,
|
|
72
|
+
TemplateProviderInfo,
|
|
73
|
+
build_template_request,
|
|
74
|
+
)
|
|
75
|
+
from .testing import capture_during, capture_email_during, temporary_endpoint
|
|
76
|
+
from .verify import (
|
|
77
|
+
RelayedRequestInfo,
|
|
78
|
+
verify_forwarded_email,
|
|
79
|
+
verify_forwarded_request,
|
|
80
|
+
verify_relayed_request,
|
|
81
|
+
verify_signature,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
__all__ = [
|
|
85
|
+
"DEFAULT_EMAIL_DOMAIN",
|
|
86
|
+
"TEMPLATE_METADATA",
|
|
87
|
+
"TEMPLATE_PROVIDERS",
|
|
88
|
+
"VERIFY_PROVIDERS",
|
|
89
|
+
"BuiltRequest",
|
|
90
|
+
"Duration",
|
|
91
|
+
"EmailAddress",
|
|
92
|
+
"EmailAttachment",
|
|
93
|
+
"EmailCapture",
|
|
94
|
+
"EmailExtracts",
|
|
95
|
+
"Emails",
|
|
96
|
+
"Endpoint",
|
|
97
|
+
"Endpoints",
|
|
98
|
+
"ExtractedLink",
|
|
99
|
+
"Forwarding",
|
|
100
|
+
"Matcher",
|
|
101
|
+
"NotFoundError",
|
|
102
|
+
"RateLimitError",
|
|
103
|
+
"RelayedRequestInfo",
|
|
104
|
+
"Request",
|
|
105
|
+
"RequestPage",
|
|
106
|
+
"Requests",
|
|
107
|
+
"RetryOptions",
|
|
108
|
+
"SendTestEmailResult",
|
|
109
|
+
"TeamShare",
|
|
110
|
+
"TemplateProviderInfo",
|
|
111
|
+
"TimeoutError",
|
|
112
|
+
"UnauthorizedError",
|
|
113
|
+
"UsageInfo",
|
|
114
|
+
"WebhookVerificationError",
|
|
115
|
+
"WebhooksCC",
|
|
116
|
+
"WebhooksCCError",
|
|
117
|
+
"__version__",
|
|
118
|
+
"build_template_request",
|
|
119
|
+
"capture_during",
|
|
120
|
+
"capture_email_during",
|
|
121
|
+
"email_address",
|
|
122
|
+
"extract_code",
|
|
123
|
+
"extract_link",
|
|
124
|
+
"is_email_request",
|
|
125
|
+
"is_valid_email_tag",
|
|
126
|
+
"match_all",
|
|
127
|
+
"match_any",
|
|
128
|
+
"match_body_path",
|
|
129
|
+
"match_body_subset",
|
|
130
|
+
"match_content_type",
|
|
131
|
+
"match_email",
|
|
132
|
+
"match_header",
|
|
133
|
+
"match_method",
|
|
134
|
+
"match_path",
|
|
135
|
+
"match_query_param",
|
|
136
|
+
"match_unverified",
|
|
137
|
+
"match_verified",
|
|
138
|
+
"parse_duration",
|
|
139
|
+
"temporary_endpoint",
|
|
140
|
+
"verify_forwarded_email",
|
|
141
|
+
"verify_forwarded_request",
|
|
142
|
+
"verify_relayed_request",
|
|
143
|
+
"verify_signature",
|
|
144
|
+
]
|
webhooks_cc/_crypto.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Signing primitives shared by request building and signature verification.
|
|
2
|
+
|
|
3
|
+
These mirror the TypeScript SDK byte for byte; the conformance vectors in
|
|
4
|
+
``packages/sdk/conformance`` check that they do.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import binascii
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import re
|
|
16
|
+
from collections.abc import Mapping, Sequence
|
|
17
|
+
from typing import Any, Literal
|
|
18
|
+
|
|
19
|
+
HashName = Literal["sha256", "sha1"]
|
|
20
|
+
|
|
21
|
+
_BASE64_ALPHABET = re.compile(r"^[A-Za-z0-9+/]*$")
|
|
22
|
+
_ASCII_WHITESPACE = re.compile(r"[\t\n\f\r ]")
|
|
23
|
+
_HEX = re.compile(r"^[a-fA-F0-9]+$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def utf8(value: str | bytes) -> bytes:
|
|
27
|
+
return value if isinstance(value, bytes) else value.encode("utf-8")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def hmac_digest(algorithm: HashName, key: str | bytes, payload: str | bytes) -> bytes:
|
|
31
|
+
"""HMAC of ``payload``; a ``str`` key or payload is taken as UTF-8."""
|
|
32
|
+
digest = hashlib.sha256 if algorithm == "sha256" else hashlib.sha1
|
|
33
|
+
return hmac.new(utf8(key), utf8(payload), digest).digest()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def to_hex(data: bytes) -> str:
|
|
37
|
+
return data.hex()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def to_base64(data: bytes) -> str:
|
|
41
|
+
return base64.b64encode(data).decode("ascii")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def to_base64url(data: bytes) -> str:
|
|
45
|
+
"""Base64url without padding."""
|
|
46
|
+
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def from_base64(value: str) -> bytes:
|
|
50
|
+
"""Decode base64 the way browsers' ``atob`` does (WHATWG forgiving-base64).
|
|
51
|
+
|
|
52
|
+
ASCII whitespace is ignored and padding is optional. Raises ``ValueError``
|
|
53
|
+
for a character outside ``A-Z a-z 0-9 + /``, an ``=`` anywhere but as one
|
|
54
|
+
or two final characters that make the length a multiple of 4, or 4n+1
|
|
55
|
+
characters once padding is removed.
|
|
56
|
+
"""
|
|
57
|
+
data = _ASCII_WHITESPACE.sub("", value)
|
|
58
|
+
if len(data) % 4 == 0:
|
|
59
|
+
if data.endswith("=="):
|
|
60
|
+
data = data[:-2]
|
|
61
|
+
elif data.endswith("="):
|
|
62
|
+
data = data[:-1]
|
|
63
|
+
if len(data) % 4 == 1 or not _BASE64_ALPHABET.match(data):
|
|
64
|
+
raise ValueError("invalid base64")
|
|
65
|
+
try:
|
|
66
|
+
return base64.b64decode(data + "=" * (-len(data) % 4))
|
|
67
|
+
except binascii.Error as error: # pragma: no cover - excluded by the checks above
|
|
68
|
+
raise ValueError("invalid base64") from error
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def hex_to_bytes(value: str) -> bytes:
|
|
72
|
+
normalized = value.strip()
|
|
73
|
+
if not _HEX.match(normalized) or len(normalized) % 2 != 0:
|
|
74
|
+
raise ValueError("Expected a hex-encoded value")
|
|
75
|
+
return bytes.fromhex(normalized)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def decode_standard_webhook_secret(secret: str) -> bytes:
|
|
79
|
+
"""The HMAC key for a Standard Webhooks style secret.
|
|
80
|
+
|
|
81
|
+
Standard Webhooks, Clerk, Resend and webhooks.cc forwarding use them.
|
|
82
|
+
|
|
83
|
+
A leading ``whsec_`` is stripped and the rest decoded as base64. When that
|
|
84
|
+
fails, the key is the UTF-8 bytes of the whole secret, prefix included.
|
|
85
|
+
"""
|
|
86
|
+
raw = secret[6:] if secret.startswith("whsec_") else secret
|
|
87
|
+
try:
|
|
88
|
+
return from_base64(raw)
|
|
89
|
+
except ValueError:
|
|
90
|
+
return secret.encode("utf-8")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
_HUBSPOT_ESCAPES = {
|
|
94
|
+
"%3A": ":",
|
|
95
|
+
"%2F": "/",
|
|
96
|
+
"%3F": "?",
|
|
97
|
+
"%40": "@",
|
|
98
|
+
"%21": "!",
|
|
99
|
+
"%24": "$",
|
|
100
|
+
"%27": "'",
|
|
101
|
+
"%28": "(",
|
|
102
|
+
"%29": ")",
|
|
103
|
+
"%2A": "*",
|
|
104
|
+
"%2C": ",",
|
|
105
|
+
"%3B": ";",
|
|
106
|
+
}
|
|
107
|
+
_HUBSPOT_ESCAPE_PATTERN = re.compile(r"%(?:3A|2F|3F|40|21|24|27|28|29|2A|2C|3B)")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def decode_hubspot_uri(uri: str) -> str:
|
|
111
|
+
"""Decode exactly the escapes HubSpot decodes before signing, uppercase only."""
|
|
112
|
+
return _HUBSPOT_ESCAPE_PATTERN.sub(lambda match: _HUBSPOT_ESCAPES[match.group(0)], uri)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _utf16_key(value: str) -> bytes:
|
|
116
|
+
# JavaScript compares strings by UTF-16 code unit; big-endian UTF-16 bytes sort the same way.
|
|
117
|
+
return value.encode("utf-16-be", "surrogatepass")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def build_twilio_signature_payload(url: str, params: Sequence[tuple[str, str]]) -> str:
|
|
121
|
+
"""The URL followed by each parameter's name and value, sorted by name, then value."""
|
|
122
|
+
ordered = sorted(params, key=lambda item: (_utf16_key(item[0]), _utf16_key(item[1])))
|
|
123
|
+
return url + "".join(f"{key}{value}" for key, value in ordered)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _adyen_field(value: Any) -> str:
|
|
127
|
+
if value is None:
|
|
128
|
+
return ""
|
|
129
|
+
if isinstance(value, str):
|
|
130
|
+
return value
|
|
131
|
+
return js_string(value)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def adyen_data_to_sign(item: Mapping[str, Any]) -> str:
|
|
135
|
+
"""The fields of an Adyen notification item that its HMAC covers, joined with ``:``."""
|
|
136
|
+
amount = item.get("amount")
|
|
137
|
+
amount = amount if isinstance(amount, Mapping) else {}
|
|
138
|
+
return ":".join(
|
|
139
|
+
_adyen_field(value)
|
|
140
|
+
for value in (
|
|
141
|
+
item.get("pspReference"),
|
|
142
|
+
item.get("originalReference"),
|
|
143
|
+
item.get("merchantAccountCode"),
|
|
144
|
+
item.get("merchantReference"),
|
|
145
|
+
amount.get("value"),
|
|
146
|
+
amount.get("currency"),
|
|
147
|
+
item.get("eventCode"),
|
|
148
|
+
item.get("success"),
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def adyen_hmac(item: Mapping[str, Any], hex_key: str) -> str:
|
|
154
|
+
"""Base64 HMAC-SHA256 of an Adyen notification item, keyed with the hex HMAC key."""
|
|
155
|
+
return to_base64(hmac_digest("sha256", hex_to_bytes(hex_key), adyen_data_to_sign(item)))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# --- JavaScript-compatible JSON -------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def js_number(value: float) -> str:
|
|
162
|
+
"""Format a number as JavaScript's ``String(number)`` does."""
|
|
163
|
+
if isinstance(value, bool):
|
|
164
|
+
raise TypeError("bool is not a number")
|
|
165
|
+
if isinstance(value, int):
|
|
166
|
+
return str(value)
|
|
167
|
+
if math.isnan(value):
|
|
168
|
+
return "NaN"
|
|
169
|
+
if math.isinf(value):
|
|
170
|
+
return "Infinity" if value > 0 else "-Infinity"
|
|
171
|
+
if value == 0:
|
|
172
|
+
return "0"
|
|
173
|
+
sign = "-" if value < 0 else ""
|
|
174
|
+
# repr() gives the shortest round-tripping digits, as ECMAScript requires.
|
|
175
|
+
mantissa, _, exp_part = repr(abs(value)).partition("e")
|
|
176
|
+
exponent = int(exp_part) if exp_part else 0
|
|
177
|
+
int_part, _, frac_part = mantissa.partition(".")
|
|
178
|
+
digits = (int_part + frac_part).lstrip("0")
|
|
179
|
+
# Position of the decimal point relative to the start of `digits` (ECMAScript's n).
|
|
180
|
+
leading_zeros = len(int_part + frac_part) - len((int_part + frac_part).lstrip("0"))
|
|
181
|
+
n = len(int_part) - leading_zeros + exponent
|
|
182
|
+
digits = digits.rstrip("0") or "0"
|
|
183
|
+
k = len(digits)
|
|
184
|
+
if k <= n <= 21:
|
|
185
|
+
return sign + digits + "0" * (n - k)
|
|
186
|
+
if 0 < n <= 21:
|
|
187
|
+
return sign + digits[:n] + "." + digits[n:]
|
|
188
|
+
if -6 < n <= 0:
|
|
189
|
+
return sign + "0." + "0" * (-n) + digits
|
|
190
|
+
e = n - 1
|
|
191
|
+
exp = f"e{'+' if e >= 0 else '-'}{abs(e)}"
|
|
192
|
+
if k == 1:
|
|
193
|
+
return sign + digits + exp
|
|
194
|
+
return sign + digits[0] + "." + digits[1:] + exp
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def js_string(value: Any) -> str:
|
|
198
|
+
"""JavaScript's ``String(value)`` for the primitive types JSON can hold."""
|
|
199
|
+
if value is None:
|
|
200
|
+
return "null"
|
|
201
|
+
if value is True:
|
|
202
|
+
return "true"
|
|
203
|
+
if value is False:
|
|
204
|
+
return "false"
|
|
205
|
+
if isinstance(value, (int, float)):
|
|
206
|
+
return js_number(value)
|
|
207
|
+
return str(value)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def js_json(value: Any) -> str:
|
|
211
|
+
"""Serialize like JavaScript's ``JSON.stringify(value)``.
|
|
212
|
+
|
|
213
|
+
Compact, keys in their order, non-ASCII characters left as they are.
|
|
214
|
+
"""
|
|
215
|
+
parts: list[str] = []
|
|
216
|
+
_write_json(value, parts)
|
|
217
|
+
return "".join(parts)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _write_json(value: Any, out: list[str]) -> None:
|
|
221
|
+
if value is None or value is True or value is False:
|
|
222
|
+
out.append(js_string(value))
|
|
223
|
+
elif isinstance(value, str):
|
|
224
|
+
out.append(json.dumps(value, ensure_ascii=False))
|
|
225
|
+
elif isinstance(value, (int, float)):
|
|
226
|
+
out.append(
|
|
227
|
+
"null"
|
|
228
|
+
if isinstance(value, float) and (math.isnan(value) or math.isinf(value))
|
|
229
|
+
else js_number(value)
|
|
230
|
+
)
|
|
231
|
+
elif isinstance(value, Mapping):
|
|
232
|
+
out.append("{")
|
|
233
|
+
first = True
|
|
234
|
+
for key, item in value.items():
|
|
235
|
+
if not first:
|
|
236
|
+
out.append(",")
|
|
237
|
+
first = False
|
|
238
|
+
out.append(json.dumps(str(key), ensure_ascii=False))
|
|
239
|
+
out.append(":")
|
|
240
|
+
_write_json(item, out)
|
|
241
|
+
out.append("}")
|
|
242
|
+
elif isinstance(value, (list, tuple)):
|
|
243
|
+
out.append("[")
|
|
244
|
+
for index, item in enumerate(value):
|
|
245
|
+
if index:
|
|
246
|
+
out.append(",")
|
|
247
|
+
_write_json(item, out)
|
|
248
|
+
out.append("]")
|
|
249
|
+
else:
|
|
250
|
+
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def json_body(value: Any) -> str:
|
|
254
|
+
"""A request body: strings are sent as they are, anything else as JSON."""
|
|
255
|
+
return value if isinstance(value, str) else js_json(value)
|
webhooks_cc/_sentinel.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""A marker for arguments that were not given, where ``None`` has a meaning of its own."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _Unset:
|
|
9
|
+
def __repr__(self) -> str:
|
|
10
|
+
return "UNSET"
|
|
11
|
+
|
|
12
|
+
def __bool__(self) -> bool:
|
|
13
|
+
return False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
UNSET: Any = _Unset()
|
webhooks_cc/_sse.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Server-sent events, as the request stream sends them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Iterator
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class SSEFrame:
|
|
11
|
+
event: str
|
|
12
|
+
data: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_sse(lines: Iterable[str]) -> Iterator[SSEFrame]:
|
|
16
|
+
"""Frames from the lines of an event stream (line endings already removed).
|
|
17
|
+
|
|
18
|
+
``data:`` lines join with newlines, a blank line ends a frame, and a
|
|
19
|
+
comment (``: ...``) is yielded as a frame with event ``"comment"``, which
|
|
20
|
+
is how the server's keepalives show up. Other fields are ignored.
|
|
21
|
+
"""
|
|
22
|
+
event = "message"
|
|
23
|
+
data: list[str] = []
|
|
24
|
+
for line in lines:
|
|
25
|
+
if line == "":
|
|
26
|
+
if data:
|
|
27
|
+
yield SSEFrame(event, "\n".join(data))
|
|
28
|
+
data = []
|
|
29
|
+
event = "message"
|
|
30
|
+
continue
|
|
31
|
+
if line.startswith(":"):
|
|
32
|
+
comment = line[1:]
|
|
33
|
+
yield SSEFrame("comment", comment[1:] if comment.startswith(" ") else comment)
|
|
34
|
+
continue
|
|
35
|
+
name, colon, value = line.partition(":")
|
|
36
|
+
if not colon:
|
|
37
|
+
continue
|
|
38
|
+
if value.startswith(" "):
|
|
39
|
+
value = value[1:]
|
|
40
|
+
if name == "event":
|
|
41
|
+
event = value
|
|
42
|
+
elif name == "data":
|
|
43
|
+
data.append(value)
|
|
44
|
+
if data:
|
|
45
|
+
yield SSEFrame(event, "\n".join(data))
|