capo-cloudtrail-data 0.2.0__tar.gz → 0.4.0__tar.gz

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 (60) hide show
  1. {capo_cloudtrail_data-0.2.0/src/capo_cloudtrail_data.egg-info → capo_cloudtrail_data-0.4.0}/PKG-INFO +2 -2
  2. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/pyproject.toml +2 -2
  3. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/_auth/_signers.py +54 -17
  4. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/_auth/_sigv4.py +164 -19
  5. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/_operations/cloud_trail_data_service/put_audit_events.py +3 -2
  6. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/_protocol/eventstream.py +15 -13
  7. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/_services/_pipeline.py +17 -0
  8. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0/src/capo_cloudtrail_data.egg-info}/PKG-INFO +2 -2
  9. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/tests/test_sigv4.py +217 -16
  10. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/LICENSE +0 -0
  11. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/README.md +0 -0
  12. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/setup.cfg +0 -0
  13. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/__init__.py +0 -0
  14. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/_async.py +0 -0
  15. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/_auth/_identity.py +0 -0
  16. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/_auth/_providers.py +0 -0
  17. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/_auth/_zapros_handler.py +0 -0
  18. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/_body.py +0 -0
  19. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/_iter.py +0 -0
  20. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/_pagination.py +0 -0
  21. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/_protocol/__init__.py +0 -0
  22. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/_protocol/errors.py +0 -0
  23. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/_protocol/serialize.py +0 -0
  24. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/_protocol/xml.py +0 -0
  25. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/_rule_engine/__init__.py +0 -0
  26. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/_rule_engine/_aws_partition.py +0 -0
  27. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/_rule_engine/_endpoint_rule_set.py +0 -0
  28. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/_rule_engine/_endpoint_runtime.py +0 -0
  29. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/_services/_aws_config.py +0 -0
  30. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/_services/async_cloud_trail_data.py +0 -0
  31. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/_services/cloud_trail_data.py +0 -0
  32. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/errors/__init__.py +0 -0
  33. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/errors/_base.py +0 -0
  34. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/errors/channel_insufficient_permission.py +0 -0
  35. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/errors/channel_not_found.py +0 -0
  36. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/errors/channel_unsupported_schema.py +0 -0
  37. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/errors/duplicated_audit_event_id.py +0 -0
  38. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/errors/invalid_channel_arn.py +0 -0
  39. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/errors/unsupported_operation_exception.py +0 -0
  40. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/py.typed +0 -0
  41. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/types/_prelude/blob.py +0 -0
  42. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/types/_prelude/timestamp.py +0 -0
  43. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/types/audit_event.py +0 -0
  44. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/types/audit_event_result_entries.py +0 -0
  45. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/types/audit_event_result_entry.py +0 -0
  46. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/types/audit_events.py +0 -0
  47. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/types/channel_arn.py +0 -0
  48. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/types/error_code.py +0 -0
  49. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/types/error_message.py +0 -0
  50. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/types/external_id.py +0 -0
  51. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/types/put_audit_events_request.py +0 -0
  52. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/types/put_audit_events_response.py +0 -0
  53. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/types/result_error_entries.py +0 -0
  54. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/types/result_error_entry.py +0 -0
  55. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data/types/uuid.py +0 -0
  56. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data.egg-info/SOURCES.txt +0 -0
  57. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data.egg-info/dependency_links.txt +0 -0
  58. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data.egg-info/requires.txt +0 -0
  59. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/src/capo_cloudtrail_data.egg-info/top_level.txt +0 -0
  60. {capo_cloudtrail_data-0.2.0 → capo_cloudtrail_data-0.4.0}/tests/test_body.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: capo-cloudtrail-data
3
- Version: 0.2.0
4
- Summary: Python SDK for Cloudtrail Data.
3
+ Version: 0.4.0
4
+ Summary: Python SDK for AWS Cloudtrail Data.
5
5
  Classifier: Development Status :: 4 - Beta
6
6
  Classifier: Intended Audience :: Developers
