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.
- common/.coveragerc +4 -0
- common/__init__.py +3 -0
- common/client/__init__.py +0 -0
- common/client/base_skyflow.py +345 -0
- common/client/utils/__init__.py +1 -0
- common/client/utils/_utils.py +127 -0
- common/errors/__init__.py +1 -0
- common/errors/_skyflow_error.py +17 -0
- common/generated/__init__.py +0 -0
- common/generated/rest/__init__.py +24 -0
- common/generated/rest/authentication/__init__.py +4 -0
- common/generated/rest/authentication/client.py +181 -0
- common/generated/rest/authentication/raw_client.py +241 -0
- common/generated/rest/client.py +153 -0
- common/generated/rest/core/__init__.py +52 -0
- common/generated/rest/core/api_error.py +23 -0
- common/generated/rest/core/client_wrapper.py +86 -0
- common/generated/rest/core/datetime_utils.py +28 -0
- common/generated/rest/core/file.py +67 -0
- common/generated/rest/core/force_multipart.py +16 -0
- common/generated/rest/core/http_client.py +543 -0
- common/generated/rest/core/http_response.py +55 -0
- common/generated/rest/core/jsonable_encoder.py +100 -0
- common/generated/rest/core/pydantic_utilities.py +255 -0
- common/generated/rest/core/query_encoder.py +58 -0
- common/generated/rest/core/remove_none_from_dict.py +11 -0
- common/generated/rest/core/request_options.py +35 -0
- common/generated/rest/core/serialization.py +276 -0
- common/generated/rest/environment.py +8 -0
- common/generated/rest/errors/__init__.py +9 -0
- common/generated/rest/errors/bad_request_error.py +14 -0
- common/generated/rest/errors/not_found_error.py +14 -0
- common/generated/rest/errors/unauthorized_error.py +14 -0
- common/generated/rest/py.typed +0 -0
- common/generated/rest/types/__init__.py +9 -0
- common/generated/rest/types/googlerpc_status.py +22 -0
- common/generated/rest/types/protobuf_any.py +21 -0
- common/generated/rest/types/v_1_get_auth_token_response.py +33 -0
- common/generated/rest/version.py +6 -0
- common/service_account/__init__.py +1 -0
- common/service_account/_utils.py +248 -0
- common/service_account/client/__init__.py +0 -0
- common/service_account/client/auth_client.py +13 -0
- common/utils/__init__.py +4 -0
- common/utils/_helpers.py +18 -0
- common/utils/_skyflow_messages.py +445 -0
- common/utils/_utils.py +50 -0
- common/utils/constants.py +291 -0
- common/utils/enums/__init__.py +12 -0
- common/utils/enums/content_types.py +9 -0
- common/utils/enums/detect_entities.py +73 -0
- common/utils/enums/detect_output_transcriptions.py +8 -0
- common/utils/enums/env.py +13 -0
- common/utils/enums/log_level.py +8 -0
- common/utils/enums/masking_method.py +5 -0
- common/utils/enums/redaction_type.py +7 -0
- common/utils/enums/request_method.py +8 -0
- common/utils/enums/token_mode.py +6 -0
- common/utils/enums/token_type.py +6 -0
- common/utils/logger/__init__.py +2 -0
- common/utils/logger/_log_helpers.py +47 -0
- common/utils/logger/_logger.py +50 -0
- common/utils/validations/__init__.py +11 -0
- common/utils/validations/_validations.py +267 -0
- common/vault/base_vault_client.py +122 -0
- common/vault/base_vault_controller.py +58 -0
- common/vault/data/__init__.py +2 -0
- common/vault/data/_base_insert_request.py +7 -0
- common/vault/data/_base_insert_response.py +11 -0
- skyflow/__init__.py +2 -0
- skyflow/client/__init__.py +1 -0
- skyflow/client/_http_config_builder.py +45 -0
- skyflow/client/skyflow.py +15 -0
- skyflow/error/__init__.py +3 -0
- skyflow/generated/__init__.py +0 -0
- skyflow/generated/rest/__init__.py +164 -0
- skyflow/generated/rest/_default_clients.py +32 -0
- skyflow/generated/rest/client.py +385 -0
- skyflow/generated/rest/core/__init__.py +127 -0
- skyflow/generated/rest/core/api_error.py +23 -0
- skyflow/generated/rest/core/client_wrapper.py +148 -0
- skyflow/generated/rest/core/datetime_utils.py +70 -0
- skyflow/generated/rest/core/file.py +67 -0
- skyflow/generated/rest/core/force_multipart.py +18 -0
- skyflow/generated/rest/core/http_client.py +940 -0
- skyflow/generated/rest/core/http_response.py +63 -0
- skyflow/generated/rest/core/http_sse/__init__.py +42 -0
- skyflow/generated/rest/core/http_sse/_api.py +455 -0
- skyflow/generated/rest/core/http_sse/_decoders.py +74 -0
- skyflow/generated/rest/core/http_sse/_exceptions.py +7 -0
- skyflow/generated/rest/core/http_sse/_models.py +17 -0
- skyflow/generated/rest/core/jsonable_encoder.py +133 -0
- skyflow/generated/rest/core/logging.py +107 -0
- skyflow/generated/rest/core/parse_error.py +36 -0
- skyflow/generated/rest/core/pydantic_utilities.py +486 -0
- skyflow/generated/rest/core/query_encoder.py +58 -0
- skyflow/generated/rest/core/remove_none_from_dict.py +11 -0
- skyflow/generated/rest/core/request_options.py +40 -0
- skyflow/generated/rest/core/serialization.py +347 -0
- skyflow/generated/rest/environment.py +8 -0
- skyflow/generated/rest/errors/__init__.py +53 -0
- skyflow/generated/rest/errors/bad_request_error.py +11 -0
- skyflow/generated/rest/errors/forbidden_error.py +11 -0
- skyflow/generated/rest/errors/internal_server_error.py +11 -0
- skyflow/generated/rest/errors/not_found_error.py +11 -0
- skyflow/generated/rest/errors/too_many_requests_error.py +11 -0
- skyflow/generated/rest/errors/unauthorized_error.py +11 -0
- skyflow/generated/rest/py.typed +0 -0
- skyflow/generated/rest/query/__init__.py +4 -0
- skyflow/generated/rest/query/client.py +139 -0
- skyflow/generated/rest/query/raw_client.py +239 -0
- skyflow/generated/rest/raw_client.py +86 -0
- skyflow/generated/rest/records/__init__.py +34 -0
- skyflow/generated/rest/records/client.py +685 -0
- skyflow/generated/rest/records/raw_client.py +988 -0
- skyflow/generated/rest/records/types/__init__.py +34 -0
- skyflow/generated/rest/records/types/update_request_update_type.py +5 -0
- skyflow/generated/rest/tokens/__init__.py +4 -0
- skyflow/generated/rest/tokens/client.py +246 -0
- skyflow/generated/rest/tokens/raw_client.py +507 -0
- skyflow/generated/rest/types/__init__.py +116 -0
- skyflow/generated/rest/types/column_redactions.py +33 -0
- skyflow/generated/rest/types/delete_response.py +23 -0
- skyflow/generated/rest/types/delete_response_object.py +42 -0
- skyflow/generated/rest/types/detokenize_response.py +23 -0
- skyflow/generated/rest/types/detokenize_response_object.py +54 -0
- skyflow/generated/rest/types/error_response.py +20 -0
- skyflow/generated/rest/types/error_response_error.py +28 -0
- skyflow/generated/rest/types/execute_query_record_response.py +22 -0
- skyflow/generated/rest/types/execute_query_response.py +26 -0
- skyflow/generated/rest/types/execute_query_response_metadata.py +26 -0
- skyflow/generated/rest/types/get_request_data.py +60 -0
- skyflow/generated/rest/types/get_response.py +23 -0
- skyflow/generated/rest/types/get_tokens_from_values_request_object.py +30 -0
- skyflow/generated/rest/types/get_tokens_from_values_response.py +23 -0
- skyflow/generated/rest/types/google_protobuf_value.py +8 -0
- skyflow/generated/rest/types/http_code.py +6 -0
- skyflow/generated/rest/types/insert_record_data.py +36 -0
- skyflow/generated/rest/types/insert_response.py +23 -0
- skyflow/generated/rest/types/record_response_object.py +70 -0
- skyflow/generated/rest/types/token_group_redactions.py +33 -0
- skyflow/generated/rest/types/tokenize_response_object.py +49 -0
- skyflow/generated/rest/types/unique_value.py +22 -0
- skyflow/generated/rest/types/update_record_data.py +52 -0
- skyflow/generated/rest/types/update_record_data_update_type.py +5 -0
- skyflow/generated/rest/types/update_response.py +23 -0
- skyflow/generated/rest/types/upsert.py +45 -0
- skyflow/generated/rest/types/upsert_update_type.py +5 -0
- skyflow/generated/rest/version.py +6 -0
- skyflow/service_account/__init__.py +15 -0
- skyflow/utils/__init__.py +9 -0
- skyflow/utils/_http_config.py +36 -0
- skyflow/utils/_response_parsing.py +66 -0
- skyflow/utils/_retry.py +97 -0
- skyflow/utils/_skyflow_messages.py +114 -0
- skyflow/utils/_utils.py +54 -0
- skyflow/utils/_version.py +1 -0
- skyflow/utils/enums/__init__.py +3 -0
- skyflow/utils/enums/_custom_header_key.py +10 -0
- skyflow/utils/enums/_env_urls.py +9 -0
- skyflow/utils/enums/_upsert_type.py +7 -0
- skyflow/utils/validations/__init__.py +9 -0
- skyflow/utils/validations/_validations.py +195 -0
- skyflow/vault/__init__.py +0 -0
- skyflow/vault/client/__init__.py +0 -0
- skyflow/vault/client/client.py +102 -0
- skyflow/vault/controller/__init__.py +1 -0
- skyflow/vault/controller/_vault.py +475 -0
- skyflow/vault/data/__init__.py +30 -0
- skyflow/vault/data/_column_redactions.py +4 -0
- skyflow/vault/data/_delete_options.py +8 -0
- skyflow/vault/data/_delete_request.py +5 -0
- skyflow/vault/data/_delete_response.py +9 -0
- skyflow/vault/data/_delete_response_record.py +9 -0
- skyflow/vault/data/_detokenize_options.py +8 -0
- skyflow/vault/data/_detokenize_request.py +9 -0
- skyflow/vault/data/_detokenize_response.py +9 -0
- skyflow/vault/data/_detokenize_response_record.py +16 -0
- skyflow/vault/data/_detokenize_response_record_metadata.py +10 -0
- skyflow/vault/data/_get_options.py +8 -0
- skyflow/vault/data/_get_request.py +17 -0
- skyflow/vault/data/_get_request_record.py +13 -0
- skyflow/vault/data/_get_response.py +9 -0
- skyflow/vault/data/_get_response_record.py +17 -0
- skyflow/vault/data/_insert_options.py +8 -0
- skyflow/vault/data/_insert_request.py +11 -0
- skyflow/vault/data/_insert_request_record.py +9 -0
- skyflow/vault/data/_insert_response.py +9 -0
- skyflow/vault/data/_insert_response_record.py +17 -0
- skyflow/vault/data/_request_context.py +16 -0
- skyflow/vault/data/_token.py +8 -0
- skyflow/vault/data/_token_group_redactions.py +4 -0
- skyflow/vault/data/_update_options.py +8 -0
- skyflow/vault/data/_update_request.py +11 -0
- skyflow/vault/data/_update_request_record.py +6 -0
- skyflow/vault/data/_update_response.py +9 -0
- skyflow/vault/data/_update_response_record.py +17 -0
- skyflow/vault/data/_upsert_options.py +7 -0
- skyflow_flowvault_python-1.0.0.dist-info/METADATA +896 -0
- skyflow_flowvault_python-1.0.0.dist-info/RECORD +202 -0
- skyflow_flowvault_python-1.0.0.dist-info/WHEEL +5 -0
- 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,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)
|