diff --git a/packages/docs/docs/lambda/python.mdx b/packages/docs/docs/lambda/python.mdx index 45d2527af67..2b83af8d486 100644 --- a/packages/docs/docs/lambda/python.mdx +++ b/packages/docs/docs/lambda/python.mdx @@ -137,7 +137,13 @@ if not REMOTION_APP_SERVE_URL: # Construct client client = RemotionClient(region=REMOTION_APP_REGION, serve_url=REMOTION_APP_SERVE_URL, - function_name=REMOTION_APP_FUNCTION_NAME) + function_name=REMOTION_APP_FUNCTION_NAME, + access_key=None, # deprecated, use session instead + secret_key=None, # deprecated, use session instead + force_path_style=False, # By default False + config=None, # Custom botocore configuration object + session=None, # Pre-configured boto3 Session object (recommended for authentication) + ) # Set render still request render_params = RenderStillParams( @@ -159,6 +165,11 @@ if render_response: ## Breaking changes +### 4.0.380 + +- Added `config` and `session` parameters to `RemotionClient` constructor. +- Less error repackaging, redirecting more AWS errors directly to the user. + ### 4.0.82 - The `data` field is now `input_props`. diff --git a/packages/example/testlambdaintegrations.mjs b/packages/example/testlambdaintegrations.mjs index 2e6c3be18b3..31d0be88fd6 100644 --- a/packages/example/testlambdaintegrations.mjs +++ b/packages/example/testlambdaintegrations.mjs @@ -11,7 +11,7 @@ const functionName = execSync( .split(' ')[0]; console.log('=== Python(render media) ==='); -execSync(`python testclient_render_media.py`, { +execSync(`uv run testclient_render_media.py`, { env: { // eslint-disable-next-line no-undef ...process.env, diff --git a/packages/lambda-python/.gitignore b/packages/lambda-python/.gitignore index 66bd0927308..868ddaa7773 100644 --- a/packages/lambda-python/.gitignore +++ b/packages/lambda-python/.gitignore @@ -16,4 +16,5 @@ test-env/ remotion-env-lint/ .idea -justfile \ No newline at end of file +justfile +.venv \ No newline at end of file diff --git a/packages/lambda-python/dev-requirements.txt b/packages/lambda-python/dev-requirements.txt index cf88bb972a7..2dc707f436c 100644 --- a/packages/lambda-python/dev-requirements.txt +++ b/packages/lambda-python/dev-requirements.txt @@ -1,4 +1,5 @@ pytest dotenv boto3-stubs[essential] -mypy \ No newline at end of file +mypy +boto3 \ No newline at end of file diff --git a/packages/lambda-python/remotion_lambda/exception.py b/packages/lambda-python/remotion_lambda/exception.py new file mode 100644 index 00000000000..ba0350d7ffa --- /dev/null +++ b/packages/lambda-python/remotion_lambda/exception.py @@ -0,0 +1,9 @@ +# pylint: disable=too-few-public-methods, missing-module-docstring, broad-exception-caught +class RemotionException(Exception): + """Base exception for Remotion client errors.""" + +class RemotionInvalidArgumentException(RemotionException, ValueError): + """Raised when an invalid argument is provided to a Remotion client method.""" + +class RemotionRenderingOutputError(RemotionException): + """Raised when the Remotion rendering process returns an error.""" diff --git a/packages/lambda-python/remotion_lambda/remotionclient.py b/packages/lambda-python/remotion_lambda/remotionclient.py index 6523dd91161..266efb83a99 100644 --- a/packages/lambda-python/remotion_lambda/remotionclient.py +++ b/packages/lambda-python/remotion_lambda/remotionclient.py @@ -5,11 +5,14 @@ import json import hashlib from math import ceil -from typing import Optional, Union, List +from typing import Optional, Union, List, Dict, Any from enum import Enum -import boto3 -from botocore.exceptions import ClientError +import warnings +from boto3.session import Session # Explicitly import Session from botocore.config import Config +from botocore.exceptions import ClientError, ParamValidationError +from botocore.response import StreamingBody # For Lambda payload + from .models import ( CostsInfo, CustomCredentials, @@ -22,17 +25,22 @@ RenderType, ) +from .exception import ( + RemotionException, + RemotionInvalidArgumentException, + RemotionRenderingOutputError +) + logger = logging.getLogger(__name__) BUCKET_NAME_PREFIX = 'remotionlambda-' REGION_US_EAST = 'us-east-1' - + # pylint: disable=too-many-arguments class RemotionClient: """A client for interacting with the Remotion service.""" - - # pylint: disable=too-many-arguments + # pylint: disable=too-many-instance-attributes def __init__( self, region: str, @@ -40,76 +48,246 @@ def __init__( function_name: str, access_key: Optional[str] = None, secret_key: Optional[str] = None, - force_path_style=False, + force_path_style: bool = False, + session: Optional[Session] = None, + config: Optional[Config] = None, ): """ Initialize the RemotionClient. - Args: - region (str): AWS region. - serve_url (str): URL for the Remotion service. - function_name (str): Name of the AWS Lambda function. - access_key (str): AWS access key (optional). - secret_key (str): AWS secret key (optional). - force_path_style (bool): Force path-style S3 URLs (optional). + Parameters + ---------- + region : str + AWS region name (e.g., 'us-east-1') + serve_url : str + URL for the Remotion service endpoint + function_name : str + Name of the AWS Lambda function to invoke + access_key : Optional[str], deprecated + **DEPRECATED** - AWS access key ID. Use `session` instead. + Will be removed in version 5.0.0. + secret_key : Optional[str], deprecated + **DEPRECATED** - AWS secret access key. Use `session` instead. + Will be removed in version 5.0.0. + force_path_style : bool, default=False + Force path-style S3 URLs instead of virtual-hosted-style + config : Optional[Config] + Custom botocore configuration object + session : Optional[Session] + Pre-configured boto3 Session object (recommended for authentication) + + Raises + ------ + RemotionInvalidArgumentException + If required parameters are missing or invalid combinations are provided + + Warnings + -------- + .. deprecated:: 4.0.377 + Parameters `access_key` and `secret_key` are deprecated. + Use `session` instead for improved security and flexibility. + + Examples + -------- + Recommended usage with session: + + >>> import boto3 + >>> session = boto3.Session(profile_name='production') + >>> client = RemotionClient( + ... region='us-east-1', + ... serve_url='https://api.example.com', + ... function_name='my-function', + ... session=session + ... ) + + Using STS AssumeRole for cross-account or temporary credentials: + + >>> import boto3 + >>> + >>> # Create STS client using your base credentials + >>> sts_client = boto3.client('sts') + >>> + >>> # Assume a role (cross-account or for temporary elevated permissions) + >>> assumed_role = sts_client.assume_role( + ... RoleArn='arn:aws:iam::123456789012:role/RemotionRenderRole', + ... RoleSessionName='remotion-render-session', + ... DurationSeconds=3600 # Optional: 1 hour (default is 1 hour, max depends on role) + ... ) + >>> + >>> # Extract temporary credentials from the response + >>> credentials = assumed_role['Credentials'] + >>> + >>> # Create a session with the temporary credentials + >>> session = boto3.Session( + ... aws_access_key_id=credentials['AccessKeyId'], + ... aws_secret_access_key=credentials['SecretAccessKey'], + ... aws_session_token=credentials['SessionToken'], + ... region_name='us-east-1' + ... ) + >>> + >>> # Use the session with RemotionClient + >>> client = RemotionClient( + ... region='us-east-1', + ... serve_url='https://api.example.com', + ... function_name='my-function', + ... session=session + ... ) + + Using custom config with timeouts and retries: + + >>> from botocore.config import Config + >>> import boto3 + >>> + >>> config = Config( + ... connect_timeout=5, + ... read_timeout=30, + ... retries={'max_attempts': 3, 'mode': 'adaptive'} + ... ) + >>> session = boto3.Session(profile_name='production') + >>> client = RemotionClient( + ... region='us-east-1', + ... serve_url='https://api.example.com', + ... function_name='my-function', + ... session=session, + ... config=config + ... ) + + Legacy usage (deprecated): + + >>> client = RemotionClient( + ... region='us-east-1', + ... serve_url='https://api.example.com', + ... function_name='my-function', + ... access_key='AKIA...', + ... secret_key='secret...' + ... ) """ - self.access_key = access_key - self.secret_key = secret_key - self.region = region - self.serve_url = serve_url - self.function_name = function_name + # Validate required parameters at construction time + if not region or not region.strip(): + raise RemotionInvalidArgumentException("'region' parameter is required and cannot be empty or whitespace") + if not serve_url or not serve_url.strip(): + raise RemotionInvalidArgumentException("'serve_url' parameter is required and cannot be empty or whitespace") + if not function_name or not function_name.strip(): + raise RemotionInvalidArgumentException("'function_name' parameter is required and cannot be empty or whitespace") + + + # Check for conflicting authentication methods + if session and (access_key or secret_key): + raise RemotionInvalidArgumentException( + "Cannot specify both 'session' and explicit credentials " + "('access_key'/'secret_key'). Please use only 'session'." + ) + + # Handle deprecated credential parameters + if access_key is not None or secret_key is not None: + warnings.warn( + "Parameters 'access_key' and 'secret_key' are deprecated " + "as of version 4.0.376 and will be removed in version 5.0.0. " + "Please migrate to using 'session' for improved security. ", + DeprecationWarning, + stacklevel=2, + ) + # Validate both keys are provided together + if access_key and not secret_key: + raise RemotionInvalidArgumentException("'secret_key' must be provided when 'access_key' is specified") + if secret_key and not access_key: + raise RemotionInvalidArgumentException("'access_key' must be provided when 'secret_key' is specified") + + # Create session from deprecated credentials + self.session = Session( + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, + region_name=region, + ) + elif session: + # Use provided session + self.session = session + else: + # Create default session (uses credential chain) + self.session = Session(region_name=region) + + # Store configuration + self.region = region.strip() + self.serve_url = serve_url.strip().rstrip('/') + self.function_name = function_name.strip() self.force_path_style = force_path_style + self.config = config or Config() # Provide default empty config - def _generate_hash(self, payload): + def _generate_hash(self, payload: str) -> str: # Added type hints """Generate a hash for the payload.""" return hashlib.sha256(payload.encode('utf-8')).hexdigest() - def _generate_random_hash(self): + def _generate_random_hash(self) -> str: # Added type hint """Generate a random hash for bucket operations.""" alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789' return ''.join(random.choice(alphabet) for _ in range(10)) - def _make_bucket_name(self): + def _make_bucket_name(self) -> str: # Added type hint """Generate a bucket name following Remotion conventions.""" # Use the same logic as JS SDK: prefix + region without dashes + random hash region_no_dashes = self.region.replace('-', '') random_suffix = self._generate_random_hash() return f"{BUCKET_NAME_PREFIX}{region_no_dashes}-{random_suffix}" - def _input_props_key(self, hash_value): + def _input_props_key(self, hash_value: str) -> str: # Added type hint """Generate S3 key for input props.""" return f"input-props/{hash_value}.json" - def _create_s3_client(self): - """Create S3 client with appropriate credentials.""" - kwargs = {'region_name': self.region} + def _create_boto_client(self, service_name: str) -> Any: + """ + Creates a boto3 client for the specified service, applying custom + configuration and credentials if provided. + """ + # Start with required args + client_kwargs: Dict[str, Any] = {'region_name': self.region} + + # Handle config + current_config = self.config + + if service_name == 's3' and self.force_path_style: + s3_config = Config(s3={'addressing_style': 'path'}) + if current_config: + current_config = current_config.merge(s3_config) + else: + current_config = s3_config - if self.force_path_style: - kwargs['config'] = Config(s3={'addressing_style': 'path'}) - if self.access_key and self.secret_key: - kwargs.update( - { - 'aws_access_key_id': self.access_key, - 'aws_secret_access_key': self.secret_key, - } - ) + # Add config only if it's not None + if current_config: + client_kwargs['config'] = current_config - return boto3.client('s3', **kwargs) + return self.session.client(service_name, **client_kwargs) # type: ignore[call-overload] + + def _create_s3_client(self) -> Any: # Returns an S3 client type + """Creates and returns a boto3 S3 client.""" + return self._create_boto_client('s3') def _get_remotion_buckets(self) -> List[str]: + """ + Retrieves a list of Remotion-related S3 buckets in the current region. + """ s3_client = self._create_s3_client() + # The exact type of s3_client is difficult to constrain without using boto3-stubs + # and more specific type hints for S3Client. + # For this method, we rely on duck-typing `list_buckets`. try: - response = s3_client.list_buckets() + # Type hint for the response from list_buckets + response: Dict[str, Any] = s3_client.list_buckets() except ClientError as e: - logger.warning("Could not list S3 buckets: %s", e) + raise e + except ParamValidationError as e: + logger.warning("Could not list S3 buckets due to parameter validation error: %s", e) return [] - remotion_buckets = [] + remotion_buckets: List[str] = [] - for bucket in response.get('Buckets', []): - bucket_name = bucket['Name'] + # Iterate through buckets, ensuring 'Name' is present + for bucket_info in response.get('Buckets', []): + if not isinstance(bucket_info, dict) or 'Name' not in bucket_info: + logger.debug("Skipping malformed bucket info: %s", bucket_info) + continue + bucket_name: str = bucket_info['Name'] if not bucket_name.startswith(BUCKET_NAME_PREFIX): continue @@ -119,35 +297,45 @@ def _get_remotion_buckets(self) -> List[str]: return remotion_buckets - def _is_bucket_in_current_region(self, s3_client, bucket_name: str) -> bool: + def _is_bucket_in_current_region(self, s3_client: Any, bucket_name: str) -> bool: + """ + Checks if a given S3 bucket is located in the client's configured region. + """ try: - bucket_region = s3_client.get_bucket_location(Bucket=bucket_name) - location = bucket_region.get('LocationConstraint') + # Type hint for the response from get_bucket_location + bucket_region_response: Dict[str, Any] = s3_client.get_bucket_location(Bucket=bucket_name) + location: Optional[str] = bucket_region_response.get('LocationConstraint') # us-east-1 returns None for LocationConstraint return location == self.region or ( location is None and self.region == REGION_US_EAST ) - except ClientError: - # Ignore buckets we can't access (permission issues, etc.) - return False + except ClientError as e: + logger.debug( + "Could not get bucket location for %s (possibly permission issue): %s", + bucket_name, + e, + ) + raise e + except ParamValidationError as e: + raise RemotionInvalidArgumentException( + f"Invalid S3 client parameters for get_bucket_location: {e}" + ) from e - def _get_or_create_bucket(self): + def _get_or_create_bucket(self) -> str: """Get existing bucket or create a new one following JS SDK logic.""" buckets = self._get_remotion_buckets() if len(buckets) > 1: - raise ValueError( + raise RemotionException( f"You have multiple buckets ({', '.join(buckets)}) in your S3 region " f"({self.region}) starting with \"remotionlambda-\". " "Please see https://remotion.dev/docs/lambda/multiple-buckets." ) if len(buckets) == 1: - # Use existing bucket - in JS SDK this also applies lifecycle rules return buckets[0] - # Create new bucket bucket_name = self._make_bucket_name() s3_client = self._create_s3_client() @@ -161,9 +349,13 @@ def _get_or_create_bucket(self): ) return bucket_name except ClientError as e: - raise ValueError(f"Failed to create bucket: {str(e)}") from e + raise e + except ParamValidationError as e: + raise RemotionInvalidArgumentException( + f"Invalid S3 client parameters for create_bucket: {e}" + ) from e - def _upload_to_s3(self, bucket_name, key, payload): + def _upload_to_s3(self, bucket_name: str, key: str, payload: str) -> None: # Added type hints """Upload payload to S3.""" s3_client = self._create_s3_client() try: @@ -174,21 +366,24 @@ def _upload_to_s3(self, bucket_name, key, payload): ContentType='application/json', ) except ClientError as e: - raise ValueError(f"Failed to upload to S3: {str(e)}") from e + raise e + except ParamValidationError as e: + raise RemotionInvalidArgumentException( + f"Invalid S3 client parameters for put_object: {e}" + ) from e - def _needs_upload(self, payload_size, render_type): + def _needs_upload(self, payload_size: int, render_type: RenderType) -> bool: # Added type hints """Determine if payload needs to be uploaded to S3.""" - # Constants based on AWS Lambda limits with margin for other payload data margin = 5_000 + 1024 # 5KB margin + 1KB for webhook data max_still_inline_size = 5_000_000 - margin max_video_inline_size = 200_000 - margin + # Using RenderType Enum for comparison max_size = ( max_still_inline_size if render_type == 'still' else max_video_inline_size ) if payload_size > max_size: - # Log warning similar to JavaScript implementation logger.warning( "Warning: The props are over %sKB (%sKB) in size. Uploading them to S3 to " "circumvent AWS Lambda payload size, which may lead to slowdown.", @@ -198,13 +393,13 @@ def _needs_upload(self, payload_size, render_type): return True return False - def _serialize_input_props(self, input_props, render_type): + def _serialize_input_props(self, input_props: Optional[Dict[str, Any]], render_type: RenderType) -> Dict[str, Any] : # Added type hints """ Serialize inputProps to a format compatible with Lambda. Args: input_props (dict): Input properties to be serialized. - render_type (str): Type of the render (e.g., 'still' or 'video-or-audio'). + render_type (RenderType): Type of the render (e.g., 'still' or 'video-or-audio'). Returns: dict: Serialized inputProps in either payload or bucket-url format. @@ -214,7 +409,6 @@ def _serialize_input_props(self, input_props, render_type): payload_size = len(payload.encode('utf-8')) if self._needs_upload(payload_size, render_type): - # Upload to S3 and return bucket-url format hash_value = self._generate_hash(payload) bucket_name = self._get_or_create_bucket() key = self._input_props_key(hash_value) @@ -226,31 +420,25 @@ def _serialize_input_props(self, input_props, render_type): 'hash': hash_value, 'bucketName': bucket_name, } - # Return payload format for smaller payloads return { 'type': 'payload', 'payload': payload if payload not in ('', 'null') else json.dumps({}), } - except (ValueError, TypeError) as error: - raise ValueError( + except (TypeError, OverflowError) as error: + raise RemotionInvalidArgumentException( 'Error serializing InputProps. Check for circular ' - + 'references or reduce the object size.' + + 'references or invalid data types in the input properties.' ) from error + except ClientError as e: + raise e - def _create_lambda_client(self): - if self.access_key and self.secret_key and self.region: - return boto3.client( - 'lambda', - aws_access_key_id=self.access_key, - aws_secret_access_key=self.secret_key, - region_name=self.region, - ) - - return boto3.client('lambda', region_name=self.region) + def _create_lambda_client(self) -> Any: # Returns a Lambda client type + """Creates and returns a boto3 Lambda client.""" + return self._create_boto_client('lambda') - def _find_json_objects(self, input_string): + def _find_json_objects(self, input_string: str) -> List[str]: # Added type hints """Finds and returns a list of complete JSON object strings.""" - objects = [] + objects: List[str] = [] depth = 0 start_index = 0 @@ -263,53 +451,83 @@ def _find_json_objects(self, input_string): depth -= 1 if depth == 0: objects.append(input_string[start_index : i + 1]) - return objects - def _parse_stream(self, stream): + def _parse_stream(self, stream: str) -> List[Dict[str, Any]]: # Added type hints """Parses a stream of concatenated JSON objects.""" json_objects = self._find_json_objects(stream) - parsed_objects = [json.loads(obj) for obj in json_objects] + parsed_objects: List[Dict[str, Any]] = [] + for obj_str in json_objects: # Renamed obj to obj_str to avoid confusion with parsed obj + try: + parsed_objects.append(json.loads(obj_str)) + except json.JSONDecodeError as e: + logger.error("Failed to decode JSON object from stream: %s", obj_str) + raise RemotionException( + f"Failed to parse Lambda response stream: {e}" + ) from e return parsed_objects - def _invoke_lambda(self, function_name, payload): - + def _invoke_lambda(self, function_name: str, payload: str) -> Dict[str, Any]: # Added type hints + """Invokes the Remotion Lambda function and parses its response.""" client = self._create_lambda_client() + result_raw: Optional[str] = None # Renamed to avoid confusion with `decoded_result` + decoded_result: Dict[str, Any] = {} + try: - response = client.invoke(FunctionName=function_name, Payload=payload) - result = response['Payload'].read().decode('utf-8') - decoded_result = self._parse_stream(result)[-1] - except client.exceptions.ResourceNotFoundException as e: - raise ValueError(f"The function {function_name} does not exist.") from e - except client.exceptions.InvalidRequestContentException as e: - raise ValueError("The request content is invalid.") from e - except client.exceptions.RequestTooLargeException as e: - raise ValueError("The request payload is too large.") from e - except client.exceptions.ServiceException as e: - raise ValueError(f"An internal service error occurred: {str(e)}") from e - except Exception as e: - raise ValueError(f"An unexpected error occurred: {str(e)}") from e + # boto3.client('lambda').invoke returns a dictionary. + # 'Payload' is a StreamingBody object. + response: Dict[str, Any] = client.invoke(FunctionName=function_name, Payload=payload) + streaming_body: StreamingBody = response['Payload'] + result_raw = streaming_body.read().decode('utf-8') + parsed_results = self._parse_stream(result_raw) + decoded_result = parsed_results[-1] if parsed_results else {} + except ClientError as e: + raise e + except ParamValidationError as e: + raise RemotionInvalidArgumentException( + f"Invalid Lambda invocation parameters: {e}" + ) from e + except json.JSONDecodeError as e: + raise RemotionException( + f"Failed to decode final Lambda response: {e}. Raw response: {result_raw}" + ) from e + except UnicodeDecodeError as e: + raise RemotionException( + f"Failed to decode Lambda response payload: {e}" + ) from e if 'errorMessage' in decoded_result: - raise ValueError(decoded_result['errorMessage']) + raise RemotionRenderingOutputError( + f"Lambda function returned an error: {decoded_result['errorMessage']}" + ) if 'type' in decoded_result and decoded_result['type'] == 'error': - raise ValueError(decoded_result['message']) + raise RemotionRenderingOutputError( + f"Remotion rendering error: {decoded_result['message']}" + ) if 'type' not in decoded_result or decoded_result['type'] != 'success': - raise ValueError(result) + raise RemotionRenderingOutputError( + f"Unexpected Lambda response format: {result_raw}" + ) return decoded_result - def _custom_serializer(self, obj): + def _custom_serializer(self, obj: Any) -> Any: # Added type hints """A custom JSON serializer that handles enums and objects.""" - if isinstance(obj, Enum): return obj.value if hasattr(obj, 'value') else obj.name + # Check if it's a dataclass instance before calling asdict + # This often works better with mypy than just a try-except. + if hasattr(obj, '__dataclass_fields__'): + return asdict(obj) if hasattr(obj, '__dict__'): return obj.__dict__ if hasattr(obj, '__iter__') and not isinstance(obj, (str, bytes, bytearray)): return list(obj) - return asdict(obj) + + raise TypeError( + f"Object of type {obj.__class__.__name__} is not JSON serializable" + ) def construct_render_request( self, @@ -320,25 +538,39 @@ def construct_render_request( Construct a render request in JSON format. Args: - render_params (RenderParams): Render parameters. + render_params (Union[RenderMediaParams, RenderStillParams]): Render parameters. + render_type (RenderType): The type of render (video-or-audio or still). Returns: str: JSON representation of the render request. """ render_params.serve_url = self.serve_url - render_params.private_serialized_input_props = self._serialize_input_props( - input_props=render_params.input_props, render_type=render_type - ) + try: + # Assuming RenderMediaParams and RenderStillParams both have an input_props attribute + # and a private_serialized_input_props attribute (even if Optional) + render_params.private_serialized_input_props = self._serialize_input_props( + input_props=render_params.input_props, render_type=render_type + ) + except (RemotionInvalidArgumentException, ClientError) as e: + raise RemotionInvalidArgumentException( + f"Failed to serialize input properties for rendering: {e}" + ) from e - payload = render_params.serialize_params() - return json.dumps(payload, default=self._custom_serializer) + # Ensure serialize_params method in models.py is typed to return Dict[str, Any] + payload: Dict[str, Any] = render_params.serialize_params() + try: + return json.dumps(payload, default=self._custom_serializer) + except (TypeError, OverflowError) as e: + raise RemotionInvalidArgumentException( + f"Failed to serialize render parameters to JSON: {e}" + ) from e def construct_render_progress_request( self, render_id: str, bucket_name: str, - log_level="info", + log_level: str = "info", # Added type hint s3_output_provider: Optional[CustomCredentials] = None, ) -> str: """ @@ -347,6 +579,8 @@ def construct_render_progress_request( Args: render_id (str): ID of the render. bucket_name (str): Name of the bucket. + log_level (str): Log level ("error", "warning", "info", "verbose"). + s3_output_provider (Optional[CustomCredentials]): Custom S3 credentials. Returns: str: JSON representation of the render progress request. @@ -359,7 +593,13 @@ def construct_render_progress_request( log_level=log_level, s3_output_provider=s3_output_provider, ) - return json.dumps(progress_params.serialize_params()) + try: + # Ensure serialize_params method in models.py is typed to return Dict[str, Any] + return json.dumps(progress_params.serialize_params()) + except (TypeError, OverflowError) as e: + raise RemotionInvalidArgumentException( + f"Failed to serialize progress parameters to JSON: {e}" + ) from e def render_media_on_lambda( self, render_params: RenderMediaParams @@ -368,20 +608,20 @@ def render_media_on_lambda( Render media using AWS Lambda. Args: - render_params (RenderParams): Render parameters. + render_params (RenderMediaParams): Render parameters. Returns: - RenderResponse: Response from the render operation. + Optional[RenderMediaResponse]: Response from the render operation, or None if no body object. """ - params = self.construct_render_request( - render_params, render_type="video-or-audio" + params_json_str = self.construct_render_request( + render_params, render_type='video-or-audio' # Using Enum member ) body_object = self._invoke_lambda( - function_name=self.function_name, payload=params + function_name=self.function_name, payload=params_json_str ) if body_object: return RenderMediaResponse( - body_object['bucketName'], body_object['renderId'] + bucket_name=body_object['bucketName'], render_id=body_object['renderId'] ) return None @@ -393,30 +633,32 @@ def render_still_on_lambda( Render still using AWS Lambda. Args: - render_params (RenderParams): Render parameters. + render_params (RenderStillParams): Render parameters. Returns: - RenderResponse: Response from the render operation. + Optional[RenderStillResponse]: Response from the render operation, or None if no body object. """ - params = self.construct_render_request(render_params, render_type='still') + params_json_str = self.construct_render_request(render_params, render_type='still') # Using Enum member body_object = self._invoke_lambda( - function_name=self.function_name, payload=params + function_name=self.function_name, payload=params_json_str ) if body_object: + # Type hinting for estimatedPrice, as it's a nested dict + estimated_price_data: Dict[str, Any] = body_object.get('estimatedPrice', {}) return RenderStillResponse( estimated_price=CostsInfo( - accrued_so_far=body_object['estimatedPrice']['accruedSoFar'], - display_cost=body_object['estimatedPrice']['displayCost'], - currency=body_object['estimatedPrice']['currency'], - disclaimer=body_object['estimatedPrice']['disclaimer'], + accrued_so_far=estimated_price_data.get('accruedSoFar', 0.0), # Use .get with defaults + display_cost=estimated_price_data.get('displayCost', ''), + currency=estimated_price_data.get('currency', ''), + disclaimer=estimated_price_data.get('disclaimer', ''), ), - url=body_object['output'], - size_in_bytes=body_object['sizeInBytes'], - bucket_name=body_object['bucketName'], - render_id=body_object['renderId'], - outKey=body_object['outKey'], + url=body_object.get('output', ''), + size_in_bytes=body_object.get('sizeInBytes', 0), + bucket_name=body_object.get('bucketName', ''), + render_id=body_object.get('renderId', ''), + outKey=body_object.get('outKey', ''), ) return None @@ -425,7 +667,7 @@ def get_render_progress( self, render_id: str, bucket_name: str, - log_level="info", + log_level: str = "info", # Added type hint s3_output_provider: Optional[CustomCredentials] = None, ) -> Optional[RenderMediaProgress]: """ @@ -435,21 +677,24 @@ def get_render_progress( render_id (str): ID of the render. bucket_name (str): Name of the bucket. log_level (str): Log level ("error", "warning", "info", "verbose"). + s3_output_provider (Optional[CustomCredentials]): Custom S3 credentials. Returns: - RenderProgress: Progress of the render. + Optional[RenderMediaProgress]: Progress of the render, or None if no progress response. """ - params = self.construct_render_progress_request( + params_json_str = self.construct_render_progress_request( render_id, bucket_name, log_level=log_level, s3_output_provider=s3_output_provider, ) - progress_response = self._invoke_lambda( - function_name=self.function_name, payload=params + progress_response_data = self._invoke_lambda( + function_name=self.function_name, payload=params_json_str ) - if progress_response: + if progress_response_data: render_progress = RenderMediaProgress() - render_progress.__dict__.update(progress_response) + # Ensure RenderMediaProgress expects a dictionary for __dict__.update() + # and that all keys match its fields, or handle gracefully. + render_progress.__dict__.update(progress_response_data) return render_progress return None diff --git a/packages/lambda-python/testclient_render_media.py b/packages/lambda-python/testclient_render_media.py index 6eec9640877..91c933dd33f 100644 --- a/packages/lambda-python/testclient_render_media.py +++ b/packages/lambda-python/testclient_render_media.py @@ -3,7 +3,8 @@ from remotion_lambda import RemotionClient import os from dotenv import load_dotenv - +import boto3 +from botocore.config import Config load_dotenv() @@ -20,14 +21,56 @@ if not REMOTION_APP_SERVE_URL: raise Exception("REMOTION_APP_SERVE_URL is not set") -# Construct client + +# --- NEW: Create a custom botocore Config for timeouts --- +# This configuration will apply to both S3 and Lambda clients created by RemotionClient +# if passed via the botocore_config parameter. +custom_botocore_config = Config( + connect_timeout=30, # Max 30 seconds to establish a connection + read_timeout=60, # Max 60 seconds to receive data after connection + retries={'max_attempts': 3, 'mode': 'adaptive'}, # Retry up to 3 times on certain errors + # You can add more settings here, e.g., proxies + # proxies={ + # 'http': 'http://proxy.example.com:8080', + # 'https': 'https://proxy.example.com:8080' +# } +) +print(f"Created custom botocore config: {custom_botocore_config.retries}") + + +# --- NEW: Create a custom boto3 session --- +# This session can be configured independently, for example, +# if you need to specify a different profile or assumed role. +# If you don't need a custom session, you can omit this and +# RemotionClient will use boto3's default session. +custom_boto_session = boto3.Session( + #region_name=REMOTION_APP_REGION, + # profile_name='your_aws_profile', # Uncomment if you use AWS profiles + # If you provide aws_access_key_id, aws_secret_access_key here, + # it will override the ones passed to RemotionClient directly. + # aws_access_key_id='YOUR_ACCESS_KEY', + # aws_secret_access_key='YOUR_SECRET_KEY', +) +print(f"Created custom boto3 session with region: {custom_boto_session.region_name}") + + +# Construct client using custom session client = RemotionClient(region=REMOTION_APP_REGION, serve_url=REMOTION_APP_SERVE_URL, - function_name=REMOTION_APP_FUNCTION_NAME) + function_name=REMOTION_APP_FUNCTION_NAME, + config=custom_botocore_config, + session=custom_boto_session # if you omit this existing functionality will still work + ) + +# You can still use the previous approach +# client = RemotionClient(region=REMOTION_APP_REGION, +# serve_url=REMOTION_APP_SERVE_URL, +# function_name=REMOTION_APP_FUNCTION_NAME) +# # Set render request render_params = RenderMediaParams( - composition="print-props", + composition="spring-with-duration", privacy=Privacy.PUBLIC, image_format=ValidStillImageFormats.JPEG, input_props={ @@ -35,20 +78,6 @@ }, ) -# Test with large payload to verify S3 compression works -# Create large input props that exceed the 200KB limit for video-or-audio renders -large_data = { - 'largeArray': ['x' * 1000] * 250, # This creates ~250KB of data - 'description': 'This is a test with large input props to verify S3 compression functionality' -} - -large_render_params = RenderMediaParams( - composition="print-props", - privacy=Privacy.PUBLIC, - image_format=ValidStillImageFormats.JPEG, - input_props=large_data, -) - print("Testing normal payload size...") render_response = client.render_media_on_lambda(render_params) @@ -68,12 +97,27 @@ print("Render done!", progress_response.outputFile) print("Testing large payload compression...") +# Test with large payload to verify S3 compression works +# Create large input props that exceed the 200KB limit for video-or-audio renders +large_data = { + 'largeArray': ['x' * 1000] * 250, # This creates ~250KB of data + 'description': 'This is a test with large input props to verify S3 compression functionality' +} + +large_render_params = RenderMediaParams( + composition="print-props", + privacy=Privacy.PUBLIC, + image_format=ValidStillImageFormats.JPEG, + input_props=large_data, +) + + # For testing large payloads, we would need valid AWS credentials # This will demonstrate the compression logic try: large_render_response = client.render_media_on_lambda(large_render_params) print("Large payload render succeeded!") - + if large_render_response: print("Large Render ID:", large_render_response.render_id) print("Large Bucket name:", large_render_response.bucket_name) diff --git a/packages/lambda-python/tests/conftest.py b/packages/lambda-python/tests/conftest.py index d5cbf45452e..faab37e695d 100644 --- a/packages/lambda-python/tests/conftest.py +++ b/packages/lambda-python/tests/conftest.py @@ -31,3 +31,15 @@ def remotion_client_with_creds(): @pytest.fixture def mock_s3_client(): return Mock() + + +@pytest.fixture +def mock_lambda_client(): + """Fixture for a mocked boto3 Lambda client.""" + return Mock() + + +@pytest.fixture +def mock_boto_client(): + """Fixture for a mocked boto3 Lambda client.""" + return Mock() diff --git a/packages/lambda-python/tests/test_large_payload_compression.py b/packages/lambda-python/tests/test_large_payload_compression.py index 0d5cdfd30f4..6ef6b1d92cb 100644 --- a/packages/lambda-python/tests/test_large_payload_compression.py +++ b/packages/lambda-python/tests/test_large_payload_compression.py @@ -1,7 +1,6 @@ import unittest -from unittest.mock import patch, MagicMock -from remotion_lambda.models import RenderMediaParams -from remotion_lambda.remotionclient import RemotionClient +from unittest.mock import patch +from remotion_lambda.remotionclient import RemotionClient, RemotionException class TestLargePayloadCompression(unittest.TestCase): @@ -100,7 +99,7 @@ def test_get_or_create_bucket_with_multiple_buckets_raises_error( 'remotionlambda-useast1-def456', ] - with self.assertRaises(ValueError) as context: + with self.assertRaises(RemotionException) as context: self.client._get_or_create_bucket() error_message = str(context.exception) diff --git a/packages/lambda-python/tests/test_remotion_client.py b/packages/lambda-python/tests/test_remotion_client.py index e8fe56ed529..79a3a23b0c7 100644 --- a/packages/lambda-python/tests/test_remotion_client.py +++ b/packages/lambda-python/tests/test_remotion_client.py @@ -1,5 +1,15 @@ +import json from tests.conftest import remotion_client, mock_s3_client, remotion_client_with_creds from remotion_lambda.remotionclient import RemotionClient +from remotion_lambda.models import ( RenderType ) +from remotion_lambda.exception import ( + RemotionException, + RemotionInvalidArgumentException, + RemotionRenderingOutputError +) + +from botocore.exceptions import ClientError, ParamValidationError + import pytest from tests.constants import ( TEST_FUNCTION_NAME, @@ -10,6 +20,7 @@ ) from unittest.mock import patch, Mock from botocore.config import Config +import boto3 def test_bucket_name_format(remotion_client: RemotionClient): @@ -21,8 +32,7 @@ def test_client_config(remotion_client: RemotionClient): assert remotion_client.function_name == TEST_FUNCTION_NAME assert remotion_client.region == TEST_REGION assert remotion_client.serve_url == TEST_SERVE_URL - assert not remotion_client.access_key - assert not remotion_client.secret_key + assert remotion_client.session assert not remotion_client.force_path_style @@ -31,8 +41,10 @@ def test_client_config_with_creds(remotion_client_with_creds: RemotionClient): assert remotion_client.function_name == TEST_FUNCTION_NAME assert remotion_client.region == TEST_REGION assert remotion_client.serve_url == TEST_SERVE_URL - assert remotion_client.access_key == TEST_AWS_ACCESS_KEY - assert remotion_client.secret_key == TEST_AWS_SECRET_KEY + + credentials = remotion_client.session.get_credentials() + assert credentials.access_key == TEST_AWS_ACCESS_KEY + assert credentials.secret_key == TEST_AWS_SECRET_KEY assert not remotion_client.force_path_style @@ -59,63 +71,81 @@ def test_generate_hash_basic_string(remotion_client: RemotionClient): assert all(c in '0123456789abcdef' for c in result) -@patch('boto3.client') -def test_create_s3_client_default( - mock_boto_client, mock_s3_client, remotion_client: RemotionClient -): - mock_boto_client.return_value = mock_s3_client - result = remotion_client._create_s3_client() - - mock_boto_client.assert_called_once_with('s3', region_name=TEST_REGION) - assert result == mock_s3_client - -@patch('boto3.client') -def test_create_s3_client_partial_creds(mock_boto_client, mock_s3_client): - remotion_client = RemotionClient( +@patch('remotion_lambda.remotionclient.Session') +def test_create_client_partial_creds(mock_session_class): + with pytest.raises(RemotionInvalidArgumentException): + RemotionClient( + region=TEST_REGION, + serve_url=TEST_SERVE_URL, + function_name=TEST_FUNCTION_NAME, + secret_key=TEST_AWS_SECRET_KEY, + ) + + +@patch('remotion_lambda.remotionclient.Session') +def test_create_client_partial_creds_and_session(mock_session_class): + with pytest.raises(RemotionInvalidArgumentException) as excinfo: + custom_boto_session = boto3.Session( + #region_name=REMOTION_APP_REGION, + # profile_name='your_aws_profile', # Uncomment if you use AWS profiles + # If you provide aws_access_key_id, aws_secret_access_key here, + # it will override the ones passed to RemotionClient directly. + # aws_access_key_id='YOUR_ACCESS_KEY', + # aws_secret_access_key='YOUR_SECRET_KEY', + ) + RemotionClient( + region=TEST_REGION, + serve_url=TEST_SERVE_URL, + function_name=TEST_FUNCTION_NAME, + secret_key=TEST_AWS_SECRET_KEY, + session=custom_boto_session + ) + + assert excinfo.type == RemotionInvalidArgumentException + assert "Cannot specify both 'session' and explicit credentials" in str(excinfo.value) + + +@patch('remotion_lambda.remotionclient.Session') +def test_session_created_with_creds(mock_session_class): + RemotionClient( region=TEST_REGION, - serve_url=TEST_SERVE_URL, - function_name=TEST_FUNCTION_NAME, + serve_url="https://test.com", + function_name="test-func", + access_key=TEST_AWS_ACCESS_KEY, secret_key=TEST_AWS_SECRET_KEY, ) - mock_boto_client.return_value = mock_s3_client - result = remotion_client._create_s3_client() - - mock_boto_client.assert_called_once_with('s3', region_name=TEST_REGION) - assert result == mock_s3_client - - -@patch('boto3.client') -def test_create_s3_client_creds( - mock_boto_client, mock_s3_client, remotion_client_with_creds -): - mock_boto_client.return_value = mock_s3_client - result = remotion_client_with_creds._create_s3_client() - - mock_boto_client.assert_called_once_with( - 's3', - region_name=TEST_REGION, + mock_session_class.assert_called_once_with( aws_access_key_id=TEST_AWS_ACCESS_KEY, aws_secret_access_key=TEST_AWS_SECRET_KEY, + region_name=TEST_REGION, ) - assert result == mock_s3_client - -@patch('boto3.client') -def test_create_s3_client_with_path_style(mock_boto_client, mock_s3_client): +@patch('remotion_lambda.remotionclient.Session') +def test_create_client_with_path_style(mock_session_class, mock_s3_client, ): + # Create the client (this creates a mock session instance) remotion_client = RemotionClient( region=TEST_REGION, serve_url=TEST_SERVE_URL, function_name=TEST_FUNCTION_NAME, force_path_style=True, ) - mock_boto_client.return_value = mock_s3_client + + # Get the mock session instance that was created + mock_session_instance = mock_session_class.return_value + mock_session_instance.client.return_value = mock_s3_client + + # Call the method result = remotion_client._create_s3_client() - mock_boto_client.assert_called_once() + # Check the .client() call on the session instance + mock_session_instance.client.assert_called_once() + + call_args = mock_session_instance.client.call_args + print(call_args) - call_args = mock_boto_client.call_args + assert call_args[0][0] == 's3' # First positional arg is service name assert 'config' in call_args[1] config = call_args[1]['config'] assert isinstance(config, Config) @@ -174,3 +204,219 @@ def test_get_remotion_buckets_single_match_us_east_1( assert result == [test_bucket_name] mock_s3_client.get_bucket_location.assert_called_once_with(Bucket=test_bucket_name) + + + +@patch.object(RemotionClient, '_create_s3_client') +def test_get_or_create_bucket_client_error_on_create_bucket( + mock_create_client, mock_s3_client, remotion_client +): + """ + Test that ClientError from create_bucket is re-raised directly. + """ + mock_s3_client.list_buckets.return_value = {'Buckets': []} # Ensure new bucket creation path + mock_s3_client.create_bucket.side_effect = ClientError({"Error": {"Code": "BucketAlreadyExists"}}, "CreateBucket") + mock_create_client.return_value = mock_s3_client + + with pytest.raises(ClientError) as excinfo: + remotion_client._get_or_create_bucket() + assert excinfo.type == ClientError + assert "BucketAlreadyExists" in str(excinfo.value) + mock_s3_client.create_bucket.assert_called_once() + + +@patch.object(RemotionClient, '_create_s3_client') +def test_upload_to_client_error_on_put_object( + mock_create_client, mock_s3_client, remotion_client +): + """ + Test that ClientError from put_object is re-raised directly. + """ + mock_s3_client.put_object.side_effect = ClientError({"Error": {"Code": "InternalError"}}, "PutObject") + mock_create_client.return_value = mock_s3_client + + with pytest.raises(ClientError) as excinfo: + remotion_client._upload_to_s3("test-bucket", "test-key", "payload") + assert excinfo.type == ClientError + assert "InternalError" in str(excinfo.value) + mock_s3_client.put_object.assert_called_once() + + + +@patch.object(RemotionClient, '_get_remotion_buckets') +def test_get_or_create_bucket_remotion_exception_on_multiple_buckets( + mock_get_remotion_buckets, remotion_client +): + """ + Test that RemotionException is raised when multiple Remotion buckets are found. + """ + mock_get_remotion_buckets.return_value = [ + 'remotionlambda-us-east-1-bucket1', + 'remotionlambda-us-east-1-bucket2', + ] + + with pytest.raises(RemotionException) as excinfo: + remotion_client._get_or_create_bucket() + + assert excinfo.type == RemotionException + assert "You have multiple buckets" in str(excinfo.value) + mock_get_remotion_buckets.assert_called_once() + + +@patch.object(RemotionClient, '_create_lambda_client') +def test_invoke_lambda_unexpected_response_format( + mock_create_lambda_client, mock_lambda_client, remotion_client +): + """ + Test that _invoke_lambda raises RemotionRenderingOutputError for unexpected response types. + """ + unexpected_payload = json.dumps([ + {'type': 'log', 'level': 'info', 'message': 'Still running'}, + {'status': 'pending', 'progress': 0.5} # Not 'success' or 'error' + ]) + mock_lambda_client.invoke.return_value = {'Payload': Mock(read=lambda: unexpected_payload.encode('utf-8'))} + mock_create_lambda_client.return_value = mock_lambda_client + + with pytest.raises(RemotionRenderingOutputError) as excinfo: + remotion_client._invoke_lambda("my-function", "{}") + + assert excinfo.type == RemotionRenderingOutputError + assert "Unexpected Lambda response format" in str(excinfo.value) + mock_lambda_client.invoke.assert_called_once() + + +@patch.object(RemotionClient, '_create_lambda_client') +def test_invoke_lambda_invalid_json_decode( + mock_create_lambda_client, mock_lambda_client, remotion_client +): + """ + Test that _invoke_lambda raises RemotionException if the stream has invalid JSON objects. + (Note: This uses RemotionException, not RemotionRenderingOutputError, as it's a structural parsing error). + """ + invalid_stream = b'{"type":"log"} {"malformed_json" ' # Incomplete JSON + mock_lambda_client.invoke.return_value = {'Payload': Mock(read=lambda: invalid_stream)} + mock_create_lambda_client.return_value = mock_lambda_client + + with pytest.raises(RemotionRenderingOutputError) as excinfo: + remotion_client._invoke_lambda("my-function", "{}") + + assert excinfo.type == RemotionRenderingOutputError + mock_lambda_client.invoke.assert_called_once() + + +@patch.object(RemotionClient, '_serialize_input_props') +def test_construct_render_request_client_error_from_serialize_input_props( + mock_serialize_input_props, remotion_client +): + """ + Test that construct_render_request raises RemotionInvalidArgumentException + if _serialize_input_props encounters and re-raises a ClientError. + """ + # Simulate _serialize_input_props raising a ClientError (e.g., from _upload_to_s3) + mock_serialize_input_props.side_effect = ClientError({"Error": {"Code": "AccessDenied"}}, "PutObject") + + mock_render_params = Mock() + mock_render_params.input_props = {"dummy": "data"} + mock_render_params.serialize_params.return_value = {"serialized": "params"} + + with pytest.raises(RemotionInvalidArgumentException) as excinfo: + remotion_client.construct_render_request(mock_render_params, "still") + + assert excinfo.type == RemotionInvalidArgumentException + assert "Failed to serialize input properties for rendering" in str(excinfo.value) + assert "AccessDenied" in str(excinfo.value) # Ensure the original ClientError info is present + mock_serialize_input_props.assert_called_once_with( + input_props=mock_render_params.input_props, + render_type="still" + ) + + +@patch('boto3.client') +def test_create_client_with_session(mock_boto3_client_func): + """ + Test that _create_s3_client uses the provided session + instead of boto3.client directly. + """ + # Arrange + mock_session = Mock() + mock_client_from_session = Mock() + mock_session.client.return_value = mock_client_from_session + + config = Config() + client = RemotionClient( + region=TEST_REGION, + serve_url=TEST_SERVE_URL, + function_name=TEST_FUNCTION_NAME, + session=mock_session, + config=config + ) + + # Act + s3_client = client._create_s3_client() + + # Assert + assert s3_client == mock_client_from_session + mock_session.client.assert_called_once_with('s3', region_name=TEST_REGION, config=config) + + +@patch('remotion_lambda.remotionclient.Session') +def test_create_client_with_custom_timeout_config(mock_session_class, mock_s3_client): + """ + Test that _create_s3_client correctly applies custom timeout settings + provided via config. + """ + # Arrange + custom_timeout = 10 # seconds + custom_config = Config(connect_timeout=custom_timeout, read_timeout=custom_timeout) + + # Set up mock BEFORE creating RemotionClient + mock_session_instance = mock_session_class.return_value + mock_session_instance.client.return_value = mock_s3_client + + client = RemotionClient( + region=TEST_REGION, + serve_url=TEST_SERVE_URL, + function_name=TEST_FUNCTION_NAME, + config=custom_config, + ) + + # Act + s3_client = client._create_s3_client() + + # Assert + assert s3_client == mock_s3_client + mock_session_instance.client.assert_called_once() + + # Extract the config argument passed to session.client + call_args, call_kwargs = mock_session_instance.client.call_args + + assert call_args[0] == 's3' + assert call_kwargs['region_name'] == TEST_REGION + + passed_config = call_kwargs.get('config') + assert isinstance(passed_config, Config) + assert passed_config.connect_timeout == custom_timeout + assert passed_config.read_timeout == custom_timeout + assert passed_config.s3 is None + + +@patch.object(RemotionClient, '_create_lambda_client') +def test_invoke_lambda_Invalid_argument( + mock_create_lambda_client, mock_lambda_client, remotion_client +): + """ + Test that _invoke_lambda raises RemotionRenderingOutputError for unexpected response types. + """ + unexpected_payload = json.dumps([ + {'type': 'log', 'level': 'info', 'message': 'Still running'}, + {'status': 'pending', 'progress': 0.5} # Not 'success' or 'error' + ]) + mock_lambda_client.invoke.return_value = {'Payload': Mock(read=lambda: unexpected_payload.encode('utf-8'))} + mock_create_lambda_client.return_value = mock_lambda_client + + with pytest.raises(RemotionRenderingOutputError) as excinfo: + remotion_client._invoke_lambda("my-function", "{}") + + assert excinfo.type == RemotionRenderingOutputError + assert "Unexpected Lambda response format" in str(excinfo.value) + mock_lambda_client.invoke.assert_called_once() diff --git a/packages/lambda-python/tests/test_render_client_render_media.py b/packages/lambda-python/tests/test_render_client_render_media.py index 1ac8cfa3e68..7283673c266 100644 --- a/packages/lambda-python/tests/test_render_client_render_media.py +++ b/packages/lambda-python/tests/test_render_client_render_media.py @@ -2,7 +2,7 @@ from remotion_lambda.models import RenderMediaParams, ShouldDownload, Webhook from remotion_lambda.remotionclient import RemotionClient - +from remotion_lambda.exception import RemotionInvalidArgumentException class TestRemotionClient(TestCase): def test_remotion_construct_request(self): @@ -27,3 +27,9 @@ def test_remotion_construct_request(self): render_params=render_params, render_type="video-or-audio" ) ) + + def test_remotion_construct_request_illegal_argument(self): + with self.assertRaises(RemotionInvalidArgumentException): + client = RemotionClient( + region="us-east-1", serve_url="", function_name="" + ) diff --git a/packages/lambda-ruby-example/Gemfile.lock b/packages/lambda-ruby-example/Gemfile.lock index d4cd888147d..26fea3b853d 100644 --- a/packages/lambda-ruby-example/Gemfile.lock +++ b/packages/lambda-ruby-example/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: ../lambda-ruby specs: - remotion_lambda (4.0.347) + remotion_lambda (4.0.379) aws-sdk-lambda (> 1.0.0) json (> 2.0.0) logger (> 1.0.0) @@ -10,8 +10,8 @@ GEM remote: https://rubygems.org/ specs: aws-eventstream (1.4.0) - aws-partitions (1.1159.0) - aws-sdk-core (3.232.0) + aws-partitions (1.1189.0) + aws-sdk-core (3.239.2) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -19,15 +19,15 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-lambda (1.160.0) - aws-sdk-core (~> 3, >= 3.231.0) + aws-sdk-lambda (1.168.0) + aws-sdk-core (~> 3, >= 3.239.1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) base64 (0.3.0) - bigdecimal (3.2.3) + bigdecimal (3.3.1) jmespath (1.6.2) - json (2.13.2) + json (2.16.0) logger (1.7.0) PLATFORMS