Source code for eodag.utils.streamresponse

# -*- coding: utf-8 -*-
# Copyright 2026, CS GROUP - France, https://www.csgroup.eu/
#
# This file is part of EODAG project
#     https://www.github.com/CS-SI/EODAG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations

import os
import signal
import threading
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Iterable, Iterator, Mapping, Optional, Union

if TYPE_CHECKING:
    from requests.structures import CaseInsensitiveDict


def _new_case_insensitive_dict(headers: Optional[Mapping[str, str]] = None):
    """Create headers mapping with a local import to avoid import-time overhead."""
    from requests.structures import CaseInsensitiveDict

    return CaseInsensitiveDict(headers) if headers else CaseInsensitiveDict()


[docs] class StreamResponseContent(Iterable[bytes]): """ ByteIO minimal compatibility, used by boto3.upload_fileobj that's usually expect BytesIO object, not Iterable[bytes] """ __initialized: bool = False __instances: list["StreamResponseContent"] = []
[docs] @staticmethod def install_signal_handlers() -> bool: """Register SIGINT/SIGTERM handlers that interrupt any live stream. This must be called explicitly from the main thread (typically during a server's startup) because :func:`signal.signal` only works there. It is a no-op if the handlers are already installed or if it is called outside of the main thread. :returns: ``True`` if the handlers were installed, ``False`` otherwise. """ if StreamResponseContent.__initialized: return False if threading.current_thread() is not threading.main_thread(): return False StreamResponseContent.__initialized = True # Catch end of main process to internal status def signal_handler(sig, frame): for stream in StreamResponseContent.__instances: stream.interrupt() StreamResponseContent.__instances = [] signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) return True
def __init__(self, content: Union[Iterable[bytes], bytes]): self.buffer: bytes = b"" self.interrupted: bool = False StreamResponseContent.__instances.append(self) if isinstance(content, bytes): content = [content] self.iterator: Iterator[bytes] = iter(content) def __iter__(self) -> Iterator[bytes]: return self.iterator def interrupt(self): if not self.interrupted: self.interrupted = True def read(self, size=-1) -> bytes: # Fill the buffer with enough bytes to fulfil the request. while not self.interrupted and (size < 0 or len(self.buffer) < size): try: chunk = next(self.iterator) self.buffer = self.buffer + bytes(chunk) except StopIteration: break if self.interrupted: raise InterruptedError() result: bytes = b"" if size < 0: result, self.buffer = self.buffer, b"" else: result, self.buffer = self.buffer[:size], self.buffer[size:] return bytes(result)
[docs] @dataclass class StreamResponse: """Represents a streaming response""" content: StreamResponseContent _filename: Optional[str] = field(default=None, repr=False, init=False) _size: Optional[int] = field(default=None, repr=False, init=False) headers: CaseInsensitiveDict[str] = field( default_factory=_new_case_insensitive_dict ) media_type: Optional[str] = None status_code: Optional[int] = None arcname: Optional[str] = None def __init__( self, content: Union[Iterable[bytes], bytes], filename: Optional[str] = None, size: Optional[int] = None, headers: Optional[Mapping[str, str]] = None, media_type: Optional[str] = None, status_code: Optional[int] = None, arcname: Optional[str] = None, ): self.content = StreamResponseContent(content) self.headers = _new_case_insensitive_dict(headers) self.media_type = media_type self.status_code = status_code self.arcname = arcname # use property setters to update headers self.filename = filename self.size = size # filename handling @property def filename(self) -> Optional[str]: """Get the filename for the streaming response. :returns: The filename, or None if not set """ return self._filename @filename.setter def filename(self, value: Optional[str]) -> None: """Set the filename and update the Content-Disposition header accordingly. :param value: The filename to set, or None to clear it """ self._filename = value if value: outputs_filename = os.path.basename(value) self.headers[ "Content-Disposition" ] = f'attachment; filename="{outputs_filename}"' elif "Content-Disposition" in self.headers: del self.headers["Content-Disposition"] # size handling @property def size(self) -> Optional[int]: """Get the content size for the streaming response. :returns: The content size in bytes, or None if not set """ return self._size @size.setter def size(self, value: Optional[int]) -> None: """Set the content size and update the Content-Length header accordingly. :param value: The content size in bytes, or None to clear it """ self._size = value if value is not None: self.headers["Content-Length"] = str(value) elif "Content-Length" in self.headers: del self.headers["Content-Length"]
__all__ = ["StreamResponse"]