From fe114b471bb1bcf0b411385edb4e6257b944f387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 2 Nov 2026 00:02:43 +0100 Subject: [PATCH 1/7] feat: support actor_path on Actor version create and update Add an `actor_path` argument to `ActorVersionCollectionClient.create` and `ActorVersionClient.update`, sync and async. It's sent as `actorPath`: the Actor's directory inside the uploaded source of a `SOURCE_FILES` or `TARBALL` version, so a monorepo Actor can be built from an upload of the whole repository. The OpenAPI spec doesn't describe `actorPath` yet, so the client passes it to the request models as an extra field under its API name. The integration test for the round trip stays skipped until the API accepts the field. --- .../_resource_clients/actor_version.py | 14 ++ .../actor_version_collection.py | 12 ++ tests/integration/test_actor_version.py | 58 ++++++ tests/unit/test_actor_version_fields.py | 181 ++++++++++++++++++ 4 files changed, 265 insertions(+) create mode 100644 tests/unit/test_actor_version_fields.py diff --git a/src/apify_client/_resource_clients/actor_version.py b/src/apify_client/_resource_clients/actor_version.py index 503e610a..ea85667c 100644 --- a/src/apify_client/_resource_clients/actor_version.py +++ b/src/apify_client/_resource_clients/actor_version.py @@ -76,6 +76,7 @@ def update( git_repo_url: str | None = None, tarball_url: str | None = None, github_gist_url: str | None = None, + actor_path: str | None = None, timeout: Timeout = 'short', ) -> Version: """Update the Actor version with specified fields. @@ -97,6 +98,10 @@ def update( Required when `source_type` is `'TARBALL'`. github_gist_url: The URL of a GitHub Gist from which the source will be downloaded. Required when `source_type` is `'GITHUB_GIST'`. + actor_path: The Actor's directory inside the uploaded source, for `'SOURCE_FILES'` and `'TARBALL'` + versions. A relative path with `/` separators, such as `actors/my-actor`. An update that sends + `source_type`, `source_files`, or `tarball_url` without `actor_path` removes the stored path, so pass + it again with every upload. timeout: Timeout for the API HTTP request. Returns: @@ -111,6 +116,8 @@ def update( git_repo_url=git_repo_url, tarball_url=tarball_url, github_gist_url=github_gist_url, + # The OpenAPI spec doesn't describe `actorPath` yet, so it goes to the model's extra fields by its API name. + actorPath=actor_path, ) result = self._update(timeout=timeout, **version_fields.model_dump(by_alias=True, exclude_none=True)) return VersionResponse.model_validate(result).data @@ -192,6 +199,7 @@ async def update( git_repo_url: str | None = None, tarball_url: str | None = None, github_gist_url: str | None = None, + actor_path: str | None = None, timeout: Timeout = 'short', ) -> Version: """Update the Actor version with specified fields. @@ -213,6 +221,10 @@ async def update( Required when `source_type` is `'TARBALL'`. github_gist_url: The URL of a GitHub Gist from which the source will be downloaded. Required when `source_type` is `'GITHUB_GIST'`. + actor_path: The Actor's directory inside the uploaded source, for `'SOURCE_FILES'` and `'TARBALL'` + versions. A relative path with `/` separators, such as `actors/my-actor`. An update that sends + `source_type`, `source_files`, or `tarball_url` without `actor_path` removes the stored path, so pass + it again with every upload. timeout: Timeout for the API HTTP request. Returns: @@ -227,6 +239,8 @@ async def update( git_repo_url=git_repo_url, tarball_url=tarball_url, github_gist_url=github_gist_url, + # The OpenAPI spec doesn't describe `actorPath` yet, so it goes to the model's extra fields by its API name. + actorPath=actor_path, ) result = await self._update(timeout=timeout, **version_fields.model_dump(by_alias=True, exclude_none=True)) return VersionResponse.model_validate(result).data diff --git a/src/apify_client/_resource_clients/actor_version_collection.py b/src/apify_client/_resource_clients/actor_version_collection.py index 9bed8fd5..659fb8e4 100644 --- a/src/apify_client/_resource_clients/actor_version_collection.py +++ b/src/apify_client/_resource_clients/actor_version_collection.py @@ -90,6 +90,7 @@ def create( git_repo_url: str | None = None, tarball_url: str | None = None, github_gist_url: str | None = None, + actor_path: str | None = None, timeout: Timeout = 'short', ) -> Version: """Create a new Actor version. @@ -112,6 +113,9 @@ def create( Required when `source_type` is `'TARBALL'`. github_gist_url: The URL of a GitHub Gist from which the source will be downloaded. Required when `source_type` is `'GITHUB_GIST'`. + actor_path: The Actor's directory inside the uploaded source, for `'SOURCE_FILES'` and `'TARBALL'` + versions. A relative path with `/` separators, such as `actors/my-actor`. Without it, the root of the + uploaded source is the Actor's directory. timeout: Timeout for the API HTTP request. Returns: @@ -127,6 +131,8 @@ def create( git_repo_url=git_repo_url, tarball_url=tarball_url, github_gist_url=github_gist_url, + # The OpenAPI spec doesn't describe `actorPath` yet, so it goes to the model's extra fields by its API name. + actorPath=actor_path, ) result = self._create(timeout=timeout, **version_fields.model_dump(by_alias=True, exclude_none=True)) return VersionResponse.model_validate(result).data @@ -196,6 +202,7 @@ async def create( git_repo_url: str | None = None, tarball_url: str | None = None, github_gist_url: str | None = None, + actor_path: str | None = None, timeout: Timeout = 'short', ) -> Version: """Create a new Actor version. @@ -218,6 +225,9 @@ async def create( Required when `source_type` is `'TARBALL'`. github_gist_url: The URL of a GitHub Gist from which the source will be downloaded. Required when `source_type` is `'GITHUB_GIST'`. + actor_path: The Actor's directory inside the uploaded source, for `'SOURCE_FILES'` and `'TARBALL'` + versions. A relative path with `/` separators, such as `actors/my-actor`. Without it, the root of the + uploaded source is the Actor's directory. timeout: Timeout for the API HTTP request. Returns: @@ -233,6 +243,8 @@ async def create( git_repo_url=git_repo_url, tarball_url=tarball_url, github_gist_url=github_gist_url, + # The OpenAPI spec doesn't describe `actorPath` yet, so it goes to the model's extra fields by its API name. + actorPath=actor_path, ) result = await self._create(timeout=timeout, **version_fields.model_dump(by_alias=True, exclude_none=True)) return VersionResponse.model_validate(result).data diff --git a/tests/integration/test_actor_version.py b/tests/integration/test_actor_version.py index e422857b..4fa85af1 100644 --- a/tests/integration/test_actor_version.py +++ b/tests/integration/test_actor_version.py @@ -5,6 +5,8 @@ from collections.abc import AsyncIterator, Iterator from typing import TYPE_CHECKING +import pytest + from .._utils import get_random_resource_name, maybe_await from apify_client._models import ActorResource, ListOfVersions, Version @@ -151,6 +153,62 @@ async def test_actor_version_update(client: ApifyClient | ApifyClientAsync) -> N await maybe_await(actor_client.delete()) +@pytest.mark.skip(reason='The Apify API does not accept `actorPath` yet.') +async def test_actor_version_actor_path(client: ApifyClient | ApifyClientAsync) -> None: + """Test that `actor_path` is stored, kept by an update of other fields, and removed by a new upload without it.""" + actor_name = get_random_resource_name('actor') + actor_path = 'actors/my-actor' + + actor = await maybe_await(client.actors().create(name=actor_name)) + assert isinstance(actor, ActorResource) + actor_client = client.actor(actor.id) + version_client = actor_client.version('0.1') + + try: + # Create a version from a monorepo upload, with the Actor in a subdirectory + created_version = await maybe_await( + actor_client.versions().create( + version_number='0.1', + source_type='SOURCE_FILES', + source_files=[ + { + 'name': f'{actor_path}/main.js', + 'format': 'TEXT', + 'content': 'console.log("Hello from a monorepo")', + } + ], + actor_path=actor_path, + ) + ) + assert isinstance(created_version, Version) + assert created_version.model_dump(by_alias=True).get('actorPath') == actor_path + + # An update that leaves the source alone keeps the path + await maybe_await(version_client.update(build_tag='beta')) + kept_version = await maybe_await(version_client.get()) + assert isinstance(kept_version, Version) + assert kept_version.model_dump(by_alias=True).get('actorPath') == actor_path + + # A new upload without `actor_path` removes the path + await maybe_await( + version_client.update( + source_files=[ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("Hello from the root")', + } + ], + ) + ) + cleared_version = await maybe_await(version_client.get()) + assert isinstance(cleared_version, Version) + assert cleared_version.model_dump(by_alias=True).get('actorPath') is None + + finally: + await maybe_await(actor_client.delete()) + + async def test_actor_version_delete(client: ApifyClient | ApifyClientAsync) -> None: """Test deleting an Actor version.""" actor_name = get_random_resource_name('actor') diff --git a/tests/unit/test_actor_version_fields.py b/tests/unit/test_actor_version_fields.py new file mode 100644 index 00000000..ed4c3024 --- /dev/null +++ b/tests/unit/test_actor_version_fields.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +import pytest +from werkzeug import Request, Response + +if TYPE_CHECKING: + from collections.abc import Callable + + from pytest_httpserver import HTTPServer + + from apify_client import ApifyClient, ApifyClientAsync + +pytestmark = pytest.mark.usefixtures('http_client_classes') + +_MOCKED_ACTOR_ID = 'test_actor_id' +_VERSION_NUMBER = '0.1' +_VERSIONS_PATH = f'/v2/actors/{_MOCKED_ACTOR_ID}/versions' +_VERSION_PATH = f'{_VERSIONS_PATH}/{_VERSION_NUMBER}' + +_ACTOR_PATH = 'actors/my-actor' +_TARBALL_URL = 'https://example.com/monorepo.zip' + +_VERSION_RESPONSE = { + 'data': { + 'versionNumber': _VERSION_NUMBER, + 'sourceType': 'TARBALL', + 'tarballUrl': _TARBALL_URL, + 'buildTag': 'latest', + 'envVars': [], + 'actorPath': _ACTOR_PATH, + } +} + + +def capture(captured: list[Request]) -> Callable[[Request], Response]: + def handler(request: Request) -> Response: + captured.append(request) + return Response(json.dumps(_VERSION_RESPONSE), status=200, mimetype='application/json') + + return handler + + +def request_body(request: Request) -> dict[str, Any]: + return json.loads(request.get_data()) + + +def test_create_sends_actor_path(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """`create` sends `actor_path` as `actorPath`.""" + captured: list[Request] = [] + httpserver.expect_request(_VERSIONS_PATH, method='POST').respond_with_handler(capture(captured)) + + versions_client = sync_client.actor(_MOCKED_ACTOR_ID).versions() + versions_client.create( + version_number=_VERSION_NUMBER, + source_type='TARBALL', + tarball_url=_TARBALL_URL, + actor_path=_ACTOR_PATH, + ) + + assert len(captured) == 1 + assert request_body(captured[0]) == { + 'versionNumber': _VERSION_NUMBER, + 'sourceType': 'TARBALL', + 'tarballUrl': _TARBALL_URL, + 'actorPath': _ACTOR_PATH, + } + + +async def test_create_sends_actor_path_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None: + """Async `create` sends `actor_path` as `actorPath`.""" + captured: list[Request] = [] + httpserver.expect_request(_VERSIONS_PATH, method='POST').respond_with_handler(capture(captured)) + + versions_client = async_client.actor(_MOCKED_ACTOR_ID).versions() + await versions_client.create( + version_number=_VERSION_NUMBER, + source_type='TARBALL', + tarball_url=_TARBALL_URL, + actor_path=_ACTOR_PATH, + ) + + assert len(captured) == 1 + assert request_body(captured[0]) == { + 'versionNumber': _VERSION_NUMBER, + 'sourceType': 'TARBALL', + 'tarballUrl': _TARBALL_URL, + 'actorPath': _ACTOR_PATH, + } + + +def test_create_omits_actor_path_when_not_passed(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """`create` leaves `actorPath` out of the request body when `actor_path` is not passed.""" + captured: list[Request] = [] + httpserver.expect_request(_VERSIONS_PATH, method='POST').respond_with_handler(capture(captured)) + + versions_client = sync_client.actor(_MOCKED_ACTOR_ID).versions() + versions_client.create( + version_number=_VERSION_NUMBER, + source_type='TARBALL', + tarball_url=_TARBALL_URL, + ) + + assert len(captured) == 1 + assert 'actorPath' not in request_body(captured[0]) + + +async def test_create_omits_actor_path_when_not_passed_async( + httpserver: HTTPServer, async_client: ApifyClientAsync +) -> None: + """Async `create` leaves `actorPath` out of the request body when `actor_path` is not passed.""" + captured: list[Request] = [] + httpserver.expect_request(_VERSIONS_PATH, method='POST').respond_with_handler(capture(captured)) + + versions_client = async_client.actor(_MOCKED_ACTOR_ID).versions() + await versions_client.create( + version_number=_VERSION_NUMBER, + source_type='TARBALL', + tarball_url=_TARBALL_URL, + ) + + assert len(captured) == 1 + assert 'actorPath' not in request_body(captured[0]) + + +def test_update_sends_actor_path(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """`update` sends `actor_path` as `actorPath`.""" + captured: list[Request] = [] + httpserver.expect_request(_VERSION_PATH, method='PUT').respond_with_handler(capture(captured)) + + version_client = sync_client.actor(_MOCKED_ACTOR_ID).version(_VERSION_NUMBER) + version_client.update( + tarball_url=_TARBALL_URL, + actor_path=_ACTOR_PATH, + ) + + assert len(captured) == 1 + assert request_body(captured[0]) == {'tarballUrl': _TARBALL_URL, 'actorPath': _ACTOR_PATH} + + +async def test_update_sends_actor_path_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None: + """Async `update` sends `actor_path` as `actorPath`.""" + captured: list[Request] = [] + httpserver.expect_request(_VERSION_PATH, method='PUT').respond_with_handler(capture(captured)) + + version_client = async_client.actor(_MOCKED_ACTOR_ID).version(_VERSION_NUMBER) + await version_client.update( + tarball_url=_TARBALL_URL, + actor_path=_ACTOR_PATH, + ) + + assert len(captured) == 1 + assert request_body(captured[0]) == {'tarballUrl': _TARBALL_URL, 'actorPath': _ACTOR_PATH} + + +def test_update_omits_actor_path_when_not_passed(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """`update` leaves `actorPath` out of the request body when `actor_path` is not passed.""" + captured: list[Request] = [] + httpserver.expect_request(_VERSION_PATH, method='PUT').respond_with_handler(capture(captured)) + + version_client = sync_client.actor(_MOCKED_ACTOR_ID).version(_VERSION_NUMBER) + version_client.update(tarball_url=_TARBALL_URL) + + assert len(captured) == 1 + assert request_body(captured[0]) == {'tarballUrl': _TARBALL_URL} + + +async def test_update_omits_actor_path_when_not_passed_async( + httpserver: HTTPServer, async_client: ApifyClientAsync +) -> None: + """Async `update` leaves `actorPath` out of the request body when `actor_path` is not passed.""" + captured: list[Request] = [] + httpserver.expect_request(_VERSION_PATH, method='PUT').respond_with_handler(capture(captured)) + + version_client = async_client.actor(_MOCKED_ACTOR_ID).version(_VERSION_NUMBER) + await version_client.update(tarball_url=_TARBALL_URL) + + assert len(captured) == 1 + assert request_body(captured[0]) == {'tarballUrl': _TARBALL_URL} From 25d24c0e299f6eed8474e4b8e469073f55220d94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 2 Nov 2026 00:10:31 +0100 Subject: [PATCH 2/7] docs: state the actor_path rules and how to remove the path The `actor_path` docstrings now list the path rules the API enforces: at most 255 characters, no leading, trailing, or repeated `/`, and no `.` or `..` segment. They also say the argument works only for `SOURCE_FILES` and `TARBALL` versions. The `update` docstring now says that `actor_path=None` alone keeps the stored path, because the client never sends `None`. To remove the path without a new upload, pass the current `source_type` again. --- .../_resource_clients/actor_version.py | 20 +++++++++++-------- .../actor_version_collection.py | 10 ++++++---- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/apify_client/_resource_clients/actor_version.py b/src/apify_client/_resource_clients/actor_version.py index ea85667c..085567eb 100644 --- a/src/apify_client/_resource_clients/actor_version.py +++ b/src/apify_client/_resource_clients/actor_version.py @@ -98,10 +98,12 @@ def update( Required when `source_type` is `'TARBALL'`. github_gist_url: The URL of a GitHub Gist from which the source will be downloaded. Required when `source_type` is `'GITHUB_GIST'`. - actor_path: The Actor's directory inside the uploaded source, for `'SOURCE_FILES'` and `'TARBALL'` - versions. A relative path with `/` separators, such as `actors/my-actor`. An update that sends - `source_type`, `source_files`, or `tarball_url` without `actor_path` removes the stored path, so pass - it again with every upload. + actor_path: The Actor's directory inside the uploaded source, only for `'SOURCE_FILES'` and `'TARBALL'` + versions. A relative path with `/` separators, such as `actors/my-actor`, of at most 255 characters, + with no leading, trailing, or repeated `/` and no `.` or `..` segment. An update that sends + `source_type`, `source_files`, or `tarball_url` without `actor_path` removes the stored path, so pass it + again with every upload. The client doesn't send `None`, so `actor_path=None` alone keeps the path. To + remove it without a new upload, pass the current `source_type` again. timeout: Timeout for the API HTTP request. Returns: @@ -221,10 +223,12 @@ async def update( Required when `source_type` is `'TARBALL'`. github_gist_url: The URL of a GitHub Gist from which the source will be downloaded. Required when `source_type` is `'GITHUB_GIST'`. - actor_path: The Actor's directory inside the uploaded source, for `'SOURCE_FILES'` and `'TARBALL'` - versions. A relative path with `/` separators, such as `actors/my-actor`. An update that sends - `source_type`, `source_files`, or `tarball_url` without `actor_path` removes the stored path, so pass - it again with every upload. + actor_path: The Actor's directory inside the uploaded source, only for `'SOURCE_FILES'` and `'TARBALL'` + versions. A relative path with `/` separators, such as `actors/my-actor`, of at most 255 characters, + with no leading, trailing, or repeated `/` and no `.` or `..` segment. An update that sends + `source_type`, `source_files`, or `tarball_url` without `actor_path` removes the stored path, so pass it + again with every upload. The client doesn't send `None`, so `actor_path=None` alone keeps the path. To + remove it without a new upload, pass the current `source_type` again. timeout: Timeout for the API HTTP request. Returns: diff --git a/src/apify_client/_resource_clients/actor_version_collection.py b/src/apify_client/_resource_clients/actor_version_collection.py index 659fb8e4..22a84673 100644 --- a/src/apify_client/_resource_clients/actor_version_collection.py +++ b/src/apify_client/_resource_clients/actor_version_collection.py @@ -113,8 +113,9 @@ def create( Required when `source_type` is `'TARBALL'`. github_gist_url: The URL of a GitHub Gist from which the source will be downloaded. Required when `source_type` is `'GITHUB_GIST'`. - actor_path: The Actor's directory inside the uploaded source, for `'SOURCE_FILES'` and `'TARBALL'` - versions. A relative path with `/` separators, such as `actors/my-actor`. Without it, the root of the + actor_path: The Actor's directory inside the uploaded source, only for `'SOURCE_FILES'` and `'TARBALL'` + versions. A relative path with `/` separators, such as `actors/my-actor`, of at most 255 characters, + with no leading, trailing, or repeated `/` and no `.` or `..` segment. Without it, the root of the uploaded source is the Actor's directory. timeout: Timeout for the API HTTP request. @@ -225,8 +226,9 @@ async def create( Required when `source_type` is `'TARBALL'`. github_gist_url: The URL of a GitHub Gist from which the source will be downloaded. Required when `source_type` is `'GITHUB_GIST'`. - actor_path: The Actor's directory inside the uploaded source, for `'SOURCE_FILES'` and `'TARBALL'` - versions. A relative path with `/` separators, such as `actors/my-actor`. Without it, the root of the + actor_path: The Actor's directory inside the uploaded source, only for `'SOURCE_FILES'` and `'TARBALL'` + versions. A relative path with `/` separators, such as `actors/my-actor`, of at most 255 characters, + with no leading, trailing, or repeated `/` and no `.` or `..` segment. Without it, the root of the uploaded source is the Actor's directory. timeout: Timeout for the API HTTP request. From 2ebafe12b05e8495807977340359dd0ad0769671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 2 Nov 2026 00:10:31 +0100 Subject: [PATCH 3/7] test: cover removing actor_path with a source_type update The skipped integration test now also sets the path again with an update and checks that an update with only the current `source_type` removes it. --- tests/integration/test_actor_version.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_actor_version.py b/tests/integration/test_actor_version.py index 4fa85af1..efdc5b6c 100644 --- a/tests/integration/test_actor_version.py +++ b/tests/integration/test_actor_version.py @@ -155,7 +155,7 @@ async def test_actor_version_update(client: ApifyClient | ApifyClientAsync) -> N @pytest.mark.skip(reason='The Apify API does not accept `actorPath` yet.') async def test_actor_version_actor_path(client: ApifyClient | ApifyClientAsync) -> None: - """Test that `actor_path` is stored, kept by an update of other fields, and removed by a new upload without it.""" + """Test that `actor_path` is stored, kept by other updates, and removed by an upload or a `source_type` update.""" actor_name = get_random_resource_name('actor') actor_path = 'actors/my-actor' @@ -205,6 +205,17 @@ async def test_actor_version_actor_path(client: ApifyClient | ApifyClientAsync) assert isinstance(cleared_version, Version) assert cleared_version.model_dump(by_alias=True).get('actorPath') is None + # Setting the path again and then passing the current source type removes it without a new upload + await maybe_await(version_client.update(actor_path=actor_path)) + restored_version = await maybe_await(version_client.get()) + assert isinstance(restored_version, Version) + assert restored_version.model_dump(by_alias=True).get('actorPath') == actor_path + + await maybe_await(version_client.update(source_type='SOURCE_FILES')) + reset_version = await maybe_await(version_client.get()) + assert isinstance(reset_version, Version) + assert reset_version.model_dump(by_alias=True).get('actorPath') is None + finally: await maybe_await(actor_client.delete()) From 0d7f129e37d50d16f315692ed79d4499a0f9f9ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 3 Nov 2026 10:52:02 +0100 Subject: [PATCH 4/7] refactor: pass `actor_path` through the generated version request models The regenerated `CreateVersionRequest` and `UpdateVersionRequest` models describe `actorPath`, so the version clients pass `actor_path` by its field name like the other source-location arguments, instead of adding `actorPath` to the models' extra fields. This needs the models regenerated from an API specification that includes `actorPath` on master first. --- src/apify_client/_resource_clients/actor_version.py | 6 ++---- .../_resource_clients/actor_version_collection.py | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/apify_client/_resource_clients/actor_version.py b/src/apify_client/_resource_clients/actor_version.py index 085567eb..2a3e4add 100644 --- a/src/apify_client/_resource_clients/actor_version.py +++ b/src/apify_client/_resource_clients/actor_version.py @@ -118,8 +118,7 @@ def update( git_repo_url=git_repo_url, tarball_url=tarball_url, github_gist_url=github_gist_url, - # The OpenAPI spec doesn't describe `actorPath` yet, so it goes to the model's extra fields by its API name. - actorPath=actor_path, + actor_path=actor_path, ) result = self._update(timeout=timeout, **version_fields.model_dump(by_alias=True, exclude_none=True)) return VersionResponse.model_validate(result).data @@ -243,8 +242,7 @@ async def update( git_repo_url=git_repo_url, tarball_url=tarball_url, github_gist_url=github_gist_url, - # The OpenAPI spec doesn't describe `actorPath` yet, so it goes to the model's extra fields by its API name. - actorPath=actor_path, + actor_path=actor_path, ) result = await self._update(timeout=timeout, **version_fields.model_dump(by_alias=True, exclude_none=True)) return VersionResponse.model_validate(result).data diff --git a/src/apify_client/_resource_clients/actor_version_collection.py b/src/apify_client/_resource_clients/actor_version_collection.py index 22a84673..f9f588ea 100644 --- a/src/apify_client/_resource_clients/actor_version_collection.py +++ b/src/apify_client/_resource_clients/actor_version_collection.py @@ -132,8 +132,7 @@ def create( git_repo_url=git_repo_url, tarball_url=tarball_url, github_gist_url=github_gist_url, - # The OpenAPI spec doesn't describe `actorPath` yet, so it goes to the model's extra fields by its API name. - actorPath=actor_path, + actor_path=actor_path, ) result = self._create(timeout=timeout, **version_fields.model_dump(by_alias=True, exclude_none=True)) return VersionResponse.model_validate(result).data @@ -245,8 +244,7 @@ async def create( git_repo_url=git_repo_url, tarball_url=tarball_url, github_gist_url=github_gist_url, - # The OpenAPI spec doesn't describe `actorPath` yet, so it goes to the model's extra fields by its API name. - actorPath=actor_path, + actor_path=actor_path, ) result = await self._create(timeout=timeout, **version_fields.model_dump(by_alias=True, exclude_none=True)) return VersionResponse.model_validate(result).data From ff50e09691a310d4111bfbb76b133213120f91fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 3 Nov 2026 10:52:02 +0100 Subject: [PATCH 5/7] test: run the `actor_path` integration test and read it from `Version` The Apify API accepts `actorPath` by the time this lands, so the test is no longer skipped. It reads the path from the regenerated `Version.actor_path` field instead of dumping the model by alias. --- tests/integration/test_actor_version.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/tests/integration/test_actor_version.py b/tests/integration/test_actor_version.py index efdc5b6c..f39c5947 100644 --- a/tests/integration/test_actor_version.py +++ b/tests/integration/test_actor_version.py @@ -5,8 +5,6 @@ from collections.abc import AsyncIterator, Iterator from typing import TYPE_CHECKING -import pytest - from .._utils import get_random_resource_name, maybe_await from apify_client._models import ActorResource, ListOfVersions, Version @@ -153,7 +151,6 @@ async def test_actor_version_update(client: ApifyClient | ApifyClientAsync) -> N await maybe_await(actor_client.delete()) -@pytest.mark.skip(reason='The Apify API does not accept `actorPath` yet.') async def test_actor_version_actor_path(client: ApifyClient | ApifyClientAsync) -> None: """Test that `actor_path` is stored, kept by other updates, and removed by an upload or a `source_type` update.""" actor_name = get_random_resource_name('actor') @@ -181,13 +178,13 @@ async def test_actor_version_actor_path(client: ApifyClient | ApifyClientAsync) ) ) assert isinstance(created_version, Version) - assert created_version.model_dump(by_alias=True).get('actorPath') == actor_path + assert created_version.actor_path == actor_path # An update that leaves the source alone keeps the path await maybe_await(version_client.update(build_tag='beta')) kept_version = await maybe_await(version_client.get()) assert isinstance(kept_version, Version) - assert kept_version.model_dump(by_alias=True).get('actorPath') == actor_path + assert kept_version.actor_path == actor_path # A new upload without `actor_path` removes the path await maybe_await( @@ -203,18 +200,18 @@ async def test_actor_version_actor_path(client: ApifyClient | ApifyClientAsync) ) cleared_version = await maybe_await(version_client.get()) assert isinstance(cleared_version, Version) - assert cleared_version.model_dump(by_alias=True).get('actorPath') is None + assert cleared_version.actor_path is None # Setting the path again and then passing the current source type removes it without a new upload await maybe_await(version_client.update(actor_path=actor_path)) restored_version = await maybe_await(version_client.get()) assert isinstance(restored_version, Version) - assert restored_version.model_dump(by_alias=True).get('actorPath') == actor_path + assert restored_version.actor_path == actor_path await maybe_await(version_client.update(source_type='SOURCE_FILES')) reset_version = await maybe_await(version_client.get()) assert isinstance(reset_version, Version) - assert reset_version.model_dump(by_alias=True).get('actorPath') is None + assert reset_version.actor_path is None finally: await maybe_await(actor_client.delete()) From 5ab6eaf6b82597bcc9f517080083e9cde3f533d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 3 Nov 2026 11:00:38 +0100 Subject: [PATCH 6/7] docs: state that `actor_path` takes 1 to 255 characters The API and the regenerated request models reject an empty `actor_path`, so the docstrings give the full length range instead of only the maximum. --- src/apify_client/_resource_clients/actor_version.py | 4 ++-- .../_resource_clients/actor_version_collection.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/apify_client/_resource_clients/actor_version.py b/src/apify_client/_resource_clients/actor_version.py index 2a3e4add..28e76140 100644 --- a/src/apify_client/_resource_clients/actor_version.py +++ b/src/apify_client/_resource_clients/actor_version.py @@ -99,7 +99,7 @@ def update( github_gist_url: The URL of a GitHub Gist from which the source will be downloaded. Required when `source_type` is `'GITHUB_GIST'`. actor_path: The Actor's directory inside the uploaded source, only for `'SOURCE_FILES'` and `'TARBALL'` - versions. A relative path with `/` separators, such as `actors/my-actor`, of at most 255 characters, + versions. A relative path with `/` separators, such as `actors/my-actor`, of 1 to 255 characters, with no leading, trailing, or repeated `/` and no `.` or `..` segment. An update that sends `source_type`, `source_files`, or `tarball_url` without `actor_path` removes the stored path, so pass it again with every upload. The client doesn't send `None`, so `actor_path=None` alone keeps the path. To @@ -223,7 +223,7 @@ async def update( github_gist_url: The URL of a GitHub Gist from which the source will be downloaded. Required when `source_type` is `'GITHUB_GIST'`. actor_path: The Actor's directory inside the uploaded source, only for `'SOURCE_FILES'` and `'TARBALL'` - versions. A relative path with `/` separators, such as `actors/my-actor`, of at most 255 characters, + versions. A relative path with `/` separators, such as `actors/my-actor`, of 1 to 255 characters, with no leading, trailing, or repeated `/` and no `.` or `..` segment. An update that sends `source_type`, `source_files`, or `tarball_url` without `actor_path` removes the stored path, so pass it again with every upload. The client doesn't send `None`, so `actor_path=None` alone keeps the path. To diff --git a/src/apify_client/_resource_clients/actor_version_collection.py b/src/apify_client/_resource_clients/actor_version_collection.py index f9f588ea..3a09a6b6 100644 --- a/src/apify_client/_resource_clients/actor_version_collection.py +++ b/src/apify_client/_resource_clients/actor_version_collection.py @@ -114,7 +114,7 @@ def create( github_gist_url: The URL of a GitHub Gist from which the source will be downloaded. Required when `source_type` is `'GITHUB_GIST'`. actor_path: The Actor's directory inside the uploaded source, only for `'SOURCE_FILES'` and `'TARBALL'` - versions. A relative path with `/` separators, such as `actors/my-actor`, of at most 255 characters, + versions. A relative path with `/` separators, such as `actors/my-actor`, of 1 to 255 characters, with no leading, trailing, or repeated `/` and no `.` or `..` segment. Without it, the root of the uploaded source is the Actor's directory. timeout: Timeout for the API HTTP request. @@ -226,7 +226,7 @@ async def create( github_gist_url: The URL of a GitHub Gist from which the source will be downloaded. Required when `source_type` is `'GITHUB_GIST'`. actor_path: The Actor's directory inside the uploaded source, only for `'SOURCE_FILES'` and `'TARBALL'` - versions. A relative path with `/` separators, such as `actors/my-actor`, of at most 255 characters, + versions. A relative path with `/` separators, such as `actors/my-actor`, of 1 to 255 characters, with no leading, trailing, or repeated `/` and no `.` or `..` segment. Without it, the root of the uploaded source is the Actor's directory. timeout: Timeout for the API HTTP request. From 75dfdfbc0a346abea50331d012a171885fa926f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 3 Nov 2026 11:00:38 +0100 Subject: [PATCH 7/7] test: check `Version.actor_path` in the `actor_path` unit tests The tests that send `actor_path` now also check that the returned version reads `actorPath` from the response, so the read side is covered without the live API. --- tests/unit/test_actor_version_fields.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/unit/test_actor_version_fields.py b/tests/unit/test_actor_version_fields.py index ed4c3024..b91dd8a4 100644 --- a/tests/unit/test_actor_version_fields.py +++ b/tests/unit/test_actor_version_fields.py @@ -48,12 +48,12 @@ def request_body(request: Request) -> dict[str, Any]: def test_create_sends_actor_path(httpserver: HTTPServer, sync_client: ApifyClient) -> None: - """`create` sends `actor_path` as `actorPath`.""" + """`create` sends `actor_path` as `actorPath` and reads it back from the response.""" captured: list[Request] = [] httpserver.expect_request(_VERSIONS_PATH, method='POST').respond_with_handler(capture(captured)) versions_client = sync_client.actor(_MOCKED_ACTOR_ID).versions() - versions_client.create( + version = versions_client.create( version_number=_VERSION_NUMBER, source_type='TARBALL', tarball_url=_TARBALL_URL, @@ -67,15 +67,16 @@ def test_create_sends_actor_path(httpserver: HTTPServer, sync_client: ApifyClien 'tarballUrl': _TARBALL_URL, 'actorPath': _ACTOR_PATH, } + assert version.actor_path == _ACTOR_PATH async def test_create_sends_actor_path_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None: - """Async `create` sends `actor_path` as `actorPath`.""" + """Async `create` sends `actor_path` as `actorPath` and reads it back from the response.""" captured: list[Request] = [] httpserver.expect_request(_VERSIONS_PATH, method='POST').respond_with_handler(capture(captured)) versions_client = async_client.actor(_MOCKED_ACTOR_ID).versions() - await versions_client.create( + version = await versions_client.create( version_number=_VERSION_NUMBER, source_type='TARBALL', tarball_url=_TARBALL_URL, @@ -89,6 +90,7 @@ async def test_create_sends_actor_path_async(httpserver: HTTPServer, async_clien 'tarballUrl': _TARBALL_URL, 'actorPath': _ACTOR_PATH, } + assert version.actor_path == _ACTOR_PATH def test_create_omits_actor_path_when_not_passed(httpserver: HTTPServer, sync_client: ApifyClient) -> None: @@ -126,33 +128,35 @@ async def test_create_omits_actor_path_when_not_passed_async( def test_update_sends_actor_path(httpserver: HTTPServer, sync_client: ApifyClient) -> None: - """`update` sends `actor_path` as `actorPath`.""" + """`update` sends `actor_path` as `actorPath` and reads it back from the response.""" captured: list[Request] = [] httpserver.expect_request(_VERSION_PATH, method='PUT').respond_with_handler(capture(captured)) version_client = sync_client.actor(_MOCKED_ACTOR_ID).version(_VERSION_NUMBER) - version_client.update( + version = version_client.update( tarball_url=_TARBALL_URL, actor_path=_ACTOR_PATH, ) assert len(captured) == 1 assert request_body(captured[0]) == {'tarballUrl': _TARBALL_URL, 'actorPath': _ACTOR_PATH} + assert version.actor_path == _ACTOR_PATH async def test_update_sends_actor_path_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None: - """Async `update` sends `actor_path` as `actorPath`.""" + """Async `update` sends `actor_path` as `actorPath` and reads it back from the response.""" captured: list[Request] = [] httpserver.expect_request(_VERSION_PATH, method='PUT').respond_with_handler(capture(captured)) version_client = async_client.actor(_MOCKED_ACTOR_ID).version(_VERSION_NUMBER) - await version_client.update( + version = await version_client.update( tarball_url=_TARBALL_URL, actor_path=_ACTOR_PATH, ) assert len(captured) == 1 assert request_body(captured[0]) == {'tarballUrl': _TARBALL_URL, 'actorPath': _ACTOR_PATH} + assert version.actor_path == _ACTOR_PATH def test_update_omits_actor_path_when_not_passed(httpserver: HTTPServer, sync_client: ApifyClient) -> None: