Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 16 additions & 26 deletions src/apify_client/_resource_clients/_resource_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: a nested None the caller passes is kept now, but a nested {} they pass is still removed, so tagged_builds={'beta': {}} is dropped. Is that intended? The JS client sends both as given.

✍️ Drafted by Claude Code


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.
Comment on lines +158 to +160

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the docstring can use the full 120-character line length.

Suggested change
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.
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.

✍️ Drafted by Claude Code


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')
Expand Down
8 changes: 4 additions & 4 deletions src/apify_client/_resource_clients/actor_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/apify_client/_resource_clients/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/test_actor_update_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
24 changes: 24 additions & 0 deletions tests/unit/test_actor_version_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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': []}
22 changes: 22 additions & 0 deletions tests/unit/test_schedule_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -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': []}
9 changes: 6 additions & 3 deletions tests/unit/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
],
)
Expand Down
Loading