capo-ebs 0.1.0__tar.gz → 0.3.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 (104) hide show
  1. {capo_ebs-0.1.0/src/capo_ebs.egg-info → capo_ebs-0.3.0}/PKG-INFO +2 -2
  2. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/pyproject.toml +2 -2
  3. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_auth/_signers.py +41 -31
  4. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_auth/_sigv4.py +156 -16
  5. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_operations/ebs/complete_snapshot.py +7 -3
  6. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_operations/ebs/get_snapshot_block.py +7 -3
  7. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_operations/ebs/list_changed_blocks.py +7 -3
  8. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_operations/ebs/list_snapshot_blocks.py +7 -3
  9. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_operations/ebs/put_snapshot_block.py +7 -3
  10. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_operations/ebs/start_snapshot.py +7 -3
  11. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_protocol/eventstream.py +3 -2
  12. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_services/_pipeline.py +17 -0
  13. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_services/async_ebs.py +26 -26
  14. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_services/ebs.py +26 -26
  15. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/get_snapshot_block_request.py +1 -1
  16. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/put_snapshot_block_request.py +2 -2
  17. capo_ebs-0.3.0/src/capo_ebs/types/start_snapshot_request.py +84 -0
  18. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/start_snapshot_response.py +1 -1
  19. {capo_ebs-0.1.0 → capo_ebs-0.3.0/src/capo_ebs.egg-info}/PKG-INFO +2 -2
  20. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/tests/test_sigv4.py +201 -17
  21. capo_ebs-0.1.0/src/capo_ebs/types/start_snapshot_request.py +0 -84
  22. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/LICENSE +0 -0
  23. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/README.md +0 -0
  24. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/setup.cfg +0 -0
  25. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/__init__.py +0 -0
  26. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_async.py +0 -0
  27. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_auth/_identity.py +0 -0
  28. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_auth/_providers.py +0 -0
  29. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_auth/_zapros_handler.py +0 -0
  30. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_body.py +0 -0
  31. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_iter.py +0 -0
  32. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_pagination.py +0 -0
  33. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_protocol/__init__.py +0 -0
  34. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_protocol/errors.py +0 -0
  35. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_protocol/serialize.py +0 -0
  36. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_protocol/xml.py +0 -0
  37. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_rule_engine/__init__.py +0 -0
  38. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_rule_engine/_aws_partition.py +0 -0
  39. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_rule_engine/_endpoint_rule_set.py +0 -0
  40. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_rule_engine/_endpoint_runtime.py +0 -0
  41. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/_services/_aws_config.py +0 -0
  42. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/errors/__init__.py +0 -0
  43. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/errors/_base.py +0 -0
  44. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/errors/access_denied_exception.py +0 -0
  45. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/errors/concurrent_limit_exceeded_exception.py +0 -0
  46. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/errors/conflict_exception.py +0 -0
  47. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/errors/internal_server_exception.py +0 -0
  48. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/errors/request_throttled_exception.py +0 -0
  49. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/errors/resource_not_found_exception.py +0 -0
  50. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/errors/service_quota_exceeded_exception.py +0 -0
  51. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/errors/validation_exception.py +0 -0
  52. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/py.typed +0 -0
  53. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/_prelude/blob.py +0 -0
  54. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/_prelude/timestamp.py +0 -0
  55. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/access_denied_exception_reason.py +0 -0
  56. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/block.py +0 -0
  57. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/block_data.py +0 -0
  58. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/block_index.py +0 -0
  59. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/block_size.py +0 -0
  60. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/block_token.py +0 -0
  61. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/blocks.py +0 -0
  62. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/boolean.py +0 -0
  63. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/changed_block.py +0 -0
  64. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/changed_blocks.py +0 -0
  65. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/changed_blocks_count.py +0 -0
  66. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/checksum.py +0 -0
  67. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/checksum_aggregation_method.py +0 -0
  68. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/checksum_algorithm.py +0 -0
  69. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/complete_snapshot_request.py +0 -0
  70. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/complete_snapshot_response.py +0 -0
  71. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/data_length.py +0 -0
  72. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/description.py +0 -0
  73. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/error_message.py +0 -0
  74. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/get_snapshot_block_response.py +0 -0
  75. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/idempotency_token.py +0 -0
  76. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/kms_key_arn.py +0 -0
  77. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/list_changed_blocks_request.py +0 -0
  78. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/list_changed_blocks_response.py +0 -0
  79. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/list_snapshot_blocks_request.py +0 -0
  80. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/list_snapshot_blocks_response.py +0 -0
  81. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/max_results.py +0 -0
  82. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/owner_id.py +0 -0
  83. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/page_token.py +0 -0
  84. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/progress.py +0 -0
  85. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/put_snapshot_block_response.py +0 -0
  86. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/request_throttled_exception_reason.py +0 -0
  87. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/resource_not_found_exception_reason.py +0 -0
  88. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/service_quota_exceeded_exception_reason.py +0 -0
  89. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/snapshot_id.py +0 -0
  90. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/sse_type.py +0 -0
  91. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/status.py +0 -0
  92. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/tag.py +0 -0
  93. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/tag_key.py +0 -0
  94. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/tag_value.py +0 -0
  95. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/tags.py +0 -0
  96. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/time_stamp.py +0 -0
  97. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/timeout.py +0 -0
  98. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/validation_exception_reason.py +0 -0
  99. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs/types/volume_size.py +0 -0
  100. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs.egg-info/SOURCES.txt +0 -0
  101. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs.egg-info/dependency_links.txt +0 -0
  102. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs.egg-info/requires.txt +0 -0
  103. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/src/capo_ebs.egg-info/top_level.txt +0 -0
  104. {capo_ebs-0.1.0 → capo_ebs-0.3.0}/tests/test_body.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: capo-ebs
