diff --git a/lf_toolkit/evaluation/image_upload.py b/lf_toolkit/evaluation/image_upload.py new file mode 100644 index 0000000..dff9233 --- /dev/null +++ b/lf_toolkit/evaluation/image_upload.py @@ -0,0 +1,179 @@ +import hashlib + +import requests +import uuid +import os +from io import BytesIO +from typing import Dict, List, Optional +from PIL import Image +from dotenv import load_dotenv + +from botocore.auth import SigV4Auth +from botocore.awsrequest import AWSRequest +from botocore.credentials import Credentials + +load_dotenv() + +MIME_TO_FORMAT: Dict[str, List[str]] = { + 'image/jpeg': ['JPEG', 'JPG'], + 'image/png': ['PNG'], + 'image/gif': ['GIF'], + 'image/bmp': ['BMP'], +} + +FORMAT_TO_MIME: Dict[str, str] = { + 'JPEG': 'image/jpeg', + 'JPG': 'image/jpeg', + 'PNG': 'image/png', + 'GIF': 'image/gif', + "BMP": 'image/bmp' +} + +class ImageUploadError(Exception): + """Custom exception for image upload failures""" + pass + + +class InvalidMimeTypeError(ImageUploadError): + """Exception for invalid MIME type""" + pass + + +class MissingEnvironmentVariableError(ImageUploadError): + """Exception for missing environment variables""" + pass + + +def generate_file_name(img: Image.Image) -> str: + """Generate filename for the image + + Args: + img: PIL Image object + + Returns: + Generated filename string + """ + unique_id: str = str(uuid.uuid4()) + format_ext: str = img.format.lower() if img.format else 'png' + return f"{unique_id}.{format_ext}" + +def get_s3_bucket_uri() -> str: + """Get S3 bucket URI from environment variable""" + s3_uri: Optional[str] = os.getenv('S3_BUCKET_URI') + + if not s3_uri: + raise MissingEnvironmentVariableError( + "S3_BUCKET_URI environment variable is not set" + ) + + return s3_uri + + +def get_aws_signed_request(full_url, buffer, mime_type): + credentials = Credentials( + access_key=os.environ['AWS_ACCESS_KEY_ID'], + secret_key=os.environ['AWS_SECRET_ACCESS_KEY'], + token=os.environ.get('AWS_SESSION_TOKEN', None) + ) + + if hasattr(buffer, 'read'): + # It's a file-like object (BytesIO, etc.) + current_pos = buffer.tell() # Save current position + buffer.seek(0) # Go to start + data = buffer.read() # Read all data + buffer.seek(current_pos) # Restore position + else: + # It's already bytes + data = buffer + + # Calculate content hash and length + content_hash = hashlib.sha256(data).hexdigest() + content_length = len(data) + + # Create the request for signing with required headers + headers = { + 'Content-Type': mime_type, + 'Content-Length': str(content_length), + 'x-amz-content-sha256': content_hash + } + + # Create the request for signing + aws_request = AWSRequest( + method='PUT', + url=full_url, + data=buffer, + headers=headers + ) + + region = os.environ.get('AWS_REGION', 'eu-west-2') + + # Sign the request + SigV4Auth(credentials, 's3', region).add_auth(aws_request) + + return aws_request + + +def upload_image(img: Image.Image, folder_name: str) -> str: + """Upload PIL image with comprehensive MIME type validation + + Args: + folder_name: name of folder to save image + img: PIL Image object to upload + + Returns: + JSON response from the server as a dictionary + + Raises: + InvalidMimeTypeError: If MIME type validation fails + MissingEnvironmentVariableError: If S3_BUCKET_URI is not set + ImageUploadError: If upload fails for any reason + """ + try: + # Get URL from environment variable + base_url: str = get_s3_bucket_uri() + + filename: str = generate_file_name(img) + + full_url = os.path.join(base_url, folder_name, filename) + + if img.format is None: + img.format = 'PNG' + + mime_type = FORMAT_TO_MIME[img.format.upper()] + + buffer: BytesIO = BytesIO() + img_format: str = img.format if img.format else 'PNG' + img.save(buffer, format=img_format) + buffer.seek(0) + + aws_request = get_aws_signed_request(full_url, buffer, mime_type).prepare() + + response: requests.Response = requests.request( + method=aws_request.method, + url=aws_request.url, + data=aws_request.body, + headers=aws_request.headers, + timeout=30 + ) + + if response.status_code != 200: + raise ImageUploadError( + f"Upload failed with status code {response.status_code}: {response.text}" + ) + + return full_url + + except (InvalidMimeTypeError, MissingEnvironmentVariableError): + raise + except requests.exceptions.RequestException as e: + raise ImageUploadError(f"Network error: {str(e)}") + except Exception as e: + raise ImageUploadError(f"Unexpected error: {str(e)}") + +if __name__ == "__main__": + img = Image.new('RGB', (100, 100), color='red') + img.format = 'JPEG' + + # Execute + result = upload_image(img, "eduvision") + print(result) diff --git a/poetry.lock b/poetry.lock index 84e4f85..58fff1f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -129,6 +129,46 @@ d = ["aiohttp (>=3.7.4) ; sys_platform != \"win32\" or implementation_name != \" jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "boto3" +version = "1.42.36" +description = "The AWS SDK for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "boto3-1.42.36-py3-none-any.whl", hash = "sha256:e0ff6f2747bfdec63405b35ea185a7aea35239c3f4fe99e4d29368a6de9c4a84"}, + {file = "boto3-1.42.36.tar.gz", hash = "sha256:a4eb51105c8c5d7b2bc2a9e2316e69baf69a55611275b9f189c0cf59f1aae171"}, +] + +[package.dependencies] +botocore = ">=1.42.36,<1.43.0" +jmespath = ">=0.7.1,<2.0.0" +s3transfer = ">=0.16.0,<0.17.0" + +[package.extras] +crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] + +[[package]] +name = "botocore" +version = "1.42.36" +description = "Low-level, data-driven core of boto 3." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "botocore-1.42.36-py3-none-any.whl", hash = "sha256:2cfae4c482e5e87bd835ab4289b711490c161ba57e852c06b65a03e7c25e08eb"}, + {file = "botocore-1.42.36.tar.gz", hash = "sha256:2ebd89cc75927944e2cee51b7adce749f38e0cb269a758a6464a27f8bcca65fb"}, +] + +[package.dependencies] +jmespath = ">=0.7.1,<2.0.0" +python-dateutil = ">=2.1,<3.0.0" +urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""} + +[package.extras] +crt = ["awscrt (==0.29.2)"] + [[package]] name = "build" version = "1.3.0" @@ -646,6 +686,20 @@ files = [ {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, ] +[[package]] +name = "dotenv" +version = "0.9.9" +description = "Deprecated package" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9"}, +] + +[package.dependencies] +python-dotenv = "*" + [[package]] name = "dulwich" version = "0.24.1" @@ -1009,6 +1063,18 @@ files = [ test = ["async-timeout ; python_version < \"3.11\"", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"] trio = ["trio"] +[[package]] +name = "jmespath" +version = "1.1.0" +description = "JSON Matching Expressions" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64"}, + {file = "jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d"}, +] + [[package]] name = "jsonrpcserver" version = "5.0.9" @@ -1328,6 +1394,115 @@ all = ["pbs-installer[download,install]"] download = ["httpx (>=0.27.0,<1)"] install = ["zstandard (>=0.21.0)"] +[[package]] +name = "pillow" +version = "12.1.0" +description = "Python Imaging Library (fork)" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd"}, + {file = "pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0"}, + {file = "pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8"}, + {file = "pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1"}, + {file = "pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda"}, + {file = "pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7"}, + {file = "pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a"}, + {file = "pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef"}, + {file = "pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09"}, + {file = "pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91"}, + {file = "pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea"}, + {file = "pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3"}, + {file = "pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0"}, + {file = "pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451"}, + {file = "pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e"}, + {file = "pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84"}, + {file = "pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0"}, + {file = "pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b"}, + {file = "pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18"}, + {file = "pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64"}, + {file = "pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75"}, + {file = "pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304"}, + {file = "pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b"}, + {file = "pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551"}, + {file = "pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208"}, + {file = "pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5"}, + {file = "pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661"}, + {file = "pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17"}, + {file = "pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670"}, + {file = "pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616"}, + {file = "pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7"}, + {file = "pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d"}, + {file = "pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c"}, + {file = "pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1"}, + {file = "pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179"}, + {file = "pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0"}, + {file = "pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587"}, + {file = "pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac"}, + {file = "pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b"}, + {file = "pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea"}, + {file = "pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c"}, + {file = "pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc"}, + {file = "pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644"}, + {file = "pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c"}, + {file = "pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171"}, + {file = "pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a"}, + {file = "pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45"}, + {file = "pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d"}, + {file = "pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0"}, + {file = "pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554"}, + {file = "pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e"}, + {file = "pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82"}, + {file = "pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4"}, + {file = "pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0"}, + {file = "pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b"}, + {file = "pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65"}, + {file = "pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0"}, + {file = "pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8"}, + {file = "pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91"}, + {file = "pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796"}, + {file = "pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd"}, + {file = "pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13"}, + {file = "pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e"}, + {file = "pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643"}, + {file = "pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5"}, + {file = "pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de"}, + {file = "pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9"}, + {file = "pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a"}, + {file = "pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a"}, + {file = "pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030"}, + {file = "pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94"}, + {file = "pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4"}, + {file = "pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2"}, + {file = "pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61"}, + {file = "pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51"}, + {file = "pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc"}, + {file = "pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14"}, + {file = "pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8"}, + {file = "pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924"}, + {file = "pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef"}, + {file = "pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988"}, + {file = "pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6"}, + {file = "pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19"}, + {file = "pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] +xmp = ["defusedxml"] + [[package]] name = "pkginfo" version = "1.12.1.2" @@ -1703,6 +1878,36 @@ pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-dotenv" +version = "1.2.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61"}, + {file = "python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + [[package]] name = "pywin32" version = "306" @@ -2128,6 +2333,24 @@ files = [ {file = "rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8"}, ] +[[package]] +name = "s3transfer" +version = "0.16.0" +description = "An Amazon S3 Transfer Manager" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe"}, + {file = "s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920"}, +] + +[package.dependencies] +botocore = ">=1.37.4,<2.0a.0" + +[package.extras] +crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] + [[package]] name = "secretstorage" version = "3.4.0" @@ -2157,6 +2380,18 @@ files = [ {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, ] +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -2613,4 +2848,4 @@ parsing = ["antlr4-python3-runtime", "lark", "latex2sympy"] [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "10f9e90114dd9d66fe62d35aabc2fee0eb962ff7b99840216a17fb1282a641f4" +content-hash = "9dc3f7e12199191cf41834205dbb2705b1e1e4b2dd851b1bb57e312d3c4e8a8b" diff --git a/pyproject.toml b/pyproject.toml index 2ae3f68..e06cf72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,10 @@ pywin32 = { version = "^306", platform = "win32", optional = true } ########################## poetry-plugin-export = "^1.9.0" pytest-asyncio = "^1.2.0" +pillow = "^12.1.0" +requests = "^2.32.5" +dotenv = "^0.9.9" +boto3 = "^1.42.36" [tool.poetry.group.dev.dependencies] black = "24.8.0" diff --git a/tests/evaluation/image_upload_test.py b/tests/evaluation/image_upload_test.py new file mode 100644 index 0000000..b4a0125 --- /dev/null +++ b/tests/evaluation/image_upload_test.py @@ -0,0 +1,334 @@ +import pytest +import uuid +from unittest.mock import Mock, patch +from PIL import Image +import requests + +# Import the module to test +from lf_toolkit.evaluation.image_upload import ( + generate_file_name, + get_s3_bucket_uri, + upload_image, + ImageUploadError, + InvalidMimeTypeError, + MissingEnvironmentVariableError, +) + + +class TestGenerateFileName: + """Test suite for generate_file_name function""" + + def test_generate_file_name_with_jpeg_format(self): + """Test filename generation for JPEG image""" + img = Mock(spec=Image.Image) + img.format = 'JPEG' + + with patch('lf_toolkit.evaluation.image_upload.uuid.uuid4') as mock_uuid: + mock_uuid.return_value = uuid.UUID('12345678-1234-5678-1234-567812345678') + filename = generate_file_name(img) + + assert filename == '12345678-1234-5678-1234-567812345678.jpeg' + + def test_generate_file_name_with_png_format(self): + """Test filename generation for PNG image""" + img = Mock(spec=Image.Image) + img.format = 'PNG' + + with patch('lf_toolkit.evaluation.image_upload.uuid.uuid4') as mock_uuid: + mock_uuid.return_value = uuid.UUID('abcdef12-3456-7890-abcd-ef1234567890') + filename = generate_file_name(img) + + assert filename == 'abcdef12-3456-7890-abcd-ef1234567890.png' + + def test_generate_file_name_with_no_format(self): + """Test filename generation when image has no format (defaults to png)""" + img = Mock(spec=Image.Image) + img.format = None + + with patch('lf_toolkit.evaluation.image_upload.uuid.uuid4') as mock_uuid: + mock_uuid.return_value = uuid.UUID('00000000-0000-0000-0000-000000000000') + filename = generate_file_name(img) + + assert filename == '00000000-0000-0000-0000-000000000000.png' + + def test_generate_file_name_unique(self): + """Test that generated filenames are unique""" + img = Mock(spec=Image.Image) + img.format = 'PNG' + + filename1 = generate_file_name(img) + filename2 = generate_file_name(img) + + assert filename1 != filename2 + + +class TestGetS3BucketUri: + """Test suite for get_s3_bucket_uri function""" + + def test_get_s3_bucket_uri_success(self): + """Test successful retrieval of S3 bucket URI""" + with patch('lf_toolkit.evaluation.image_upload.os.getenv') as mock_getenv: + mock_getenv.return_value = 'https://s3.amazonaws.com/my-bucket' + + uri = get_s3_bucket_uri() + + assert uri == 'https://s3.amazonaws.com/my-bucket' + mock_getenv.assert_called_once_with('S3_BUCKET_URI') + + def test_get_s3_bucket_uri_missing(self): + """Test error when S3_BUCKET_URI is not set""" + with patch('lf_toolkit.evaluation.image_upload.os.getenv') as mock_getenv: + mock_getenv.return_value = None + + with pytest.raises(MissingEnvironmentVariableError) as exc_info: + get_s3_bucket_uri() + + assert "S3_BUCKET_URI environment variable is not set" in str(exc_info.value) + + def test_get_s3_bucket_uri_empty_string(self): + """Test error when S3_BUCKET_URI is empty string""" + with patch('lf_toolkit.evaluation.image_upload.os.getenv') as mock_getenv: + mock_getenv.return_value = '' + + with pytest.raises(MissingEnvironmentVariableError): + get_s3_bucket_uri() + + +class TestUploadImage: + """Test suite for upload_image function""" + + @patch('lf_toolkit.evaluation.image_upload.requests.request') + @patch('lf_toolkit.evaluation.image_upload.get_aws_signed_request') + @patch('lf_toolkit.evaluation.image_upload.os.getenv') + @patch('lf_toolkit.evaluation.image_upload.uuid.uuid4') + def test_successful_upload(self, mock_uuid, mock_getenv, mock_get_aws_signed_request, mock_request): + """Test successful image upload with UUID-based filename""" + # Setup mocks + mock_uuid.return_value = uuid.UUID('12345678-1234-5678-1234-567812345678') + mock_getenv.return_value = 'https://s3.amazonaws.com/eduvision' + + # Mock the AWS signed request + mock_prepared_request = Mock() + mock_prepared_request.method = 'PUT' + mock_prepared_request.url = 'https://s3.amazonaws.com/eduvision/eduvision/12345678-1234-5678-1234-567812345678.jpeg' + mock_prepared_request.body = b'mock_body' + mock_prepared_request.headers = {'Content-Type': 'image/jpeg'} + + mock_aws_request = Mock() + mock_aws_request.prepare.return_value = mock_prepared_request + mock_get_aws_signed_request.return_value = mock_aws_request + + mock_response = Mock() + mock_response.status_code = 200 + mock_request.return_value = mock_response + + # Create a real PIL image for testing + img = Image.new('RGB', (100, 100), color='red') + img.format = 'JPEG' + + # Execute + result = upload_image(img, "eduvision") + + # Verify response + assert result == 'https://s3.amazonaws.com/eduvision/eduvision/12345678-1234-5678-1234-567812345678.jpeg' + assert mock_request.called + assert mock_request.call_args[1]['timeout'] == 30 + + @patch('lf_toolkit.evaluation.image_upload.requests.request') + @patch('lf_toolkit.evaluation.image_upload.get_aws_signed_request') + @patch('lf_toolkit.evaluation.image_upload.os.getenv') + @patch('lf_toolkit.evaluation.image_upload.uuid.uuid4') + def test_upload_with_png(self, mock_uuid, mock_getenv, mock_get_aws_signed_request, mock_request): + """Test uploading PNG image with UUID-based filename""" + # Setup mocks + mock_uuid.return_value = uuid.UUID('12345678-1234-5678-1234-567812345678') + mock_getenv.return_value = 'https://s3.amazonaws.com/eduvision' + + # Mock the AWS signed request + mock_prepared_request = Mock() + mock_prepared_request.method = 'PUT' + mock_prepared_request.url = 'https://s3.amazonaws.com/eduvision/eduvision/12345678-1234-5678-1234-567812345678.png' + mock_prepared_request.body = b'mock_body' + mock_prepared_request.headers = {'Content-Type': 'image/jpeg'} + + mock_aws_request = Mock() + mock_aws_request.prepare.return_value = mock_prepared_request + mock_get_aws_signed_request.return_value = mock_aws_request + + mock_response = Mock() + mock_response.status_code = 200 + mock_request.return_value = mock_response + + img = Image.new('RGBA', (50, 50), color=(0, 255, 0, 128)) + img.format = 'PNG' + + result = upload_image(img, "eduvision") + + assert result == 'https://s3.amazonaws.com/eduvision/eduvision/12345678-1234-5678-1234-567812345678.png' + + @patch('lf_toolkit.evaluation.image_upload.os.getenv') + def test_upload_missing_s3_uri(self, mock_getenv): + """Test upload fails when S3_BUCKET_URI is missing""" + mock_getenv.return_value = None + + img = Image.new('RGB', (100, 100)) + img.format = 'JPEG' + + with pytest.raises(MissingEnvironmentVariableError): + upload_image(img, "eduvision") + + @patch('lf_toolkit.evaluation.image_upload.requests.request') + @patch('lf_toolkit.evaluation.image_upload.get_aws_signed_request') + @patch('lf_toolkit.evaluation.image_upload.os.getenv') + @patch('lf_toolkit.evaluation.image_upload.uuid.uuid4') + def test_upload_server_error(self, mock_uuid, mock_getenv, mock_get_aws_signed_request, mock_request): + """Test upload fails when server returns error""" + mock_uuid.return_value = uuid.UUID('12345678-1234-5678-1234-567812345678') + mock_getenv.return_value = 'https://s3.amazonaws.com/bucket' + + # Mock the AWS signed request + mock_prepared_request = Mock() + mock_prepared_request.method = 'PUT' + mock_prepared_request.url = 'https://s3.amazonaws.com/bucket/eduvision/12345678-1234-5678-1234-567812345678.jpeg' + mock_prepared_request.body = b'mock_body' + mock_prepared_request.headers = {'Content-Type': 'image/jpeg'} + + mock_aws_request = Mock() + mock_aws_request.prepare.return_value = mock_prepared_request + mock_get_aws_signed_request.return_value = mock_aws_request + + mock_response = Mock() + mock_response.status_code = 500 + mock_response.text = 'Internal Server Error' + mock_request.return_value = mock_response + + img = Image.new('RGB', (100, 100)) + img.format = 'JPEG' + + with pytest.raises(ImageUploadError) as exc_info: + upload_image(img, "eduvision") + + assert "Upload failed with status code 500" in str(exc_info.value) + + @patch('lf_toolkit.evaluation.image_upload.requests.request') + @patch('lf_toolkit.evaluation.image_upload.get_aws_signed_request') + @patch('lf_toolkit.evaluation.image_upload.os.getenv') + @patch('lf_toolkit.evaluation.image_upload.uuid.uuid4') + def test_upload_network_error(self, mock_uuid, mock_getenv, mock_get_aws_signed_request, mock_request): + """Test upload fails on network error""" + mock_uuid.return_value = uuid.UUID('12345678-1234-5678-1234-567812345678') + mock_getenv.return_value = 'https://s3.amazonaws.com/bucket' + + # Mock the AWS signed request + mock_prepared_request = Mock() + mock_prepared_request.method = 'PUT' + mock_prepared_request.url = 'https://s3.amazonaws.com/bucket/eduvision/12345678-1234-5678-1234-567812345678.jpeg' + mock_prepared_request.body = b'mock_body' + mock_prepared_request.headers = {'Content-Type': 'image/jpeg'} + + mock_aws_request = Mock() + mock_aws_request.prepare.return_value = mock_prepared_request + mock_get_aws_signed_request.return_value = mock_aws_request + + mock_request.side_effect = requests.exceptions.ConnectionError('Connection failed') + + img = Image.new('RGB', (100, 100)) + img.format = 'JPEG' + + with pytest.raises(ImageUploadError) as exc_info: + upload_image(img, "eduvision") + + assert "Network error" in str(exc_info.value) + + @patch('lf_toolkit.evaluation.image_upload.requests.request') + @patch('lf_toolkit.evaluation.image_upload.get_aws_signed_request') + @patch('lf_toolkit.evaluation.image_upload.os.getenv') + @patch('lf_toolkit.evaluation.image_upload.uuid.uuid4') + def test_upload_timeout_error(self, mock_uuid, mock_getenv, mock_get_aws_signed_request, mock_request): + """Test upload fails on timeout""" + mock_uuid.return_value = uuid.UUID('12345678-1234-5678-1234-567812345678') + mock_getenv.return_value = 'https://s3.amazonaws.com/bucket' + + # Mock the AWS signed request + mock_prepared_request = Mock() + mock_prepared_request.method = 'PUT' + mock_prepared_request.url = 'https://s3.amazonaws.com/bucket/eduvision/12345678-1234-5678-1234-567812345678.jpeg' + mock_prepared_request.body = b'mock_body' + mock_prepared_request.headers = {'Content-Type': 'image/jpeg'} + + mock_aws_request = Mock() + mock_aws_request.prepare.return_value = mock_prepared_request + mock_get_aws_signed_request.return_value = mock_aws_request + + mock_request.side_effect = requests.exceptions.Timeout('Request timed out') + + img = Image.new('RGB', (100, 100)) + img.format = 'JPEG' + + with pytest.raises(ImageUploadError) as exc_info: + upload_image(img, "eduvision") + + assert "Network error" in str(exc_info.value) + + @patch('lf_toolkit.evaluation.image_upload.requests.request') + @patch('lf_toolkit.evaluation.image_upload.get_aws_signed_request') + @patch('lf_toolkit.evaluation.image_upload.os.getenv') + @patch('lf_toolkit.evaluation.image_upload.uuid.uuid4') + def test_upload_image_no_format(self, mock_uuid, mock_getenv, mock_get_aws_signed_request, mock_request): + """Test upload with image that has no format (defaults to PNG) uses UUID filename""" + mock_uuid.return_value = uuid.UUID('12345678-1234-5678-1234-567812345678') + mock_getenv.return_value = 'https://s3.amazonaws.com/bucket/' + + # Mock the AWS signed request + mock_prepared_request = Mock() + mock_prepared_request.method = 'PUT' + mock_prepared_request.url = 'https://s3.amazonaws.com/bucket/eduvision/12345678-1234-5678-1234-567812345678.png' + mock_prepared_request.body = b'mock_body' + mock_prepared_request.headers = {'Content-Type': 'image/png'} + + mock_aws_request = Mock() + mock_aws_request.prepare.return_value = mock_prepared_request + mock_get_aws_signed_request.return_value = mock_aws_request + + mock_response = Mock() + mock_response.status_code = 200 + mock_request.return_value = mock_response + + img = Image.new('RGB', (100, 100)) + img.format = None + + result = upload_image(img, "eduvision") + + assert result == 'https://s3.amazonaws.com/bucket/eduvision/12345678-1234-5678-1234-567812345678.png' + + +class TestExceptionHierarchy: + """Test suite for custom exception classes""" + + def test_image_upload_error_is_exception(self): + """Test that ImageUploadError inherits from Exception""" + assert issubclass(ImageUploadError, Exception) + + def test_invalid_mime_type_error_is_image_upload_error(self): + """Test that InvalidMimeTypeError inherits from ImageUploadError""" + assert issubclass(InvalidMimeTypeError, ImageUploadError) + assert issubclass(InvalidMimeTypeError, Exception) + + def test_missing_environment_variable_error_is_image_upload_error(self): + """Test that MissingEnvironmentVariableError inherits from ImageUploadError""" + assert issubclass(MissingEnvironmentVariableError, ImageUploadError) + assert issubclass(MissingEnvironmentVariableError, Exception) + + def test_can_raise_and_catch_image_upload_error(self): + """Test that custom exceptions can be raised and caught""" + with pytest.raises(ImageUploadError): + raise ImageUploadError("Test error") + + def test_invalid_mime_type_error_caught_as_image_upload_error(self): + """Test that InvalidMimeTypeError can be caught as ImageUploadError""" + with pytest.raises(ImageUploadError): + raise InvalidMimeTypeError("Invalid MIME") + + +if __name__ == '__main__': + pytest.main([__file__, '-v'])