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
32 changes: 31 additions & 1 deletion tests/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,20 @@

import asyncio
import inspect
import os
import secrets
import string
import time
from collections.abc import AsyncIterator, Iterator
from collections.abc import AsyncGenerator, AsyncIterator, Iterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Protocol, TypeVar, cast, overload

import pytest

from apify_client import ApifyClient, ApifyClientAsync
from apify_client._consts import DEFAULT_API_URL

if TYPE_CHECKING:
from collections.abc import Awaitable, Callable

Expand Down Expand Up @@ -65,6 +70,31 @@ class HttpClientClasses:
async_: type[HttpClientAsync]


@asynccontextmanager
async def make_client(
token: str, client_type: str, http_client_classes: HttpClientClasses
) -> AsyncGenerator[ApifyClient | ApifyClientAsync]:
"""Build a sync or async client with the given token and HTTP client implementation, closing it on exit."""
api_url = os.getenv(API_URL_ENV_VAR) or DEFAULT_API_URL
if client_type == 'sync':
http_client = http_client_classes.sync()
yield ApifyClient.with_custom_http_client(
token,
api_url=api_url,
http_client=http_client,
)
http_client.close()
return

http_client_async = http_client_classes.async_()
yield ApifyClientAsync.with_custom_http_client(
token,
api_url=api_url,
http_client=http_client_async,
)
await http_client_async.aclose()


def get_crypto_random_object_id(length: int = 17) -> str:
"""Generate a cryptographically secure random object ID."""
chars = 'abcdefghijklmnopqrstuvwxyzABCEDFGHIJKLMNOPQRSTUVWXYZ0123456789'
Expand Down
21 changes: 3 additions & 18 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
HttpClientClasses,
KvsFixture,
get_crypto_random_object_id,
make_client,
)
from apify_client import ApifyClient, ApifyClientAsync
from apify_client._consts import DEFAULT_API_URL
Expand Down Expand Up @@ -132,24 +133,8 @@ async def client(
http_client_classes: HttpClientClasses,
) -> AsyncGenerator[ApifyClient | ApifyClientAsync]:
"""Return each sync/async and HTTP client implementation combination."""
api_url = os.getenv(API_URL_ENV_VAR) or DEFAULT_API_URL
if client_type == 'sync':
http_client = http_client_classes.sync()
yield ApifyClient.with_custom_http_client(
api_token,
api_url=api_url,
http_client=http_client,
)
http_client.close()
return

http_client_async = http_client_classes.async_()
yield ApifyClientAsync.with_custom_http_client(
api_token,
api_url=api_url,
http_client=http_client_async,
)
await http_client_async.aclose()
async with make_client(api_token, client_type, http_client_classes) as client:
yield client


@pytest.fixture
Expand Down
107 changes: 70 additions & 37 deletions tests/integration/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING

from .._utils import get_random_resource_name, maybe_await, poll_until_condition
from .._utils import HttpClientClasses, get_random_resource_name, make_client, maybe_await, poll_until_condition
from apify_client._models import (
ActorResource,
Build,
Expand Down Expand Up @@ -316,48 +316,81 @@ async def test_run_runs_client(client: ApifyClient | ApifyClientAsync) -> None:
assert first_run.act_id is not None


async def test_run_metamorph(client: ApifyClient | ApifyClientAsync) -> None:
"""Test metamorphing a run into another Actor."""
# Start an actor that will run long enough to metamorph. We use hello-world and try to metamorph it into itself
actor = client.actor(HELLO_WORLD_ACTOR)
run = await maybe_await(actor.start())
assert isinstance(run, Run)
assert run.id is not None

run_client = client.run(run.id)

try:
# Wait until the run starts (leaves READY), with exponential backoff: container startup time varies widely
async def get_run() -> Run | None:
return await maybe_await(run_client.get())

await poll_until_condition(
get_run, lambda run: isinstance(run, Run) and run.status != 'READY', backoff_factor=2
async def test_run_metamorph(client_type: str, http_client_classes: HttpClientClasses, api_token: str) -> None:
"""Test metamorphing a run into another Actor.

We have to cheat a bit - metamorphing from outside is disallowed, so we need the Actor to leak its run token into
the key-value store, which we then use to construct a new client and do the test we want.
"""
async with make_client(api_token, client_type, http_client_classes) as default_client:
created_actor = await maybe_await(
default_client.actors().create(
name=get_random_resource_name('actor'),
versions=[
{
'versionNumber': '0.0',
'sourceType': 'SOURCE_FILES',
'buildTag': 'latest',
# The Actor stores its own run token in its key-value store, so the test can metamorph with it.
'sourceFiles': [
{
'name': 'Dockerfile',
'format': 'TEXT',
'content': 'FROM apify/actor-node:22\nCOPY . ./\nCMD ["node", "main.js"]\n',
},
{
'name': 'main.js',
'format': 'TEXT',
'content': """
const { Actor } = require('apify');
Actor.main(async () => {
await Actor.setValue(
'TOKEN', process.env.APIFY_TOKEN, { contentType: 'text/plain' }
);
await new Promise((resolve) => setTimeout(resolve, 60000));
});
""",
},
],
}
],
)
)
assert isinstance(created_actor, ActorResource)
actor_client = default_client.actor(created_actor.id)
run_client = None

# Metamorph the run into the same actor (allowed) with new input
# Note: hello-world may finish before we can metamorph, so we handle that case
try:
metamorphed_run = await maybe_await(
run_client.metamorph(
target_actor_id=HELLO_WORLD_ACTOR,
run_input={'message': 'Hello from metamorph!'},
# build and run the Actor
started_build = await maybe_await(actor_client.build(version_number='0.0'))
assert isinstance(started_build, Build)
await maybe_await(default_client.build(started_build.id).wait_for_finish())
run = await maybe_await(actor_client.start())
assert isinstance(run, Run)
run_client = default_client.run(run.id)

# get the run token
async def get_token_record() -> dict | None:
return await maybe_await(
default_client.key_value_store(run.default_key_value_store_id).get_record('TOKEN')
)
)
assert isinstance(metamorphed_run, Run)
assert metamorphed_run.id == run.id # Same run ID

# Wait for the metamorphed run to finish
final_run = await maybe_await(run_client.wait_for_finish())
assert isinstance(final_run, Run)
except ApifyApiError as exc:
# If the actor finished before we could metamorph, that's OK - the test still verified the API call
if 'already finished' not in str(exc):
raise
record = await poll_until_condition(get_token_record, lambda rec: rec is not None, backoff_factor=2)
assert record is not None

finally:
await maybe_await(run_client.wait_for_finish())
await maybe_await(run_client.delete())
# the actual test
async with make_client(record['value'], client_type, http_client_classes) as run_token_client:
metamorphed_run = await maybe_await(
run_token_client.run(run.id).metamorph(target_actor_id=HELLO_WORLD_ACTOR)
)
assert isinstance(metamorphed_run, Run)
assert metamorphed_run.id == run.id
finally:
if run_client is not None:
await maybe_await(run_client.abort())
await maybe_await(run_client.wait_for_finish())
await maybe_await(run_client.delete())
await maybe_await(actor_client.delete())


async def test_run_reboot(client: ApifyClient | ApifyClientAsync) -> None:
Expand Down
Loading