3
- Version: 0.1.0
4
- Summary: Python SDK for EBS.
3
+ Version: 0.3.0
4
+ Summary: Python SDK for AWS EBS.
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-ebs"
3
- version = "0.1.0"
4
- description = "Python SDK for EBS."
3
+ version = "0.3.0"
4
+ description = "Python SDK for AWS EBS."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  classifiers = [
@@ -32,7 +32,9 @@ class SigV4Signer(Signer[Credentials]):
32
32
  endpoint rule-set's ``authSchemes`` property or built by the generated
33
33
  ``get_signer`` from operation defaults. ``unsigned_payload`` mirrors the
34
34
  operation's ``aws.auth#unsignedPayload`` trait: the body is sent but left
35
- out of the signature.
35
+ out of the signature. ``event_stream`` marks a request event stream: the
36
+ request is signed with the ``STREAMING-AWS4-HMAC-SHA256-EVENTS`` payload
37
+ marker and every event with a signature chained from it.
36
38
  """
37
39
 
38
40
  def __init__(
@@ -41,10 +43,12 @@ class SigV4Signer(Signer[Credentials]):
41
43
  *,
42
44
  auth_scheme: dict[str, Any],
43
45
  unsigned_payload: bool = False,
46
+ event_stream: bool = False,
44
47
  ) -> None:
45
48
  super().__init__(provider)
46
49
  self._auth_scheme = auth_scheme
47
50
  self._unsigned_payload = unsigned_payload
51
+ self._event_stream = event_stream
48
52
 
49
53
  async def asign(self, req: Request) -> Request:
50
54
  creds = await self.provider.aresolve_identity()
@@ -62,21 +66,24 @@ class SigV4Signer(Signer[Credentials]):
62
66
  "disableNormalizePath", False
63
67
  ),
64
68
  }
65
- if self._unsigned_payload:
66
- body: bytes | None = None
67
- elif req.body is None:
68
- body = b""
69
- elif isinstance(req.body, bytes):
70
- body = req.body
71
- elif self._auth_scheme["signingName"] in S3_SIGNING_NAMES:
72
- # S3 accepts UNSIGNED-PAYLOAD for any operation; streamed bodies rely on it.
73
- body = None
74
- else:
75
- raise NotImplementedError(
76
- "Currently we don't support signed chunked payloads, so buffer the body and "
77
- "pass bytes as a workaround; chunked signed implementation coming soon"
78
- )
79
- return sign_sigv4(req, ctx, body)
69
+ unsigned_payload = self._unsigned_payload
70
+ if (
71
+ not unsigned_payload
72
+ and not self._event_stream
73
+ and req.body is not None
74
+ and not isinstance(req.body, bytes)
75
+ ):
76
+ if self._auth_scheme["signingName"] in S3_SIGNING_NAMES:
77
+ # S3 accepts UNSIGNED-PAYLOAD for any operation; streamed bodies rely on it.
78
+ unsigned_payload = True
79
+ else:
80
+ raise NotImplementedError(
81
+ "Currently we don't support signed chunked payloads, so buffer the body and "
82
+ "pass bytes as a workaround; chunked signed implementation coming soon"
83
+ )
84
+ return sign_sigv4(
85
+ req, ctx, unsigned_payload=unsigned_payload, event_stream=self._event_stream
86
+ )
80
87
 
81
88
  def sign(self, req: Request) -> Request:
82
89
  creds = self.provider.resolve_identity()
@@ -94,18 +101,21 @@ class SigV4Signer(Signer[Credentials]):
94
101
  "disableNormalizePath", False
95
102
  ),
96
103
  }
97
- if self._unsigned_payload:
98
- body: bytes | None = None
99
- elif req.body is None:
100
- body = b""
101
- elif isinstance(req.body, bytes):
102
- body = req.body
103
- elif self._auth_scheme["signingName"] in S3_SIGNING_NAMES:
104
- # S3 accepts UNSIGNED-PAYLOAD for any operation; streamed bodies rely on it.
105
- body = None
106
- else:
107
- raise NotImplementedError(
108
- "Currently we don't support signed chunked payloads, so buffer the body and "
109
- "pass bytes as a workaround; chunked signed implementation coming soon"
110
- )
111
- return sign_sigv4(req, ctx, body)
104
+ unsigned_payload = self._unsigned_payload
105
+ if (
106
+ not unsigned_payload
107
+ and not self._event_stream
108
+ and req.body is not None
109
+ and not isinstance(req.body, bytes)
110
+ ):
111
+ if self._auth_scheme["signingName"] in S3_SIGNING_NAMES:
112
+ # S3 accepts UNSIGNED-PAYLOAD for any operation; streamed bodies rely on it.
113
+ unsigned_payload = True
114
+ else:
115
+ raise NotImplementedError(
116
+ "Currently we don't support signed chunked payloads, so buffer the body and "
117
+ "pass bytes as a workaround; chunked signed implementation coming soon"
118
+ )
119
+ return sign_sigv4(
120
+ req, ctx, unsigned_payload=unsigned_payload, event_stream=self._event_stream
121
+ )
@@ -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:
@@ -233,13 +241,20 @@ def _canonical_query_from_pairs(pairs: list[tuple[str, str]]) -> str:
233
241
  def sign_sigv4(
234
242
  request: Request,
235
243
  ctx: SigV4AuthContext,
236
- body: bytes | None,
244
+ *,
245
+ unsigned_payload: bool = False,
246
+ event_stream: bool = False,
237
247
  ) -> Request:
238
248
  """Return a new ``Request`` carrying SigV4 single-chunk auth headers.
239
249
 
240
- Pass ``body=None`` to sign with ``UNSIGNED-PAYLOAD`` (streaming S3 requests
241
- and operations carrying ``aws.auth#unsignedPayload``). The original
242
- ``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.
243
258
  """
244
259
  service = ctx["signing_name"]
245
260
  region = ctx["signing_region"]
@@ -253,23 +268,31 @@ def sign_sigv4(
253
268
  date_stamp = amz_date[:8]
254
269
  else:
255
270
  now = _amz_now()
256
- amz_date = now.strftime("%Y%m%dT%H%M%SZ")
271
+ amz_date = now.strftime(_AMZ_DATE_FORMAT)
257
272
  date_stamp = now.strftime("%Y%m%d")
258
273
  headers["X-Amz-Date"] = amz_date
259
274
 
260
275
  # Payload hash. For S3-family services, x-amz-content-sha256 is mandatory;
261
- # for every service it is the only way to announce an unsigned payload.
276
+ # for every service it is the only way to announce an unsigned payload or
277
+ # an event stream.
262
278
  # Either way it must be set BEFORE computing the canonical request (it
263
279
  # gets signed).
264
280
  payload_hash = headers.get("X-Amz-Content-SHA256")
265
281
  if payload_hash is None:
266
- if body is None:
282
+ if event_stream:
283
+ payload_hash = _EVENT_STREAM_PAYLOAD
284
+ elif unsigned_payload:
267
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()
268
290
  else:
269
- payload_hash = (
270
- 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"
271
294
  )
272
- if service in S3_SIGNING_NAMES or body is None:
295
+ if service in S3_SIGNING_NAMES or unsigned_payload or event_stream:
273
296
  headers["X-Amz-Content-SHA256"] = payload_hash
274
297
 
275
298
  # Session token (STS / assumed-role credentials).
@@ -313,18 +336,135 @@ def sign_sigv4(
313
336
  f"Signature={signature}"
314
337
  )
315
338
 
316
- effective_body = body if body is not None else request.body
317
- 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:
318
349
  return Request(
319
350
  request.url,
320
351
  request.method,
321
352
  headers,
322
- body=effective_body,
353
+ body=body,
323
354
  context=request.context,
324
355
  )
325
356
  return Request(request.url, request.method, headers, context=request.context)
326
357
 
327
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
+
328
468
  def presign_sigv4(
329
469
  request: Request,
330
470
  ctx: SigV4AuthContext,
@@ -351,7 +491,7 @@ def presign_sigv4(
351
491
  service = ctx["signing_name"]
352
492
  region = ctx["signing_region"]
353
493
 
354
- amz_date = sign_time.strftime("%Y%m%dT%H%M%SZ")
494
+ amz_date = sign_time.strftime(_AMZ_DATE_FORMAT)
355
495
  date_stamp = sign_time.strftime("%Y%m%d")
356
496
  credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
357
497
 
@@ -25,7 +25,11 @@ import capo_ebs.types.complete_snapshot_response
25
25
  import capo_ebs.types.status
26
26
  from capo_ebs._protocol.errors import parse_error_metadata_json
27
27
  from capo_ebs._rule_engine._endpoint_rule_set import EndpointParams, resolve
28
- from capo_ebs._services._pipeline import AsyncOperationOptions, OperationOptions
28
+ from capo_ebs._services._pipeline import (
29
+ AsyncOperationOptions,
30
+ OperationOptions,
31
+ raise_error,
32
+ )
29
33
  from capo_ebs.errors import UnknownServiceError
30
34
 
31
35
 
@@ -166,7 +170,7 @@ def complete_snapshot(
166
170
  try:
167
171
  if response.status >= 300:
168
172
  response.read()
169
- handle_error(response)
173
+ raise_error(response, handle_error)
170
174
  return handle_response(response), response
171
175
  except BaseException:
172
176
  response.close()
@@ -183,7 +187,7 @@ async def async_complete_snapshot(
183
187
  try:
184
188
  if response.status >= 300:
185
189
  await response.aread()
186
- handle_error(response)
190
+ raise_error(response, handle_error)
187
191
  return await async_handle_response(response), response
188
192
  except BaseException:
189
193
  await response.aclose()
@@ -24,7 +24,11 @@ import capo_ebs.types.get_snapshot_block_request
24
24
  import capo_ebs.types.get_snapshot_block_response
25
25
  from capo_ebs._protocol.errors import parse_error_metadata_json
26
26
  from capo_ebs._rule_engine._endpoint_rule_set import EndpointParams, resolve
27
- from capo_ebs._services._pipeline import AsyncOperationOptions, OperationOptions
27
+ from capo_ebs._services._pipeline import (
28
+ AsyncOperationOptions,
29
+ OperationOptions,
30
+ raise_error,
31
+ )
28
32
  from capo_ebs.errors import UnknownServiceError
29
33
 
30
34
 
@@ -163,7 +167,7 @@ def get_snapshot_block(
163
167
  try:
164
168
  if response.status >= 300:
165
169
  response.read()
166
- handle_error(response)
170
+ raise_error(response, handle_error)
167
171
  return handle_response(response), response
168
172
  except BaseException:
169
173
  response.close()
@@ -180,7 +184,7 @@ async def async_get_snapshot_block(
180
184
  try:
181
185
  if response.status >= 300:
182
186
  await response.aread()
183
- handle_error(response)
187
+ raise_error(response, handle_error)
184
188
  return await async_handle_response(response), response
185
189
  except BaseException:
186
190
  await response.aclose()
@@ -24,7 +24,11 @@ import capo_ebs.types.list_changed_blocks_response
24
24
  import capo_ebs.types.time_stamp
25
25
  from capo_ebs._protocol.errors import parse_error_metadata_json
26
26
  from capo_ebs._rule_engine._endpoint_rule_set import EndpointParams, resolve
27
- from capo_ebs._services._pipeline import AsyncOperationOptions, OperationOptions
27
+ from capo_ebs._services._pipeline import (
28
+ AsyncOperationOptions,
29
+ OperationOptions,
30
+ raise_error,
31
+ )
28
32
  from capo_ebs.errors import UnknownServiceError
29
33
 
30
34
 
@@ -157,7 +161,7 @@ def list_changed_blocks(
157
161
  try:
158
162
  if response.status >= 300:
159
163
  response.read()
160
- handle_error(response)
164
+ raise_error(response, handle_error)
161
165
  return handle_response(response), response
162
166
  except BaseException:
163
167
  response.close()
@@ -175,7 +179,7 @@ async def async_list_changed_blocks(
175
179
  try:
176
180
  if response.status >= 300:
177
181
  await response.aread()
178
- handle_error(response)
182
+ raise_error(response, handle_error)
179
183
  return await async_handle_response(response), response
180
184
  except BaseException:
181
185
  await response.aclose()
@@ -24,7 +24,11 @@ import capo_ebs.types.list_snapshot_blocks_response
24
24
  import capo_ebs.types.time_stamp
25
25
  from capo_ebs._protocol.errors import parse_error_metadata_json
26
26
  from capo_ebs._rule_engine._endpoint_rule_set import EndpointParams, resolve
27
- from capo_ebs._services._pipeline import AsyncOperationOptions, OperationOptions
27
+ from capo_ebs._services._pipeline import (
28
+ AsyncOperationOptions,
29
+ OperationOptions,
30
+ raise_error,
31
+ )
28
32
  from capo_ebs.errors import UnknownServiceError
29
33
 
30
34
 
@@ -153,7 +157,7 @@ def list_snapshot_blocks(
153
157
  try:
154
158
  if response.status >= 300:
155
159
  response.read()
156
- handle_error(response)
160
+ raise_error(response, handle_error)
157
161
  return handle_response(response), response
158
162
  except BaseException:
159
163
  response.close()
@@ -171,7 +175,7 @@ async def async_list_snapshot_blocks(
171
175
  try:
172
176
  if response.status >= 300:
173
177
  await response.aread()
174
- handle_error(response)
178
+ raise_error(response, handle_error)
175
179
  return await async_handle_response(response), response
176
180
  except BaseException:
177
181
  await response.aclose()
@@ -26,7 +26,11 @@ import capo_ebs.types.put_snapshot_block_request
26
26
  import capo_ebs.types.put_snapshot_block_response
27
27
  from capo_ebs._protocol.errors import parse_error_metadata_json
28
28
  from capo_ebs._rule_engine._endpoint_rule_set import EndpointParams, resolve
29
- from capo_ebs._services._pipeline import AsyncOperationOptions, OperationOptions
29
+ from capo_ebs._services._pipeline import (
30
+ AsyncOperationOptions,
31
+ OperationOptions,
32
+ raise_error,
33
+ )
30
34
  from capo_ebs.errors import UnknownServiceError
31
35
 
32
36
 
@@ -244,7 +248,7 @@ def put_snapshot_block(
244
248
  try:
245
249
  if response.status >= 300:
246
250
  response.read()
247
- handle_error(response)
251
+ raise_error(response, handle_error)
248
252
  return handle_response(response), response
249
253
  except BaseException:
250
254
  response.close()
@@ -263,7 +267,7 @@ async def async_put_snapshot_block(
263
267
  try:
264
268
  if response.status >= 300:
265
269
  await response.aread()
266
- handle_error(response)
270
+ raise_error(response, handle_error)
267
271
  return await async_handle_response(response), response
268
272
  except BaseException:
269
273
  await response.aclose()
@@ -27,7 +27,11 @@ import capo_ebs.types.tags
27
27
  import capo_ebs.types.time_stamp
28
28
  from capo_ebs._protocol.errors import parse_error_metadata_json
29
29
  from capo_ebs._rule_engine._endpoint_rule_set import EndpointParams, resolve
30
- from capo_ebs._services._pipeline import AsyncOperationOptions, OperationOptions
30
+ from capo_ebs._services._pipeline import (
31
+ AsyncOperationOptions,
32
+ OperationOptions,
33
+ raise_error,
34
+ )
31
35
  from capo_ebs.errors import UnknownServiceError
32
36
 
33
37
 
@@ -159,7 +163,7 @@ def start_snapshot(
159
163
  try:
160
164
  if response.status >= 300:
161
165
  response.read()
162
- handle_error(response)
166
+ raise_error(response, handle_error)
163
167
  return handle_response(response), response
164
168
  except BaseException:
165
169
  response.close()
@@ -176,7 +180,7 @@ async def async_start_snapshot(
176
180
  try:
177
181
  if response.status >= 300:
178
182
  await response.aread()
179
- handle_error(response)
183
+ raise_error(response, handle_error)
180
184
  return await async_handle_response(response), response
181
185
  except BaseException:
182
186
  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,7 +124,7 @@ 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
130
  # total_length covers the whole frame, trailing message CRC included.
@@ -234,6 +234,7 @@ __all__ = [
234
234
  "MessageDecoder",
235
235
  "async_raw_stream_to_events",
236
236
  "async_read_messages",
237
+ "encode_headers",
237
238
  "raw_stream_to_events",
238
239
  "read_messages",
239
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