Skip to content
Draft
16 changes: 16 additions & 0 deletions src/apify_client/_resource_clients/actor_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/apify_client/_resource_clients/actor_version_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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
66 changes: 66 additions & 0 deletions tests/integration/test_actor_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
185 changes: 185 additions & 0 deletions tests/unit/test_actor_version_fields.py
Original file line number Diff line number Diff line change
@@ -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}
Loading