skyflow-flowvault-python 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. common/.coveragerc +4 -0
  2. common/__init__.py +3 -0
  3. common/client/__init__.py +0 -0
  4. common/client/base_skyflow.py +345 -0
  5. common/client/utils/__init__.py +1 -0
  6. common/client/utils/_utils.py +127 -0
  7. common/errors/__init__.py +1 -0
  8. common/errors/_skyflow_error.py +17 -0
  9. common/generated/__init__.py +0 -0
  10. common/generated/rest/__init__.py +24 -0
  11. common/generated/rest/authentication/__init__.py +4 -0
  12. common/generated/rest/authentication/client.py +181 -0
  13. common/generated/rest/authentication/raw_client.py +241 -0
  14. common/generated/rest/client.py +153 -0
  15. common/generated/rest/core/__init__.py +52 -0
  16. common/generated/rest/core/api_error.py +23 -0
  17. common/generated/rest/core/client_wrapper.py +86 -0
  18. common/generated/rest/core/datetime_utils.py +28 -0
  19. common/generated/rest/core/file.py +67 -0
  20. common/generated/rest/core/force_multipart.py +16 -0
  21. common/generated/rest/core/http_client.py +543 -0
  22. common/generated/rest/core/http_response.py +55 -0
  23. common/generated/rest/core/jsonable_encoder.py +100 -0
  24. common/generated/rest/core/pydantic_utilities.py +255 -0
  25. common/generated/rest/core/query_encoder.py +58 -0
  26. common/generated/rest/core/remove_none_from_dict.py +11 -0
  27. common/generated/rest/core/request_options.py +35 -0
  28. common/generated/rest/core/serialization.py +276 -0
  29. common/generated/rest/environment.py +8 -0
  30. common/generated/rest/errors/__init__.py +9 -0
  31. common/generated/rest/errors/bad_request_error.py +14 -0
  32. common/generated/rest/errors/not_found_error.py +14 -0
  33. common/generated/rest/errors/unauthorized_error.py +14 -0
  34. common/generated/rest/py.typed +0 -0
  35. common/generated/rest/types/__init__.py +9 -0
  36. common/generated/rest/types/googlerpc_status.py +22 -0
  37. common/generated/rest/types/protobuf_any.py +21 -0
  38. common/generated/rest/types/v_1_get_auth_token_response.py +33 -0
  39. common/generated/rest/version.py +6 -0
  40. common/service_account/__init__.py +1 -0
  41. common/service_account/_utils.py +248 -0
  42. common/service_account/client/__init__.py +0 -0
  43. common/service_account/client/auth_client.py +13 -0
  44. common/utils/__init__.py +4 -0
  45. common/utils/_helpers.py +18 -0
  46. common/utils/_skyflow_messages.py +445 -0
  47. common/utils/_utils.py +50 -0
  48. common/utils/constants.py +291 -0
  49. common/utils/enums/__init__.py +12 -0
  50. common/utils/enums/content_types.py +9 -0
  51. common/utils/enums/detect_entities.py +73 -0
  52. common/utils/enums/detect_output_transcriptions.py +8 -0
  53. common/utils/enums/env.py +13 -0
  54. common/utils/enums/log_level.py +8 -0
  55. common/utils/enums/masking_method.py +5 -0
  56. common/utils/enums/redaction_type.py +7 -0
  57. common/utils/enums/request_method.py +8 -0
  58. common/utils/enums/token_mode.py +6 -0
  59. common/utils/enums/token_type.py +6 -0
  60. common/utils/logger/__init__.py +2 -0
  61. common/utils/logger/_log_helpers.py +47 -0
  62. common/utils/logger/_logger.py +50 -0
  63. common/utils/validations/__init__.py +11 -0
  64. common/utils/validations/_validations.py +267 -0
  65. common/vault/base_vault_client.py +122 -0
  66. common/vault/base_vault_controller.py +58 -0
  67. common/vault/data/__init__.py +2 -0
  68. common/vault/data/_base_insert_request.py +7 -0
  69. common/vault/data/_base_insert_response.py +11 -0
  70. skyflow/__init__.py +2 -0
  71. skyflow/client/__init__.py +1 -0
  72. skyflow/client/_http_config_builder.py +45 -0
  73. skyflow/client/skyflow.py +15 -0
  74. skyflow/error/__init__.py +3 -0
  75. skyflow/generated/__init__.py +0 -0
  76. skyflow/generated/rest/__init__.py +164 -0
  77. skyflow/generated/rest/_default_clients.py +32 -0
  78. skyflow/generated/rest/client.py +385 -0
  79. skyflow/generated/rest/core/__init__.py +127 -0
  80. skyflow/generated/rest/core/api_error.py +23 -0
  81. skyflow/generated/rest/core/client_wrapper.py +148 -0
  82. skyflow/generated/rest/core/datetime_utils.py +70 -0
  83. skyflow/generated/rest/core/file.py +67 -0
  84. skyflow/generated/rest/core/force_multipart.py +18 -0
  85. skyflow/generated/rest/core/http_client.py +940 -0
  86. skyflow/generated/rest/core/http_response.py +63 -0
  87. skyflow/generated/rest/core/http_sse/__init__.py +42 -0
  88. skyflow/generated/rest/core/http_sse/_api.py +455 -0
  89. skyflow/generated/rest/core/http_sse/_decoders.py +74 -0
  90. skyflow/generated/rest/core/http_sse/_exceptions.py +7 -0
  91. skyflow/generated/rest/core/http_sse/_models.py +17 -0
  92. skyflow/generated/rest/core/jsonable_encoder.py +133 -0
  93. skyflow/generated/rest/core/logging.py +107 -0
  94. skyflow/generated/rest/core/parse_error.py +36 -0
  95. skyflow/generated/rest/core/pydantic_utilities.py +486 -0
  96. skyflow/generated/rest/core/query_encoder.py +58 -0
  97. skyflow/generated/rest/core/remove_none_from_dict.py +11 -0
  98. skyflow/generated/rest/core/request_options.py +40 -0
  99. skyflow/generated/rest/core/serialization.py +347 -0
  100. skyflow/generated/rest/environment.py +8 -0
  101. skyflow/generated/rest/errors/__init__.py +53 -0
  102. skyflow/generated/rest/errors/bad_request_error.py +11 -0
  103. skyflow/generated/rest/errors/forbidden_error.py +11 -0
  104. skyflow/generated/rest/errors/internal_server_error.py +11 -0
  105. skyflow/generated/rest/errors/not_found_error.py +11 -0
  106. skyflow/generated/rest/errors/too_many_requests_error.py +11 -0
  107. skyflow/generated/rest/errors/unauthorized_error.py +11 -0
  108. skyflow/generated/rest/py.typed +0 -0
  109. skyflow/generated/rest/query/__init__.py +4 -0
  110. skyflow/generated/rest/query/client.py +139 -0
  111. skyflow/generated/rest/query/raw_client.py +239 -0
  112. skyflow/generated/rest/raw_client.py +86 -0
  113. skyflow/generated/rest/records/__init__.py +34 -0
  114. skyflow/generated/rest/records/client.py +685 -0
  115. skyflow/generated/rest/records/raw_client.py +988 -0
  116. skyflow/generated/rest/records/types/__init__.py +34 -0
  117. skyflow/generated/rest/records/types/update_request_update_type.py +5 -0
  118. skyflow/generated/rest/tokens/__init__.py +4 -0
  119. skyflow/generated/rest/tokens/client.py +246 -0
  120. skyflow/generated/rest/tokens/raw_client.py +507 -0
  121. skyflow/generated/rest/types/__init__.py +116 -0
  122. skyflow/generated/rest/types/column_redactions.py +33 -0
  123. skyflow/generated/rest/types/delete_response.py +23 -0
  124. skyflow/generated/rest/types/delete_response_object.py +42 -0
  125. skyflow/generated/rest/types/detokenize_response.py +23 -0
  126. skyflow/generated/rest/types/detokenize_response_object.py +54 -0
  127. skyflow/generated/rest/types/error_response.py +20 -0
  128. skyflow/generated/rest/types/error_response_error.py +28 -0
  129. skyflow/generated/rest/types/execute_query_record_response.py +22 -0
  130. skyflow/generated/rest/types/execute_query_response.py +26 -0
  131. skyflow/generated/rest/types/execute_query_response_metadata.py +26 -0
  132. skyflow/generated/rest/types/get_request_data.py +60 -0
  133. skyflow/generated/rest/types/get_response.py +23 -0
  134. skyflow/generated/rest/types/get_tokens_from_values_request_object.py +30 -0
  135. skyflow/generated/rest/types/get_tokens_from_values_response.py +23 -0
  136. skyflow/generated/rest/types/google_protobuf_value.py +8 -0
  137. skyflow/generated/rest/types/http_code.py +6 -0
  138. skyflow/generated/rest/types/insert_record_data.py +36 -0
  139. skyflow/generated/rest/types/insert_response.py +23 -0
  140. skyflow/generated/rest/types/record_response_object.py +70 -0
  141. skyflow/generated/rest/types/token_group_redactions.py +33 -0
  142. skyflow/generated/rest/types/tokenize_response_object.py +49 -0
  143. skyflow/generated/rest/types/unique_value.py +22 -0
  144. skyflow/generated/rest/types/update_record_data.py +52 -0
  145. skyflow/generated/rest/types/update_record_data_update_type.py +5 -0
  146. skyflow/generated/rest/types/update_response.py +23 -0
  147. skyflow/generated/rest/types/upsert.py +45 -0
  148. skyflow/generated/rest/types/upsert_update_type.py +5 -0
  149. skyflow/generated/rest/version.py +6 -0
  150. skyflow/service_account/__init__.py +15 -0
  151. skyflow/utils/__init__.py +9 -0
  152. skyflow/utils/_http_config.py +36 -0
  153. skyflow/utils/_response_parsing.py +66 -0
  154. skyflow/utils/_retry.py +97 -0
  155. skyflow/utils/_skyflow_messages.py +114 -0
  156. skyflow/utils/_utils.py +54 -0
  157. skyflow/utils/_version.py +1 -0
  158. skyflow/utils/enums/__init__.py +3 -0
  159. skyflow/utils/enums/_custom_header_key.py +10 -0
  160. skyflow/utils/enums/_env_urls.py +9 -0
  161. skyflow/utils/enums/_upsert_type.py +7 -0
  162. skyflow/utils/validations/__init__.py +9 -0
  163. skyflow/utils/validations/_validations.py +195 -0
  164. skyflow/vault/__init__.py +0 -0
  165. skyflow/vault/client/__init__.py +0 -0
  166. skyflow/vault/client/client.py +102 -0
  167. skyflow/vault/controller/__init__.py +1 -0
  168. skyflow/vault/controller/_vault.py +475 -0
  169. skyflow/vault/data/__init__.py +30 -0
  170. skyflow/vault/data/_column_redactions.py +4 -0
  171. skyflow/vault/data/_delete_options.py +8 -0
  172. skyflow/vault/data/_delete_request.py +5 -0
  173. skyflow/vault/data/_delete_response.py +9 -0
  174. skyflow/vault/data/_delete_response_record.py +9 -0
  175. skyflow/vault/data/_detokenize_options.py +8 -0
  176. skyflow/vault/data/_detokenize_request.py +9 -0
  177. skyflow/vault/data/_detokenize_response.py +9 -0
  178. skyflow/vault/data/_detokenize_response_record.py +16 -0
  179. skyflow/vault/data/_detokenize_response_record_metadata.py +10 -0
  180. skyflow/vault/data/_get_options.py +8 -0
  181. skyflow/vault/data/_get_request.py +17 -0
  182. skyflow/vault/data/_get_request_record.py +13 -0
  183. skyflow/vault/data/_get_response.py +9 -0
  184. skyflow/vault/data/_get_response_record.py +17 -0
  185. skyflow/vault/data/_insert_options.py +8 -0
  186. skyflow/vault/data/_insert_request.py +11 -0
  187. skyflow/vault/data/_insert_request_record.py +9 -0
  188. skyflow/vault/data/_insert_response.py +9 -0
  189. skyflow/vault/data/_insert_response_record.py +17 -0
  190. skyflow/vault/data/_request_context.py +16 -0
  191. skyflow/vault/data/_token.py +8 -0
  192. skyflow/vault/data/_token_group_redactions.py +4 -0
  193. skyflow/vault/data/_update_options.py +8 -0
  194. skyflow/vault/data/_update_request.py +11 -0
  195. skyflow/vault/data/_update_request_record.py +6 -0
  196. skyflow/vault/data/_update_response.py +9 -0
  197. skyflow/vault/data/_update_response_record.py +17 -0
  198. skyflow/vault/data/_upsert_options.py +7 -0
  199. skyflow_flowvault_python-1.0.0.dist-info/METADATA +896 -0
  200. skyflow_flowvault_python-1.0.0.dist-info/RECORD +202 -0
  201. skyflow_flowvault_python-1.0.0.dist-info/WHEEL +5 -0
  202. skyflow_flowvault_python-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,63 @@
