diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7a4c38f..822fb02 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,24 +12,19 @@ on: env: AWS_ACCOUNT_ID: '013230744746' - RELEASE_ACCESS_KEY_ID: AKIAQGFE5ESVJ7IYEUVR - REPO_BUCKET: gutenberg-ext-imglambda-repobucket-1i58nyytyztdl + RELEASE_ACCESS_KEY_ID_US_EAST_1: AKIAQGFE5ESVJ7IYEUVR + RELEASE_ACCESS_KEY_ID_AP_NORTHEAST_1: AKIAQGFE5ESVG2GCWRBX + REPO_BUCKET_US_EAST_1: gutenberg-ext-imglambda-repobucket-1i58nyytyztdl + REPO_BUCKET_AP_NORTHEAST_1: gutenberg-ext-imglambda-repobucket-mm723a9uponj TEST_ACCESS_KEY_ID: AKIAQGFE5ESVKGVB3XGS TEST_GENERATED_BUCKET: gutenberg-ext-imglambda-testgeneratedbucket-n8c79jl0mzd2 TEST_ORIGINAL_BUCKET: gutenberg-ext-imglambda-testoriginalbucket-fz28m8cblu5t PYTHON_VERSION: 3.13.5 jobs: - build: - runs-on: ubuntu-24.04 + buildnumber: + runs-on: ubuntu-24.04-arm steps: - - uses: actions/checkout@main - - uses: actions/setup-python@main - with: - python-version: '~${{env.PYTHON_VERSION}}' - - name: Python Poetry Action - uses: abatilo/actions-poetry@master - - run: script/recreate-release-venv - name: Generate build number uses: onyxmueller/build-tag-number@main with: @@ -38,19 +33,67 @@ jobs: - name: Print new build number run: echo "Build number is $BUILD_NUMBER" - name: Save the build number - run: echo "$BUILD_NUMBER" > work/BUILD_NUMBER + run: echo "$BUILD_NUMBER" > BUILD_NUMBER - name: Embed build number into code run: echo "build-$BUILD_NUMBER" > VERSION - - run: script/create-lambda - uses: actions/upload-artifact@main with: - name: artifact - path: work/imglambda.zip + name: build-number + path: BUILD_NUMBER if-no-files-found: error + + build-amd64: + needs: + - buildnumber + runs-on: ubuntu-24.04 + steps: + - uses: actions/download-artifact@main + with: + name: build-number + - name: set BUILD_NUMBER + run: echo "BUILD_NUMBER=$(< ./BUILD_NUMBER)" >> $GITHUB_ENV + - uses: actions/checkout@main + - uses: actions/setup-python@main + with: + python-version: '~${{env.PYTHON_VERSION}}' + - name: Python Poetry Action + uses: abatilo/actions-poetry@master + - run: script/recreate-release-venv + - name: Embed build number into code + run: echo "build-$BUILD_NUMBER" > VERSION + - run: script/create-lambda + - run: mv work/imglambda.zip work/imglambda-amd64.zip - uses: actions/upload-artifact@main + with: + name: artifact-amd64 + path: work/imglambda-amd64.zip + if-no-files-found: error + + build-arm64: + needs: + - buildnumber + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/download-artifact@main with: name: build-number - path: work/BUILD_NUMBER + - name: set BUILD_NUMBER + run: echo "BUILD_NUMBER=$(< ./BUILD_NUMBER)" >> $GITHUB_ENV + - uses: actions/checkout@main + - uses: actions/setup-python@main + with: + python-version: '~${{env.PYTHON_VERSION}}' + - name: Python Poetry Action + uses: abatilo/actions-poetry@master + - run: script/recreate-release-venv + - name: Embed build number into code + run: echo "build-$BUILD_NUMBER" > VERSION + - run: script/create-lambda + - run: mv work/imglambda.zip work/imglambda-arm64.zip + - uses: actions/upload-artifact@main + with: + name: artifact-arm64 + path: work/imglambda-arm64.zip if-no-files-found: error test: @@ -80,20 +123,24 @@ jobs: with: files: work/report-imglambda.xml - release: + release-github: needs: - - build + - buildnumber + - build-amd64 + - build-arm64 - test if: github.ref == 'refs/heads/master' runs-on: ubuntu-24.04 env: # Workaround for https://github.com/github/vscode-github-actions/issues/222 BUILD_NUMBER: "" - ASSET_NAME: "" steps: - uses: actions/download-artifact@main with: - name: artifact + name: artifact-amd64 + - uses: actions/download-artifact@main + with: + name: artifact-arm64 - uses: actions/download-artifact@main with: name: build-number @@ -113,12 +160,72 @@ jobs: GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} with: upload_url: ${{steps.create_release.outputs.upload_url}} - asset_path: ./imglambda.zip + asset_path: ./imglambda-amd64.zip asset_name: imglambda.build-${{env.BUILD_NUMBER}}-awslambda-python3.13-amd64.zip asset_content_type: application/octet-stream + - uses: actions/upload-release-asset@main + env: + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + with: + upload_url: ${{steps.create_release.outputs.upload_url}} + asset_path: ./imglambda-arm64.zip + asset_name: imglambda.build-${{env.BUILD_NUMBER}}-awslambda-python3.13-arm64.zip + asset_content_type: application/octet-stream + + release-s3-us-east-1: + needs: + - buildnumber + - build-amd64 + - test + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-24.04 + env: + # Workaround for https://github.com/github/vscode-github-actions/issues/222 + BUILD_NUMBER: "" + steps: + - uses: actions/download-artifact@main + with: + name: artifact-amd64 + - uses: actions/download-artifact@main + with: + name: build-number + - name: set BUILD_NUMBER + run: echo "BUILD_NUMBER=$(< ./BUILD_NUMBER)" >> $GITHUB_ENV - uses: aws-actions/configure-aws-credentials@main with: - aws-access-key-id: ${{env.RELEASE_ACCESS_KEY_ID}} - aws-secret-access-key: ${{secrets.RELEASE_AWS_SECRET_ACCESS_KEY}} + aws-access-key-id: ${{env.RELEASE_ACCESS_KEY_ID_US_EAST_1}} + aws-secret-access-key: ${{secrets.RELEASE_AWS_SECRET_ACCESS_KEY_US_EAST_1}} aws-region: us-east-1 - - run: aws s3 cp ./imglambda.zip s3://${{env.REPO_BUCKET}}/imglambda.build-${{env.BUILD_NUMBER}}-awslambda-python3.13-amd64.zip + - run: >- + aws s3 cp + ./imglambda-amd64.zip + s3://${{env.REPO_BUCKET_US_EAST_1}}/imglambda.build-${{env.BUILD_NUMBER}}-awslambda-python3.13-amd64.zip + + release-s3-ap-northeast-1: + needs: + - buildnumber + - build-arm64 + - test + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-24.04 + env: + # Workaround for https://github.com/github/vscode-github-actions/issues/222 + BUILD_NUMBER: "" + steps: + - uses: actions/download-artifact@main + with: + name: artifact-arm64 + - uses: actions/download-artifact@main + with: + name: build-number + - name: set BUILD_NUMBER + run: echo "BUILD_NUMBER=$(< ./BUILD_NUMBER)" >> $GITHUB_ENV + - uses: aws-actions/configure-aws-credentials@main + with: + aws-access-key-id: ${{env.RELEASE_ACCESS_KEY_ID_AP_NORTHEAST_1}} + aws-secret-access-key: ${{secrets.RELEASE_AWS_SECRET_ACCESS_KEY_AP_NORTHEAST_1}} + aws-region: ap-northeast-1 + - run: >- + aws s3 cp + ./imglambda-arm64.zip + s3://${{env.REPO_BUCKET_AP_NORTHEAST_1}}/imglambda.build-${{env.BUILD_NUMBER}}-awslambda-python3.13-arm64.zip diff --git a/config/development/s3-bucket-us-east-1.sample b/config/development/s3-bucket-us-east-1.sample new file mode 100644 index 0000000..8d6207f --- /dev/null +++ b/config/development/s3-bucket-us-east-1.sample @@ -0,0 +1 @@ +development-s3-bucket diff --git a/imglambda/originrequest/index.py b/imglambda/originrequest/index.py index ec9c80a..9e2d1cf 100644 --- a/imglambda/originrequest/index.py +++ b/imglambda/originrequest/index.py @@ -13,12 +13,13 @@ from logging import Logger from pathlib import Path from tempfile import NamedTemporaryFile -from typing import Any, Optional, Self, Tuple +from typing import Any, Callable, Literal, Optional, Self, Tuple, cast from urllib import parse import boto3 from botocore.exceptions import ClientError from dateutil import parser, tz +from mypy_boto3_lambda import LambdaClient from mypy_boto3_s3.client import S3Client from mypy_boto3_s3.type_defs import ( GetObjectOutputTypeDef, @@ -32,9 +33,14 @@ import imglambda from imglambda.typing import ( + ErrorLambdaResponse, HttpPath, OriginRequestEvent, Request, + ResizeRequestImageData, + ResizeRequestImageSource, + ResizeRequestPayload, + ResizeResponsePayload, ResponseResult, S3Key ) @@ -50,12 +56,22 @@ LAMBDA_EXPIRATION_MARGIN = 60 RESPONSE_BODY_LIMIT = 1024 * 1024 +# Maximum payload size for AWS Lambda is 6MiB +MAX_IMAGE_PAYLOAD_SIZE = 4 * 1024 * 1024 + 512 * 1024 # 4.5MiB + PADDING_COLOR = [230.0, 230.0, 230.0] long_exts = [ '.min.css', ] +MIME_TO_EXT = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/webp': '.webp', + 'image/avif': '.avif', +} + expiration_re = re.compile(r'\s*([\w-]+)="([^"]*)"(:?,|$)') path_subsize_re = re.compile(r'-(\d+)x(\d+)(\.[A-Za-z0-9]+)$') @@ -122,10 +138,15 @@ class FieldUpdate: uri: Optional[str] = None +@dataclasses.dataclass(frozen=True) +class Base64Str: + base64_str: str + + @dataclasses.dataclass(frozen=True) class InstantResponse: status: int - body: Optional[bytes | str] + body: Optional[bytes | str | Base64Str] cache_control: str content_type: Optional[str] vips_us: Optional[int] @@ -153,6 +174,7 @@ class XParams: expiration_margin: int basedir: str resize_mode: ResizeMode + resize_function: str class OptimImageType(Enum): @@ -275,13 +297,13 @@ def __init__(self, types: list[bool]): self.types = types @classmethod - def from_str(cls, accept_header: str) -> Self: + def from_str(cls, accept: str) -> Self: types = [False] * len(OptimImageType) - if 'image/avif' in accept_header: + if 'image/avif' in accept: types[OptimImageType.AVIF.value] = True - if 'image/webp' in accept_header: + if 'image/webp' in accept: types[OptimImageType.WEBP.value] = True return cls(types) @@ -556,12 +578,32 @@ class ResizeParam: source: HttpPath width: int height: int + mime: str + quality: int + + @staticmethod + def parse_accept_header(accept: AcceptHeader, source: HttpPath) -> Tuple[str, int]: + if accept.supports(OptimImageType.WEBP): + mime = 'image/webp' + else: + match Path(source).suffix: + case '.jpg': + mime = 'image/jpeg' + case '.jpeg': + mime = 'image/jpeg' + case '.png': + mime = 'image/png' + case _: + raise Exception('system error') + + return mime, 80 @classmethod def maybe_from_querystring( cls, source: HttpPath, qs: dict[str, list[str]], + accept: AcceptHeader, ) -> Optional['ResizeParam']: if 'w' not in qs or 'h' not in qs: return None @@ -575,10 +617,12 @@ def maybe_from_querystring( if width <= 0 or height <= 0: return None - return cls(source, width, height) + mime, quality = cls.parse_accept_header(accept, source) + + return cls(source, width, height, mime, quality) @classmethod - def maybe_from_path(cls, path: HttpPath) -> Optional['ResizeParam']: + def maybe_from_path(cls, path: HttpPath, accept: AcceptHeader) -> Optional['ResizeParam']: m = path_subsize_re.search(str(path)) if m is None: return None @@ -594,7 +638,64 @@ def maybe_from_path(cls, path: HttpPath) -> Optional['ResizeParam']: source = HttpPath(path[:m.start()] + str(m[3])) - return cls(source, width, height) + mime, quality = cls.parse_accept_header(accept, source) + + return cls(source, width, height, mime, quality) + + +def resize_image( + logger: Callable[[str, dict[str, Any]], None], + image: Image, + target: Size, + focalarea: Optional[Area], +) -> Image | Literal['INVALID_METADATA']: + original = Size.from_image(image) + + if focalarea is None: + focalarea = Area(0, 0, original.width, original.height) + + if not focalarea.is_in(original): + return 'INVALID_METADATA' + + scale = calc_resize_scale(original, target, focalarea) + if scale is None: + resized = original + resized_focalarea = focalarea + else: + assert scale[0] < scale[1] + image = image.resize(scale[0] / scale[1]) + resized = Size.from_image(image) + resized_focalarea = focalarea.scale(scale[0], scale[1]).fit_into(resized) + + croparea = calc_crop(resized, target, resized_focalarea) + padding, padded = calc_padding(resized, croparea) + + image = image.extract_area(padded.x, padded.y, padded.width, padded.height) + + if padding != NO_FOUR_SIDES: + size = padding.add_to(padded.to_size()) + image = image.embed( + padding.left, + padding.top, + size.width, + size.height, + extend=Extend.BACKGROUND, + background=PADDING_COLOR) + + logger( + 'resize param', { + 'original': original, + 'target': target, + 'focalarea': focalarea, + 'resized_focalarea': resized_focalarea, + 'scale': scale, + 'resized': resized, + 'croparea': croparea, + 'padding': padding, + 'padded': padded, + }) + + return image class ImgServer: @@ -606,6 +707,7 @@ def __init__( region: str, sqs: SQSClient, s3: S3Client, + awslambda: LambdaClient, generated_domain: str, original_bucket: str, generated_key_prefix: str, @@ -617,11 +719,13 @@ def __init__( expiration_margin: int, basedir: str, resize_mode: ResizeMode, + resize_function: Optional[str], ): self.log = log self.region = region self.sqs = sqs self.s3 = s3 + self.awslambda = awslambda self.generated_domain = generated_domain self.generated_bucket = generated_domain.split('.', 1)[0] self.original_bucket = original_bucket @@ -634,10 +738,11 @@ def __init__( self.expiration_margin = datetime.timedelta(seconds=expiration_margin) self.basedir = basedir self.resize_mode = resize_mode - self.log_context = {'path': '', 'qstr': '', 'accept_header': ''} + self.log_context = {'path': '', 'qstr': '', 'accept': ''} self.cache_control_perm = f'public, max-age={self.perm_resp_max_age}' self.cache_control_temp = f'public, max-age={self.temp_resp_max_age}' self.cache_control_error = f'public, max-age={self.error_max_age}' + self.resize_function = resize_function @classmethod def from_lambda( @@ -659,6 +764,7 @@ def from_lambda( bypass_minifier_patterns = get_header_or(req, 'x-env-bypass-minifier-patterns') basedir = get_header_or(req, 'x-env-basedir') resize_mode = ResizeMode[get_header_or(req, 'x-env-resize-mode', 'Disabled').upper()] + resize_function = get_header_or(req, 'x-env-resize-function') except KeyError as e: log.warning({ 'message': 'environment variable not found', @@ -678,11 +784,13 @@ def from_lambda( bypass_minifier_patterns=bypass_minifier_patterns, expiration_margin=expiration_margin, basedir=basedir, - resize_mode=resize_mode) + resize_mode=resize_mode, + resize_function=resize_function) if server_key not in cls.instances: sqs = boto3.client('sqs', region_name=region) s3 = boto3.client('s3', region_name=region) + awslambda = boto3.client('lambda', region_name=region) path_spec = ( None if bypass_minifier_patterns == '' else PathSpec.from_lines( GitWildMatchPattern, bypass_minifier_patterns.split(','))) @@ -691,6 +799,7 @@ def from_lambda( region=region, sqs=sqs, s3=s3, + awslambda=awslambda, generated_domain=generated_domain, original_bucket=original_bucket, generated_key_prefix=generated_key_prefix, @@ -701,7 +810,8 @@ def from_lambda( bypass_path_spec=path_spec, expiration_margin=expiration_margin, basedir=basedir, - resize_mode=resize_mode) + resize_mode=resize_mode, + resize_function=None if resize_function == '' else resize_function) return cls.instances[server_key] @@ -794,19 +904,60 @@ def keep_uptodate(self, key: S3Key) -> None: self.sqs.send_message(QueueUrl=self.sqs_queue_url, MessageBody=json_dump(body)) self.log_debug('enqueued', {'body': body}) - def resize_image( + def call_process_resize( + self, + resize_param: ResizeParam, + strict: bool, + ) -> Optional[InstantResponse]: + match self.resize_function: + case None: + resize = self.process_resize + case str(): + resize = self.external_process_resize + case _: + raise Exception('system error') + + return resize(resize_param, strict) + + def external_process_resize( self, - filename: str, - target: Size, - focalarea: Optional[Area], - ) -> Image | InstantResponse | None: - image: Image = Image.new_from_file(filename) - original = Size.from_image(image) + resize_param: ResizeParam, + strict: bool, + ) -> Optional[InstantResponse]: + assert self.resize_function is not None + key = key_from_path(resize_param.source) - if focalarea is None: - focalarea = Area(0, 0, original.width, original.height) + try: + res = self.s3.get_object(Bucket=self.original_bucket, Key=key) + if MAX_IMAGE_PAYLOAD_SIZE < res['ContentLength']: + image: ResizeRequestImageData | ResizeRequestImageSource = { + 'bucket': self.original_bucket, + 'key': key, + } + else: + image = {'base64': base64.b64encode(res['Body'].read()).decode()} + except ClientError as e: + if is_not_found_client_error(e): + return InstantResponse( + status=HTTPStatus.NOT_FOUND, + body='Image file not found', + cache_control=self.cache_control_error, + content_type='text/plain', + vips_us=None, + img_size=None) + raise e + + payload: ResizeRequestPayload = { + 'target': (resize_param.width, resize_param.height), + 'image': image, + 'mime': resize_param.mime, + 'quality': resize_param.quality, + } - if not focalarea.is_in(original): + try: + orig_meta = ObjectMeta.from_original_object(res) + except InvalidMetadata as e: + self.log_warning('failed to read orig metadata', {'reason': str(e), 'key': key}) return InstantResponse( status=HTTPStatus.INTERNAL_SERVER_ERROR, body='Invalid metadata', @@ -815,66 +966,111 @@ def resize_image( vips_us=None, img_size=None) - scale = calc_resize_scale(original, target, focalarea) - if scale is None: - resized = original - resized_focalarea = focalarea - else: - assert scale[0] < scale[1] - image = image.resize(scale[0] / scale[1]) - resized = Size.from_image(image) - resized_focalarea = focalarea.scale(scale[0], scale[1]).fit_into(resized) - - croparea = calc_crop(resized, target, resized_focalarea) - padding, padded = calc_padding(resized, croparea) - - image = image.extract_area(padded.x, padded.y, padded.width, padded.height) - - if padding != NO_FOUR_SIDES: - size = padding.add_to(padded.to_size()) - image = image.embed( - padding.left, - padding.top, - size.width, - size.height, - extend=Extend.BACKGROUND, - background=PADDING_COLOR) - - self.log_debug( - 'resize param', { - 'original': original, - 'target': target, - 'focalarea': focalarea, - 'resized_focalarea': resized_focalarea, - 'scale': scale, - 'resized': resized, - 'croparea': croparea, - 'padding': padding, - 'padded': padded, - }) + target = Size(resize_param.width, resize_param.height) + if strict and target not in orig_meta.subsizes: + return None + + start_ns = time.time_ns() + + if orig_meta.focalarea is not None: + payload['focalarea'] = ( + orig_meta.focalarea.x, + orig_meta.focalarea.y, + orig_meta.focalarea.width, + orig_meta.focalarea.height, + ) + + try: + res2 = self.awslambda.invoke( + FunctionName=self.resize_function, + Payload=json.dumps(dict(payload), separators=(',', ':'), sort_keys=True)) + response: ResizeResponsePayload | ErrorLambdaResponse = json.loads(res2['Payload'].read()) - return image + vips_us = (time.time_ns() - start_ns) // 1000 + + if 'errorType' in response: + response = cast(ErrorLambdaResponse, response) + self.log_warning( + 'failed to call resize', { + 'reason': f"{response['errorType']}: {response['errorMessage']}", + 'key': key, + 'traceback': '\n'.join(response['stackTrace']), + }) + return InstantResponse( + status=HTTPStatus.INTERNAL_SERVER_ERROR, + body='failed to call resize', + cache_control=self.cache_control_error, + content_type='text/plain', + vips_us=None, + img_size=None) + + result = response['result'] + + if isinstance(result, dict): + if RESPONSE_BODY_LIMIT < result['size']: + return InstantResponse( + status=HTTPStatus.BAD_REQUEST, + body='Response size too large', + cache_control=self.cache_control_error, + content_type='text/plain', + vips_us=None, + img_size=None) + return InstantResponse( + status=HTTPStatus.OK, + body=Base64Str(result['body_b64']), + cache_control=self.cache_control_perm, + content_type=resize_param.mime, + vips_us=vips_us, + img_size=result['size']) + + match result: + case 'INVALID_IMAGE': + return InstantResponse( + status=HTTPStatus.INTERNAL_SERVER_ERROR, + body='Invalid image file', + cache_control=self.cache_control_error, + content_type='text/plain', + vips_us=None, + img_size=None) + case 'INVALID_METADATA': + return InstantResponse( + status=HTTPStatus.INTERNAL_SERVER_ERROR, + body='Invalid metadata', + cache_control=self.cache_control_error, + content_type='text/plain', + vips_us=None, + img_size=None) + case 'RESPONSE_TOO_LARGE': + return InstantResponse( + status=HTTPStatus.BAD_REQUEST, + body='Response size too large', + cache_control=self.cache_control_error, + content_type='text/plain', + vips_us=None, + img_size=None) + case _: + raise Exception('system error') + except Exception as e: + self.log_warning( + 'failed to call resize', { + 'reason': str(e), + 'key': key, + 'traceback': traceback.format_exc(), + }) + return InstantResponse( + status=HTTPStatus.INTERNAL_SERVER_ERROR, + body='failed to call resize', + cache_control=self.cache_control_error, + content_type='text/plain', + vips_us=None, + img_size=None) def process_resize( self, resize_param: ResizeParam, - accept: AcceptHeader, strict: bool, ) -> Optional[InstantResponse]: key = key_from_path(resize_param.source) - if accept.supports(OptimImageType.WEBP): - extension = '.webp' - quality = 80 - content_type = 'image/webp' - else: - extension = Path(resize_param.source).suffix - quality = 80 - if extension in ['.jpg', '.jpeg']: - content_type = 'image/jpeg' - elif extension == '.png': - content_type = 'image/png' - else: - raise Exception('system error') with NamedTemporaryFile(delete_on_close=False) as orig: try: @@ -912,11 +1108,11 @@ def process_resize( start_ns = time.time_ns() try: - match self.resize_image(orig.name, target, orig_meta.focalarea): - case None: - return None + image: Image = Image.new_from_file(orig.name) + match resize_image(self.log_debug, image, target, orig_meta.focalarea): case Image() as image: - resized: bytes = image.write_to_buffer(extension, Q=quality) + resized: bytes = image.write_to_buffer( + MIME_TO_EXT[resize_param.mime], Q=resize_param.quality) if RESPONSE_BODY_LIMIT < len(resized): return InstantResponse( @@ -933,11 +1129,17 @@ def process_resize( status=HTTPStatus.OK, body=resized, cache_control=self.cache_control_perm, - content_type=content_type, + content_type=resize_param.mime, vips_us=vips_us, img_size=len(resized)) - case InstantResponse() as response: - return response + case 'INVALID_METADATA': + return InstantResponse( + status=HTTPStatus.INTERNAL_SERVER_ERROR, + body='Invalid metadata', + cache_control=self.cache_control_error, + content_type='text/plain', + vips_us=None, + img_size=None) case _: raise Exception('system error') except Exception as e: @@ -959,27 +1161,27 @@ def process_for_image_requester( self, path: HttpPath, qs: dict[str, list[str]], - accept_header: AcceptHeader, + accept: AcceptHeader, ) -> FieldUpdate | InstantResponse: match self.resize_mode: case ResizeMode.DISABLED: pass case ResizeMode.STRICT: - resize_param = ResizeParam.maybe_from_path(path) + resize_param = ResizeParam.maybe_from_path(path, accept) if resize_param is not None: - instant_res = self.process_resize(resize_param, accept_header, True) + instant_res = self.call_process_resize(resize_param, True) if instant_res is not None: return instant_res case ResizeMode.RELAXED: - resize_param = ResizeParam.maybe_from_path(path) + resize_param = ResizeParam.maybe_from_path(path, accept) if resize_param is not None: - instant_res = self.process_resize(resize_param, accept_header, False) + instant_res = self.call_process_resize(resize_param, False) if instant_res is not None: return instant_res case ResizeMode.FREESTYLE: - resize_param = ResizeParam.maybe_from_querystring(path, qs) + resize_param = ResizeParam.maybe_from_querystring(path, qs, accept) if resize_param is not None: - instant_res = self.process_resize(resize_param, accept_header, False) + instant_res = self.call_process_resize(resize_param, False) if instant_res is not None: return instant_res @@ -1007,7 +1209,7 @@ def process_for_image_requester( if need_update: self.keep_uptodate(key) - if not accept_header.has_response_type(orig_meta.optimize_type): + if not accept.has_response_type(orig_meta.optimize_type): return FieldUpdate(reason='opt not accepted', res_cache_control=self.cache_control_perm) if need_update: @@ -1056,7 +1258,7 @@ def process( self, path: HttpPath, qs: dict[str, list[str]], - accept_header: AcceptHeader, + accept: AcceptHeader, ) -> FieldUpdate | InstantResponse: def run(path: HttpPath): @@ -1069,7 +1271,7 @@ def run(path: HttpPath): ext = get_normalized_extension(path) if ext in ['.jpg', '.jpeg', '.png']: - return self.process_for_image_requester(path, qs, accept_header) + return self.process_for_image_requester(path, qs, accept) elif ext == '.css': return self.process_for_css_requester(path) else: @@ -1096,13 +1298,13 @@ def run(path: HttpPath): return fu - def set_log_context(self, path: HttpPath, qstr: str, accept_header: str) -> None: - self.log_context = {'path': str(path), 'qstr': qstr, 'accept_header': accept_header} + def set_log_context(self, path: HttpPath, qstr: str, accept: str) -> None: + self.log_context = {'path': str(path), 'qstr': qstr, 'accept': accept} def lambda_main(event: OriginRequestEvent) -> Request | ResponseResult: req = event['Records'][0]['cf']['request'] - accept_header = req['headers']['accept'][0]['value'] if 'accept' in req['headers'] else '' + accept = req['headers']['accept'][0]['value'] if 'accept' in req['headers'] else '' # Set default value req['headers'][CACHE_CONTROL] = [{'value': ''}] @@ -1115,8 +1317,8 @@ def lambda_main(event: OriginRequestEvent) -> Request | ResponseResult: path = req['uri'] qstr = req['querystring'] - server.set_log_context(path, qstr, accept_header) - result = server.process(path, parse.parse_qs(qstr), AcceptHeader.from_str(accept_header)) + server.set_log_context(path, qstr, accept) + result = server.process(path, parse.parse_qs(qstr), AcceptHeader.from_str(accept)) if isinstance(result, FieldUpdate): if result.origin_domain is not None: @@ -1166,11 +1368,14 @@ def lambda_main(event: OriginRequestEvent) -> Request | ResponseResult: match result.body: case None: pass - case str(): - response_result['body'] = result.body + case Base64Str() as body: + response_result['body'] = body.base64_str + response_result['bodyEncoding'] = 'base64' + case str() as body: + response_result['body'] = body response_result['bodyEncoding'] = 'text' - case bytes(): - response_result['body'] = base64.b64encode(result.body).decode() + case bytes() as body: + response_result['body'] = base64.b64encode(body).decode() response_result['bodyEncoding'] = 'base64' case _: raise Exception('system error') @@ -1188,3 +1393,48 @@ def lambda_main(event: OriginRequestEvent) -> Request | ResponseResult: return response_result else: raise Exception('system error') + + +def log_debug(message: str, dict: dict[str, Any]) -> None: + logger.debug({ + 'message': message, + **dict, + }) + + +def lambda_resize(event: ResizeRequestPayload) -> ResizeResponsePayload: + if 'base64' in event['image']: + try: + image: Image = Image.new_from_buffer( + base64.b64decode(event['image']['base64']), '') # type: ignore[typeddict-item] + + resized = resize_image( + log_debug, + image, + Size(*event['target']), + Area(*event['focalarea']) if 'focalarea' in event else None, + ) + + match resized: + case Image() as image: + image_bs: bytes = image.write_to_buffer(MIME_TO_EXT[event['mime']], Q=event['quality']) + if MAX_IMAGE_PAYLOAD_SIZE < len(image_bs): + return {'result': 'RESPONSE_TOO_LARGE'} + + return { + 'result': { + 'body_b64': base64.b64encode(image_bs).decode(), + 'size': len(image_bs), + } + } + case 'INVALID_METADATA': + return {'result': 'INVALID_METADATA'} + case _: + pass + except Exception as e: + return { + 'result': 'INVALID_IMAGE', + 'message': f'{str(e)}\n{traceback.format_exc()}', + } + + raise Exception('system error') diff --git a/imglambda/originrequest/test_index.py b/imglambda/originrequest/test_index.py index 6f08463..fa6c9de 100644 --- a/imglambda/originrequest/test_index.py +++ b/imglambda/originrequest/test_index.py @@ -32,6 +32,7 @@ from .index import ( FOCALAREA_METADATA, + MIME_TO_EXT, OPTIMIZE_QUALITY_METADATA, OPTIMIZE_TYPE_METADATA, SUBSIZES_METADATA, @@ -107,13 +108,6 @@ 'heifload_buffer': 'image/avif', } -CONTENT_TYPE_MAP = { - JPEG_MIME: '.jpg', - PNG_MIME: '.png', - WEBP_MIME: '.webp', - AVIF_MIME: '.avif', -} - def get_bypass_minifier_patterns(key_prefix: str) -> list[str]: return [ @@ -149,12 +143,14 @@ def create_img_server( warnings.filterwarnings('ignore', category=ResourceWarning, message='unclosed.*') sqs = sess.client('sqs', region_name=REGION) s3 = sess.client('s3', region_name=REGION) + awslambda = sess.client('lambda', region_name=REGION) return ImgServer( log=log, region=REGION, sqs=sqs, s3=s3, + awslambda=awslambda, generated_domain=f"{read_test_config('generated-bucket')}.s3.example.com", original_bucket=read_test_config('original-bucket'), generated_key_prefix=GENERATED_KEY_PREFIX, @@ -166,7 +162,8 @@ def create_img_server( GitWildMatchPattern, get_bypass_minifier_patterns(key_prefix)), expiration_margin=expiration_margin, basedir=basedir, - resize_mode=resize_mode) + resize_mode=resize_mode, + resize_function=None) def get_test_sqs_queue_name_from_url(sqs_queue_url: str) -> str: @@ -1577,10 +1574,10 @@ def test_generated( image = Image.new_from_buffer(update.body, '') assert 'size' in instant_response - content_type = LOADER_MAP[image.get('vips-loader')] - assert update.content_type == content_type + mime = LOADER_MAP[image.get('vips-loader')] + assert update.content_type == mime - with open(f'{work_dir}/response{CONTENT_TYPE_MAP[content_type]}', 'wb') as f: + with open(f'{work_dir}/response{MIME_TO_EXT[mime]}', 'wb') as f: f.write(update.body) assert instant_response['size'] == (image.get('width'), image.get('height')) diff --git a/imglambda/typing.py b/imglambda/typing.py index dde1b7f..8b76388 100644 --- a/imglambda/typing.py +++ b/imglambda/typing.py @@ -1,10 +1,37 @@ -from typing import Literal, NewType, NotRequired, ReadOnly, TypedDict +from typing import Literal, NewType, NotRequired, ReadOnly, Tuple, TypedDict HttpPath = NewType('HttpPath', str) S3Key = NewType('S3Key', str) FilePath = NewType('FilePath', str) +class ResizeResponseImage(TypedDict): + body_b64: str + size: int + + +class ResizeResponsePayload(TypedDict): + result: ResizeResponseImage | Literal['INVALID_IMAGE', 'INVALID_METADATA', 'RESPONSE_TOO_LARGE'] + message: NotRequired[str] + + +class ResizeRequestImageSource(TypedDict): + bucket: str + key: str + + +class ResizeRequestImageData(TypedDict): + base64: str + + +class ResizeRequestPayload(TypedDict): + image: ResizeRequestImageData | ResizeRequestImageSource + target: Tuple[int, int] + mime: str + quality: int + focalarea: NotRequired[Tuple[int, int, int, int]] + + class Header(TypedDict): key: NotRequired[ReadOnly[str]] value: str @@ -108,3 +135,10 @@ class ResponseResult(TypedDict): headers: NotRequired[dict[str, list[Header]]] status: str statusDescription: NotRequired[str] + + +class ErrorLambdaResponse(TypedDict): + errorMessage: str + errorType: str + requestId: str + stackTrace: list[str] diff --git a/index.py b/index.py index 2d88561..945203c 100644 --- a/index.py +++ b/index.py @@ -6,6 +6,8 @@ OriginRequestEvent, OriginResponseEvent, Request, + ResizeRequestPayload, + ResizeResponsePayload, Response, ResponseResult ) @@ -28,6 +30,23 @@ def origin_request_lambda_handler( return ret +def resize_lambda_handler( + event: ResizeRequestPayload, + _: LambdaContext, +) -> ResizeResponsePayload: + # # For debugging + # print('event:') + # print(json.dumps(event)) + + ret = originrequest.lambda_resize(event) + + # # For debugging + # print('return:') + # print(json.dumps(ret)) + + return ret + + def origin_response_lambda_handler( event: OriginResponseEvent, _: LambdaContext, diff --git a/poetry.lock b/poetry.lock index e1aac32..d0f23a1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1236,50 +1236,50 @@ files = [ [[package]] name = "mypy" -version = "1.17.1" +version = "1.18.2" description = "Optional static typing for Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "mypy-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3fbe6d5555bf608c47203baa3e72dbc6ec9965b3d7c318aa9a4ca76f465bd972"}, - {file = "mypy-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80ef5c058b7bce08c83cac668158cb7edea692e458d21098c7d3bce35a5d43e7"}, - {file = "mypy-1.17.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a580f8a70c69e4a75587bd925d298434057fe2a428faaf927ffe6e4b9a98df"}, - {file = "mypy-1.17.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd86bb649299f09d987a2eebb4d52d10603224500792e1bee18303bbcc1ce390"}, - {file = "mypy-1.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a76906f26bd8d51ea9504966a9c25419f2e668f012e0bdf3da4ea1526c534d94"}, - {file = "mypy-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:e79311f2d904ccb59787477b7bd5d26f3347789c06fcd7656fa500875290264b"}, - {file = "mypy-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad37544be07c5d7fba814eb370e006df58fed8ad1ef33ed1649cb1889ba6ff58"}, - {file = "mypy-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:064e2ff508e5464b4bd807a7c1625bc5047c5022b85c70f030680e18f37273a5"}, - {file = "mypy-1.17.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70401bbabd2fa1aa7c43bb358f54037baf0586f41e83b0ae67dd0534fc64edfd"}, - {file = "mypy-1.17.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e92bdc656b7757c438660f775f872a669b8ff374edc4d18277d86b63edba6b8b"}, - {file = "mypy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c1fdf4abb29ed1cb091cf432979e162c208a5ac676ce35010373ff29247bcad5"}, - {file = "mypy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:ff2933428516ab63f961644bc49bc4cbe42bbffb2cd3b71cc7277c07d16b1a8b"}, - {file = "mypy-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69e83ea6553a3ba79c08c6e15dbd9bfa912ec1e493bf75489ef93beb65209aeb"}, - {file = "mypy-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b16708a66d38abb1e6b5702f5c2c87e133289da36f6a1d15f6a5221085c6403"}, - {file = "mypy-1.17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89e972c0035e9e05823907ad5398c5a73b9f47a002b22359b177d40bdaee7056"}, - {file = "mypy-1.17.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03b6d0ed2b188e35ee6d5c36b5580cffd6da23319991c49ab5556c023ccf1341"}, - {file = "mypy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c837b896b37cd103570d776bda106eabb8737aa6dd4f248451aecf53030cdbeb"}, - {file = "mypy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:665afab0963a4b39dff7c1fa563cc8b11ecff7910206db4b2e64dd1ba25aed19"}, - {file = "mypy-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93378d3203a5c0800c6b6d850ad2f19f7a3cdf1a3701d3416dbf128805c6a6a7"}, - {file = "mypy-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15d54056f7fe7a826d897789f53dd6377ec2ea8ba6f776dc83c2902b899fee81"}, - {file = "mypy-1.17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:209a58fed9987eccc20f2ca94afe7257a8f46eb5df1fb69958650973230f91e6"}, - {file = "mypy-1.17.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:099b9a5da47de9e2cb5165e581f158e854d9e19d2e96b6698c0d64de911dd849"}, - {file = "mypy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ffadfbe6994d724c5a1bb6123a7d27dd68fc9c059561cd33b664a79578e14"}, - {file = "mypy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:9a2b7d9180aed171f033c9f2fc6c204c1245cf60b0cb61cf2e7acc24eea78e0a"}, - {file = "mypy-1.17.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:15a83369400454c41ed3a118e0cc58bd8123921a602f385cb6d6ea5df050c733"}, - {file = "mypy-1.17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55b918670f692fc9fba55c3298d8a3beae295c5cded0a55dccdc5bbead814acd"}, - {file = "mypy-1.17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62761474061feef6f720149d7ba876122007ddc64adff5ba6f374fda35a018a0"}, - {file = "mypy-1.17.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c49562d3d908fd49ed0938e5423daed8d407774a479b595b143a3d7f87cdae6a"}, - {file = "mypy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:397fba5d7616a5bc60b45c7ed204717eaddc38f826e3645402c426057ead9a91"}, - {file = "mypy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:9d6b20b97d373f41617bd0708fd46aa656059af57f2ef72aa8c7d6a2b73b74ed"}, - {file = "mypy-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5d1092694f166a7e56c805caaf794e0585cabdbf1df36911c414e4e9abb62ae9"}, - {file = "mypy-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:79d44f9bfb004941ebb0abe8eff6504223a9c1ac51ef967d1263c6572bbebc99"}, - {file = "mypy-1.17.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b01586eed696ec905e61bd2568f48740f7ac4a45b3a468e6423a03d3788a51a8"}, - {file = "mypy-1.17.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43808d9476c36b927fbcd0b0255ce75efe1b68a080154a38ae68a7e62de8f0f8"}, - {file = "mypy-1.17.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:feb8cc32d319edd5859da2cc084493b3e2ce5e49a946377663cc90f6c15fb259"}, - {file = "mypy-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d7598cf74c3e16539d4e2f0b8d8c318e00041553d83d4861f87c7a72e95ac24d"}, - {file = "mypy-1.17.1-py3-none-any.whl", hash = "sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9"}, - {file = "mypy-1.17.1.tar.gz", hash = "sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01"}, + {file = "mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c"}, + {file = "mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e"}, + {file = "mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b"}, + {file = "mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66"}, + {file = "mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428"}, + {file = "mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed"}, + {file = "mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f"}, + {file = "mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341"}, + {file = "mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d"}, + {file = "mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86"}, + {file = "mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37"}, + {file = "mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8"}, + {file = "mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34"}, + {file = "mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764"}, + {file = "mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893"}, + {file = "mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914"}, + {file = "mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8"}, + {file = "mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074"}, + {file = "mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc"}, + {file = "mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e"}, + {file = "mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986"}, + {file = "mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d"}, + {file = "mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba"}, + {file = "mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544"}, + {file = "mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce"}, + {file = "mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d"}, + {file = "mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c"}, + {file = "mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb"}, + {file = "mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075"}, + {file = "mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf"}, + {file = "mypy-1.18.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:25a9c8fb67b00599f839cf472713f54249a62efd53a54b565eb61956a7e3296b"}, + {file = "mypy-1.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2b9c7e284ee20e7598d6f42e13ca40b4928e6957ed6813d1ab6348aa3f47133"}, + {file = "mypy-1.18.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6985ed057513e344e43a26cc1cd815c7a94602fb6a3130a34798625bc2f07b6"}, + {file = "mypy-1.18.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f27105f1525ec024b5c630c0b9f36d5c1cc4d447d61fe51ff4bd60633f47ac"}, + {file = "mypy-1.18.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:030c52d0ea8144e721e49b1f68391e39553d7451f0c3f8a7565b59e19fcb608b"}, + {file = "mypy-1.18.2-cp39-cp39-win_amd64.whl", hash = "sha256:aa5e07ac1a60a253445797e42b8b2963c9675563a94f11291ab40718b016a7a0"}, + {file = "mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e"}, + {file = "mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b"}, ] [package.dependencies] @@ -1323,6 +1323,18 @@ setuptools = "*" build = ["setuptools"] check = ["boto3-stubs", "types-aioboto3", "types-aiobotocore"] +[[package]] +name = "mypy-boto3-lambda" +version = "1.40.7" +description = "Type annotations for boto3 Lambda 1.40.7 service generated with mypy-boto3-builder 8.11.0" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "mypy_boto3_lambda-1.40.7-py3-none-any.whl", hash = "sha256:398c9dd051278430168e907b6b6c2078a3a1bca3948c2bf4d47eda8d7da28573"}, + {file = "mypy_boto3_lambda-1.40.7.tar.gz", hash = "sha256:e8bedf03a67fade5db861fe902df063064292352eed5f785f74cd0e591948db9"}, +] + [[package]] name = "mypy-boto3-s3" version = "1.40.0" @@ -1929,4 +1941,4 @@ platformdirs = ">=3.5.1" [metadata] lock-version = "2.1" python-versions = "~3.13" -content-hash = "9767a0250e57174b85066bc1693e0894309ec6340eb4c5b054d75e5d051208bf" +content-hash = "1a7e134a24715e0a09a0e2ad15e334b9ece993bbbf1f25a76308eef679b12972" diff --git a/pyproject.toml b/pyproject.toml index f615e3e..8f7af56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ pyvips = { extras = ["binary"], version = "^3.0.0" } aws-lambda-powertools = "*" mypy-boto3-s3 = "*" mypy-boto3-sqs = "*" +mypy-boto3-lambda = "*" [tool.poetry.group.dev.dependencies] autopep8 = "*" diff --git a/script/upload-development b/script/upload-development index fb55afa..6665b9a 100755 --- a/script/upload-development +++ b/script/upload-development @@ -6,7 +6,9 @@ export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY="$(< config/development/secret-access-key)" export AWS_SECRET_ACCESS_KEY +s3_bucket_us_east_1="$(< config/development/s3-bucket-us-east-1)" s3_bucket="$(< config/development/s3-bucket)" s3_key_prefix="$(< config/development/s3-key-prefix)" +find work/development -type f -printf '%P\0' | xargs -0 -I{} aws s3 cp "work/development/{}" "s3://$s3_bucket_us_east_1/$s3_key_prefix{}" find work/development -type f -printf '%P\0' | xargs -0 -I{} aws s3 cp "work/development/{}" "s3://$s3_bucket/$s3_key_prefix{}"