7
7
  Classifier: License :: OSI Approved :: MIT License
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "capo-cloudtrail-data"
3
- version = "0.2.0"
4
- description = "Python SDK for Cloudtrail Data."
3
+ version = "0.4.0"
4
+ description = "Python SDK for AWS Cloudtrail Data."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  classifiers = [
@@ -7,7 +7,11 @@ from zapros import Request
7
7
 
8
8
  from capo_cloudtrail_data._auth._identity import Credentials, Identity
9
9
  from capo_cloudtrail_data._auth._providers import IdentityProvider
10
- from capo_cloudtrail_data._auth._sigv4 import SigV4AuthContext, sign_sigv4
10
+ from capo_cloudtrail_data._auth._sigv4 import (
11
+ S3_SIGNING_NAMES,
12
+ SigV4AuthContext,
13
+ sign_sigv4,
14
+ )
11
15
 
12
16
  IdentityT = TypeVar("IdentityT", bound="Identity")
13
17
 
@@ -30,14 +34,25 @@ class SigV4Signer(Signer[Credentials]):
30
34
  The full auth scheme (``name`` variant, ``signingName``, ``signingRegion``,
31
35
  encoding/normalization flags) is provided by the caller — either from the
32
36
  endpoint rule-set's ``authSchemes`` property or built by the generated
33
- ``get_signer`` from operation defaults.
37
+ ``get_signer`` from operation defaults. ``unsigned_payload`` mirrors the
38
+ operation's ``aws.auth#unsignedPayload`` trait: the body is sent but left
39
+ out of the signature. ``event_stream`` marks a request event stream: the
40
+ request is signed with the ``STREAMING-AWS4-HMAC-SHA256-EVENTS`` payload
41
+ marker and every event with a signature chained from it.
34
42
  """
35
43
 
36
44
  def __init__(
37
- self, provider: IdentityProvider[Credentials], *, auth_scheme: dict[str, Any]
45
+ self,
46
+ provider: IdentityProvider[Credentials],
47
+ *,
48
+ auth_scheme: dict[str, Any],
49
+ unsigned_payload: bool = False,
50
+ event_stream: bool = False,
38
51
  ) -> None:
39
52
  super().__init__(provider)
40
53
  self._auth_scheme = auth_scheme
54
+ self._unsigned_payload = unsigned_payload
55
+ self._event_stream = event_stream
41
56
 
42
57
  async def asign(self, req: Request) -> Request:
43
58
  creds = await self.provider.aresolve_identity()
@@ -55,13 +70,24 @@ class SigV4Signer(Signer[Credentials]):
55
70
  "disableNormalizePath", False
56
71
  ),
57
72
  }
58
- if req.body is None:
59
- body: bytes | None = b""
60
- elif isinstance(req.body, bytes):
61
- body = req.body
62
- else:
63
- body = None
64
- return sign_sigv4(req, ctx, body)
73
+ unsigned_payload = self._unsigned_payload
74
+ if (
75
+ not unsigned_payload
76
+ and not self._event_stream
77
+ and req.body is not None
78
+ and not isinstance(req.body, bytes)
79
+ ):
80
+ if self._auth_scheme["signingName"] in S3_SIGNING_NAMES:
81
+ # S3 accepts UNSIGNED-PAYLOAD for any operation; streamed bodies rely on it.
82
+ unsigned_payload = True
83
+ else:
84
+ raise NotImplementedError(
85
+ "Currently we don't support signed chunked payloads, so buffer the body and "
86
+ "pass bytes as a workaround; chunked signed implementation coming soon"
87
+ )
88
+ return sign_sigv4(
89
+ req, ctx, unsigned_payload=unsigned_payload, event_stream=self._event_stream
90
+ )
65
91
 
66
92
  def sign(self, req: Request) -> Request:
67
93
  creds = self.provider.resolve_identity()
@@ -79,10 +105,21 @@ class SigV4Signer(Signer[Credentials]):
79
105
  "disableNormalizePath", False
80
106
  ),
81
107
  }
82
- if req.body is None:
83
- body: bytes | None = b""
84
- elif isinstance(req.body, bytes):
85
- body = req.body
86
- else:
87
- body = None
88
- return sign_sigv4(req, ctx, body)
108
+ unsigned_payload = self._unsigned_payload
109
+ if (
110
+ not unsigned_payload
111
+ and not self._event_stream
112
+ and req.body is not None
113
+ and not isinstance(req.body, bytes)
114
+ ):
115
+ if self._auth_scheme["signingName"] in S3_SIGNING_NAMES:
116
+ # S3 accepts UNSIGNED-PAYLOAD for any operation; streamed bodies rely on it.
117
+ unsigned_payload = True
118
+ else:
119
+ raise NotImplementedError(
120
+ "Currently we don't support signed chunked payloads, so buffer the body and "
121
+ "pass bytes as a workaround; chunked signed implementation coming soon"
122
+ )
123
+ return sign_sigv4(
124
+ req, ctx, unsigned_payload=unsigned_payload, event_stream=self._event_stream
125
+ )
@@ -1,4 +1,4 @@
1
- """AWS Signature Version 4 — single-chunk signing.
1
+ """AWS Signature Version 4 — single-chunk and event-stream signing.
2
2
 
3
3
  Reference:
4
4
  https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html
@@ -15,7 +15,8 @@ import functools
15
15
  import hashlib
16
16
  import hmac
17
17
  import re
18
- from typing import Any, Literal, TypedDict
18
+ from collections.abc import AsyncIterator, Iterator
19
+ from typing import Any, Literal, TypedDict, cast
19
20
  from urllib.parse import quote, unquote
20
21
 
21
22
  import zapros
@@ -23,6 +24,9 @@ from pywhatwgurl import URLSearchParams
23
24
  from zapros import Headers, Request
24
25
  from zapros._utils import get_host_header_value
25
26
 
27
+ from .._iter import AnyIterator
28
+ from .._protocol.eventstream import HeaderValue, Message, encode_headers
29
+
26
30
 
27
31
  def build_sigv4_auth_scheme(
28
32
  signing_name: str, region: str | None, endpoint_scheme: dict[str, Any] | None = None
@@ -65,6 +69,10 @@ class SigV4AuthContext(TypedDict):
65
69
 
66
70
 
67
71
  _SIGV4_ALGORITHM = "AWS4-HMAC-SHA256"
72
+ # Per-event algorithm and request payload marker of a signed event stream.
73
+ _EVENT_ALGORITHM = "AWS4-HMAC-SHA256-PAYLOAD"
74
+ _EVENT_STREAM_PAYLOAD = "STREAMING-AWS4-HMAC-SHA256-EVENTS"
75
+ _AMZ_DATE_FORMAT = "%Y%m%dT%H%M%SZ"
68
76
  _EMPTY_PAYLOAD_SHA256 = hashlib.sha256(b"").hexdigest()
69
77
 
70
78
  # Headers excluded from the signed-headers set. Mirrors botocore's denylist:
@@ -95,9 +103,11 @@ _UNSIGNED_HEADERS = frozenset(
95
103
 
96
104
  _MULTI_SPACE = re.compile(r" +")
97
105
 
98
- # Services that require the payload hash to travel in ``x-amz-content-sha256``.
99
- # Other services sign the hash into the canonical request without sending it.
100
- _S3_SIGNING_NAMES = frozenset({"s3", "s3express", "s3-outposts", "s3-object-lambda"})
106
+ # Services that require the payload hash to travel in ``x-amz-content-sha256``
107
+ # on every request, and that accept ``UNSIGNED-PAYLOAD`` for any operation.
108
+ # Other services sign the hash into the canonical request without sending it,
109
+ # and only see the header when the payload is left unsigned.
110
+ S3_SIGNING_NAMES = frozenset({"s3", "s3express", "s3-outposts", "s3-object-lambda"})
101
111
 
102
112
 
103
113
  def _uri_encode(value: str) -> str:
@@ -231,12 +241,20 @@ def _canonical_query_from_pairs(pairs: list[tuple[str, str]]) -> str:
231
241
  def sign_sigv4(
232
242
  request: Request,
233
243
  ctx: SigV4AuthContext,
234
- body: bytes | None,
244
+ *,
245
+ unsigned_payload: bool = False,
246
+ event_stream: bool = False,
235
247
  ) -> Request:
236
248
  """Return a new ``Request`` carrying SigV4 single-chunk auth headers.
237
249
 
238
- Pass ``body=None`` to sign with ``UNSIGNED-PAYLOAD`` (streaming requests).
239
- The original ``request.body`` is forwarded unchanged in that case.
250
+ The payload hash covers ``request.body``, which must be ``bytes`` or
251
+ ``None``. ``unsigned_payload`` signs ``UNSIGNED-PAYLOAD`` instead and
252
+ forwards any body unchanged (streaming S3 requests and operations carrying
253
+ ``aws.auth#unsignedPayload``). ``event_stream`` signs a request event
254
+ stream: the ``STREAMING-AWS4-HMAC-SHA256-EVENTS`` marker stands in for the
255
+ payload hash and the body — an iterator of encoded event messages — is
256
+ wrapped in an :class:`EventStreamIterator` that signs every event with a
257
+ signature chained from this request's.
240
258
  """
241
259
  service = ctx["signing_name"]
242
260
  region = ctx["signing_region"]
@@ -250,21 +268,31 @@ def sign_sigv4(
250
268
  date_stamp = amz_date[:8]
251
269
  else:
252
270
  now = _amz_now()
253
- amz_date = now.strftime("%Y%m%dT%H%M%SZ")
271
+ amz_date = now.strftime(_AMZ_DATE_FORMAT)
254
272
  date_stamp = now.strftime("%Y%m%d")
255
273
  headers["X-Amz-Date"] = amz_date
256
274
 
257
- # Payload hash. For S3-family services, x-amz-content-sha256 is mandatory
258
- # and must be set BEFORE computing the canonical request (it gets signed).
275
+ # Payload hash. For S3-family services, x-amz-content-sha256 is mandatory;
276
+ # for every service it is the only way to announce an unsigned payload or
277
+ # an event stream.
278
+ # Either way it must be set BEFORE computing the canonical request (it
279
+ # gets signed).
259
280
  payload_hash = headers.get("X-Amz-Content-SHA256")
260
281
  if payload_hash is None:
261
- if body is None:
282
+ if event_stream:
283
+ payload_hash = _EVENT_STREAM_PAYLOAD
284
+ elif unsigned_payload:
262
285
  payload_hash = "UNSIGNED-PAYLOAD"
286
+ elif request.body is None:
287
+ payload_hash = _EMPTY_PAYLOAD_SHA256
288
+ elif isinstance(request.body, bytes):
289
+ payload_hash = hashlib.sha256(request.body).hexdigest()
263
290
  else:
264
- payload_hash = (
265
- hashlib.sha256(body).hexdigest() if body else _EMPTY_PAYLOAD_SHA256
291
+ raise TypeError(
292
+ "sign_sigv4 hashes a bytes body only; pass unsigned_payload=True "
293
+ "to send a streamed body without a payload hash"
266
294
  )
267
- if service in _S3_SIGNING_NAMES:
295
+ if service in S3_SIGNING_NAMES or unsigned_payload or event_stream:
268
296
  headers["X-Amz-Content-SHA256"] = payload_hash
269
297
 
270
298
  # Session token (STS / assumed-role credentials).
@@ -308,18 +336,135 @@ def sign_sigv4(
308
336
  f"Signature={signature}"
309
337
  )
310
338
 
311
- effective_body = body if body is not None else request.body
312
- if effective_body is not None:
339
+ body = request.body
340
+ if event_stream:
341
+ if not isinstance(body, (Iterator, AsyncIterator)):
342
+ raise TypeError(
343
+ "event_stream requires an iterator of encoded events as the body"
344
+ )
345
+ body = EventStreamIterator(
346
+ cast("Iterator[bytes] | AsyncIterator[bytes]", body), ctx, signature
347
+ )
348
+ if body is not None:
313
349
  return Request(
314
350
  request.url,
315
351
  request.method,
316
352
  headers,
317
- body=effective_body,
353
+ body=body,
318
354
  context=request.context,
319
355
  )
320
356
  return Request(request.url, request.method, headers, context=request.context)
321
357
 
322
358
 
359
+ def _sign_event(
360
+ ctx: SigV4AuthContext, prior_signature: str, payload: bytes
361
+ ) -> tuple[bytes, str]:
362
+ """Wrap one encoded event in a signed outer message.
363
+
364
+ Returns ``(frame, signature)``; the hex signature seeds the next event.
365
+ The string to sign chains every frame to its predecessor — the first one
366
+ to the request's ``Authorization`` signature — so the service can verify
367
+ the stream incrementally::
368
+
369
+ AWS4-HMAC-SHA256-PAYLOAD
370
+ <date, YYYYMMDDTHHMMSSZ>
371
+ <credential scope>
372
+ <prior signature, hex>
373
+ <hex sha256 of the encoded ``:date`` header>
374
+ <hex sha256 of the payload>
375
+
376
+ The AWS SDKs (and the service) use the ``-PAYLOAD`` algorithm name; the
377
+ Transcribe developer guide's pseudocode omits the suffix.
378
+ """
379
+ service = ctx["signing_name"]
380
+ region = ctx["signing_region"]
381
+ now = _amz_now()
382
+ amz_date = now.strftime(_AMZ_DATE_FORMAT)
383
+ date_stamp = amz_date[:8]
384
+ credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
385
+ date_header: dict[str, HeaderValue] = {":date": now}
386
+ string_to_sign = "\n".join(
387
+ (
388
+ _EVENT_ALGORITHM,
389
+ amz_date,
390
+ credential_scope,
391
+ prior_signature,
392
+ hashlib.sha256(encode_headers(date_header)).hexdigest(),
393
+ hashlib.sha256(payload).hexdigest(),
394
+ )
395
+ )
396
+ signing_key = _derive_signing_key(
397
+ ctx["secret_access_key"], date_stamp, region, service
398
+ )
399
+ signature = hmac.new(
400
+ signing_key, string_to_sign.encode("utf-8"), hashlib.sha256
401
+ ).hexdigest()
402
+ frame = Message(
403
+ {**date_header, ":chunk-signature": bytes.fromhex(signature)}, payload
404
+ ).encode()
405
+ return frame, signature
406
+
407
+
408
+ class EventStreamIterator(AnyIterator[bytes]):
409
+ """Sign a request event stream, one frame per event.
410
+
411
+ Wraps an iterator of encoded event messages (as produced by the generated
412
+ ``serialize_event_*`` functions). Each event becomes the payload of an
413
+ outer message carrying ``:date`` and ``:chunk-signature`` headers, the
414
+ signature chained from the previous frame's and seeded by the request's
415
+ ``Authorization`` signature. Once the events are exhausted, one empty
416
+ signed frame is yielded to mark the end of the stream.
417
+
418
+ Like :class:`~capo._body.Body`, it is both a sync and an async iterator:
419
+ iterate it the way the wrapped source is iterable (``next`` over a sync
420
+ source, ``async for`` over an async one).
421
+
422
+ Reference:
423
+ https://docs.aws.amazon.com/transcribe/latest/dg/streaming-setting-up.html
424
+ """
425
+
426
+ def __init__(
427
+ self,
428
+ events: Iterator[bytes] | AsyncIterator[bytes],
429
+ ctx: SigV4AuthContext,
430
+ seed_signature: str,
431
+ ) -> None:
432
+ self._events = events
433
+ self._ctx = ctx
434
+ self._prior_signature = seed_signature
435
+ self._ended = False
436
+
437
+ def _frame(self, payload: bytes) -> bytes:
438
+ frame, self._prior_signature = _sign_event(
439
+ self._ctx, self._prior_signature, payload
440
+ )
441
+ return frame
442
+
443
+ def __next__(self) -> bytes:
444
+ if self._ended:
445
+ raise StopIteration
446
+ if not isinstance(self._events, Iterator):
447
+ raise TypeError("this event stream wraps an async source; use `async for`")
448
+ try:
449
+ payload = next(cast(Iterator[bytes], self._events))
450
+ except StopIteration:
451
+ self._ended = True
452
+ payload = b""
453
+ return self._frame(payload)
454
+
455
+ async def __anext__(self) -> bytes:
456
+ if self._ended:
457
+ raise StopAsyncIteration
458
+ if not isinstance(self._events, AsyncIterator):
459
+ raise TypeError("this event stream wraps a sync source; use `for`")
460
+ try:
461
+ payload = await cast(AsyncIterator[bytes], self._events).__anext__()
462
+ except StopAsyncIteration:
463
+ self._ended = True
464
+ payload = b""
465
+ return self._frame(payload)
466
+
467
+
323
468
  def presign_sigv4(
324
469
  request: Request,
325
470
  ctx: SigV4AuthContext,
@@ -346,7 +491,7 @@ def presign_sigv4(
346
491
  service = ctx["signing_name"]
347
492
  region = ctx["signing_region"]
348
493
 
349
- amz_date = sign_time.strftime("%Y%m%dT%H%M%SZ")
494
+ amz_date = sign_time.strftime(_AMZ_DATE_FORMAT)
350
495
  date_stamp = sign_time.strftime("%Y%m%d")
351
496
  credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
352
497
 
@@ -27,6 +27,7 @@ from capo_cloudtrail_data._rule_engine._endpoint_rule_set import EndpointParams,
27
27
  from capo_cloudtrail_data._services._pipeline import (
28
28
  AsyncOperationOptions,
29
29
  OperationOptions,
30
+ raise_error,
30
31
  )
31
32
  from capo_cloudtrail_data.errors import UnknownServiceError
32
33
 
@@ -157,7 +158,7 @@ def put_audit_events(
157
158
  try:
158
159
  if response.status >= 300:
159
160
  response.read()
160
- handle_error(response)
161
+ raise_error(response, handle_error)
161
162
  return handle_response(response), response
162
163
  except BaseException:
163
164
  response.close()
@@ -175,7 +176,7 @@ async def async_put_audit_events(
175
176
  try:
176
177
  if response.status >= 300:
177
178
  await response.aread()
178
- handle_error(response)
179
+ raise_error(response, handle_error)
179
180
  return await async_handle_response(response), response
180
181
  except BaseException:
181
182
  await response.aclose()
@@ -89,7 +89,7 @@ def _decode_header_value(
89
89
  raise ValueError(f"unsupported eventstream header type: {type_byte:#x}")
90
90
 
91
91
 
92
- def _encode_headers(headers: dict[str, HeaderValue]) -> bytes:
92
+ def encode_headers(headers: dict[str, HeaderValue]) -> bytes:
93
93
  parts: list[bytes] = []
94
94
  for name, value in headers.items():
95
95
  name_bytes = name.encode("utf-8")
@@ -124,10 +124,11 @@ class Message:
124
124
 
125
125
  def encode(self) -> bytes:
126
126
  """Serialize this message into eventstream framing bytes."""
127
- headers_bytes = _encode_headers(self.headers)
127
+ headers_bytes = encode_headers(self.headers)
128
128
  headers_length = len(headers_bytes)
129
129
  payload = self.payload
130
- total_length = 12 + headers_length + len(payload)
130
+ # total_length covers the whole frame, trailing message CRC included.
131
+ total_length = 12 + headers_length + len(payload) + 4
131
132
 
132
133
  prelude = total_length.to_bytes(4, "big") + headers_length.to_bytes(4, "big")
133
134
  prelude_crc = zlib.crc32(prelude).to_bytes(4, "big")
@@ -158,31 +159,31 @@ class MessageDecoder:
158
159
  prelude = bytes(self._buffer[:8])
159
160
  prelude_crc = int.from_bytes(self._buffer[8:12], "big")
160
161
 
161
- if total_length < 12 + headers_length:
162
+ # total_length covers the whole frame: 12-byte prelude, headers,
163
+ # payload, and the trailing 4-byte message CRC.
164
+ if total_length < 16 + headers_length:
162
165
  raise ValueError(
163
166
  f"invalid eventstream message: total_length={total_length} "
164
- f"is smaller than minimum {12 + headers_length}"
167
+ f"is smaller than minimum {16 + headers_length}"
165
168
  )
166
169
 
167
- message_end = total_length + 4
168
- if len(self._buffer) < message_end:
170
+ if len(self._buffer) < total_length:
169
171
  return
170
172
 
171
173
  computed_prelude_crc = zlib.crc32(prelude) & 0xFFFFFFFF
172
174
  if computed_prelude_crc != prelude_crc:
173
175
  raise ValueError("eventstream prelude checksum mismatch")
174
176
 
175
- message_bytes = bytes(self._buffer[:total_length])
176
- message_crc = int.from_bytes(
177
- self._buffer[total_length : total_length + 4], "big"
178
- )
177
+ payload_end = total_length - 4
178
+ message_bytes = bytes(self._buffer[:payload_end])
179
+ message_crc = int.from_bytes(self._buffer[payload_end:total_length], "big")
179
180
  computed_message_crc = zlib.crc32(message_bytes) & 0xFFFFFFFF
180
181
  if computed_message_crc != message_crc:
181
182
  raise ValueError("eventstream message checksum mismatch")
182
183
 
183
184
  headers = _decode_headers(self._buffer[12 : 12 + headers_length])
184
- payload = bytes(self._buffer[12 + headers_length : total_length])
185
- del self._buffer[:message_end]
185
+ payload = bytes(self._buffer[12 + headers_length : payload_end])
186
+ del self._buffer[:total_length]
186
187
  yield Message(headers=headers, payload=payload)
187
188
 
188
189
 
@@ -233,6 +234,7 @@ __all__ = [
233
234
  "MessageDecoder",
234
235
  "async_raw_stream_to_events",
235
236
  "async_read_messages",
237
+ "encode_headers",
236
238
  "raw_stream_to_events",
237
239
  "read_messages",
238
240
  ]
@@ -6,6 +6,7 @@ from collections.abc import AsyncIterator, Iterator, Sequence
6
6
  from dataclasses import dataclass
7
7
  from typing import TYPE_CHECKING, Awaitable, Callable, Generic, TypeVar, cast
8
8
 
9
+ from typing_extensions import Never
9
10
  from zapros import (
10
11
  AsyncClient,
11
12
  Client,
@@ -135,6 +136,22 @@ async def aexecute_pipeline(
135
136
  return await make_chain(0)(request)
136
137
 
137
138
 
139
+ def raise_error(response: Response, handle_error: Callable[[Response], Never]) -> Never:
140
+ try:
141
+ handle_error(response)
142
+ except ServiceError as exc:
143
+ # The response itself can say a retry is safe, even when the error is
144
+ # not marked @retryable: a 429, a transient 5xx or a Retry-After header.
145
+ if response.status == 429:
146
+ exc.is_throttling_error = True
147
+ if (
148
+ response.status in (429, 500, 502, 503, 504)
149
+ or "retry-after" in response.headers
150
+ ):
151
+ exc.is_retryable = True
152
+ raise
153
+
154
+
138
155
  def _is_retryable(exc: Exception) -> bool:
139
156
  if isinstance(exc, ServiceError):
140
157
  return exc.is_retryable
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: capo-cloudtrail-data
3
- Version: 0.2.0
4
- Summary: Python SDK for Cloudtrail Data.
3
+ Version: 0.4.0
4
+ Summary: Python SDK for AWS Cloudtrail Data.
5
5
  Classifier: Development Status :: 4 - Beta
6
6
  Classifier: Intended Audience :: Developers
7
7
  Classifier: License :: OSI Approved :: MIT License
@@ -13,12 +13,20 @@ Vectors come from two sources:
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
+ import asyncio
17
+ import datetime as dt
18
+ import hashlib
19
+ import hmac
20
+ from collections.abc import AsyncIterator, Iterator
21
+
16
22
  import pytest
17
23
 
18
24
  from pywhatwgurl import URL
19
25
  from zapros import Headers, Request
20
26
 
27
+ from capo_cloudtrail_data._auth import _sigv4
21
28
  from capo_cloudtrail_data._auth._sigv4 import (
29
+ EventStreamIterator,
22
30
  SigV4AuthContext,
23
31
  build_sigv4_auth_scheme,
24
32
  _build_canonical_request,
@@ -30,6 +38,7 @@ from capo_cloudtrail_data._auth._sigv4 import (
30
38
  _uri_encode,
31
39
  sign_sigv4,
32
40
  )
41
+ from capo_cloudtrail_data._protocol.eventstream import Message, MessageDecoder, encode_headers
33
42
 
34
43
 
35
44
  # ---------------------------------------------------------------------------
@@ -201,11 +210,16 @@ _TEST_SUITE_CTX: SigV4AuthContext = {
201
210
  }
202
211
 
203
212
 
204
- def _make_request(method: str, url: str, headers: dict[str, str]) -> Request:
213
+ def _make_request(
214
+ method: str,
215
+ url: str,
216
+ headers: dict[str, str],
217
+ body: bytes | Iterator[bytes] | AsyncIterator[bytes] | None = None,
218
+ ) -> Request:
205
219
  """Build a Request and strip headers Request adds by default that the
206
220
  AWS test suite does not include (Accept, User-Agent, Accept-Encoding).
207
221
  """
208
- req = Request(URL(url), method, headers=headers)
222
+ req = Request(URL(url), method, headers=headers, body=body)
209
223
  for h in ("accept", "user-agent", "accept-encoding"):
210
224
  if h in req.headers:
211
225
  del req.headers[h]
@@ -219,7 +233,7 @@ def test_get_vanilla():
219
233
  "https://example.amazonaws.com/",
220
234
  {"Host": "example.amazonaws.com", "X-Amz-Date": "20150830T123600Z"},
221
235
  )
222
- signed = sign_sigv4(req, _TEST_SUITE_CTX, b"")
236
+ signed = sign_sigv4(req, _TEST_SUITE_CTX)
223
237
  assert signed.headers["Authorization"] == (
224
238
  "AWS4-HMAC-SHA256 "
225
239
  "Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request,"
@@ -235,7 +249,7 @@ def test_get_vanilla_query():
235
249
  "https://example.amazonaws.com/?Param1=value1",
236
250
  {"Host": "example.amazonaws.com", "X-Amz-Date": "20150830T123600Z"},
237
251
  )
238
- signed = sign_sigv4(req, _TEST_SUITE_CTX, b"")
252
+ signed = sign_sigv4(req, _TEST_SUITE_CTX)
239
253
  assert signed.headers["Authorization"] == (
240
254
  "AWS4-HMAC-SHA256 "
241
255
  "Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request,"
@@ -255,7 +269,7 @@ def test_get_vanilla_query_order_key():
255
269
  "https://example.amazonaws.com/?Param2=value2&Param1=value1",
256
270
  {"Host": "example.amazonaws.com", "X-Amz-Date": "20150830T123600Z"},
257
271
  )
258
- signed = sign_sigv4(req, _TEST_SUITE_CTX, b"")
272
+ signed = sign_sigv4(req, _TEST_SUITE_CTX)
259
273
  assert signed.headers["Authorization"] == (
260
274
  "AWS4-HMAC-SHA256 "
261
275
  "Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request,"
@@ -271,7 +285,7 @@ def test_post_vanilla():
271
285
  "https://example.amazonaws.com/",
272
286
  {"Host": "example.amazonaws.com", "X-Amz-Date": "20150830T123600Z"},
273
287
  )
274
- signed = sign_sigv4(req, _TEST_SUITE_CTX, b"")
288
+ signed = sign_sigv4(req, _TEST_SUITE_CTX)
275
289
  assert signed.headers["Authorization"] == (
276
290
  "AWS4-HMAC-SHA256 "
277
291
  "Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request,"
@@ -308,7 +322,7 @@ def test_s3_get_object_example():
308
322
  "X-Amz-Date": "20130524T000000Z",
309
323
  },
310
324
  )
311
- signed = sign_sigv4(req, _S3_DOCS_CTX, b"")
325
+ signed = sign_sigv4(req, _S3_DOCS_CTX)
312
326
 
313
327
  # Auto-injected payload hash for an empty body.
314
328
  assert signed.headers["X-Amz-Content-SHA256"] == (
@@ -334,7 +348,7 @@ def test_session_token_added_and_signed():
334
348
  "https://example.amazonaws.com/",
335
349
  {"Host": "example.amazonaws.com", "X-Amz-Date": "20150830T123600Z"},
336
350
  )
337
- signed = sign_sigv4(req, ctx, b"")
351
+ signed = sign_sigv4(req, ctx)
338
352
  assert signed.headers["X-Amz-Security-Token"] == "TOKEN123"
339
353
  # x-amz-security-token must appear in the signed-headers list.
340
354
  assert "x-amz-security-token" in signed.headers["Authorization"]
@@ -345,9 +359,9 @@ def test_s3_sets_payload_hash_for_body():
345
359
  "PUT",
346
360
  "https://examplebucket.s3.amazonaws.com/key",
347
361
  {"Host": "examplebucket.s3.amazonaws.com", "X-Amz-Date": "20130524T000000Z"},
362
+ body=b"hello",
348
363
  )
349
- body = b"hello"
350
- signed = sign_sigv4(req, _S3_DOCS_CTX, body)
364
+ signed = sign_sigv4(req, _S3_DOCS_CTX)
351
365
  # sha256("hello")
352
366
  assert signed.headers["X-Amz-Content-SHA256"] == (
353
367
  "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
@@ -380,8 +394,9 @@ def test_s3_outposts_put_with_encoded_key():
380
394
  "Host": "s3-outposts.us-west-2.amazonaws.com",
381
395
  "X-Amz-Date": "20130524T000000Z",
382
396
  },
397
+ body=b"hello",
383
398
  )
384
- signed = sign_sigv4(req, _s3_family_ctx("s3-outposts", "us-west-2"), b"hello")
399
+ signed = sign_sigv4(req, _s3_family_ctx("s3-outposts", "us-west-2"))
385
400
  assert signed.headers["X-Amz-Content-SHA256"] == (
386
401
  "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
387
402
  )
@@ -400,7 +415,7 @@ def test_s3express_get_with_encoded_key():
400
415
  f"https://{host}/a%20b?list-type=2",
401
416
  {"Host": host, "X-Amz-Date": "20130524T000000Z"},
402
417
  )
403
- signed = sign_sigv4(req, _s3_family_ctx("s3express", "us-west-2"), b"")
418
+ signed = sign_sigv4(req, _s3_family_ctx("s3express", "us-west-2"))
404
419
  assert signed.headers["X-Amz-Content-SHA256"] == (
405
420
  "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
406
421
  )
@@ -419,7 +434,7 @@ def test_s3_object_lambda_get_with_encoded_key():
419
434
  f"https://{host}/dir/a%20b",
420
435
  {"Host": host, "X-Amz-Date": "20130524T000000Z"},
421
436
  )
422
- signed = sign_sigv4(req, _s3_family_ctx("s3-object-lambda", "us-east-1"), b"")
437
+ signed = sign_sigv4(req, _s3_family_ctx("s3-object-lambda", "us-east-1"))
423
438
  assert signed.headers["X-Amz-Content-SHA256"] == (
424
439
  "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
425
440
  )
@@ -438,19 +453,205 @@ def test_non_s3_does_not_set_payload_hash_header():
438
453
  "https://iam.amazonaws.com/",
439
454
  {"Host": "iam.amazonaws.com", "X-Amz-Date": "20150830T123600Z"},
440
455
  )
441
- signed = sign_sigv4(req, ctx, b"")
456
+ signed = sign_sigv4(req, ctx)
442
457
  # IAM/STS/etc. signs the payload hash into the canonical request but does
443
458
  # not transmit an X-Amz-Content-SHA256 header.
444
459
  assert "X-Amz-Content-SHA256" not in signed.headers
445
460
 
446
461
 
462
+ def test_unsigned_payload_sends_header_for_non_s3():
463
+ """``aws.auth#unsignedPayload`` on a non-S3 operation (Lex RecognizeUtterance):
464
+ the header is the only way the service learns not to hash the body."""
465
+ ctx: SigV4AuthContext = {**_TEST_SUITE_CTX, "signing_name": "lex"}
466
+ req = _make_request(
467
+ "POST",
468
+ "https://runtime-v2-lex.us-east-1.amazonaws.com/bots/b/utterance",
469
+ {
470
+ "Host": "runtime-v2-lex.us-east-1.amazonaws.com",
471
+ "X-Amz-Date": "20150830T123600Z",
472
+ },
473
+ body=iter([b"audio"]),
474
+ )
475
+ signed = sign_sigv4(req, ctx, unsigned_payload=True)
476
+ assert signed.headers["X-Amz-Content-SHA256"] == "UNSIGNED-PAYLOAD"
477
+ assert "x-amz-content-sha256" in signed.headers["Authorization"].split("SignedHeaders=")[1]
478
+
479
+
480
+ def test_streamed_body_rejected_without_unsigned_payload():
481
+ req = _make_request(
482
+ "PUT",
483
+ "https://example.amazonaws.com/",
484
+ {"Host": "example.amazonaws.com"},
485
+ body=iter([b"chunk"]),
486
+ )
487
+ with pytest.raises(TypeError):
488
+ sign_sigv4(req, _TEST_SUITE_CTX)
489
+
490
+
491
+ # ---------------------------------------------------------------------------
492
+ # Request event streams (Transcribe StartStreamTranscription & co.)
493
+ #
494
+ # Frame vectors computed with the amazon-transcribe-streaming-sdk EventSigner
495
+ # for the same credentials, clock and seed signature.
496
+ # ---------------------------------------------------------------------------
497
+
498
+ _EVENT_CTX: SigV4AuthContext = {**_TEST_SUITE_CTX, "signing_name": "transcribe"}
499
+ _EVENT_NOW = dt.datetime(2015, 8, 30, 12, 36, 0, 123000, tzinfo=dt.timezone.utc)
500
+ _SEED_SIGNATURE = "5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31"
501
+ _EXPECTED_CHUNK_SIGNATURES = [
502
+ "790059050e9798603e8e42a1ad7d6722d26b05ec6b02d78e6337e5b8c3c41c90",
503
+ "137143c4f956f3fe0ea64f7b27848aa1b9168e770143b06b8a78723c6d0c62c5",
504
+ "cb01529bff77188ec0f881ecdccab5e237925b6a14357c1ef10f873b4fc8793a",
505
+ ]
506
+
507
+
508
+ def _audio_events() -> list[bytes]:
509
+ headers = {
510
+ ":message-type": "event",
511
+ ":event-type": "AudioEvent",
512
+ ":content-type": "application/octet-stream",
513
+ }
514
+ return [
515
+ Message(headers, b"\x01\x02\x03").encode(),
516
+ Message(headers, b"").encode(),
517
+ ]
518
+
519
+
520
+ def _decode_frames(frames: list[bytes]) -> list[Message]:
521
+ decoder = MessageDecoder()
522
+ return [message for frame in frames for message in decoder.feed(frame)]
523
+
524
+
525
+ def _chunk_signature(message: Message) -> str:
526
+ signature = message.headers[":chunk-signature"]
527
+ assert isinstance(signature, bytes) and len(signature) == 32
528
+ return signature.hex()
529
+
530
+
531
+ def _expected_event_signature(prior: str, payload: bytes) -> str:
532
+ """The per-frame string to sign, straight from the Transcribe developer
533
+ guide (with the ``-PAYLOAD`` algorithm name every AWS SDK uses)."""
534
+ string_to_sign = "\n".join(
535
+ [
536
+ "AWS4-HMAC-SHA256-PAYLOAD",
537
+ "20150830T123600Z",
538
+ "20150830/us-east-1/transcribe/aws4_request",
539
+ prior,
540
+ hashlib.sha256(encode_headers({":date": _EVENT_NOW})).hexdigest(),
541
+ hashlib.sha256(payload).hexdigest(),
542
+ ]
543
+ )
544
+ key = _derive_signing_key(
545
+ _EVENT_CTX["secret_access_key"], "20150830", "us-east-1", "transcribe"
546
+ )
547
+ return hmac.new(key, string_to_sign.encode(), hashlib.sha256).hexdigest()
548
+
549
+
550
+ def test_event_stream_frames_chain_from_seed_signature(monkeypatch):
551
+ monkeypatch.setattr(_sigv4, "_amz_now", lambda: _EVENT_NOW)
552
+ events = _audio_events()
553
+
554
+ frames = list(EventStreamIterator(iter(events), _EVENT_CTX, _SEED_SIGNATURE))
555
+ messages = _decode_frames(frames)
556
+
557
+ # One frame per event, plus the empty end frame.
558
+ assert [m.payload for m in messages] == [*events, b""]
559
+ prior = _SEED_SIGNATURE
560
+ for message, payload, expected in zip(
561
+ messages, [*events, b""], _EXPECTED_CHUNK_SIGNATURES
562
+ ):
563
+ assert set(message.headers) == {":date", ":chunk-signature"}
564
+ assert message.headers[":date"] == _EVENT_NOW
565
+ signature = _chunk_signature(message)
566
+ assert signature == _expected_event_signature(prior, payload) == expected
567
+ prior = signature
568
+
569
+
570
+ def test_event_stream_empty_source_still_sends_end_frame(monkeypatch):
571
+ monkeypatch.setattr(_sigv4, "_amz_now", lambda: _EVENT_NOW)
572
+ frames = list(EventStreamIterator(iter([]), _EVENT_CTX, _SEED_SIGNATURE))
573
+ (message,) = _decode_frames(frames)
574
+ assert message.payload == b""
575
+ assert message.headers[":chunk-signature"] == bytes.fromhex(
576
+ _expected_event_signature(_SEED_SIGNATURE, b"")
577
+ )
578
+
579
+
580
+ def test_async_event_stream_matches_sync(monkeypatch):
581
+ monkeypatch.setattr(_sigv4, "_amz_now", lambda: _EVENT_NOW)
582
+ events = _audio_events()
583
+
584
+ async def source() -> AsyncIterator[bytes]:
585
+ for event in events:
586
+ yield event
587
+
588
+ async def main() -> list[bytes]:
589
+ return [
590
+ f async for f in EventStreamIterator(source(), _EVENT_CTX, _SEED_SIGNATURE)
591
+ ]
592
+
593
+ frames = asyncio.run(main())
594
+ assert frames == list(EventStreamIterator(iter(events), _EVENT_CTX, _SEED_SIGNATURE))
595
+ assert [_chunk_signature(m) for m in _decode_frames(frames)] == _EXPECTED_CHUNK_SIGNATURES
596
+
597
+
598
+ def test_event_stream_request_signs_marker_and_wraps_body(monkeypatch):
599
+ """A request event stream signs the ``STREAMING-AWS4-HMAC-SHA256-EVENTS``
600
+ marker, sends it in the header, and wraps the body so every event is
601
+ signed with a chain seeded by the Authorization signature."""
602
+ monkeypatch.setattr(_sigv4, "_amz_now", lambda: _EVENT_NOW)
603
+ events = _audio_events()
604
+ req = _make_request(
605
+ "POST",
606
+ "https://transcribestreaming.us-east-1.amazonaws.com/stream-transcription",
607
+ {
608
+ "Host": "transcribestreaming.us-east-1.amazonaws.com",
609
+ "X-Amz-Date": "20150830T123600Z",
610
+ },
611
+ body=iter(events),
612
+ )
613
+ signed = sign_sigv4(req, _EVENT_CTX, event_stream=True)
614
+ assert signed.headers["X-Amz-Content-SHA256"] == "STREAMING-AWS4-HMAC-SHA256-EVENTS"
615
+ assert "x-amz-content-sha256" in signed.headers["Authorization"].split("SignedHeaders=")[1]
616
+ seed = signed.headers["Authorization"].rsplit("Signature=", 1)[1]
617
+
618
+ assert isinstance(signed.body, EventStreamIterator)
619
+ messages = _decode_frames(list(signed.body))
620
+ assert [m.payload for m in messages] == [*events, b""]
621
+ assert messages[0].headers[":chunk-signature"] == bytes.fromhex(
622
+ _expected_event_signature(seed, events[0])
623
+ )
624
+
625
+
626
+ def test_event_stream_request_wraps_async_body():
627
+ async def source() -> AsyncIterator[bytes]:
628
+ yield b""
629
+
630
+ req = _make_request(
631
+ "POST",
632
+ "https://transcribestreaming.us-east-1.amazonaws.com/stream-transcription",
633
+ {"Host": "transcribestreaming.us-east-1.amazonaws.com"},
634
+ body=source(),
635
+ )
636
+ signed = sign_sigv4(req, _EVENT_CTX, event_stream=True)
637
+ assert isinstance(signed.body, EventStreamIterator)
638
+ assert isinstance(signed.body, AsyncIterator)
639
+ with pytest.raises(TypeError):
640
+ next(signed.body)
641
+ assert len(asyncio.run(_collect(signed.body))) == 2
642
+
643
+
644
+ async def _collect(source: AsyncIterator[bytes]) -> list[bytes]:
645
+ return [chunk async for chunk in source]
646
+
647
+
447
648
  def test_amz_date_autopopulated_when_missing():
448
649
  req = _make_request(
449
650
  "GET",
450
651
  "https://example.amazonaws.com/",
451
652
  {"Host": "example.amazonaws.com"},
452
653
  )
453
- signed = sign_sigv4(req, _TEST_SUITE_CTX, b"")
654
+ signed = sign_sigv4(req, _TEST_SUITE_CTX)
454
655
  amz_date = signed.headers["X-Amz-Date"]
455
656
  # Format: YYYYMMDDTHHMMSSZ
456
657
  assert len(amz_date) == 16
@@ -464,7 +665,7 @@ def test_authorization_header_format():
464
665
  "https://example.amazonaws.com/",
465
666
  {"Host": "example.amazonaws.com", "X-Amz-Date": "20150830T123600Z"},
466
667
  )
467
- signed = sign_sigv4(req, _TEST_SUITE_CTX, b"")
668
+ signed = sign_sigv4(req, _TEST_SUITE_CTX)
468
669
  auth = signed.headers["Authorization"]
469
670
  assert auth.startswith("AWS4-HMAC-SHA256 ")
470
671
  # Spec: comma-separated, no required whitespace between parts.