1
+ # This file was auto-generated by Fern from our API Definition.
2
+
3
+ from typing import Dict, Generic, TypeVar
4
+
5
+ import httpx
6
+
7
+ # Generic to represent the underlying type of the data wrapped by the HTTP response.
8
+ T = TypeVar("T")
9
+
10
+
11
+ class BaseHttpResponse:
12
+ """Minimalist HTTP response wrapper that exposes response headers and status code."""
13
+
14
+ _response: httpx.Response
15
+
16
+ def __init__(self, response: httpx.Response):
17
+ self._response = response
18
+
19
+ @property
20
+ def headers(self) -> Dict[str, str]:
21
+ return dict(self._response.headers)
22
+
23
+ @property
24
+ def status_code(self) -> int:
25
+ return self._response.status_code
26
+
27
+ @property
28
+ def response(self) -> httpx.Response:
29
+ return self._response
30
+
31
+
32
+ class HttpResponse(Generic[T], BaseHttpResponse):
33
+ """HTTP response wrapper that exposes response headers and data."""
34
+
35
+ _data: T
36
+
37
+ def __init__(self, response: httpx.Response, data: T):
38
+ super().__init__(response)
39
+ self._data = data
40
+
41
+ @property
42
+ def data(self) -> T:
43
+ return self._data
44
+
45
+ def close(self) -> None:
46
+ self._response.close()
47
+
48
+
49
+ class AsyncHttpResponse(Generic[T], BaseHttpResponse):
50
+ """HTTP response wrapper that exposes response headers and data."""
51
+
52
+ _data: T
53
+
54
+ def __init__(self, response: httpx.Response, data: T):
55
+ super().__init__(response)
56
+ self._data = data
57
+
58
+ @property
59
+ def data(self) -> T:
60
+ return self._data
61
+
62
+ async def close(self) -> None:
63
+ await self._response.aclose()
@@ -0,0 +1,42 @@
1
+ # This file was auto-generated by Fern from our API Definition.
2
+
3
+ # isort: skip_file
4
+
5
+ import typing
6
+ from importlib import import_module
7
+
8
+ if typing.TYPE_CHECKING:
9
+ from ._api import EventSource, aconnect_sse, connect_sse
10
+ from ._exceptions import SSEError
11
+ from ._models import ServerSentEvent
12
+ _dynamic_imports: typing.Dict[str, str] = {
13
+ "EventSource": "._api",
14
+ "SSEError": "._exceptions",
15
+ "ServerSentEvent": "._models",
16
+ "aconnect_sse": "._api",
17
+ "connect_sse": "._api",
18
+ }
19
+
20
+
21
+ def __getattr__(attr_name: str) -> typing.Any:
22
+ module_name = _dynamic_imports.get(attr_name)
23
+ if module_name is None:
24
+ raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}")
25
+ try:
26
+ module = import_module(module_name, __package__)
27
+ if module_name == f".{attr_name}":
28
+ return module
29
+ else:
30
+ return getattr(module, attr_name)
31
+ except ImportError as e:
32
+ raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e
33
+ except AttributeError as e:
34
+ raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e
35
+
36
+
37
+ def __dir__():
38
+ lazy_attrs = list(_dynamic_imports.keys())
39
+ return sorted(lazy_attrs)
40
+
41
+
42
+ __all__ = ["EventSource", "SSEError", "ServerSentEvent", "aconnect_sse", "connect_sse"]
@@ -0,0 +1,455 @@
1
+ # This file was auto-generated by Fern from our API Definition.
2
+
3
+ import codecs
4
+ import re
5
+ import time
6
+ from contextlib import asynccontextmanager, contextmanager
7
+ from typing import (
8
+ Any,
9
+ AsyncContextManager,
10
+ AsyncGenerator,
11
+ AsyncIterator,
12
+ Callable,
13
+ ContextManager,
14
+ Iterator,
15
+ Optional,
16
+ )
17
+
18
+ import anyio
19
+ import httpx
20
+ from ._decoders import SSEDecoder
21
+ from ._exceptions import SSEError
22
+ from ._models import ServerSentEvent
23
+
24
+ MAX_LINE_SIZE: int = 1_048_576 # 1 MiB
25
+
26
+ # Reconnection defaults, mirroring the TypeScript SDK's Stream implementation.
27
+ DEFAULT_MAX_RECONNECTION_ATTEMPTS: int = 5
28
+ DEFAULT_RECONNECT_DELAY_MS: int = 1_000
29
+ MAX_RECONNECT_DELAY_MS: int = 30_000
30
+
31
+
32
+ # A reconnect callback re-issues the original request (with a ``Last-Event-ID``
33
+ # header set to the supplied event id) and returns a *context manager* yielding
34
+ # a fresh streaming ``httpx.Response``. Sync clients supply a sync context
35
+ # manager; async clients supply an async one.
36
+ class EventSource:
37
+ def __init__(
38
+ self,
39
+ response: httpx.Response,
40
+ *,
41
+ resumable: bool = False,
42
+ stream_reconnection_enabled: bool = True,
43
+ max_stream_reconnection_attempts: Optional[int] = None,
44
+ stream_terminator: Optional[str] = None,
45
+ reconnect: Optional[Callable[[str], Any]] = None,
46
+ ) -> None:
47
+ self._response = response
48
+ self._resumable = resumable
49
+ self._stream_reconnection_enabled = stream_reconnection_enabled
50
+ self._max_stream_reconnection_attempts = max_stream_reconnection_attempts
51
+ self._stream_terminator = stream_terminator
52
+ self._reconnect = reconnect
53
+
54
+ @staticmethod
55
+ def _is_event_stream(response: httpx.Response) -> bool:
56
+ content_type = response.headers.get("content-type", "").partition(";")[0]
57
+ return "text/event-stream" in content_type
58
+
59
+ def _check_content_type(self) -> None:
60
+ if not self._is_event_stream(self._response):
61
+ content_type = self._response.headers.get("content-type", "").partition(";")[0]
62
+ raise SSEError(
63
+ f"Expected response header Content-Type to contain 'text/event-stream', got {content_type!r}"
64
+ )
65
+
66
+ def _is_reconnect_response_usable(self, response: httpx.Response) -> bool:
67
+ """Whether a reconnected response can be resumed as an SSE stream.
68
+
69
+ ``httpx.stream`` does not raise on non-success status, so a resume that
70
+ returns an error page (e.g. ``200 text/html`` or a ``500`` body) would
71
+ otherwise be parsed as SSE and yield garbage/zero events. Such a
72
+ response is treated as a failed attempt (back off and retry) instead.
73
+ """
74
+ return response.status_code < 400 and self._is_event_stream(response)
75
+
76
+ def _get_charset(self, response: Optional[httpx.Response] = None) -> str:
77
+ """Extract charset from Content-Type header, fallback to UTF-8."""
78
+ resolved = response if response is not None else self._response
79
+ content_type = resolved.headers.get("content-type", "")
80
+
81
+ # Parse charset parameter using regex
82
+ charset_match = re.search(r"charset=([^;\s]+)", content_type, re.IGNORECASE)
83
+ if charset_match:
84
+ charset = charset_match.group(1).strip("\"'")
85
+ # Validate that it's a known encoding
86
+ try:
87
+ # Test if the charset is valid by trying to encode/decode
88
+ "test".encode(charset).decode(charset)
89
+ return charset
90
+ except (LookupError, UnicodeError):
91
+ # If charset is invalid, fall back to UTF-8
92
+ pass
93
+
94
+ # Default to UTF-8 if no charset specified or invalid charset
95
+ return "utf-8"
96
+
97
+ @property
98
+ def response(self) -> httpx.Response:
99
+ return self._response
100
+
101
+ @staticmethod
102
+ def _normalize_sse_line_endings(buf: str) -> str:
103
+ """Normalize line endings per the SSE spec (\\r\\n → \\n, bare \\r → \\n).
104
+
105
+ A trailing \\r is preserved because it may pair with a leading \\n in
106
+ the next chunk to form a single \\r\\n terminator.
107
+ """
108
+ buf = buf.replace("\r\n", "\n")
109
+ if buf.endswith("\r"):
110
+ return buf[:-1].replace("\r", "\n") + "\r"
111
+ return buf.replace("\r", "\n")
112
+
113
+ def _new_text_decoder(self, response: Optional[httpx.Response] = None) -> "codecs.IncrementalDecoder":
114
+ return codecs.getincrementaldecoder(self._get_charset(response))(errors="replace")
115
+
116
+ def _reconnect_applicable(self) -> bool:
117
+ """Whether reconnection is configured for this stream at all.
118
+
119
+ This is the terminator-gating half of the reconnect decision, kept
120
+ separate from :meth:`_should_reconnect` (which additionally requires a
121
+ last *dispatched* id and an unexhausted attempt budget). The split lets
122
+ a mid-stream transport error terminate consistently:
123
+ - a stream that can never reconnect (non-resumable, no terminator,
124
+ disabled, or no callback) must re-raise the error to the caller, so a
125
+ truncated stream is not mistaken for a clean completion;
126
+ - a resumable stream that has merely run out of attempts (or has no id
127
+ to resume from) ends cleanly — the same way an exhausted empty/error
128
+ -body resume already does, matching the TypeScript ``return``.
129
+ """
130
+ return (
131
+ self._resumable
132
+ and self._stream_terminator is not None
133
+ and self._stream_reconnection_enabled
134
+ and self._reconnect is not None
135
+ )
136
+
137
+ def _should_reconnect(self, last_dispatched_id: Optional[str], reconnect_attempts: int) -> bool:
138
+ """Decide whether a prematurely-ended stream should be reconnected.
139
+
140
+ Mirrors the TypeScript ``shouldReconnect`` gating:
141
+ - only resumable SSE endpoints with a configured terminator, reconnect
142
+ enabled, and a reconnect callback are eligible (see
143
+ :meth:`_reconnect_applicable`);
144
+ - a last *dispatched* event id must exist to resume from;
145
+ - the consecutive-failed-attempt cap must not be exceeded.
146
+ """
147
+ if not self._reconnect_applicable():
148
+ return False
149
+ if not last_dispatched_id:
150
+ return False
151
+ max_attempts = (
152
+ self._max_stream_reconnection_attempts
153
+ if self._max_stream_reconnection_attempts is not None
154
+ else DEFAULT_MAX_RECONNECTION_ATTEMPTS
155
+ )
156
+ if reconnect_attempts >= max_attempts:
157
+ return False
158
+ return True
159
+
160
+ def _reconnect_delay_seconds(self, last_retry: Optional[int]) -> float:
161
+ """Backoff before a reconnect.
162
+
163
+ Uses the server's most recent ``retry:`` directive (milliseconds) when
164
+ present, otherwise a default of ``DEFAULT_RECONNECT_DELAY_MS``, clamped
165
+ to ``MAX_RECONNECT_DELAY_MS``.
166
+ """
167
+ base_ms = last_retry if (last_retry is not None and last_retry > 0) else DEFAULT_RECONNECT_DELAY_MS
168
+ return min(base_ms, MAX_RECONNECT_DELAY_MS) / 1000.0
169
+
170
+ def _sleep_before_reconnect(self, last_retry: Optional[int]) -> None:
171
+ # ``time.sleep`` blocks the calling thread but remains interruptible by
172
+ # signals (e.g. ``KeyboardInterrupt``), which propagate out and abort
173
+ # the reconnect without issuing another request.
174
+ time.sleep(self._reconnect_delay_seconds(last_retry))
175
+
176
+ async def _asleep_before_reconnect(self, last_retry: Optional[int]) -> None:
177
+ # ``anyio.sleep`` is cancellation-aware: if the consumer cancels the task
178
+ # or closes the async generator mid-delay, this raises (and no further
179
+ # request is issued) instead of blocking for the whole interval.
180
+ await anyio.sleep(self._reconnect_delay_seconds(last_retry))
181
+
182
+ def _decode_response(
183
+ self,
184
+ response: httpx.Response,
185
+ decoder: SSEDecoder,
186
+ text_decoder: "codecs.IncrementalDecoder",
187
+ ) -> Iterator[ServerSentEvent]:
188
+ buf = ""
189
+ for chunk in response.iter_bytes():
190
+ buf += text_decoder.decode(chunk)
191
+ buf = self._normalize_sse_line_endings(buf)
192
+
193
+ while "\n" in buf:
194
+ line, buf = buf.split("\n", 1)
195
+ sse = decoder.decode(line)
196
+ if sse is not None:
197
+ yield sse
198
+
199
+ if len(buf) > MAX_LINE_SIZE:
200
+ raise SSEError(
201
+ f"SSE line exceeded maximum size of {MAX_LINE_SIZE} characters without encountering a newline"
202
+ )
203
+
204
+ yield from self._flush_decoder(buf, decoder, text_decoder)
205
+
206
+ async def _adecode_response(
207
+ self,
208
+ response: httpx.Response,
209
+ decoder: SSEDecoder,
210
+ text_decoder: "codecs.IncrementalDecoder",
211
+ ) -> AsyncGenerator[ServerSentEvent, None]:
212
+ buf = ""
213
+ async for chunk in response.aiter_bytes():
214
+ buf += text_decoder.decode(chunk)
215
+ buf = self._normalize_sse_line_endings(buf)
216
+
217
+ while "\n" in buf:
218
+ line, buf = buf.split("\n", 1)
219
+ sse = decoder.decode(line)
220
+ if sse is not None:
221
+ yield sse
222
+
223
+ if len(buf) > MAX_LINE_SIZE:
224
+ raise SSEError(
225
+ f"SSE line exceeded maximum size of {MAX_LINE_SIZE} characters without encountering a newline"
226
+ )
227
+
228
+ for sse in self._flush_decoder(buf, decoder, text_decoder):
229
+ yield sse
230
+
231
+ def _flush_decoder(
232
+ self,
233
+ buf: str,
234
+ decoder: SSEDecoder,
235
+ text_decoder: "codecs.IncrementalDecoder",
236
+ ) -> Iterator[ServerSentEvent]:
237
+ # Flush any remaining bytes from the incremental decoder
238
+ buf += text_decoder.decode(b"", final=True)
239
+ buf = buf.replace("\r\n", "\n").replace("\r", "\n")
240
+
241
+ if len(buf) > MAX_LINE_SIZE:
242
+ raise SSEError(
243
+ f"SSE line exceeded maximum size of {MAX_LINE_SIZE} characters without encountering a newline"
244
+ )
245
+
246
+ while "\n" in buf:
247
+ line, buf = buf.split("\n", 1)
248
+ sse = decoder.decode(line)
249
+ if sse is not None:
250
+ yield sse
251
+
252
+ if buf.strip():
253
+ sse = decoder.decode(buf)
254
+ if sse is not None:
255
+ yield sse
256
+
257
+ def iter_sse(self) -> Iterator[ServerSentEvent]:
258
+ self._check_content_type()
259
+ decoder = SSEDecoder()
260
+ text_decoder = self._new_text_decoder()
261
+
262
+ last_dispatched_id: Optional[str] = None
263
+ last_retry: Optional[int] = None
264
+ # Consecutive failed reconnection attempts. Reset to 0 whenever an event
265
+ # is successfully dispatched (reset-on-progress) — matching browser
266
+ # `EventSource` semantics: a server that emits >=1 event then drops on
267
+ # every connection can reconnect indefinitely.
268
+ reconnect_attempts = 0
269
+
270
+ # ``None`` means there is no live stream to read this iteration (e.g. a
271
+ # failed reconnect); the loop then re-evaluates the reconnect decision
272
+ # without re-reading an exhausted response.
273
+ response: Optional[httpx.Response] = self._response
274
+ # Context manager for a response we opened ourselves and must close.
275
+ # The initial response is owned by the caller, so it starts as None.
276
+ owned_cm: Optional[ContextManager[httpx.Response]] = None
277
+ try:
278
+ while True:
279
+ if response is not None:
280
+ events = self._decode_response(response, decoder, text_decoder)
281
+ while True:
282
+ try:
283
+ sse = next(events)
284
+ except StopIteration:
285
+ break
286
+ except SSEError:
287
+ # A protocol violation (e.g. an oversized line) is a
288
+ # genuine error, not a dropped connection; propagate it.
289
+ # Listed first because ``SSEError`` subclasses
290
+ # ``httpx.TransportError``.
291
+ raise
292
+ except httpx.TransportError:
293
+ # A transport error mid-stream (e.g. the server dropped
294
+ # the connection: ``ReadError``/``RemoteProtocolError``)
295
+ # is a premature end. Only swallow it when reconnection
296
+ # is configured for this stream; otherwise re-raise so a
297
+ # non-resumable stream still surfaces the error to the
298
+ # caller instead of looking like a clean completion.
299
+ # When reconnection is applicable but the attempt budget
300
+ # is exhausted, we ``break`` and end cleanly below — the
301
+ # same way an exhausted empty/error-body resume does, so
302
+ # give-up is consistent regardless of failure shape.
303
+ # ``next`` is used rather than ``for`` so this cannot
304
+ # swallow a ``GeneratorExit`` raised at a ``yield``.
305
+ if not self._reconnect_applicable():
306
+ raise
307
+ break
308
+ yield sse
309
+ if sse.id:
310
+ last_dispatched_id = sse.id
311
+ if sse.retry is not None:
312
+ last_retry = sse.retry
313
+ reconnect_attempts = 0
314
+
315
+ if not self._should_reconnect(last_dispatched_id, reconnect_attempts):
316
+ return
317
+ reconnect_attempts += 1
318
+
319
+ self._sleep_before_reconnect(last_retry)
320
+
321
+ # Close the previously-opened reconnect response before opening
322
+ # a new one so we never hold more than one extra connection.
323
+ if owned_cm is not None:
324
+ owned_cm.__exit__(None, None, None)
325
+ owned_cm = None
326
+
327
+ assert self._reconnect is not None # guaranteed by _should_reconnect
328
+ try:
329
+ cm: ContextManager[httpx.Response] = self._reconnect(last_dispatched_id or "")
330
+ new_response = cm.__enter__()
331
+ except Exception:
332
+ # A failed reconnect consumes an attempt; back off and retry.
333
+ response = None
334
+ continue
335
+ owned_cm = cm
336
+ if new_response is None or not self._is_reconnect_response_usable(new_response):
337
+ # Null/empty body or a non-SSE/error response (e.g. 204/304,
338
+ # a 500, or an HTML error page): treat as a failed attempt.
339
+ response = None
340
+ continue
341
+
342
+ response = new_response
343
+ # Drop any partial event left over from the dropped stream, but
344
+ # keep the last event id (per the SSE spec) and start a fresh
345
+ # incremental text decoder for the new connection.
346
+ decoder.reset_in_progress_event()
347
+ text_decoder = self._new_text_decoder(new_response)
348
+ finally:
349
+ if owned_cm is not None:
350
+ owned_cm.__exit__(None, None, None)
351
+
352
+ async def aiter_sse(self) -> AsyncGenerator[ServerSentEvent, None]:
353
+ self._check_content_type()
354
+ decoder = SSEDecoder()
355
+ text_decoder = self._new_text_decoder()
356
+
357
+ last_dispatched_id: Optional[str] = None
358
+ last_retry: Optional[int] = None
359
+ reconnect_attempts = 0
360
+
361
+ response: Optional[httpx.Response] = self._response
362
+ owned_cm: Optional[AsyncContextManager[httpx.Response]] = None
363
+ try:
364
+ while True:
365
+ if response is not None:
366
+ events = self._adecode_response(response, decoder, text_decoder)
367
+ while True:
368
+ try:
369
+ sse = await events.__anext__()
370
+ except StopAsyncIteration:
371
+ break
372
+ except SSEError:
373
+ # A protocol violation (e.g. an oversized line) is a
374
+ # genuine error, not a dropped connection; propagate it.
375
+ # Listed first because ``SSEError`` subclasses
376
+ # ``httpx.TransportError``.
377
+ raise
378
+ except httpx.TransportError:
379
+ # A transport error mid-stream (e.g. the server dropped
380
+ # the connection: ``ReadError``/``RemoteProtocolError``)
381
+ # is a premature end. Only swallow it when reconnection
382
+ # is configured for this stream; otherwise re-raise so a
383
+ # non-resumable stream still surfaces the error to the
384
+ # caller instead of looking like a clean completion.
385
+ # When reconnection is applicable but the attempt budget
386
+ # is exhausted, we ``break`` and end cleanly below — the
387
+ # same way an exhausted empty/error-body resume does, so
388
+ # give-up is consistent regardless of failure shape.
389
+ if not self._reconnect_applicable():
390
+ raise
391
+ break
392
+ yield sse
393
+ if sse.id:
394
+ last_dispatched_id = sse.id
395
+ if sse.retry is not None:
396
+ last_retry = sse.retry
397
+ reconnect_attempts = 0
398
+
399
+ if not self._should_reconnect(last_dispatched_id, reconnect_attempts):
400
+ return
401
+ reconnect_attempts += 1
402
+
403
+ await self._asleep_before_reconnect(last_retry)
404
+
405
+ if owned_cm is not None:
406
+ await owned_cm.__aexit__(None, None, None)
407
+ owned_cm = None
408
+
409
+ assert self._reconnect is not None # guaranteed by _should_reconnect
410
+ try:
411
+ cm: AsyncContextManager[httpx.Response] = self._reconnect(last_dispatched_id or "")
412
+ new_response = await cm.__aenter__()
413
+ except Exception:
414
+ response = None
415
+ continue
416
+ owned_cm = cm
417
+ if new_response is None or not self._is_reconnect_response_usable(new_response):
418
+ response = None
419
+ continue
420
+
421
+ response = new_response
422
+ decoder.reset_in_progress_event()
423
+ text_decoder = self._new_text_decoder(new_response)
424
+ finally:
425
+ if owned_cm is not None:
426
+ # Shield the close so a cancellation delivered while reading a
427
+ # reconnected response still fully tears the connection down
428
+ # instead of leaking it until the client is closed.
429
+ with anyio.CancelScope(shield=True):
430
+ await owned_cm.__aexit__(None, None, None)
431
+
432
+
433
+ @contextmanager
434
+ def connect_sse(client: httpx.Client, method: str, url: str, **kwargs: Any) -> Iterator[EventSource]:
435
+ headers = kwargs.pop("headers", {})
436
+ headers["Accept"] = "text/event-stream"
437
+ headers["Cache-Control"] = "no-store"
438
+
439
+ with client.stream(method, url, headers=headers, **kwargs) as response:
440
+ yield EventSource(response)
441
+
442
+
443
+ @asynccontextmanager
444
+ async def aconnect_sse(
445
+ client: httpx.AsyncClient,
446
+ method: str,
447
+ url: str,
448
+ **kwargs: Any,
449
+ ) -> AsyncIterator[EventSource]:
450
+ headers = kwargs.pop("headers", {})
451
+ headers["Accept"] = "text/event-stream"
452
+ headers["Cache-Control"] = "no-store"
453
+
454
+ async with client.stream(method, url, headers=headers, **kwargs) as response:
455
+ yield EventSource(response)
@@ -0,0 +1,74 @@
1
+ # This file was auto-generated by Fern from our API Definition.
2
+
3
+ from typing import List, Optional
4
+
5
+ from ._models import ServerSentEvent
6
+
7
+
8
+ class SSEDecoder:
9
+ def __init__(self) -> None:
10
+ self._event = ""
11
+ self._data: List[str] = []
12
+ self._last_event_id = ""
13
+ self._retry: Optional[int] = None
14
+
15
+ def reset_in_progress_event(self) -> None:
16
+ """Discard any partially-parsed (undispatched) event.
17
+
18
+ Used when a stream ends mid-event before reconnecting: the buffered
19
+ ``event``/``data``/``retry`` fields of the never-dispatched event must
20
+ be dropped so they do not corrupt the first event of the reconnected
21
+ stream. Per the SSE spec the last event id is *not* reset here — it
22
+ persists across connections.
23
+ """
24
+ self._event = ""
25
+ self._data = []
26
+ self._retry = None
27
+
28
+ def decode(self, line: str) -> Optional[ServerSentEvent]:
29
+ # See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation # noqa: E501
30
+
31
+ if not line:
32
+ if not self._event and not self._data and not self._last_event_id and self._retry is None:
33
+ return None
34
+
35
+ sse = ServerSentEvent(
36
+ event=self._event,
37
+ data="\n".join(self._data),
38
+ id=self._last_event_id,
39
+ retry=self._retry,
40
+ )
41
+
42
+ # NOTE: as per the SSE spec, do not reset last_event_id.
43
+ self._event = ""
44
+ self._data = []
45
+ self._retry = None
46
+
47
+ return sse
48
+
49
+ if line.startswith(":"):
50
+ return None
51
+
52
+ fieldname, _, value = line.partition(":")
53
+
54
+ if value.startswith(" "):
55
+ value = value[1:]
56
+
57
+ if fieldname == "event":
58
+ self._event = value
59
+ elif fieldname == "data":
60
+ self._data.append(value)
61
+ elif fieldname == "id":
62
+ if "\0" in value:
63
+ pass
64
+ else:
65
+ self._last_event_id = value
66
+ elif fieldname == "retry":
67
+ try:
68
+ self._retry = int(value)
69
+ except (TypeError, ValueError):
70
+ pass
71
+ else:
72
+ pass # Field is ignored.
73
+
74
+ return None
@@ -0,0 +1,7 @@
1
+ # This file was auto-generated by Fern from our API Definition.
2
+
3
+ import httpx
4
+
5
+
6
+ class SSEError(httpx.TransportError):
7
+ pass
@@ -0,0 +1,17 @@
1
+ # This file was auto-generated by Fern from our API Definition.
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Any, Optional
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class ServerSentEvent:
10
+ event: str = "message"
11
+ data: str = ""
12
+ id: str = ""
13
+ retry: Optional[int] = None
14
+
15
+ def json(self) -> Any:
16
+ """Parse the data field as JSON."""
17
+ return json.loads(self.data)