tilebox-core 0.68.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.
@@ -0,0 +1,23 @@
1
+ .venv/
2
+
3
+ __pycache__/
4
+ .ipynb_checkpoints/
5
+ .virtual_documents/
6
+
7
+ .idea/
8
+
9
+ .hypothesis/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ .pytest_cache/
13
+
14
+ dist/
15
+
16
+ coverage.xml
17
+ .coverage*
18
+ test-report.xml
19
+
20
+ *.egg-info/
21
+
22
+ .amp/*
23
+ !.amp/services.yaml
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.5
2
+ Name: tilebox-core
3
+ Version: 0.68.0
4
+ Summary: Shared types and client infrastructure for Tilebox
5
+ Project-URL: Homepage, https://tilebox.com
6
+ Project-URL: Documentation, https://docs.tilebox.com/
7
+ Author-email: "Tilebox, Inc." <support@tilebox.com>
8
+ License: MIT License
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: anyio>=4
11
+ Requires-Dist: connectrpc<0.14,>=0.13.0
12
+ Requires-Dist: protobuf-py<0.8,>=0.7.0
13
+ Requires-Dist: pyqwest>=0.7.0
14
+ Description-Content-Type: text/markdown
15
+
16
+ # Tilebox Core
17
+
18
+ Shared types and client infrastructure for the Tilebox Python SDKs.
19
+
20
+ Public imports are `tilebox.query` for `TimeInterval`, `IDInterval`, and `Pagination`,
21
+ and `tilebox.errors` for SDK exceptions. The `_tilebox` namespace contains private
22
+ connections, generated messages, conversions, retries, pagination, and test utilities.
23
+
24
+ Core does not depend on datasets or its scientific libraries. Parsing ordinary Python
25
+ datetimes requires no optional libraries. Strings and scientific datetime values use
26
+ pandas when parsed; xarray inputs also require xarray. Install pandas explicitly for
27
+ these parsing conveniences in workflows-only environments. Datasets already installs
28
+ these libraries.
29
+
30
+ Run tests in the workspace with `uv sync --all-packages` followed by
31
+ `uv run --no-sync pytest tilebox-core`. Transport tests use generated datasets clients.
@@ -0,0 +1,16 @@
1
+ # Tilebox Core
2
+
3
+ Shared types and client infrastructure for the Tilebox Python SDKs.
4
+
5
+ Public imports are `tilebox.query` for `TimeInterval`, `IDInterval`, and `Pagination`,
6
+ and `tilebox.errors` for SDK exceptions. The `_tilebox` namespace contains private
7
+ connections, generated messages, conversions, retries, pagination, and test utilities.
8
+
9
+ Core does not depend on datasets or its scientific libraries. Parsing ordinary Python
10
+ datetimes requires no optional libraries. Strings and scientific datetime values use
11
+ pandas when parsed; xarray inputs also require xarray. Install pandas explicitly for
12
+ these parsing conveniences in workflows-only environments. Datasets already installs
13
+ these libraries.
14
+
15
+ Run tests in the workspace with `uv sync --all-packages` followed by
16
+ `uv run --no-sync pytest tilebox-core`. Transport tests use generated datasets clients.
@@ -0,0 +1 @@
1
+ """Shared native Connect transport support for Tilebox SDKs."""
@@ -0,0 +1 @@
1
+ """Asynchronous pagination and concurrency helpers."""
@@ -0,0 +1,65 @@
1
+ import asyncio
2
+ import inspect
3
+ from collections.abc import AsyncIterator, Awaitable, Callable
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ from typing import Any, TypeVar
6
+
7
+ from anyio import EndOfStream, create_memory_object_stream, create_task_group
8
+ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
9
+
10
+ T = TypeVar("T")
11
+
12
+
13
+ async def async_producer_consumer(
14
+ producer: AsyncIterator[T],
15
+ consumer: Callable[[T], None] | Callable[[T], Awaitable[Any]],
16
+ buffer_size: int = 10,
17
+ ) -> None:
18
+ """
19
+ Consume messages from a async producer (e.g. a service endpoint) as they come in. This ensures that the consumer
20
+ is already computing each produced message while the next message is being produced.
21
+
22
+ If the consumer is actually slower than the producer producing new message, the messages are buffered up to the
23
+ given buffer size. If the buffer is full, the producer will block until the consumer has consumed a message.
24
+
25
+ Args:
26
+ producer: The async generator to consume from
27
+ consumer: The consumer function to call for each message
28
+ buffer_size: The maximum number of messages to buffer before blocking the producer
29
+ """
30
+ streams: Any = create_memory_object_stream(max_buffer_size=buffer_size)
31
+ send: MemoryObjectSendStream[T] = streams[0]
32
+ receive: MemoryObjectReceiveStream[T] = streams[1]
33
+ async with create_task_group() as task_group:
34
+ task_group.start_soon(_producer, producer, send)
35
+ task_group.start_soon(_consumer, consumer, receive)
36
+
37
+
38
+ async def _producer(producer: AsyncIterator[T], send_stream: MemoryObjectSendStream[T]) -> None:
39
+ """
40
+ Forward all messages from a producer (async iterator) and send them to a buffered send stream.
41
+ """
42
+ async with send_stream:
43
+ async for message in producer:
44
+ await send_stream.send(message)
45
+
46
+
47
+ async def _consumer(
48
+ consume: Callable[[T], None] | Callable[[T], Awaitable[Any]], receive_stream: MemoryObjectReceiveStream[T]
49
+ ) -> None:
50
+ """
51
+ Receive messages from a buffered receive stream and forward them to a consumer until no more messages are available.
52
+ """
53
+
54
+ loop = asyncio.get_running_loop()
55
+
56
+ with ThreadPoolExecutor(max_workers=1) as executor, receive_stream:
57
+ try:
58
+ while True:
59
+ message = await receive_stream.receive()
60
+ # result = consume(message), but in a background thread so that async loop can continue in the meantime:
61
+ result = await loop.run_in_executor(executor, consume, message)
62
+ if inspect.isawaitable(result):
63
+ await result
64
+ except EndOfStream:
65
+ pass
@@ -0,0 +1,29 @@
1
+ from collections.abc import AsyncIterator, Awaitable, Callable
2
+
3
+ from _tilebox.pagination import AnyResultPage, Pagination
4
+
5
+
6
+ async def paginated_request(
7
+ paging_request: Callable[[Pagination], Awaitable[AnyResultPage]],
8
+ initial_page: Pagination,
9
+ ) -> AsyncIterator[AnyResultPage]:
10
+ """Make a paginated request to a service endpoint.
11
+
12
+ The endpoint is expected to return a next_page field, which is used for subsequent requests. Once no such
13
+ next_page field is returned, the request is completed.
14
+
15
+ Args:
16
+ paging_request: A function that takes a page as input and returns a Datapoints object
17
+ Often this will be a functools.partial object that wraps a gRPC service endpoint
18
+ and only leaves the page argument remaining
19
+ initial_page: The initial page to request
20
+
21
+ Yields:
22
+ Datapoints: The individual pages of the response
23
+ """
24
+ response = await paging_request(initial_page)
25
+ yield response
26
+
27
+ while response.next_page.starting_after is not None:
28
+ response = await paging_request(response.next_page)
29
+ yield response
@@ -0,0 +1,211 @@
1
+ """Owned HTTP transports for native Connect clients."""
2
+
3
+ import asyncio
4
+ import time
5
+ from collections.abc import Awaitable, Callable, Iterable
6
+ from typing import Literal, Self, TypeAlias, TypeVar
7
+ from urllib.parse import urlsplit, urlunsplit
8
+
9
+ from connectrpc.client import ConnectClient, ConnectClientSync
10
+ from connectrpc.code import Code
11
+ from connectrpc.errors import ConnectError
12
+ from connectrpc.interceptor import Interceptor, InterceptorSync
13
+ from connectrpc.request import RequestContext
14
+ from pyqwest import Client, HTTPTransport, HTTPVersion, SyncClient, SyncHTTPTransport
15
+
16
+ from _tilebox.client_metadata import client_metadata
17
+ from _tilebox.errors import translate_connect_error
18
+
19
+ Transport: TypeAlias = Literal["http2", "http1", "grpc"]
20
+
21
+ Sync = TypeVar("Sync", bound=ConnectClientSync)
22
+ Async = TypeVar("Async", bound=ConnectClient)
23
+ Req = TypeVar("Req")
24
+ Res = TypeVar("Res")
25
+
26
+
27
+ def _http_version(transport: Transport) -> HTTPVersion:
28
+ match transport:
29
+ case "http2":
30
+ return HTTPVersion.HTTP2
31
+ case "http1":
32
+ return HTTPVersion.HTTP1
33
+ case _:
34
+ raise ValueError(f"Unsupported transport {transport!r}; use 'http2' or 'http1' (both use Connect)")
35
+
36
+
37
+ def _retry_delay(ctx: RequestContext, error: ConnectError, attempt: int) -> float | None:
38
+ delay = min(0.02 * 3**attempt, 1.0)
39
+ if (
40
+ attempt >= 4
41
+ or error.code not in {Code.UNAVAILABLE, Code.RESOURCE_EXHAUSTED}
42
+ or ctx.timeout_ms is None
43
+ or ctx.timeout_ms <= delay * 1000
44
+ ):
45
+ return None
46
+ return delay
47
+
48
+
49
+ def connect_address(url: str, rpc_method_prefix: str | None = None) -> str:
50
+ """Normalize endpoints, defaulting bare hosts to TLS and development ports to HTTP."""
51
+ if url.startswith("unix:"):
52
+ raise ValueError("Connect HTTP transport does not support unix socket URLs")
53
+ if "://" not in url:
54
+ parsed = urlsplit(f"//{url}")
55
+ scheme = "http" if parsed.port not in (None, 443) else "https"
56
+ url = f"{scheme}://{url}"
57
+ parsed = urlsplit(url)
58
+ if (
59
+ parsed.scheme not in {"http", "https"}
60
+ or not parsed.hostname
61
+ or parsed.username
62
+ or parsed.password
63
+ or parsed.query
64
+ or parsed.fragment
65
+ or any(character.isspace() for character in url)
66
+ ):
67
+ raise ValueError(f"Invalid URL: {url}")
68
+ if parsed.scheme == "http" and parsed.port is None:
69
+ raise ValueError("Explicit port required for insecure HTTP connection")
70
+ if parsed.scheme == "https" and parsed.port == 443:
71
+ url = urlunsplit((parsed.scheme, parsed.netloc.removesuffix(":443"), parsed.path, "", ""))
72
+ prefix = (rpc_method_prefix or "").strip("/")
73
+ return url.rstrip("/") + (f"/{prefix}" if prefix else "")
74
+
75
+
76
+ class _MetadataErrors:
77
+ def __init__(self, token: str | None, address: str) -> None:
78
+ self.headers = client_metadata()
79
+ self.token = token
80
+ self.prefix = urlsplit(address).path.rstrip("/")
81
+
82
+ def _prepare(self, ctx: RequestContext) -> str:
83
+ for name, value in self.headers.items():
84
+ ctx.request_headers[name] = value
85
+ if self.token is not None:
86
+ ctx.request_headers["authorization"] = f"Bearer {self.token}"
87
+ return f"{self.prefix}/{ctx.method.service_name}/{ctx.method.name}"
88
+
89
+ def intercept_unary_sync(
90
+ self,
91
+ call_next: Callable[[Req, RequestContext], Res],
92
+ request: Req,
93
+ ctx: RequestContext,
94
+ ) -> Res:
95
+ method = self._prepare(ctx)
96
+ attempt = 0
97
+ while True:
98
+ try:
99
+ return call_next(request, ctx)
100
+ except ConnectError as error:
101
+ delay = _retry_delay(ctx, error, attempt)
102
+ if delay is None:
103
+ raise translate_connect_error(error, method) from None
104
+ time.sleep(delay)
105
+ attempt += 1
106
+
107
+ async def intercept_unary(
108
+ self,
109
+ call_next: Callable[[Req, RequestContext], Awaitable[Res]],
110
+ request: Req,
111
+ ctx: RequestContext,
112
+ ) -> Res:
113
+ method = self._prepare(ctx)
114
+ attempt = 0
115
+ while True:
116
+ try:
117
+ return await call_next(request, ctx)
118
+ except ConnectError as error:
119
+ delay = _retry_delay(ctx, error, attempt)
120
+ if delay is None:
121
+ raise translate_connect_error(error, method) from None
122
+ await asyncio.sleep(delay)
123
+ attempt += 1
124
+
125
+
126
+ class Connection:
127
+ """Share one explicitly owned HTTP transport across generated synchronous clients."""
128
+
129
+ def __init__(
130
+ self,
131
+ url: str,
132
+ token: str | None = None,
133
+ rpc_method_prefix: str | None = None,
134
+ *,
135
+ transport: Transport = "http2",
136
+ interceptors: Iterable[InterceptorSync] = (),
137
+ ) -> None:
138
+ self.address = connect_address(url, rpc_method_prefix)
139
+ self.transport = "http2" if transport == "grpc" else transport
140
+ self._transport = SyncHTTPTransport(http_version=_http_version(self.transport), tls_include_system_certs=True)
141
+ self._http = SyncClient(self._transport)
142
+ self._interceptors = (_MetadataErrors(token, self.address), *interceptors)
143
+ self._closed = False
144
+
145
+ def client(self, client_class: type[Sync]) -> Sync:
146
+ """Construct a native generated client using this connection's HTTP client."""
147
+ if self._closed:
148
+ raise RuntimeError("Connection is closed")
149
+ return client_class(
150
+ self.address,
151
+ http_client=self._http,
152
+ interceptors=self._interceptors,
153
+ read_max_bytes=512 * 1024 * 1024,
154
+ timeout_ms=60_000,
155
+ )
156
+
157
+ def close(self) -> None:
158
+ """Release this connection's transport; safe to call more than once."""
159
+ if not self._closed:
160
+ self._closed = True
161
+ self._transport.close()
162
+
163
+ def __enter__(self) -> Self:
164
+ return self
165
+
166
+ def __exit__(self, *exc: object) -> None:
167
+ self.close()
168
+
169
+
170
+ class AsyncConnection:
171
+ """Share one explicitly owned HTTP transport across generated asynchronous clients."""
172
+
173
+ def __init__(
174
+ self,
175
+ url: str,
176
+ token: str | None = None,
177
+ rpc_method_prefix: str | None = None,
178
+ *,
179
+ transport: Transport = "http2",
180
+ interceptors: Iterable[Interceptor] = (),
181
+ ) -> None:
182
+ self.address = connect_address(url, rpc_method_prefix)
183
+ self.transport = "http2" if transport == "grpc" else transport
184
+ self._transport = HTTPTransport(http_version=_http_version(self.transport), tls_include_system_certs=True)
185
+ self._http = Client(self._transport)
186
+ self._interceptors = (_MetadataErrors(token, self.address), *interceptors)
187
+ self._closed = False
188
+
189
+ def client(self, client_class: type[Async]) -> Async:
190
+ """Construct a native generated client without opening an event loop."""
191
+ if self._closed:
192
+ raise RuntimeError("Connection is closed")
193
+ return client_class(
194
+ self.address,
195
+ http_client=self._http,
196
+ interceptors=self._interceptors,
197
+ read_max_bytes=512 * 1024 * 1024,
198
+ timeout_ms=60_000,
199
+ )
200
+
201
+ async def close(self) -> None:
202
+ """Release this connection's transport; safe to call more than once."""
203
+ if not self._closed:
204
+ self._closed = True
205
+ await self._transport.aclose()
206
+
207
+ async def __aenter__(self) -> Self:
208
+ return self
209
+
210
+ async def __aexit__(self, *exc: object) -> None:
211
+ await self.close()
@@ -0,0 +1,210 @@
1
+ import builtins
2
+ import os
3
+ import platform
4
+ import sys
5
+ from collections.abc import Mapping
6
+ from functools import lru_cache
7
+ from importlib.metadata import PackageNotFoundError
8
+ from importlib.metadata import version as package_version
9
+
10
+ CLIENT_HEADER = "Tilebox-Client"
11
+
12
+ _MAX_FIELD_BYTES = 256
13
+ _MAX_HEADER_BYTES = 2 * 1024
14
+
15
+
16
+ @lru_cache(maxsize=1)
17
+ def client_metadata() -> dict[str, str]:
18
+ """Return cached metadata identifying this SDK and its execution context."""
19
+ return {CLIENT_HEADER: _client_header()}
20
+
21
+
22
+ def _client_header() -> str:
23
+ fields = {
24
+ "name": "python",
25
+ "version": _package_version(),
26
+ "runtime": "python",
27
+ "runtime-version": platform.python_version(),
28
+ "os": _os_name(),
29
+ "os-version": platform.release(),
30
+ "arch": _architecture(),
31
+ "execution-environment": _execution_environment(os.environ),
32
+ **_invoker(os.environ),
33
+ **_cloud_environment(os.environ),
34
+ }
35
+ return _serialize_dictionary(fields)
36
+
37
+
38
+ def _package_version() -> str | None:
39
+ try:
40
+ return package_version("tilebox-python")
41
+ except PackageNotFoundError:
42
+ try:
43
+ return package_version("tilebox-core")
44
+ except PackageNotFoundError:
45
+ return None
46
+
47
+
48
+ def _os_name() -> str | None:
49
+ return platform.system().lower() or None
50
+
51
+
52
+ def _architecture() -> str | None:
53
+ machine = platform.machine().lower()
54
+ architectures = {
55
+ "aarch64": "arm64",
56
+ "amd64": "amd64",
57
+ "arm64": "arm64",
58
+ "armv6l": "arm",
59
+ "armv7l": "arm",
60
+ "i386": "386",
61
+ "i686": "386",
62
+ "x86": "386",
63
+ "x86_64": "amd64",
64
+ }
65
+ return architectures.get(machine, machine or None)
66
+
67
+
68
+ def _execution_environment(environ: Mapping[str, str]) -> str | None:
69
+ signals = (
70
+ (environ.get("GITHUB_ACTIONS") == "true", "github-actions"),
71
+ (environ.get("GITLAB_CI") == "true", "gitlab-ci"),
72
+ (_env_set(environ, "BUILDKITE"), "buildkite"),
73
+ (_env_set(environ, "CIRCLECI"), "circleci"),
74
+ (_env_set(environ, "JENKINS_URL"), "jenkins"),
75
+ (_env_set(environ, "TEAMCITY_VERSION"), "teamcity"),
76
+ (_env_set(environ, "TF_BUILD"), "azure-pipelines"),
77
+ (_env_set(environ, "K_SERVICE", "CLOUD_RUN_JOB"), "google-cloud-run"),
78
+ (environ.get("AWS_EXECUTION_ENV", "").startswith("AWS_Lambda_"), "aws-lambda"),
79
+ (_env_set(environ, "FUNCTIONS_WORKER_RUNTIME"), "azure-functions"),
80
+ (_env_set(environ, "KUBERNETES_SERVICE_HOST"), "kubernetes"),
81
+ )
82
+ environment = next((name for detected, name in signals if detected), None)
83
+
84
+ if environment is not None:
85
+ return environment
86
+
87
+ shell = _interactive_shell()
88
+ if _env_set(environ, "COLAB_RELEASE_TAG") or shell == "google-colab":
89
+ environment = "google-colab"
90
+ elif shell == "jupyter" or _env_set(environ, "JPY_PARENT_PID"):
91
+ jupyterlab_variables = ("JUPYTERLAB_DIR", "JUPYTERLAB_SETTINGS_DIR", "JUPYTERLAB_WORKSPACES_DIR")
92
+ environment = "jupyterlab" if _env_set(environ, *jupyterlab_variables) else "jupyter"
93
+ elif shell == "terminal" or hasattr(sys, "ps1") or (sys.stdin is not None and sys.stdin.isatty()):
94
+ environment = "terminal"
95
+ return environment
96
+
97
+
98
+ def _interactive_shell() -> str | None:
99
+ get_ipython = getattr(builtins, "get_ipython", None)
100
+ if not callable(get_ipython):
101
+ return None
102
+ shell = get_ipython()
103
+ shell_type = type(shell)
104
+ qualified_name = f"{shell_type.__module__}.{shell_type.__name__}".lower()
105
+ if "google.colab" in qualified_name:
106
+ return "google-colab"
107
+ if "zmqinteractiveshell" in qualified_name:
108
+ return "jupyter"
109
+ if "terminalinteractiveshell" in qualified_name:
110
+ return "terminal"
111
+ return None
112
+
113
+
114
+ def _invoker(environ: Mapping[str, str]) -> dict[str, str]:
115
+ invoker = None
116
+ version = None
117
+ if environ.get("AGENT") == "amp":
118
+ invoker = "amp"
119
+ elif _env_set(environ, "COPILOT_AGENT_SESSION_ID"):
120
+ invoker = "github-copilot"
121
+ elif environ.get("OPENCODE") == "1":
122
+ invoker = "opencode"
123
+ elif _env_set(environ, "CLAUDECODE"):
124
+ invoker = "claude-code"
125
+ elif _env_set(environ, "CURSOR_AGENT"):
126
+ invoker = "cursor"
127
+ elif _env_set(environ, "CODEX_SESSION_ID", "CODEX_THREAD_ID"):
128
+ invoker = "codex"
129
+ version = environ.get("CODEX_VERSION")
130
+ elif _env_set(environ, "GEMINI_CLI"):
131
+ invoker = "gemini-cli"
132
+ return _optional_version(invoker, version) if invoker else {}
133
+
134
+
135
+ def _optional_version(invoker: str, version: str | None) -> dict[str, str]:
136
+ metadata = {"invoker": invoker}
137
+ if version:
138
+ metadata["invoker-version"] = version
139
+ return metadata
140
+
141
+
142
+ def _cloud_environment(environ: Mapping[str, str]) -> dict[str, str]:
143
+ provider, platform_name, region = _detect_cloud_environment(environ)
144
+ return {
145
+ key: value
146
+ for key, value in (
147
+ ("cloud-provider", provider),
148
+ ("cloud-platform", platform_name),
149
+ ("cloud-region", region),
150
+ )
151
+ if value
152
+ }
153
+
154
+
155
+ def _detect_cloud_environment(environ: Mapping[str, str]) -> tuple[str | None, str | None, str | None]:
156
+ if _env_set(environ, "AWS_EXECUTION_ENV", "AWS_REGION", "AWS_DEFAULT_REGION"):
157
+ execution_environment = environ.get("AWS_EXECUTION_ENV", "")
158
+ platform_name = None
159
+ if execution_environment.startswith("AWS_Lambda_"):
160
+ platform_name = "aws_lambda"
161
+ elif execution_environment.startswith("AWS_ECS_"):
162
+ platform_name = "aws_ecs"
163
+ return "aws", platform_name, _first_environment_value(environ, "AWS_REGION", "AWS_DEFAULT_REGION")
164
+
165
+ if _env_set(environ, "K_SERVICE", "CLOUD_RUN_JOB", "GAE_ENV"):
166
+ platform_name = "gcp_cloud_run" if _env_set(environ, "K_SERVICE", "CLOUD_RUN_JOB") else "gcp_app_engine"
167
+ region = _first_environment_value(environ, "GOOGLE_CLOUD_REGION", "CLOUD_RUN_REGION", "FUNCTION_REGION")
168
+ return "gcp", platform_name, region
169
+
170
+ if _env_set(environ, "WEBSITE_INSTANCE_ID", "FUNCTIONS_WORKER_RUNTIME", "REGION_NAME"):
171
+ platform_name = None
172
+ if _env_set(environ, "FUNCTIONS_WORKER_RUNTIME"):
173
+ platform_name = "azure_functions"
174
+ elif _env_set(environ, "WEBSITE_INSTANCE_ID"):
175
+ platform_name = "azure_app_service"
176
+ return "azure", platform_name, environ.get("REGION_NAME")
177
+
178
+ return None, None, None
179
+
180
+
181
+ def _env_set(environ: Mapping[str, str], *names: str) -> bool:
182
+ return any(environ.get(name) for name in names)
183
+
184
+
185
+ def _first_environment_value(environ: Mapping[str, str], *names: str) -> str | None:
186
+ return next((value for name in names if (value := environ.get(name))), None)
187
+
188
+
189
+ def _serialize_dictionary(fields: Mapping[str, str | None]) -> str:
190
+ members: list[str] = []
191
+ header_size = 0
192
+ for key, value in fields.items():
193
+ if not value or not _valid_string(value):
194
+ continue
195
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
196
+ member = f'{key}="{escaped}"'
197
+ member_size = len(member.encode("ascii")) + (2 if members else 0)
198
+ if header_size + member_size > _MAX_HEADER_BYTES:
199
+ continue
200
+ members.append(member)
201
+ header_size += member_size
202
+ return ", ".join(members)
203
+
204
+
205
+ def _valid_string(value: str) -> bool:
206
+ try:
207
+ encoded = value.encode("ascii")
208
+ except UnicodeEncodeError:
209
+ return False
210
+ return len(encoded) <= _MAX_FIELD_BYTES and all(0x20 <= byte <= 0x7E for byte in encoded)
@@ -0,0 +1,45 @@
1
+ """Bounded producer/consumer concurrency with exception propagation."""
2
+
3
+ from collections.abc import Callable, Iterator
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ from queue import Empty, Full, Queue
6
+ from threading import Event
7
+ from typing import TypeVar
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ def concurrent_producer_consumer(
13
+ producer: Iterator[T],
14
+ consumer: Callable[[T], object],
15
+ buffer_size: int = 10,
16
+ ) -> None:
17
+ """Produce on the calling thread and consume on one worker, propagating failures."""
18
+ if buffer_size < 1:
19
+ raise ValueError("buffer_size must be positive")
20
+ queue: Queue[T] = Queue(buffer_size)
21
+ done = Event()
22
+
23
+ def consume() -> None:
24
+ while not done.is_set() or not queue.empty():
25
+ try:
26
+ item = queue.get(timeout=0.05)
27
+ except Empty:
28
+ continue
29
+ consumer(item)
30
+
31
+ with ThreadPoolExecutor(max_workers=1) as executor:
32
+ future = executor.submit(consume)
33
+ try:
34
+ for item in producer:
35
+ while True:
36
+ if future.done():
37
+ future.result()
38
+ try:
39
+ queue.put(item, timeout=0.05)
40
+ break
41
+ except Full:
42
+ continue
43
+ finally:
44
+ done.set()
45
+ future.result()