From 8abf8712ff099e7b8c9141a69cd92661e5f9761b Mon Sep 17 00:00:00 2001 From: Aleksandr Movchan Date: Wed, 11 Dec 2024 16:07:02 +0000 Subject: [PATCH 1/4] refactor file upload handling and improve error management in API endpoints --- aana/api/api_generation.py | 49 +++++++++++++--- aana/core/models/image.py | 117 +++++++++++++++++++------------------ aana/utils/json.py | 9 ++- 3 files changed, 111 insertions(+), 64 deletions(-) diff --git a/aana/api/api_generation.py b/aana/api/api_generation.py index ef1524db..d42ba985 100644 --- a/aana/api/api_generation.py +++ b/aana/api/api_generation.py @@ -4,10 +4,11 @@ from inspect import isasyncgenfunction from typing import Annotated, Any, get_origin -from fastapi import FastAPI, File, Form, Query, UploadFile +from fastapi import FastAPI, File, Form, Query, Request, UploadFile from fastapi.responses import StreamingResponse from pydantic import ConfigDict, Field, ValidationError, create_model from pydantic.main import BaseModel +from starlette.datastructures import UploadFile as StarletteUploadFile from aana.api.event_handlers.event_handler import EventHandler from aana.api.event_handlers.event_manager import EventManager @@ -282,7 +283,7 @@ def __create_endpoint_func( # noqa: C901 bound_path = self.path async def route_func_body( # noqa: C901 - body: str, files: list[UploadFile] | None = None, defer=False + body: str, files: dict[str, StarletteUploadFile] | None = None, defer=False ): if not self.initialized: await self.initialize() @@ -294,9 +295,19 @@ async def route_func_body( # noqa: C901 data = RequestModel.model_validate_json(body) # if the input requires file upload, add the files to the data - if file_upload_field and files: - files_as_bytes = [await file.read() for file in files] - getattr(data, file_upload_field.name).set_files(files_as_bytes) + if files: + # files_as_bytes = [await file.read() for file in files] + # getattr(data, file_upload_field.name).set_files(files) + # breakpoint() + # Check if any field have set_files method + for field_name in data.model_fields: + field_value = getattr(data, field_name) + if hasattr(field_value, "set_files"): + await field_value.set_files(files) + elif files and not file_upload_field: + raise ValueError( # noqa: TRY003 + "This endpoint does not accept file uploads." + ) # We have to do this instead of data.dict() because # data.dict() will convert all nested models to dicts @@ -339,17 +350,41 @@ async def generator_wrapper() -> AsyncGenerator[bytes, None]: if file_upload_field: files = File(None, description=file_upload_field.description) else: - files = None + files = File( + None, description="This endpoint does not accept file uploads." + ) async def route_func( + request: Request, body: str = Form(...), - files=files, + # files: list[UploadFile] = files, defer: bool = Query( description="Defer execution of the endpoint to the task queue.", default=False, include_in_schema=aana_settings.task_queue.enabled, ), ): + # Log the headers + print(f"Headers: {request.headers}") + + # Log the form data body + form_data = await request.form() + print(f"Form Data: {form_data}") + + # Extract the files from the form data (fields with type UploadFile) + files = {} + for field_name, field_value in form_data.items(): + if isinstance(field_value, StarletteUploadFile): + files[field_name] = field_value + + print(f"Files: {files}") + + # breakpoint() + + # # Log the uploaded files + # uploaded_files = [file.filename for file in files] + # print(f"Uploaded Files: {uploaded_files}") + return await route_func_body(body=body, files=files, defer=defer) return route_func diff --git a/aana/core/models/image.py b/aana/core/models/image.py index 2d2f545f..d1548535 100644 --- a/aana/core/models/image.py +++ b/aana/core/models/image.py @@ -17,6 +17,7 @@ model_validator, ) from pydantic_core import InitErrorDetails +from starlette.datastructures import UploadFile as StarletteUploadFile from typing_extensions import Self from aana.configs.settings import settings @@ -268,74 +269,71 @@ class ImageInput(BaseModel): AfterValidator(lambda x: str(x) if x else None), Field(None, description="The URL of the image."), ] - content: bytes | None = Field( + content: str | None = Field( None, description=( "The content of the image in bytes. " - "Set this field to 'file' to upload files to the endpoint." + "Set this field to the name of the file uploaded to the endpoint." ), ) - numpy: bytes | None = Field( + numpy: str | None = Field( None, description=( "The image as a numpy array. " - "Set this field to 'file' to upload files to the endpoint." + "Set this field to the name of the file uploaded to the endpoint." ), ) media_id: MediaId = Field( default_factory=lambda: str(uuid.uuid4()), description="The ID of the image. If not provided, it will be generated automatically.", ) + files: dict[str, bytes] | None = Field(None, exclude=True) - def set_file(self, file: bytes): - """Sets the instance internal file data. - - If 'content' or 'numpy' is set to 'file', - the image will be loaded from the file uploaded to the endpoint. - - set_file() should be called after the files are uploaded to the endpoint. - - Args: - file (bytes): the file uploaded to the endpoint - - Raises: - ValueError: if the content or numpy isn't set to 'file' - """ - if self.content == b"file": - self.content = file - elif self.numpy == b"file": - self.numpy = file - else: - raise ValueError( # noqa: TRY003 - "The content or numpy of the image must be 'file' to set files." - ) - - def set_files(self, files: list[bytes]): + async def set_files(self, files: dict[str, StarletteUploadFile]): """Set the files for the image. Args: - files (List[bytes]): the files uploaded to the endpoint + files (dict[str, StarletteUploadFile]): the files uploaded to the endpoint Raises: ValidationError: if the number of images and files aren't the same """ - if len(files) != 1: - raise ValidationError.from_exception_data( - title=self.__class__.__name__, - line_errors=[ - InitErrorDetails( - loc=("images",), - type="value_error", - ctx={ - "error": ValueError( - "The number of images and files must be the same." - ) - }, - input=None, - ) - ], - ) - self.set_file(files[0]) + file_name = self.content or self.numpy + if file_name: + if file_name not in files: + raise ValueError(f"File {file_name} not found.") # noqa: TRY003 + self.files = {file_name: await files[file_name].read()} + + # breakpoint() + # if self.content: + # file_name = self.content + # if file_name not in files: + # raise ValueError(f"File {file_name} not found.") + # self.content = await files[file_name].read() + + # if self.numpy: + # file_name = self.numpy + # if file_name not in files: + # raise ValueError(f"File {file_name} not found.") + # self.numpy = await files[file_name].read() + + # if len(files) != 1: + # raise ValidationError.from_exception_data( + # title=self.__class__.__name__, + # line_errors=[ + # InitErrorDetails( + # loc=("images",), + # type="value_error", + # ctx={ + # "error": ValueError( + # "The number of images and files must be the same." + # ) + # }, + # input=None, + # ) + # ], + # ) + # self.set_file(files[0]) @model_validator(mode="after") def check_only_one_field(self) -> Self: @@ -365,21 +363,28 @@ def convert_input_to_object(self) -> Image: Raises: ValueError: if the numpy file isn't set + ValueError: if the file isn't found """ - if self.numpy and self.numpy != b"file": - try: - numpy = np.load(io.BytesIO(self.numpy), allow_pickle=False) - except ValueError: - raise ValueError("The numpy file isn't valid.") # noqa: TRY003, TRY200, B904 TODO - elif self.numpy == b"file": - raise ValueError("The numpy file isn't set. Call set_files() to set it.") # noqa: TRY003 - else: - numpy = None + file_name = self.content or self.numpy + if file_name and file_name not in self.files: + raise ValueError(f"File {file_name} not found.") # noqa: TRY003 + + content = None + numpy = None + if self.files: + if self.content: + content = self.files[self.content] + elif self.numpy: + file_bytes = self.files[self.numpy] + try: + numpy = np.load(io.BytesIO(file_bytes), allow_pickle=False) + except ValueError as e: + raise ValueError("The numpy file isn't valid.") from e # noqa: TRY003 return Image( path=Path(self.path) if self.path else None, url=self.url, - content=self.content, + content=content, numpy=numpy, media_id=self.media_id, ) diff --git a/aana/utils/json.py b/aana/utils/json.py index 6ff89b10..6e832190 100644 --- a/aana/utils/json.py +++ b/aana/utils/json.py @@ -36,15 +36,22 @@ def json_serializer_default(obj: object) -> object: return str(obj) if isinstance(obj, type): return str(type) + if isinstance(obj, bytes): + return obj.decode() from aana.core.models.media import Media + if isinstance(obj, Media): return str(obj) raise TypeError(type(obj)) -def jsonify(data: Any, option: int | None = orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_SORT_KEYS, as_bytes: bool = False) -> str | bytes: +def jsonify( + data: Any, + option: int | None = orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_SORT_KEYS, + as_bytes: bool = False, +) -> str | bytes: """Serialize content using orjson. Args: From b89a08f99b090e389c028d0f39efe6cc9cb32e3e Mon Sep 17 00:00:00 2001 From: Aleksandr Movchan Date: Thu, 12 Dec 2024 10:06:03 +0000 Subject: [PATCH 2/4] refactor exception handling and introduce UploadedFileNotFound exception for better file upload error management --- aana/api/api_generation.py | 91 +------------------- aana/core/models/image.py | 105 +++++++----------------- aana/core/models/video.py | 101 ++++++----------------- aana/exceptions/runtime.py | 44 +++++----- aana/tests/units/test_api_generation.py | 58 ++++--------- aana/tests/units/test_image_input.py | 102 ++++++++++------------- aana/tests/units/test_video_input.py | 86 +++++++++---------- 7 files changed, 187 insertions(+), 400 deletions(-) diff --git a/aana/api/api_generation.py b/aana/api/api_generation.py index d42ba985..bd74e4c6 100644 --- a/aana/api/api_generation.py +++ b/aana/api/api_generation.py @@ -16,9 +16,6 @@ from aana.api.responses import AanaJSONResponse from aana.configs.settings import settings as aana_settings from aana.core.models.exception import ExceptionResponseModel -from aana.exceptions.runtime import ( - MultipleFileUploadNotAllowed, -) from aana.storage.repository.task import TaskRepository from aana.storage.session import get_session @@ -33,19 +30,6 @@ def get_default_values(func): } -@dataclass -class FileUploadField: - """Class used to represent a file upload field. - - Attributes: - name (str): Name of the field. - description (str): Description of the field. - """ - - name: str - description: str - - @dataclass class Endpoint: """Class used to represent an endpoint. @@ -106,14 +90,12 @@ def register( RequestModel = self.get_request_model() ResponseModel = self.get_response_model() - file_upload_field = self.__get_file_upload_field() if self.event_handlers: for handler in self.event_handlers: event_manager.register_handler_for_events(handler, [self.path]) route_func = self.__create_endpoint_func( RequestModel=RequestModel, - file_upload_field=file_upload_field, event_manager=event_manager, ) @@ -230,39 +212,6 @@ def get_response_model(self) -> type[BaseModel]: model_name, **output_fields, __config__=ConfigDict(extra="forbid") ) - def __get_file_upload_field(self) -> FileUploadField | None: - """Get the file upload field for the endpoint. - - Returns: - Optional[FileUploadField]: File upload field or None if not found. - - Raises: - MultipleFileUploadNotAllowed: If multiple inputs require file upload. - """ - file_upload_field = None - for arg_name, arg_type in self.run.__annotations__.items(): - if arg_name == "return": - continue - - # check if pydantic model has file_upload field and it's set to True - if isinstance(arg_type, type) and issubclass(arg_type, BaseModel): - file_upload_enabled = arg_type.model_config.get("file_upload", False) - file_upload_description = arg_type.model_config.get( - "file_upload_description", "" - ) - else: - file_upload_enabled = False - file_upload_description = "" - - if file_upload_enabled and file_upload_field is None: - file_upload_field = FileUploadField( - name=arg_name, description=file_upload_description - ) - elif file_upload_enabled and file_upload_field is not None: - # raise an exception if multiple inputs require file upload - raise MultipleFileUploadNotAllowed(arg_name) - return file_upload_field - @classmethod def is_streaming_response(cls) -> bool: """Check if the endpoint returns a streaming response. @@ -275,7 +224,6 @@ def is_streaming_response(cls) -> bool: def __create_endpoint_func( # noqa: C901 self, RequestModel: type[BaseModel], - file_upload_field: FileUploadField | None = None, event_manager: EventManager | None = None, ) -> Callable: """Create a function for routing an endpoint.""" @@ -283,7 +231,7 @@ def __create_endpoint_func( # noqa: C901 bound_path = self.path async def route_func_body( # noqa: C901 - body: str, files: dict[str, StarletteUploadFile] | None = None, defer=False + body: str, files: dict[str, bytes] | None = None, defer=False ): if not self.initialized: await self.initialize() @@ -296,18 +244,10 @@ async def route_func_body( # noqa: C901 # if the input requires file upload, add the files to the data if files: - # files_as_bytes = [await file.read() for file in files] - # getattr(data, file_upload_field.name).set_files(files) - # breakpoint() - # Check if any field have set_files method for field_name in data.model_fields: field_value = getattr(data, field_name) if hasattr(field_value, "set_files"): - await field_value.set_files(files) - elif files and not file_upload_field: - raise ValueError( # noqa: TRY003 - "This endpoint does not accept file uploads." - ) + field_value.set_files(files) # We have to do this instead of data.dict() because # data.dict() will convert all nested models to dicts @@ -347,44 +287,21 @@ async def generator_wrapper() -> AsyncGenerator[bytes, None]: return custom_exception_handler(None, e) return AanaJSONResponse(content=output) - if file_upload_field: - files = File(None, description=file_upload_field.description) - else: - files = File( - None, description="This endpoint does not accept file uploads." - ) - async def route_func( request: Request, body: str = Form(...), - # files: list[UploadFile] = files, defer: bool = Query( description="Defer execution of the endpoint to the task queue.", default=False, include_in_schema=aana_settings.task_queue.enabled, ), ): - # Log the headers - print(f"Headers: {request.headers}") - - # Log the form data body form_data = await request.form() - print(f"Form Data: {form_data}") - # Extract the files from the form data (fields with type UploadFile) - files = {} + files: dict[str, bytes] = {} for field_name, field_value in form_data.items(): if isinstance(field_value, StarletteUploadFile): - files[field_name] = field_value - - print(f"Files: {files}") - - # breakpoint() - - # # Log the uploaded files - # uploaded_files = [file.filename for file in files] - # print(f"Uploaded Files: {uploaded_files}") - + files[field_name] = await field_value.read() return await route_func_body(body=body, files=files, defer=defer) return route_func diff --git a/aana/core/models/image.py b/aana/core/models/image.py index d1548535..78aca604 100644 --- a/aana/core/models/image.py +++ b/aana/core/models/image.py @@ -25,6 +25,7 @@ from aana.core.models.base import BaseListModel from aana.core.models.media import Media, MediaId from aana.exceptions.io import ImageReadingException +from aana.exceptions.runtime import UploadedFileNotFound from aana.integrations.external.opencv import OpenCVWrapper from aana.utils.download import download_file @@ -272,68 +273,33 @@ class ImageInput(BaseModel): content: str | None = Field( None, description=( - "The content of the image in bytes. " - "Set this field to the name of the file uploaded to the endpoint." + "The name of the file uploaded to the endpoint. The image will be loaded from the file automatically." ), ) numpy: str | None = Field( None, - description=( - "The image as a numpy array. " - "Set this field to the name of the file uploaded to the endpoint." - ), + description="The name of the file uploaded to the endpoint. The image will be loaded from the file automatically.", ) media_id: MediaId = Field( default_factory=lambda: str(uuid.uuid4()), description="The ID of the image. If not provided, it will be generated automatically.", ) - files: dict[str, bytes] | None = Field(None, exclude=True) + _file: bytes | None = None - async def set_files(self, files: dict[str, StarletteUploadFile]): + def set_files(self, files: dict[str, bytes]): """Set the files for the image. Args: - files (dict[str, StarletteUploadFile]): the files uploaded to the endpoint + files (dict[str, bytes]): the files uploaded to the endpoint Raises: - ValidationError: if the number of images and files aren't the same + UploadedFileNotFound: if the file isn't found """ file_name = self.content or self.numpy if file_name: if file_name not in files: - raise ValueError(f"File {file_name} not found.") # noqa: TRY003 - self.files = {file_name: await files[file_name].read()} - - # breakpoint() - # if self.content: - # file_name = self.content - # if file_name not in files: - # raise ValueError(f"File {file_name} not found.") - # self.content = await files[file_name].read() - - # if self.numpy: - # file_name = self.numpy - # if file_name not in files: - # raise ValueError(f"File {file_name} not found.") - # self.numpy = await files[file_name].read() - - # if len(files) != 1: - # raise ValidationError.from_exception_data( - # title=self.__class__.__name__, - # line_errors=[ - # InitErrorDetails( - # loc=("images",), - # type="value_error", - # ctx={ - # "error": ValueError( - # "The number of images and files must be the same." - # ) - # }, - # input=None, - # ) - # ], - # ) - # self.set_file(files[0]) + raise UploadedFileNotFound(filename=file_name) + self._file = files[file_name] @model_validator(mode="after") def check_only_one_field(self) -> Self: @@ -362,24 +328,23 @@ def convert_input_to_object(self) -> Image: Image: the image object corresponding to the image input Raises: - ValueError: if the numpy file isn't set + UploadedFileNotFound: if the file isn't found ValueError: if the file isn't found """ file_name = self.content or self.numpy - if file_name and file_name not in self.files: - raise ValueError(f"File {file_name} not found.") # noqa: TRY003 + if file_name and not self._file: + raise UploadedFileNotFound(filename=file_name) content = None numpy = None - if self.files: - if self.content: - content = self.files[self.content] - elif self.numpy: - file_bytes = self.files[self.numpy] - try: - numpy = np.load(io.BytesIO(file_bytes), allow_pickle=False) - except ValueError as e: - raise ValueError("The numpy file isn't valid.") from e # noqa: TRY003 + if self._file and self.content: + content = self._file + elif self._file and self.numpy: + file_bytes = self._file + try: + numpy = np.load(io.BytesIO(file_bytes), allow_pickle=False) + except ValueError as e: + raise ValueError("The numpy file isn't valid.") from e # noqa: TRY003 return Image( path=Path(self.path) if self.path else None, @@ -393,16 +358,14 @@ def convert_input_to_object(self) -> Image: json_schema_extra={ "description": ( "An image. \n" - "Exactly one of 'path', 'url', or 'content' must be provided. \n" + "Exactly one of 'path', 'url', 'content' or 'numpy' must be provided. \n" "If 'path' is provided, the image will be loaded from the path. \n" "If 'url' is provided, the image will be downloaded from the url. \n" - "The 'content' will be loaded automatically " - "if files are uploaded to the endpoint (should be set to 'file' for that)." + "The 'content' and 'numpy' will be loaded automatically " + "if files are uploaded to the endpoint and the corresponding field is set to the file name." ) }, validate_assignment=True, - file_upload=True, - file_upload_description="Upload image file.", ) @@ -429,21 +392,17 @@ def check_non_empty(self) -> Self: raise ValueError("The list of images must not be empty.") # noqa: TRY003 return self - def set_files(self, files: list[bytes]): + def set_files(self, files: dict[str, bytes]): """Set the files for the images. Args: - files (List[bytes]): the files uploaded to the endpoint + files (dict[str, bytes]): the files uploaded to the endpoint Raises: - ValidationError: if the number of images and files aren't the same + UploadedFileNotFound: if the file isn't found """ - if len(self.root) != len(files): - error = ValueError("The number of images and files must be the same.") - # raise ValidationError(error, - raise error - for image, file in zip(self.root, files, strict=False): - image.set_file(file) + for image in self.root: + image.set_files(files) def convert_input_to_object(self) -> list[Image]: """Convert the list of image inputs to a list of image objects. @@ -457,13 +416,11 @@ def convert_input_to_object(self) -> list[Image]: json_schema_extra={ "description": ( "A list of images. \n" - "Exactly one of 'path', 'url', or 'content' must be provided for each image. \n" + "Exactly one of 'path', 'url', 'content' or 'numpy' must be provided for each image. \n" "If 'path' is provided, the image will be loaded from the path. \n" "If 'url' is provided, the image will be downloaded from the url. \n" - "The 'content' will be loaded automatically " - "if files are uploaded to the endpoint (should be set to 'file' for that)." + "The 'content' and 'numpy' will be loaded automatically " + "if files are uploaded to the endpoint and the corresponding field is set to the file name." ) }, - file_upload=True, - file_upload_description="Upload image files.", ) diff --git a/aana/core/models/video.py b/aana/core/models/video.py index 97139c8f..1197b644 100644 --- a/aana/core/models/video.py +++ b/aana/core/models/video.py @@ -23,6 +23,7 @@ from aana.core.models.base import BaseListModel from aana.core.models.media import MediaId +from aana.exceptions.runtime import UploadedFileNotFound __all__ = ["VideoMetadata", "VideoParams"] @@ -196,17 +197,17 @@ class VideoInput(BaseModel): AfterValidator(lambda x: str(x) if x else None), Field(None, description="The URL of the video (supports YouTube videos)."), ] - content: bytes | None = Field( + content: str | None = Field( None, description=( - "The content of the video in bytes. " - "Set this field to 'file' to upload files to the endpoint." + "The name of the file uploaded to the endpoint. The content of the video will be loaded automatically." ), ) media_id: MediaId = Field( default_factory=lambda: str(uuid.uuid4()), description="The ID of the video. If not provided, it will be generated automatically.", ) + _file: bytes | None = None @model_validator(mode="after") def check_only_one_field(self) -> Self: @@ -225,66 +226,38 @@ def check_only_one_field(self) -> Self: ) return self - def set_file(self, file: bytes): - """Sets the file. - - If 'content' is set to 'file', - the video will be loaded from the file uploaded to the endpoint. - - set_file() should be called after the files are uploaded to the endpoint. - - Args: - file (bytes): the file uploaded to the endpoint - - Raises: - ValueError: if the content isn't set to 'file' - """ - if self.content == b"file": - self.content = file - else: - raise ValueError("The content of the video must be 'file' to set files.") # noqa: TRY003 - - def set_files(self, files: list[bytes]): + def set_files(self, files: dict[str, bytes]): """Set the files for the video. Args: - files (List[bytes]): the files uploaded to the endpoint + files (Dict[str, bytes]): the files uploaded to the endpoint Raises: - ValidationError: if the number of files isn't 1 + UploadedFileNotFound: if the file is not found """ - if len(files) != 1: - raise ValidationError.from_exception_data( - title=self.__class__.__name__, - line_errors=[ - InitErrorDetails( - loc=("video",), - type="value_error", - ctx={ - "error": ValueError( - "The number of videos and files must be the same." - ) - }, - input=None, - ) - ], - ) - self.set_file(files[0]) + if self.content: + file_name = self.content + if file_name not in files: + raise UploadedFileNotFound(filename=file_name) + self._file = files[file_name] def convert_input_to_object(self) -> Video: """Convert the video input to a video object. Returns: Video: the video object corresponding to the video input + + Raises: + UploadedFileNotFound: if the file is not found """ - if self.content == b"file": - raise ValueError( # noqa: TRY003 - "The content of the video isn't set. Please upload files and call set_files()." - ) + if self.content and not self._file: + raise UploadedFileNotFound(filename=self.content) + content = self._file if self.content else None + return Video( path=Path(self.path) if self.path is not None else None, url=self.url, - content=self.content, + content=content, media_id=self.media_id, ) @@ -296,12 +269,10 @@ def convert_input_to_object(self) -> Video: "If 'path' is provided, the video will be loaded from the path. \n" "If 'url' is provided, the video will be downloaded from the url. \n" "The 'content' will be loaded automatically " - "if files are uploaded to the endpoint (should be set to 'file' for that)." + "if files are uploaded to the endpoint and 'content' is set to the filename." ) }, validate_assignment=True, - file_upload=True, - file_upload_description="Upload video file.", ) @@ -329,33 +300,17 @@ def check_non_empty(self) -> Self: raise ValueError("The list of videos must not be empty.") # noqa: TRY003 return self - def set_files(self, files: list[bytes]): + def set_files(self, files: dict[str, bytes]): """Set the files for the videos. Args: - files (List[bytes]): the files uploaded to the endpoint + files (dict[str, bytes]): the files uploaded to the endpoint Raises: - ValidationError: if the number of videos and files aren't the same + UploadedFileNotFound: if the file is not found """ - if len(self.root) != len(files): - raise ValidationError.from_exception_data( - title=self.__class__.__name__, - line_errors=[ - InitErrorDetails( - loc=("videos",), - type="value_error", - ctx={ - "error": ValueError( - "The number of videos and files must be the same." - ) - }, - input=None, - ) - ], - ) - for video, file in zip(self.root, files, strict=False): - video.set_file(file) + for video in self.root: + video.set_files(files) def convert_input_to_object(self) -> list[VideoInput]: """Convert the VideoInputList to a list of video inputs. @@ -373,9 +328,7 @@ def convert_input_to_object(self) -> list[VideoInput]: "If 'path' is provided, the video will be loaded from the path. \n" "If 'url' is provided, the video will be downloaded from the url. \n" "The 'content' will be loaded automatically " - "if files are uploaded to the endpoint (should be set to 'file' for that)." + "if files are uploaded to the endpoint and 'content' is set to the filename." ) }, - file_upload=True, - file_upload_description="Upload video files.", ) diff --git a/aana/exceptions/runtime.py b/aana/exceptions/runtime.py index 32f71f97..78e64195 100644 --- a/aana/exceptions/runtime.py +++ b/aana/exceptions/runtime.py @@ -2,12 +2,16 @@ __all__ = [ "InferenceException", - "MultipleFileUploadNotAllowed", "PromptTooLongException", "EndpointNotFoundException", "TooManyRequestsException", "HandlerAlreadyRegisteredException", "HandlerNotRegisteredException", + "UploadedFileNotFound", + "DeploymentException", + "InsufficientResources", + "FailedDeployment", + "EmptyMigrationsException", ] @@ -37,27 +41,6 @@ def __reduce__(self): return (self.__class__, (self.model_name,)) -class MultipleFileUploadNotAllowed(BaseException): - """Exception raised when multiple inputs require file upload. - - Attributes: - input_name -- name of the input - """ - - def __init__(self, input_name: str): - """Initialize the exception. - - Args: - input_name (str): name of the input that caused the exception - """ - super().__init__(input_name=input_name) - self.input_name = input_name - - def __reduce__(self): - """Used for pickling.""" - return (self.__class__, (self.input_name,)) - - class PromptTooLongException(BaseException): """Exception raised when the prompt is too long. @@ -176,3 +159,20 @@ class FailedDeployment(DeploymentException): """Exception raised when there is an error during deployment.""" pass + + +class UploadedFileNotFound(Exception): + """Exception raised when the uploaded file is not found.""" + + def __init__(self, filename: str): + """Initialize the exception. + + Args: + filename (str): the name of the file that was not found + """ + super().__init__() + self.filename = filename + + def __reduce__(self): + """Used for pickling.""" + return (self.__class__, (self.filename,)) diff --git a/aana/tests/units/test_api_generation.py b/aana/tests/units/test_api_generation.py index 5f38f924..7f072695 100644 --- a/aana/tests/units/test_api_generation.py +++ b/aana/tests/units/test_api_generation.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field from aana.api.api_generation import Endpoint -from aana.exceptions.runtime import MultipleFileUploadNotAllowed +from aana.exceptions.runtime import UploadedFileNotFound class InputModel(BaseModel): @@ -19,21 +19,19 @@ class InputModel(BaseModel): class FileUploadModel(BaseModel): """Model for a file upload input.""" - content: bytes | None = Field( + content: str | None = Field( None, - description="The content in bytes. Set this field to 'file' to upload files to the endpoint.", + description="The name of the file to upload.", ) + _file: bytes | None = None - def set_files(self, files): + def set_files(self, files: dict[str, bytes]): """Set files.""" - if files: - if isinstance(files, list): - files = files[0] - self.content = files + if self.content and not self._file: + raise UploadedFileNotFound(self.content) + self._file = files[self.content] - model_config = ConfigDict( - extra="forbid", file_upload=True, file_upload_description="Upload image files." - ) + model_config = ConfigDict(extra="forbid") class TestEndpointOutput(TypedDict): @@ -51,7 +49,7 @@ async def run(self, input_data: InputModel) -> TestEndpointOutput: class TestFileUploadEndpoint(Endpoint): - """Test endpoint for __get_file_upload_field.""" + """Test endpoint for file uploads.""" async def run(self, input_data: FileUploadModel) -> TestEndpointOutput: """Run the endpoint.""" @@ -59,7 +57,7 @@ async def run(self, input_data: FileUploadModel) -> TestEndpointOutput: class TestMultipleFileUploadEndpoint(Endpoint): - """Test endpoint for __get_file_upload_field with multiple file uploads.""" + """Test endpoint for multiple file uploads.""" async def run( self, input_data: FileUploadModel, input_data2: FileUploadModel @@ -123,33 +121,13 @@ def test_get_response_model(): assert ResponseModel.model_fields["output"].annotation == str -def test_get_file_upload_field(): - """Test the __get_file_upload_field function.""" - endpoint = TestFileUploadEndpoint( - name="test_endpoint", - summary="Test endpoint", - path="/test_endpoint", - ) - - file_upload_field = endpoint._Endpoint__get_file_upload_field() - - # Check that the file upload field named correctly - assert file_upload_field.name == "input_data" - - # Check that the file upload field has the correct description - assert file_upload_field.description == "Upload image files." - - -def test_get_file_upload_field_multiple_file_uploads(): - """Test the __get_file_upload_field function with multiple file uploads.""" - endpoint = TestMultipleFileUploadEndpoint( - name="test_endpoint", - summary="Test endpoint", - path="/test_endpoint", - ) - - with pytest.raises(MultipleFileUploadNotAllowed): - endpoint._Endpoint__get_file_upload_field() +# def test_get_file_upload_field_multiple_file_uploads(): +# """Test the __get_file_upload_field function with multiple file uploads.""" +# endpoint = TestMultipleFileUploadEndpoint( +# name="test_endpoint", +# summary="Test endpoint", +# path="/test_endpoint", +# ) def test_get_response_model_missing_return(): diff --git a/aana/tests/units/test_image_input.py b/aana/tests/units/test_image_input.py index 4b8e8ee3..fb4c5323 100644 --- a/aana/tests/units/test_image_input.py +++ b/aana/tests/units/test_image_input.py @@ -8,6 +8,7 @@ from pydantic import ValidationError from aana.core.models.image import ImageInput, ImageInputList +from aana.exceptions.runtime import UploadedFileNotFound @pytest.fixture @@ -28,11 +29,11 @@ def test_new_imageinput_success(): image_input = ImageInput(url="http://image.png") assert image_input.url == "http://image.png/" - image_input = ImageInput(content=b"file") - assert image_input.content == b"file" + image_input = ImageInput(content="file") + assert image_input.content == "file" - image_input = ImageInput(numpy=b"file") - assert image_input.numpy == b"file" + image_input = ImageInput(numpy="file") + assert image_input.numpy == "file" def test_imageinput_invalid_media_id(): @@ -98,61 +99,36 @@ def test_imageinput_check_only_one_field(): ImageInput() -def test_imageinput_set_file(): - """Test that the file can be set for the image.""" - file_content = b"image data" - - # If 'content' is set to 'file', - # the image can be set from the file uploaded to the endpoint. - image_input = ImageInput(content=b"file") - image_input.set_file(file_content) - assert image_input.content == file_content - - # If 'numpy' is set to 'file', - # the image can be set from the file uploaded to the endpoint. - image_input = ImageInput(numpy=b"file") - image_input.set_file(file_content) - assert image_input.numpy == file_content - - # If neither 'content' nor 'numpy' is set to 'file', - # an error should be raised. - image_input = ImageInput(path="image.png") - with pytest.raises(ValueError): - image_input.set_file(file_content) - - def test_imageinput_set_files(): """Test that the files can be set for the image.""" - files = [b"image data"] + files = { + "file": b"image data", + "numpy_file": b"numpy data", + } - # If 'content' is set to 'file', + # If 'content' is set to filename, # the image can be set from the file uploaded to the endpoint. - image_input = ImageInput(content=b"file") + image_input = ImageInput(content="file") image_input.set_files(files) - assert image_input.content == files[0] + assert image_input.content == "file" + assert image_input._file == files["file"] - # If 'numpy' is set to 'file', + # If 'numpy' is set to filename, # the image can be set from the file uploaded to the endpoint. - image_input = ImageInput(numpy=b"file") + image_input = ImageInput(numpy="numpy_file") image_input.set_files(files) - assert image_input.numpy == files[0] + assert image_input.numpy == "numpy_file" + assert image_input._file == files["numpy_file"] - # If neither 'content' nor 'numpy' is set to 'file', - # an error should be raised. + # If neither 'content' nor 'numpy' is set to 'file' + # set_files doesn't do anything. image_input = ImageInput(path="image.png") - with pytest.raises(ValueError): - image_input.set_files(files) - - # If the number of images and files aren't the same, - # an error should be raised. - files = [b"image data", b"another image data"] - image_input = ImageInput(content=b"file") - with pytest.raises(ValidationError): - image_input.set_files(files) + image_input.set_files(files) + assert image_input._file is None - files = [] - image_input = ImageInput(content=b"file") - with pytest.raises(ValidationError): + # If the file is not found, an error should be raised. + image_input = ImageInput(content="unknown_file") + with pytest.raises(UploadedFileNotFound): image_input.set_files(files) @@ -175,7 +151,9 @@ def test_imageinput_convert_input_to_object(mock_download_file): image_object.cleanup() content = Path(path).read_bytes() - image_input = ImageInput(content=content) + files = {"file": content} + image_input = ImageInput(content="file") + image_input.set_files(files) try: image_object = image_input.convert_input_to_object() assert image_object.content == content @@ -187,7 +165,8 @@ def test_imageinput_convert_input_to_object(mock_download_file): buffer = io.BytesIO() np.save(buffer, numpy) numpy_bytes = buffer.getvalue() - image_input = ImageInput(numpy=numpy_bytes) + image_input = ImageInput(numpy="numpy_file") + image_input.set_files({"numpy_file": numpy_bytes}) try: image_object = image_input.convert_input_to_object() assert np.array_equal(image_object.numpy, numpy) @@ -204,15 +183,16 @@ def test_imageinput_convert_input_to_object_invalid_numpy(): numpy_bytes = buffer.getvalue() # remove the last byte numpy_bytes = numpy_bytes[:-1] - image_input = ImageInput(numpy=numpy_bytes) + image_input = ImageInput(numpy="numpy_file") + image_input.set_files({"numpy_file": numpy_bytes}) with pytest.raises(ValueError): image_input.convert_input_to_object() def test_imageinput_convert_input_to_object_numpy_not_set(): """Test that ImageInput can't be converted to Image if numpy file isn't set with set_file().""" - image_input = ImageInput(numpy=b"file") - with pytest.raises(ValueError): + image_input = ImageInput(numpy="numpy_file") + with pytest.raises(UploadedFileNotFound): image_input.convert_input_to_object() @@ -236,18 +216,24 @@ def test_imagelistinput(): def test_imagelistinput_set_files(): """Test that the files can be set for the images.""" - files = [b"image data 1", b"image data 2"] + # files = [b"image data 1", b"image data 2"] + files = { + "file": b"image data 1", + "numpy_file": b"image data 2", + } images = [ - ImageInput(content=b"file"), - ImageInput(numpy=b"file"), + ImageInput(content="file"), + ImageInput(numpy="numpy_file"), ] image_list_input = ImageInputList(images) image_list_input.set_files(files) - assert image_list_input[0].content == files[0] - assert image_list_input[1].numpy == files[1] + assert image_list_input[0].content == "file" + assert image_list_input[1].numpy == "numpy_file" + assert image_list_input[0]._file == files["file"] + assert image_list_input[1]._file == files["numpy_file"] def test_imagelistinput_non_empty(): diff --git a/aana/tests/units/test_video_input.py b/aana/tests/units/test_video_input.py index a9578fcf..7b27b0d8 100644 --- a/aana/tests/units/test_video_input.py +++ b/aana/tests/units/test_video_input.py @@ -6,6 +6,7 @@ from pydantic import ValidationError from aana.core.models.video import VideoInput, VideoInputList +from aana.exceptions.runtime import UploadedFileNotFound @pytest.fixture @@ -26,8 +27,8 @@ def test_new_videoinput_success(): video_input = VideoInput(url="http://example.com/video.mp4") assert video_input.url == "http://example.com/video.mp4" - video_input = VideoInput(content=b"file") - assert video_input.content == b"file" + video_input = VideoInput(content="file") + assert video_input.content == "file" def test_videoinput_invalid_media_id(): @@ -78,44 +79,28 @@ def test_videoinput_check_only_one_field(): VideoInput() -def test_videoinput_set_file(): - """Test that the file can be set for the video.""" - file_content = b"video data" - video_input = VideoInput(content=b"file") - video_input.set_file(file_content) - assert video_input.content == file_content - - # If 'content' is not set to 'file', - # an error should be raised. - video_input = VideoInput(path="video.mp4") - with pytest.raises(ValueError): - video_input.set_file(file_content) - - def test_videoinput_set_files(): """Test that the files can be set for the video.""" - files = [b"video data"] - - video_input = VideoInput(content=b"file") + files = {"file": b"video data"} + video_input = VideoInput(content="file") video_input.set_files(files) - assert video_input.content == files[0] + assert video_input.content == "file" + assert video_input._file == files["file"] # If 'content' is not set to 'file', - # an error should be raised. + # the file will be ignored. video_input = VideoInput(path="video.mp4") - with pytest.raises(ValueError): - video_input.set_files(files) + video_input.set_files(files) - # If the number of files is not 1, - # an error should be raised. - files = [b"video data", b"another video data"] - video_input = VideoInput(content=b"file") - with pytest.raises(ValidationError): - video_input.set_files(files) + files = {"file": b"video data", "another_file": b"another video data"} + video_input = VideoInput(content="file") + video_input.set_files(files) + assert video_input.content == "file" + assert video_input._file == files["file"] - files = [] - video_input = VideoInput(content=b"file") - with pytest.raises(ValidationError): + files = {"file": b"video data"} + video_input = VideoInput(content="unknown_file") + with pytest.raises(UploadedFileNotFound): video_input.set_files(files) @@ -138,7 +123,9 @@ def test_videoinput_convert_input_to_object(mock_download_file): video_object.cleanup() content = Path(path).read_bytes() - video_input = VideoInput(content=content) + files = {"file": content} + video_input = VideoInput(content="file") + video_input.set_files(files) try: video_object = video_input.convert_input_to_object() assert video_object.content == content @@ -164,28 +151,37 @@ def test_videoinputlist(): def test_videoinputlist_set_files(): """Test that the files can be set for the video list.""" - files = [b"video data", b"another video data"] + files = { + "file": b"video data", + "another_file": b"another video data", + } videos = [ - VideoInput(content=b"file"), - VideoInput(content=b"file"), + VideoInput(content="file"), + VideoInput(content="another_file"), ] video_list_input = VideoInputList(root=videos) video_list_input.set_files(files) - assert video_list_input[0].content == files[0] - assert video_list_input[1].content == files[1] - - # If the number of files is not the same as the number of videos, - # an error should be raised. - files = [b"video data", b"another video data", b"yet another video data"] + assert video_list_input[0].content == "file" + assert video_list_input[1].content == "another_file" + assert video_list_input[0]._file == files["file"] + assert video_list_input[1]._file == files["another_file"] + + # If there are more files than videos, + # the extra files will be ignored. + files = { + "file": b"video data", + "another_file": b"another video data", + "yet_another_file": b"yet another video data", + } video_list_input = VideoInputList(root=videos) - with pytest.raises(ValidationError): - video_list_input.set_files(files) + video_list_input.set_files(files) + # If files are missing, an error should be raised. files = [] video_list_input = VideoInputList(root=videos) - with pytest.raises(ValidationError): + with pytest.raises(UploadedFileNotFound): video_list_input.set_files(files) From 0fe0c9250654ba55364f3a1fbc2263d62ec7b1a3 Mon Sep 17 00:00:00 2001 From: Aleksandr Movchan Date: Thu, 12 Dec 2024 11:30:00 +0000 Subject: [PATCH 3/4] refactor file upload handling and introduce new test for file upload endpoint --- aana/api/api_generation.py | 2 +- aana/core/models/image.py | 3 - aana/core/models/video.py | 2 - aana/tests/units/test_api_generation.py | 46 ------------- aana/tests/units/test_app_upload.py | 90 +++++++++++++++++++++++++ 5 files changed, 91 insertions(+), 52 deletions(-) create mode 100644 aana/tests/units/test_app_upload.py diff --git a/aana/api/api_generation.py b/aana/api/api_generation.py index bd74e4c6..3df92480 100644 --- a/aana/api/api_generation.py +++ b/aana/api/api_generation.py @@ -4,7 +4,7 @@ from inspect import isasyncgenfunction from typing import Annotated, Any, get_origin -from fastapi import FastAPI, File, Form, Query, Request, UploadFile +from fastapi import FastAPI, Form, Query, Request from fastapi.responses import StreamingResponse from pydantic import ConfigDict, Field, ValidationError, create_model from pydantic.main import BaseModel diff --git a/aana/core/models/image.py b/aana/core/models/image.py index 78aca604..c1d10b51 100644 --- a/aana/core/models/image.py +++ b/aana/core/models/image.py @@ -13,11 +13,8 @@ BaseModel, ConfigDict, Field, - ValidationError, model_validator, ) -from pydantic_core import InitErrorDetails -from starlette.datastructures import UploadFile as StarletteUploadFile from typing_extensions import Self from aana.configs.settings import settings diff --git a/aana/core/models/video.py b/aana/core/models/video.py index 1197b644..60e59a52 100644 --- a/aana/core/models/video.py +++ b/aana/core/models/video.py @@ -15,10 +15,8 @@ BaseModel, ConfigDict, Field, - ValidationError, model_validator, ) -from pydantic_core import InitErrorDetails from typing_extensions import Self from aana.core.models.base import BaseListModel diff --git a/aana/tests/units/test_api_generation.py b/aana/tests/units/test_api_generation.py index 7f072695..e0beeda2 100644 --- a/aana/tests/units/test_api_generation.py +++ b/aana/tests/units/test_api_generation.py @@ -6,7 +6,6 @@ from pydantic import BaseModel, ConfigDict, Field from aana.api.api_generation import Endpoint -from aana.exceptions.runtime import UploadedFileNotFound class InputModel(BaseModel): @@ -16,24 +15,6 @@ class InputModel(BaseModel): model_config = ConfigDict(extra="forbid") -class FileUploadModel(BaseModel): - """Model for a file upload input.""" - - content: str | None = Field( - None, - description="The name of the file to upload.", - ) - _file: bytes | None = None - - def set_files(self, files: dict[str, bytes]): - """Set files.""" - if self.content and not self._file: - raise UploadedFileNotFound(self.content) - self._file = files[self.content] - - model_config = ConfigDict(extra="forbid") - - class TestEndpointOutput(TypedDict): """The output of the test endpoint.""" @@ -48,24 +29,6 @@ async def run(self, input_data: InputModel) -> TestEndpointOutput: return {"output": input_data.input} -class TestFileUploadEndpoint(Endpoint): - """Test endpoint for file uploads.""" - - async def run(self, input_data: FileUploadModel) -> TestEndpointOutput: - """Run the endpoint.""" - return {"output": "file uploaded"} - - -class TestMultipleFileUploadEndpoint(Endpoint): - """Test endpoint for multiple file uploads.""" - - async def run( - self, input_data: FileUploadModel, input_data2: FileUploadModel - ) -> TestEndpointOutput: - """Run the endpoint.""" - return {"output": "file uploaded"} - - class TestEndpointMissingReturn(Endpoint): """Test endpoint for get_response_model with missing return type.""" @@ -121,15 +84,6 @@ def test_get_response_model(): assert ResponseModel.model_fields["output"].annotation == str -# def test_get_file_upload_field_multiple_file_uploads(): -# """Test the __get_file_upload_field function with multiple file uploads.""" -# endpoint = TestMultipleFileUploadEndpoint( -# name="test_endpoint", -# summary="Test endpoint", -# path="/test_endpoint", -# ) - - def test_get_response_model_missing_return(): """Test the get_response_model function with missing return type.""" endpoint = TestEndpointMissingReturn( diff --git a/aana/tests/units/test_app_upload.py b/aana/tests/units/test_app_upload.py new file mode 100644 index 00000000..536c5966 --- /dev/null +++ b/aana/tests/units/test_app_upload.py @@ -0,0 +1,90 @@ +# ruff: noqa: S101, S113 +import io +import json +from typing import TypedDict + +import requests +from pydantic import BaseModel, ConfigDict, Field + +from aana.api.api_generation import Endpoint +from aana.exceptions.runtime import UploadedFileNotFound + + +class FileUploadModel(BaseModel): + """Model for a file upload input.""" + + content: str | None = Field( + None, + description="The name of the file to upload.", + ) + _file: bytes | None = None + + def set_files(self, files: dict[str, bytes]): + """Set files.""" + if self.content: + if self.content not in files: + raise UploadedFileNotFound(self.content) + self._file = files[self.content] + + model_config = ConfigDict(extra="forbid") + + +class FileUploadEndpointOutput(TypedDict): + """The output of the file upload endpoint.""" + + text: str + + +class FileUploadEndpoint(Endpoint): + """File upload endpoint.""" + + async def run(self, file: FileUploadModel) -> FileUploadEndpointOutput: + """Upload a file. + + Args: + file (FileUploadModel): The file to upload + + Returns: + FileUploadEndpointOutput: The uploaded file + """ + file = file._file + return {"text": file.decode()} + + +deployments = [] + +endpoints = [ + { + "name": "file_upload", + "path": "/file_upload", + "summary": "Upload a file", + "endpoint_cls": FileUploadEndpoint, + } +] + + +def test_file_upload_app(create_app): + """Test the app with a file upload endpoint.""" + aana_app = create_app(deployments, endpoints) + + port = aana_app.port + route_prefix = "" + + # Check that the server is ready + response = requests.get(f"http://localhost:{port}{route_prefix}/api/ready") + assert response.status_code == 200 + assert response.json() == {"ready": True} + + # Test lowercase endpoint + # data = {"content": "file.txt"} + data = {"file": {"content": "file.txt"}} + file = b"Hello world! This is a test." + files = {"file.txt": io.BytesIO(file)} + response = requests.post( + f"http://localhost:{port}{route_prefix}/file_upload", + data={"body": json.dumps(data)}, + files=files, + ) + assert response.status_code == 200, response.text + text = response.json().get("text") + assert text == file.decode() From ccdeb48a836a6af1af192593d7ac536e45216ea1 Mon Sep 17 00:00:00 2001 From: Aleksandr Movchan Date: Thu, 12 Dec 2024 13:58:55 +0000 Subject: [PATCH 4/4] update README and documentation to include API request examples for structured and binary data --- README.md | 69 +++++++++++++++++++++++++++ aana/tests/units/test_image_input.py | 1 - docs/index.md | 70 ++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6d51e257..e82312da 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,75 @@ aana_app.deploy() # Deploys the application. All you need to do is define the deployments and endpoints you want to use in your application, and Aana SDK will take care of the rest. + +## API + +Aana SDK uses form data for API requests, which allows sending both binary data and structured fields in a single request. The request body is sent as a JSON string in the `body` field, and any binary data is sent as files. + +### Making API Requests + +You can send requests to the SDK endpoints with only structured data or a combination of structured data and binary data. + +#### Only Structured Data +When your request includes only structured data, you can send it as a JSON string in the `body` field. + +- **cURL Example:** + ```bash + curl http://127.0.0.1:8000/endpoint \ + -F body='{"input": "data", "param": "value"}' + ``` + +- **Python Example:** + ```python + import json, requests + + url = "http://127.0.0.1:8000/endpoint" + body = { + "input": "data", + "param": "value" + } + + response = requests.post( + url, + data={"body": json.dumps(body)} + ) + + print(response.json()) + ``` + +#### With Binary Data +When your request includes binary files (images, audio, etc.), you can send them as files in the request and include the names of the files in the `body` field as a reference. + +For example, if you want to send an image, you can use [`aana.core.models.image.ImageInput`](https://mobiusml.github.io/aana_sdk/reference/models/media/#aana.core.models.ImageInput) as the input type that supports binary data upload. The `content` field in the input type should be set to the name of the file you are sending. + +- **cURL Example:** + ```bash + curl http://127.0.0.1:8000/process_images \ + -H "Content-Type: multipart/form-data" \ + -F body='{"image": {"content": "file1"}}' \ + -F file1="@image.jpeg" + ``` + +- **Python Example:** + ```python + import json, requests + + url = "http://127.0.0.1:8000/process_images" + body = { + "image": {"content": "file1"} + } + with open("image.jpeg", "rb") as file: + files = {"file1": file} + + response = requests.post( + url, + data={"body": json.dumps(body)}, + files=files + ) + + print(response.text) + ``` + ## Serve Config Files The [Serve Config Files](https://docs.ray.io/en/latest/serve/production-guide/config.html#serve-config-files) is the recommended way to deploy and update your applications in production. Aana SDK provides a way to build the Serve Config Files for the Aana applications. See the [Serve Config Files documentation](https://mobiusml.github.io/aana_sdk/pages/serve_config_files/) on how to build and deploy the applications using the Serve Config Files. diff --git a/aana/tests/units/test_image_input.py b/aana/tests/units/test_image_input.py index fb4c5323..e40f7cc2 100644 --- a/aana/tests/units/test_image_input.py +++ b/aana/tests/units/test_image_input.py @@ -216,7 +216,6 @@ def test_imagelistinput(): def test_imagelistinput_set_files(): """Test that the files can be set for the images.""" - # files = [b"image data 1", b"image data 2"] files = { "file": b"image data 1", "numpy_file": b"image data 2", diff --git a/docs/index.md b/docs/index.md index 5e5da29f..275187c9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -315,3 +315,73 @@ aana_app.deploy() # Deploys the application. All you need to do is define the deployments and endpoints you want to use in your application, and Aana SDK will take care of the rest. + +## API + +Aana SDK uses form data for API requests, which allows sending both binary data and structured fields in a single request. The request body is sent as a JSON string in the `body` field, and any binary data is sent as files. + +### Making API Requests + +You can send requests to the SDK endpoints with only structured data or a combination of structured data and binary data. + +#### Only Structured Data +When your request includes only structured data, you can send it as a JSON string in the `body` field. + +- **cURL Example:** + ```bash + curl http://127.0.0.1:8000/endpoint \ + -F body='{"input": "data", "param": "value"}' + ``` + +- **Python Example:** + ```python + import json, requests + + url = "http://127.0.0.1:8000/endpoint" + body = { + "input": "data", + "param": "value" + } + + response = requests.post( + url, + data={"body": json.dumps(body)} + ) + + print(response.json()) + ``` + +#### With Binary Data +When your request includes binary files (images, audio, etc.), you can send them as files in the request and include the names of the files in the `body` field as a reference. + +For example, if you want to send an image, you can use [`aana.core.models.image.ImageInput`](reference/models/media.md#aana.core.models.ImageInput) as the input type that supports binary data upload. The `content` field in the input type should be set to the name of the file you are sending. + +You can send multiple files in a single request by including multiple files in the request and referencing them in the `body` field even if they are of different types. + +- **cURL Example:** + ```bash + curl http://127.0.0.1:8000/process_images \ + -H "Content-Type: multipart/form-data" \ + -F body='{"image": {"content": "file1"}}' \ + -F file1="@image.jpeg" + ``` + +- **Python Example:** + ```python + import json, requests + + url = "http://127.0.0.1:8000/process_images" + body = { + "image": {"content": "file1"} + } + with open("image.jpeg", "rb") as file: + files = {"file1": file} + + response = requests.post( + url, + data={"body": json.dumps(body)}, + files=files + ) + + print(response.text) + ``` \ No newline at end of file