22
33from __future__ import annotations
44
5- import base64
65import hashlib
76import hmac
87import io
98import json
109import string
1110import time
11+ from base64 import b64encode , urlsafe_b64encode
1212from enum import Enum
1313from http import HTTPStatus
1414from typing import TYPE_CHECKING , Any , TypeVar
2020
2121T = TypeVar ('T' )
2222
23+ _BASE62_CHARSET = string .digits + string .ascii_letters
24+ """Module-level constant for base62 encoding."""
25+
2326
2427def catch_not_found_or_throw (exc : ApifyApiError ) -> None :
2528 """Suppress 404 Not Found errors and re-raise all other API errors.
@@ -41,32 +44,62 @@ def filter_none_values(
4144 * ,
4245 remove_empty_dicts : bool | None = None ,
4346) -> dict :
44- """Remove None values from a dictionary recursively.
47+ """Recursively remove None values from a dictionary.
48+
49+ The Apify API ignores missing fields but may reject fields explicitly set to None. This helper prepares
50+ request payloads by stripping None values from nested dictionaries.
4551
46- The Apify API ignores missing fields but may reject fields explicitly set to None. This function prepares request
47- payloads by recursively removing None values from nested dictionaries.
52+ Uses an iterative, stack-based approach for better performance on deeply nested structures.
4853
4954 Args:
50- data: The dictionary to clean.
51- remove_empty_dicts: If True, also remove empty dictionaries after filtering None values .
55+ data: Dictionary to clean.
56+ remove_empty_dicts: Whether to remove empty dictionaries after filtering.
5257
5358 Returns:
54- A new dictionary with None values removed at all nesting levels .
59+ A new dictionary with all None values removed.
5560 """
61+ # Use an explicit stack to avoid recursion overhead
62+ result = {}
63+
64+ # Stack entries are (source_dict, target_dict)
65+ stack : list [tuple [dict , dict ]] = [(data , result )]
66+
67+ while stack :
68+ source , target = stack .pop ()
69+
70+ for key , val in source .items ():
71+ if val is None :
72+ continue
5673
57- def _internal (dictionary : dict , * , remove_empty : bool | None = None ) -> dict | None :
58- result = {}
59- for key , val in dictionary .items ():
6074 if isinstance (val , dict ):
61- val = _internal (val , remove_empty = remove_empty ) # noqa: PLW2901
62- if val is not None :
63- result [key ] = val
64- if not result and remove_empty :
65- return None
66- return result
75+ nested = {}
76+ target [key ] = nested
77+ stack .append ((val , nested ))
78+ else :
79+ target [key ] = val
80+
81+ # Optionally remove empty dictionaries
82+ if remove_empty_dicts :
83+ _remove_empty_dicts_inplace (result )
6784
68- result = _internal (data , remove_empty = remove_empty_dicts )
69- return result if result is not None else {}
85+ return result
86+
87+
88+ def _remove_empty_dicts_inplace (data : dict [str , Any ]) -> None :
89+ """Recursively remove empty dictionaries from a dict in place.
90+
91+ This is a helper function for filter_none_values.
92+ """
93+ keys_to_remove = list [str ]()
94+
95+ for key , val in data .items ():
96+ if isinstance (val , dict ):
97+ _remove_empty_dicts_inplace (val )
98+ if not val :
99+ keys_to_remove .append (key )
100+
101+ for key in keys_to_remove :
102+ del data [key ]
70103
71104
72105def encode_webhook_list_to_base64 (webhooks : list [dict ]) -> str :
@@ -79,6 +112,7 @@ def encode_webhook_list_to_base64(webhooks: list[dict]) -> str:
79112 A base64-encoded JSON string.
80113 """
81114 data = list [dict ]()
115+
82116 for webhook in webhooks :
83117 webhook_representation = {
84118 'eventTypes' : [enum_to_value (event_type ) for event_type in webhook ['event_types' ]],
@@ -90,7 +124,7 @@ def encode_webhook_list_to_base64(webhooks: list[dict]) -> str:
90124 webhook_representation ['headersTemplate' ] = webhook ['headers_template' ]
91125 data .append (webhook_representation )
92126
93- return base64 . b64encode (json .dumps (data ).encode ('utf-8' )).decode ('ascii' )
127+ return b64encode (json .dumps (data ).encode ('utf-8' )).decode ('ascii' )
94128
95129
96130def encode_key_value_store_record_value (value : Any , content_type : str | None = None ) -> tuple [Any , str ]:
@@ -116,7 +150,13 @@ def encode_key_value_store_record_value(value: Any, content_type: str | None = N
116150 and not isinstance (value , (bytes , bytearray , io .IOBase ))
117151 and not isinstance (value , str )
118152 ):
119- value = json .dumps (value , ensure_ascii = False , indent = 2 , allow_nan = False , default = str ).encode ('utf-8' )
153+ # Don't use indentation to reduce size.
154+ value = json .dumps (
155+ value ,
156+ ensure_ascii = False ,
157+ allow_nan = False ,
158+ default = str ,
159+ ).encode ('utf-8' )
120160
121161 return (value , content_type )
122162
@@ -196,16 +236,17 @@ def encode_base62(num: int) -> str:
196236 Returns:
197237 The base62-encoded string.
198238 """
199- charset = string .digits + string .ascii_letters
200-
201239 if num == 0 :
202- return charset [0 ]
240+ return _BASE62_CHARSET [0 ]
203241
204- res = ''
242+ # Use list to build result for O(n) complexity instead of O(n^2) string concatenation.
243+ parts = []
205244 while num > 0 :
206245 num , remainder = divmod (num , 62 )
207- res = charset [remainder ] + res
208- return res
246+ parts .append (_BASE62_CHARSET [remainder ])
247+
248+ # Reverse and join once at the end.
249+ return '' .join (reversed (parts ))
209250
210251
211252def create_hmac_signature (secret_key : str , message : str ) -> str :
@@ -253,5 +294,5 @@ def create_storage_content_signature(
253294 message_to_sign = f'{ version } .{ expires_at } .{ resource_id } '
254295 hmac_sig = create_hmac_signature (url_signing_secret_key , message_to_sign )
255296
256- base64url_encoded_payload = base64 . urlsafe_b64encode (f'{ version } .{ expires_at } .{ hmac_sig } ' .encode ())
297+ base64url_encoded_payload = urlsafe_b64encode (f'{ version } .{ expires_at } .{ hmac_sig } ' .encode ())
257298 return base64url_encoded_payload .decode ('utf-8' )
0 commit comments