diff --git a/src/apify_client/_resource_clients/actor_version.py b/src/apify_client/_resource_clients/actor_version.py index 503e610a..28e76140 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,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, only for `'SOURCE_FILES'` and `'TARBALL'` + 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 + remove it without a new upload, pass the current `source_type` again. timeout: Timeout for the API HTTP request. Returns: @@ -111,6 +118,7 @@ def update( git_repo_url=git_repo_url, tarball_url=tarball_url, github_gist_url=github_gist_url, + 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 @@ -192,6 +200,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 +222,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, only for `'SOURCE_FILES'` and `'TARBALL'` + 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 + remove it without a new upload, pass the current `source_type` again. timeout: Timeout for the API HTTP request. Returns: @@ -227,6 +242,7 @@ async def update( git_repo_url=git_repo_url, tarball_url=tarball_url, github_gist_url=github_gist_url, + 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 9bed8fd5..3a09a6b6 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,10 @@ 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, only for `'SOURCE_FILES'` and `'TARBALL'` + 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. Returns: @@ -127,6 +132,7 @@ def create( git_repo_url=git_repo_url, tarball_url=tarball_url, github_gist_url=github_gist_url, + 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 @@ -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,10 @@ 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, only for `'SOURCE_FILES'` and `'TARBALL'` + 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. Returns: @@ -233,6 +244,7 @@ async def create( git_repo_url=git_repo_url, tarball_url=tarball_url, github_gist_url=github_gist_url, + 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 diff --git a/tests/integration/test_actor_version.py b/tests/integration/test_actor_version.py index e422857b..f39c5947 100644 --- a/tests/integration/test_actor_version.py +++ b/tests/integration/test_actor_version.py @@ -151,6 +151,72 @@ async def test_actor_version_update(client: ApifyClient | ApifyClientAsync) -> N await maybe_await(actor_client.delete()) +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') + 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.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.actor_path == 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.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.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.actor_path 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..b91dd8a4 --- /dev/null +++ b/tests/unit/test_actor_version_fields.py @@ -0,0 +1,185 @@ +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` 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() + version = 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, + } + 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` 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() + version = 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, + } + assert version.actor_path == _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` 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 = 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` 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) + 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: + """`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}