Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 74 additions & 1 deletion getstream/base.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import json
import mimetypes
import os
import time
import uuid
import asyncio
from typing import Any, Dict, Optional, Type, cast, get_origin
from typing import Any, Dict, List, Optional, Tuple, Type, cast, get_origin

from getstream.models import APIError
from getstream.rate_limit import extract_rate_limit
Expand All @@ -25,6 +27,11 @@
import ijson


def _read_file_bytes(file_path: str) -> bytes:
with open(file_path, "rb") as f:
return f.read()


def build_path(path: str, path_params: Optional[Dict[str, Any]]) -> str:
if path_params is None:
return path
Expand Down Expand Up @@ -293,6 +300,39 @@ def delete(
data_type=data_type,
)

def _upload_multipart(
self,
path: str,
data_type: Type[T],
file_path: str,
*,
path_params: Optional[Dict[str, str]] = None,
query_params: Optional[Dict[str, str]] = None,
form_fields: Optional[List[Tuple[str, str]]] = None,
) -> StreamResponse[T]:
"""Send a multipart/form-data upload request, matching Go/PHP SDK behavior."""
file_name = os.path.basename(file_path)
content_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
with open(file_path, "rb") as f:
file_content = f.read()

files = {"file": (file_name, file_content, content_type)}
data: Dict[str, str] = {}
for field_name, field_value in form_fields or []:
data[field_name] = field_value

kwargs: Dict[str, Any] = {"files": files}
if data:
kwargs["data"] = data

return self._request_sync(
"POST",
path,
query_params=query_params,
kwargs=kwargs | {"path_params": path_params},
data_type=data_type,
)

