Skip to content
Merged
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
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