Spaces:
Build error
Build error
import os | |
from typing import Any, TypedDict | |
import boto3 | |
import botocore | |
from openhands.storage.files import FileStore | |
class S3ObjectDict(TypedDict): | |
Key: str | |
class GetObjectOutputDict(TypedDict): | |
Body: Any | |
class ListObjectsV2OutputDict(TypedDict): | |
Contents: list[S3ObjectDict] | None | |
class S3FileStore(FileStore): | |
def __init__(self, bucket_name: str | None) -> None: | |
access_key = os.getenv('AWS_ACCESS_KEY_ID') | |
secret_key = os.getenv('AWS_SECRET_ACCESS_KEY') | |
secure = os.getenv('AWS_S3_SECURE', 'true').lower() == 'true' | |
endpoint = self._ensure_url_scheme(secure, os.getenv('AWS_S3_ENDPOINT')) | |
if bucket_name is None: | |
bucket_name = os.environ['AWS_S3_BUCKET'] | |
self.bucket: str = bucket_name | |
self.client: Any = boto3.client( | |
's3', | |
aws_access_key_id=access_key, | |
aws_secret_access_key=secret_key, | |
endpoint_url=endpoint, | |
use_ssl=secure, | |
) | |
def write(self, path: str, contents: str | bytes) -> None: | |
try: | |
as_bytes = ( | |
contents.encode('utf-8') if isinstance(contents, str) else contents | |
) | |
self.client.put_object(Bucket=self.bucket, Key=path, Body=as_bytes) | |
except botocore.exceptions.ClientError as e: | |
if e.response['Error']['Code'] == 'AccessDenied': | |
raise FileNotFoundError( | |
f"Error: Access denied to bucket '{self.bucket}'." | |
) | |
elif e.response['Error']['Code'] == 'NoSuchBucket': | |
raise FileNotFoundError( | |
f"Error: The bucket '{self.bucket}' does not exist." | |
) | |
raise FileNotFoundError( | |
f"Error: Failed to write to bucket '{self.bucket}' at path {path}: {e}" | |
) | |
def read(self, path: str) -> str: | |
try: | |
response: GetObjectOutputDict = self.client.get_object( | |
Bucket=self.bucket, Key=path | |
) | |
with response['Body'] as stream: | |
return str(stream.read().decode('utf-8')) | |
except botocore.exceptions.ClientError as e: | |
# Catch all S3-related errors | |
if e.response['Error']['Code'] == 'NoSuchBucket': | |
raise FileNotFoundError( | |
f"Error: The bucket '{self.bucket}' does not exist." | |
) | |
elif e.response['Error']['Code'] == 'NoSuchKey': | |
raise FileNotFoundError( | |
f"Error: The object key '{path}' does not exist in bucket '{self.bucket}'." | |
) | |
else: | |
raise FileNotFoundError( | |
f"Error: Failed to read from bucket '{self.bucket}' at path {path}: {e}" | |
) | |
except Exception as e: | |
raise FileNotFoundError( | |
f"Error: Failed to read from bucket '{self.bucket}' at path {path}: {e}" | |
) | |
def list(self, path: str) -> list[str]: | |
if not path or path == '/': | |
path = '' | |
elif not path.endswith('/'): | |
path += '/' | |
# The delimiter logic screens out directories, so we can't use it. :( | |
# For example, given a structure: | |
# foo/bar/zap.txt | |
# foo/bar/bang.txt | |
# ping.txt | |
# prefix=None, delimiter="/" yields ["ping.txt"] # :( | |
# prefix="foo", delimiter="/" yields [] # :( | |
results: set[str] = set() | |
prefix_len = len(path) | |
response: ListObjectsV2OutputDict = self.client.list_objects_v2( | |
Bucket=self.bucket, Prefix=path | |
) | |
contents = response.get('Contents') | |
if not contents: | |
return [] | |
paths = [obj['Key'] for obj in contents] | |
for sub_path in paths: | |
if sub_path == path: | |
continue | |
try: | |
index = sub_path.index('/', prefix_len + 1) | |
if index != prefix_len: | |
results.add(sub_path[: index + 1]) | |
except ValueError: | |
results.add(sub_path) | |
return list(results) | |
def delete(self, path: str) -> None: | |
try: | |
# Sanitize path | |
if not path or path == '/': | |
path = '' | |
if path.endswith('/'): | |
path = path[:-1] | |
# Try to delete any child resources (Assume the path is a directory) | |
response = self.client.list_objects_v2( | |
Bucket=self.bucket, Prefix=f'{path}/' | |
) | |
for content in response.get('Contents') or []: | |
self.client.delete_object(Bucket=self.bucket, Key=content['Key']) | |
# Next try to delete item as a file | |
self.client.delete_object(Bucket=self.bucket, Key=path) | |
except botocore.exceptions.ClientError as e: | |
if e.response['Error']['Code'] == 'NoSuchBucket': | |
raise FileNotFoundError( | |
f"Error: The bucket '{self.bucket}' does not exist." | |
) | |
elif e.response['Error']['Code'] == 'AccessDenied': | |
raise FileNotFoundError( | |
f"Error: Access denied to bucket '{self.bucket}'." | |
) | |
elif e.response['Error']['Code'] == 'NoSuchKey': | |
raise FileNotFoundError( | |
f"Error: The object key '{path}' does not exist in bucket '{self.bucket}'." | |
) | |
else: | |
raise FileNotFoundError( | |
f"Error: Failed to delete key '{path}' from bucket '{self.bucket}': {e}" | |
) | |
except Exception as e: | |
raise FileNotFoundError( | |
f"Error: Failed to delete key '{path}' from bucket '{self.bucket}: {e}" | |
) | |
def _ensure_url_scheme(self, secure: bool, url: str | None) -> str | None: | |
if not url: | |
return None | |
if secure: | |
if not url.startswith('https://'): | |
url = 'https://' + url.removeprefix('http://') | |
else: | |
if not url.startswith('http://'): | |
url = 'http://' + url.removeprefix('https://') | |
return url | |