diff --git a/src/apify_client/_resource_clients/_resource_client.py b/src/apify_client/_resource_clients/_resource_client.py index 4be84764..b782c3bc 100644 --- a/src/apify_client/_resource_clients/_resource_client.py +++ b/src/apify_client/_resource_clients/_resource_client.py @@ -153,38 +153,28 @@ def _build_params(self, **kwargs: QueryParamValue) -> dict[str, QueryParamValue] @staticmethod def _clean_json_payload(data: dict) -> dict: - """Remove None values and empty nested dicts from an API request payload. + """Remove unset top-level fields and empty nested dicts from an API request payload. - The Apify API ignores missing fields but may reject fields explicitly set to None. - Nested sub-models serialized by Pydantic may produce empty dicts when all their - fields are None - these are also removed. + A top-level `None` means the caller did not pass the argument, so the field is left out. `None` inside + a nested value the caller passed is kept and sent as `null`, so for example + `tagged_builds={'beta': None}` sends `{"taggedBuilds": {"beta": null}}` and removes the tag. - Uses an iterative stack-based approach, analogous to _build_params for query params. + Nested sub-models serialized by Pydantic with `exclude_none=True` produce empty dicts when all their + fields are unset - these are removed at every nesting level. """ - result: dict = {} - stack: list[tuple[dict, dict]] = [(data, result)] - - while stack: - source, target = stack.pop() - for key, val in source.items(): - if val is None: - continue + + def _remove_empty(d: dict) -> dict: + result: dict = {} + for key, val in d.items(): if isinstance(val, dict): - nested: dict = {} - target[key] = nested - stack.append((val, nested)) + cleaned = _remove_empty(val) + if cleaned: + result[key] = cleaned else: - target[key] = val - - # Remove dicts that became empty after None filtering - def _remove_empty(d: dict) -> None: - for key in [k for k, v in d.items() if isinstance(v, dict)]: - _remove_empty(d[key]) - if not d[key]: - del d[key] + result[key] = val + return result - _remove_empty(result) - return result + return _remove_empty({key: val for key, val in data.items() if val is not None}) @docs_group('Resource clients') diff --git a/src/apify_client/_resource_clients/actor_version.py b/src/apify_client/_resource_clients/actor_version.py index 28e76140..20c849a4 100644 --- a/src/apify_client/_resource_clients/actor_version.py +++ b/src/apify_client/_resource_clients/actor_version.py @@ -111,10 +111,10 @@ def update( """ version_fields = UpdateVersionRequest( build_tag=build_tag, - env_vars=[EnvVarRequest.model_validate(v) for v in env_vars] if env_vars else None, + env_vars=[EnvVarRequest.model_validate(v) for v in env_vars] if env_vars is not None else None, apply_env_vars_to_build=apply_env_vars_to_build, source_type=source_type, - source_files=_source_file_list_adapter.validate_python(source_files) if source_files else None, + source_files=_source_file_list_adapter.validate_python(source_files) if source_files is not None else None, git_repo_url=git_repo_url, tarball_url=tarball_url, github_gist_url=github_gist_url, @@ -235,10 +235,10 @@ async def update( """ version_fields = UpdateVersionRequest( build_tag=build_tag, - env_vars=[EnvVarRequest.model_validate(v) for v in env_vars] if env_vars else None, + env_vars=[EnvVarRequest.model_validate(v) for v in env_vars] if env_vars is not None else None, apply_env_vars_to_build=apply_env_vars_to_build, source_type=source_type, - source_files=_source_file_list_adapter.validate_python(source_files) if source_files else None, + source_files=_source_file_list_adapter.validate_python(source_files) if source_files is not None else None, git_repo_url=git_repo_url, tarball_url=tarball_url, github_gist_url=github_gist_url, diff --git a/src/apify_client/_resource_clients/schedule.py b/src/apify_client/_resource_clients/schedule.py index 2787e107..28e3b96f 100644 --- a/src/apify_client/_resource_clients/schedule.py +++ b/src/apify_client/_resource_clients/schedule.py @@ -95,7 +95,7 @@ def update( is_enabled=is_enabled, is_exclusive=is_exclusive, name=name, - actions=actions or None, + actions=actions, description=description, timezone=timezone, title=title, @@ -215,7 +215,7 @@ async def update( is_enabled=is_enabled, is_exclusive=is_exclusive, name=name, - actions=actions or None, + actions=actions, description=description, timezone=timezone, title=title, diff --git a/tests/unit/test_actor_update_fields.py b/tests/unit/test_actor_update_fields.py index c2d00389..698be03e 100644 --- a/tests/unit/test_actor_update_fields.py +++ b/tests/unit/test_actor_update_fields.py @@ -199,3 +199,27 @@ async def test_create_sends_actor_permission_level_async( assert len(captured) == 1 assert json.loads(captured[0].get_data()) == {'name': 'test-actor', 'actorPermissionLevel': 'LIMITED_PERMISSIONS'} + + +def test_update_sends_null_to_remove_tagged_build(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """`update` keeps `None` inside `tagged_builds`, so a tag can be removed.""" + captured: list[Request] = [] + httpserver.expect_request(_ACTOR_PATH, method='PUT').respond_with_handler(capture(captured)) + + sync_client.actor(_MOCKED_ACTOR_ID).update(tagged_builds={'beta': None, 'latest': {'buildId': 'abc'}}) + + assert len(captured) == 1 + assert json.loads(captured[0].get_data()) == {'taggedBuilds': {'beta': None, 'latest': {'buildId': 'abc'}}} + + +async def test_update_sends_null_to_remove_tagged_build_async( + httpserver: HTTPServer, async_client: ApifyClientAsync +) -> None: + """Async `update` keeps `None` inside `tagged_builds`, so a tag can be removed.""" + captured: list[Request] = [] + httpserver.expect_request(_ACTOR_PATH, method='PUT').respond_with_handler(capture(captured)) + + await async_client.actor(_MOCKED_ACTOR_ID).update(tagged_builds={'beta': None}) + + assert len(captured) == 1 + assert json.loads(captured[0].get_data()) == {'taggedBuilds': {'beta': None}} diff --git a/tests/unit/test_actor_version_fields.py b/tests/unit/test_actor_version_fields.py index b91dd8a4..bb9f97de 100644 --- a/tests/unit/test_actor_version_fields.py +++ b/tests/unit/test_actor_version_fields.py @@ -183,3 +183,27 @@ async def test_update_omits_actor_path_when_not_passed_async( assert len(captured) == 1 assert request_body(captured[0]) == {'tarballUrl': _TARBALL_URL} + + +def test_update_sends_empty_env_vars_and_source_files(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """`update` sends explicitly passed empty `env_vars` and `source_files` lists instead of dropping them.""" + captured: list[Request] = [] + httpserver.expect_request(_VERSION_PATH, method='PUT').respond_with_handler(capture(captured)) + + sync_client.actor(_MOCKED_ACTOR_ID).version(_VERSION_NUMBER).update(env_vars=[], source_files=[]) + + assert len(captured) == 1 + assert request_body(captured[0]) == {'envVars': [], 'sourceFiles': []} + + +async def test_update_sends_empty_env_vars_and_source_files_async( + httpserver: HTTPServer, async_client: ApifyClientAsync +) -> None: + """Async `update` sends explicitly passed empty `env_vars` and `source_files` lists instead of dropping them.""" + captured: list[Request] = [] + httpserver.expect_request(_VERSION_PATH, method='PUT').respond_with_handler(capture(captured)) + + await async_client.actor(_MOCKED_ACTOR_ID).version(_VERSION_NUMBER).update(env_vars=[], source_files=[]) + + assert len(captured) == 1 + assert request_body(captured[0]) == {'envVars': [], 'sourceFiles': []} diff --git a/tests/unit/test_schedule_notifications.py b/tests/unit/test_schedule_notifications.py index e1342599..12554cfd 100644 --- a/tests/unit/test_schedule_notifications.py +++ b/tests/unit/test_schedule_notifications.py @@ -129,3 +129,25 @@ async def test_create_sends_notifications_async(httpserver: HTTPServer, async_cl assert len(captured) == 1 assert body_of(captured[0])['notifications'] == {'email': True} + + +def test_update_sends_empty_actions(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """`update` sends an explicitly passed empty `actions` list instead of dropping it.""" + captured: list[Request] = [] + httpserver.expect_request(_SCHEDULE_PATH, method='PUT').respond_with_handler(capture(captured)) + + sync_client.schedule(_MOCKED_SCHEDULE_ID).update(actions=[]) + + assert len(captured) == 1 + assert body_of(captured[0]) == {'actions': []} + + +async def test_update_sends_empty_actions_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None: + """Async `update` sends an explicitly passed empty `actions` list instead of dropping it.""" + captured: list[Request] = [] + httpserver.expect_request(_SCHEDULE_PATH, method='PUT').respond_with_handler(capture(captured)) + + await async_client.schedule(_MOCKED_SCHEDULE_ID).update(actions=[]) + + assert len(captured) == 1 + assert body_of(captured[0]) == {'actions': []} diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 1a2c395a..e6cf46b1 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -246,10 +246,13 @@ def test_catch_not_found_or_throw(status_code: HTTPStatus, error_type: str, *, s ('input_dict', 'expected'), [ pytest.param({'a': 1, 'b': None, 'c': 3}, {'a': 1, 'c': 3}, id='Simple case'), - pytest.param({'a': {'b': None, 'c': 2}, 'd': None}, {'a': {'c': 2}}, id='Nested dictionaries'), - pytest.param({'a': {'b': {'c': None, 'd': 4}}}, {'a': {'b': {'d': 4}}}, id='Deep nesting'), + pytest.param({'a': {'b': None, 'c': 2}, 'd': None}, {'a': {'b': None, 'c': 2}}, id='Keep nested None'), + pytest.param({'a': {'b': {'c': None, 'd': 4}}}, {'a': {'b': {'c': None, 'd': 4}}}, id='Deep nesting'), pytest.param({'a': None, 'b': None}, {}, id='Empty dict after filtering'), - pytest.param({'a': {'b': None}, 'c': 3}, {'c': 3}, id='Remove empty dicts'), + pytest.param({'a': {}, 'c': 3}, {'c': 3}, id='Remove empty dicts'), + pytest.param({'a': {'b': {}}, 'c': 3}, {'c': 3}, id='Remove nested empty dicts'), + pytest.param({'a': {'b': None}, 'c': 3}, {'a': {'b': None}, 'c': 3}, id='Dict with only None is kept'), + pytest.param({'a': [], 'b': [None]}, {'a': [], 'b': [None]}, id='Keep lists as passed'), pytest.param({'a': 0, 'b': '', 'c': False}, {'a': 0, 'b': '', 'c': False}, id='Keep falsy non-None values'), ], )