def close(self):
"""
Close HTTPX client.
Expand Down Expand Up @@ -333,6 +373,39 @@ async def aclose(self):
"""Close HTTPX async client (closes pools/keep-alives)."""
await self.client.aclose()

async def _upload_multipart(
self,
path: str,
data_type: Type[T],
file_path: str,
*,
path_params: Optional[Dict[str, str]] = None,
query_params: Optional[Dict[str, str]] = None,
form_fields: Optional[List[Tuple[str, str]]] = None,
) -> StreamResponse[T]:
"""Send a multipart/form-data upload request, matching Go/PHP SDK behavior."""
file_name = os.path.basename(file_path)
content_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"

file_content = await asyncio.to_thread(_read_file_bytes, file_path)

files = {"file": (file_name, file_content, content_type)}
data: Dict[str, str] = {}
for field_name, field_value in form_fields or []:
data[field_name] = field_value

kwargs: Dict[str, Any] = {"files": files}
if data:
kwargs["data"] = data

return await self._request_async(
"POST",
path,
query_params=query_params,
kwargs=kwargs | {"path_params": path_params},
data_type=data_type,
)

def _endpoint_name(self, path: str) -> str:
op = getattr(self, "_operation_name", None)
return op or current_operation(self._normalize_endpoint_from_path(path)) or ""
Expand Down
54 changes: 54 additions & 0 deletions getstream/chat/async_client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import json
from typing import List, Optional

from getstream.chat.async_channel import Channel
from getstream.chat.async_rest_client import ChatRestClient
from getstream.common import telemetry
from getstream.models import (
ImageSize,
OnlyUserID,
UploadChannelFileResponse,
UploadChannelResponse,
)
from getstream.stream_response import StreamResponse


class ChatClient(ChatRestClient):
Expand All @@ -15,3 +26,46 @@ def __init__(self, api_key: str, base_url, token, timeout, stream, user_agent=No

def channel(self, call_type: str, id: str) -> Channel:
return Channel(self, call_type, id)

@telemetry.operation_name("getstream.api.chat.upload_channel_file")
async def upload_channel_file(
self,
type: str,
id: str,
file: str,
user: Optional[OnlyUserID] = None,
) -> StreamResponse[UploadChannelFileResponse]:
form_fields = []
if user is not None:
form_fields.append(("user", json.dumps(user.to_dict())))
return await self._upload_multipart(
"/api/v2/chat/channels/{type}/{id}/file",
UploadChannelFileResponse,
file,
path_params={"type": type, "id": id},
form_fields=form_fields,
)

@telemetry.operation_name("getstream.api.chat.upload_channel_image")
async def upload_channel_image(
self,
type: str,
id: str,
file: str,
upload_sizes: Optional[List[ImageSize]] = None,
user: Optional[OnlyUserID] = None,
) -> StreamResponse[UploadChannelResponse]:
form_fields = []
if user is not None:
form_fields.append(("user", json.dumps(user.to_dict())))
if upload_sizes is not None:
form_fields.append(
("upload_sizes", json.dumps([s.to_dict() for s in upload_sizes]))
)
return await self._upload_multipart(
"/api/v2/chat/channels/{type}/{id}/image",
UploadChannelResponse,
file,
path_params={"type": type, "id": id},
form_fields=form_fields,
)
Comment on lines +50 to +71
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Same type issue for upload_channel_image.

Proposed fix
     `@telemetry.operation_name`("getstream.api.chat.upload_channel_image")
     async def upload_channel_image(
         self,
         type: str,
         id: str,
-        file: Optional[str] = None,
+        file: str,
         upload_sizes: Optional[List[ImageSize]] = None,
         user: Optional[OnlyUserID] = None,
     ) -> StreamResponse[UploadChannelResponse]:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def upload_channel_image(
self,
type: str,
id: str,
file: Optional[str] = None,
upload_sizes: Optional[List[ImageSize]] = None,
user: Optional[OnlyUserID] = None,
) -> StreamResponse[UploadChannelResponse]:
form_fields = []
if user is not None:
form_fields.append(("user", json.dumps(user.to_dict())))
if upload_sizes is not None:
form_fields.append(
("upload_sizes", json.dumps([s.to_dict() for s in upload_sizes]))
)
return await self._upload_multipart(
"/api/v2/chat/channels/{type}/{id}/image",
UploadChannelResponse,
file,
path_params={"type": type, "id": id},
form_fields=form_fields,
)
async def upload_channel_image(
self,
type: str,
id: str,
file: str,
upload_sizes: Optional[List[ImageSize]] = None,
user: Optional[OnlyUserID] = None,
) -> StreamResponse[UploadChannelResponse]:
form_fields = []
if user is not None:
form_fields.append(("user", json.dumps(user.to_dict())))
if upload_sizes is not None:
form_fields.append(
("upload_sizes", json.dumps([s.to_dict() for s in upload_sizes]))
)
return await self._upload_multipart(
"/api/v2/chat/channels/{type}/{id}/image",
UploadChannelResponse,
file,
path_params={"type": type, "id": id},
form_fields=form_fields,
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@getstream/chat/async_client.py` around lines 50 - 71, The parameter name
"type" in upload_channel_image shadows the built-in and matches the earlier
"Same type issue" — rename the parameter to channel_type (and update any
references) so the signature becomes upload_channel_image(self, channel_type:
str, id: str, ...), then pass it into path_params as {"type": channel_type,
"id": id} when calling _upload_multipart; update any internal references or
callers of upload_channel_image to use the new parameter name.

54 changes: 54 additions & 0 deletions getstream/chat/client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import json
from typing import List, Optional

from getstream.chat.channel import Channel
from getstream.chat.rest_client import ChatRestClient
from getstream.common import telemetry
from getstream.models import (
ImageSize,
OnlyUserID,
UploadChannelFileResponse,
UploadChannelResponse,
)
from getstream.stream_response import StreamResponse


class ChatClient(ChatRestClient):
Expand All @@ -15,3 +26,46 @@ def __init__(self, api_key: str, base_url, token, timeout, stream, user_agent=No

def channel(self, call_type: str, id: str) -> Channel:
return Channel(self, call_type, id)

@telemetry.operation_name("getstream.api.chat.upload_channel_file")
def upload_channel_file(
self,
type: str,
id: str,
file: str,
user: Optional[OnlyUserID] = None,
) -> StreamResponse[UploadChannelFileResponse]:
form_fields = []
if user is not None:
form_fields.append(("user", json.dumps(user.to_dict())))
return self._upload_multipart(
"/api/v2/chat/channels/{type}/{id}/file",
UploadChannelFileResponse,
file,
path_params={"type": type, "id": id},
form_fields=form_fields,
)

@telemetry.operation_name("getstream.api.chat.upload_channel_image")
def upload_channel_image(
self,
type: str,
id: str,
file: str,
upload_sizes: Optional[List[ImageSize]] = None,
user: Optional[OnlyUserID] = None,
) -> StreamResponse[UploadChannelResponse]:
form_fields = []
if user is not None:
form_fields.append(("user", json.dumps(user.to_dict())))
if upload_sizes is not None:
form_fields.append(
("upload_sizes", json.dumps([s.to_dict() for s in upload_sizes]))
)
return self._upload_multipart(
"/api/v2/chat/channels/{type}/{id}/image",
UploadChannelResponse,
file,
path_params={"type": type, "id": id},
form_fields=form_fields,
)
Comment on lines +50 to +71
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Same type issue for upload_channel_image.

Proposed fix
     `@telemetry.operation_name`("getstream.api.chat.upload_channel_image")
     def upload_channel_image(
         self,
         type: str,
         id: str,
-        file: Optional[str] = None,
+        file: str,
         upload_sizes: Optional[List[ImageSize]] = None,
         user: Optional[OnlyUserID] = None,
     ) -> StreamResponse[UploadChannelResponse]:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def upload_channel_image(
self,
type: str,
id: str,
file: Optional[str] = None,
upload_sizes: Optional[List[ImageSize]] = None,
user: Optional[OnlyUserID] = None,
) -> StreamResponse[UploadChannelResponse]:
form_fields = []
if user is not None:
form_fields.append(("user", json.dumps(user.to_dict())))
if upload_sizes is not None:
form_fields.append(
("upload_sizes", json.dumps([s.to_dict() for s in upload_sizes]))
)
return self._upload_multipart(
"/api/v2/chat/channels/{type}/{id}/image",
UploadChannelResponse,
file,
path_params={"type": type, "id": id},
form_fields=form_fields,
)
def upload_channel_image(
self,
type: str,
id: str,
file: str,
upload_sizes: Optional[List[ImageSize]] = None,
user: Optional[OnlyUserID] = None,
) -> StreamResponse[UploadChannelResponse]:
form_fields = []
if user is not None:
form_fields.append(("user", json.dumps(user.to_dict())))
if upload_sizes is not None:
form_fields.append(
("upload_sizes", json.dumps([s.to_dict() for s in upload_sizes]))
)
return self._upload_multipart(
"/api/v2/chat/channels/{type}/{id}/image",
UploadChannelResponse,
file,
path_params={"type": type, "id": id},
form_fields=form_fields,
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@getstream/chat/client.py` around lines 50 - 71, The method
upload_channel_image uses a parameter named "type" which conflicts with the
Python built-in and causes the same type/name issue; rename the parameter to a
clear identifier (e.g., channel_type) in the upload_channel_image signature,
update all internal references (path_params={"type": channel_type} and any uses
in the method body), and adjust any callers or tests to pass channel_type
instead of type so the path interpolation
"/api/v2/chat/channels/{type}/{id}/image" still receives the correct value.

46 changes: 46 additions & 0 deletions getstream/common/async_client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
import json
from typing import List, Optional

from getstream.common import telemetry
from getstream.common.async_rest_client import CommonRestClient
from getstream.models import (
FileUploadResponse,
ImageSize,
ImageUploadResponse,
OnlyUserID,
)
from getstream.stream_response import StreamResponse


class CommonClient(CommonRestClient):
Expand All @@ -10,3 +21,38 @@ def __init__(self, api_key: str, base_url, token, timeout, user_agent=None):
timeout=timeout,
user_agent=user_agent,
)

@telemetry.operation_name("getstream.api.common.upload_file")
async def upload_file(
self, file: str, user: Optional[OnlyUserID] = None
) -> StreamResponse[FileUploadResponse]:
form_fields = []
if user is not None:
form_fields.append(("user", json.dumps(user.to_dict())))
return await self._upload_multipart(
"/api/v2/uploads/file",
FileUploadResponse,
file,
form_fields=form_fields,
)

@telemetry.operation_name("getstream.api.common.upload_image")
async def upload_image(
self,
file: str,
upload_sizes: Optional[List[ImageSize]] = None,
user: Optional[OnlyUserID] = None,
) -> StreamResponse[ImageUploadResponse]:
form_fields = []
if user is not None:
form_fields.append(("user", json.dumps(user.to_dict())))
if upload_sizes is not None:
form_fields.append(
("upload_sizes", json.dumps([s.to_dict() for s in upload_sizes]))
)
return await self._upload_multipart(
"/api/v2/uploads/image",
ImageUploadResponse,
file,
form_fields=form_fields,
)
Comment on lines +40 to +58
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Same type issue for upload_image.

Proposed fix
     `@telemetry.operation_name`("getstream.api.common.upload_image")
     async def upload_image(
         self,
-        file: Optional[str] = None,
+        file: str,
         upload_sizes: Optional[List[ImageSize]] = None,
         user: Optional[OnlyUserID] = None,
     ) -> StreamResponse[ImageUploadResponse]:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@getstream/common/async_client.py` around lines 40 - 58, The type annotation
for the `file` parameter in async def upload_image currently uses Optional[str],
which is incorrect; change it to match the accepted upload types used by
_upload_multipart (for example Optional[Union[str, bytes, BinaryIO]] or your
project’s UploadFile alias), update the import(s) for Union/BinaryIO if needed,
and ensure the function signature in upload_image and any call sites align with
_upload_multipart’s expected file type (refer to the upload_image function and
the _upload_multipart usage).

46 changes: 46 additions & 0 deletions getstream/common/client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
import json
from typing import List, Optional

from getstream.common import telemetry
from getstream.common.rest_client import CommonRestClient
from getstream.models import (
FileUploadResponse,
ImageSize,
ImageUploadResponse,
OnlyUserID,
)
from getstream.stream_response import StreamResponse


class CommonClient(CommonRestClient):
Expand All @@ -10,3 +21,38 @@ def __init__(self, api_key: str, base_url, token, timeout, user_agent=None):
timeout=timeout,
user_agent=user_agent,
)

@telemetry.operation_name("getstream.api.common.upload_file")
def upload_file(
self, file: str, user: Optional[OnlyUserID] = None
) -> StreamResponse[FileUploadResponse]:
form_fields = []
if user is not None:
form_fields.append(("user", json.dumps(user.to_dict())))
return self._upload_multipart(
"/api/v2/uploads/file",
FileUploadResponse,
file,
form_fields=form_fields,
)

@telemetry.operation_name("getstream.api.common.upload_image")
def upload_image(
self,
file: str,
upload_sizes: Optional[List[ImageSize]] = None,
user: Optional[OnlyUserID] = None,
) -> StreamResponse[ImageUploadResponse]:
form_fields = []
if user is not None:
form_fields.append(("user", json.dumps(user.to_dict())))
if upload_sizes is not None:
form_fields.append(
("upload_sizes", json.dumps([s.to_dict() for s in upload_sizes]))
)
return self._upload_multipart(
"/api/v2/uploads/image",
ImageUploadResponse,
file,
form_fields=form_fields,
)
Comment on lines +40 to +58
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Same type mismatch applies to upload_image.

The file parameter here also has the same Optional[str] type issue as upload_file.

Proposed fix
     `@telemetry.operation_name`("getstream.api.common.upload_image")
     def upload_image(
         self,
-        file: Optional[str] = None,
+        file: str,
         upload_sizes: Optional[List[ImageSize]] = None,
         user: Optional[OnlyUserID] = None,
     ) -> StreamResponse[ImageUploadResponse]:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def upload_image(
self,
file: Optional[str] = None,
upload_sizes: Optional[List[ImageSize]] = None,
user: Optional[OnlyUserID] = None,
) -> StreamResponse[ImageUploadResponse]:
form_fields = []
if user is not None:
form_fields.append(("user", json.dumps(user.to_dict())))
if upload_sizes is not None:
form_fields.append(
("upload_sizes", json.dumps([s.to_dict() for s in upload_sizes]))
)
return self._upload_multipart(
"/api/v2/uploads/image",
ImageUploadResponse,
file,
form_fields=form_fields,
)
def upload_image(
self,
file: str,
upload_sizes: Optional[List[ImageSize]] = None,
user: Optional[OnlyUserID] = None,
) -> StreamResponse[ImageUploadResponse]:
form_fields = []
if user is not None:
form_fields.append(("user", json.dumps(user.to_dict())))
if upload_sizes is not None:
form_fields.append(
("upload_sizes", json.dumps([s.to_dict() for s in upload_sizes]))
)
return self._upload_multipart(
"/api/v2/uploads/image",
ImageUploadResponse,
file,
form_fields=form_fields,
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@getstream/common/client.py` around lines 40 - 58, The upload_image function's
file parameter is incorrectly typed as Optional[str]; change its annotation to
the correct file-like type (e.g., Optional[IO[bytes]] or Optional[BinaryIO]) to
match how _upload_multipart expects a file object, update any related imports
(typing.IO or typing.BinaryIO) and adjust docstring/comments if present; ensure
the signature in upload_image and any similar functions (e.g., upload_file) use
the same file-like type so callers and type checkers align with
_upload_multipart's handling.

Loading
Loading