|
2 | 2 |
|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
5 | | -from datetime import UTC, datetime |
| 5 | +from collections.abc import AsyncIterator, Iterator |
| 6 | +from datetime import UTC, datetime, timedelta |
6 | 7 | from typing import TYPE_CHECKING |
7 | 8 |
|
8 | 9 | from ._utils import get_random_resource_name, maybe_await |
9 | | -from apify_client._models import Actor, Build, ListOfActors, Run |
| 10 | +from apify_client._models import ( |
| 11 | + Actor, |
| 12 | + ActorChargeEvent, |
| 13 | + ActorShort, |
| 14 | + Build, |
| 15 | + ListOfActors, |
| 16 | + ListOfWebhooks, |
| 17 | + PayPerEventActorPricingInfo, |
| 18 | + PricePerDatasetItemActorPricingInfo, |
| 19 | + Run, |
| 20 | +) |
10 | 21 | from apify_client._resource_clients import BuildClient, BuildClientAsync |
11 | 22 |
|
12 | 23 | if TYPE_CHECKING: |
13 | 24 | from apify_client import ApifyClient, ApifyClientAsync |
14 | 25 |
|
| 26 | +# Actor carrying pricing-info entries for every non-trivial variant: FLAT_PRICE_PER_MONTH, |
| 27 | +# both flat and tiered PRICE_PER_DATASET_ITEM, and tiered PAY_PER_EVENT with |
| 28 | +# `isPrimaryEvent` / `isOneTimeEvent` fields. |
| 29 | +ALL_PRICING_VARIANTS_ACTOR = 'apify/facebook-pages-scraper' |
| 30 | + |
15 | 31 |
|
16 | 32 | async def test_get_public_actor(client: ApifyClient | ApifyClientAsync) -> None: |
17 | 33 | """Test getting a public Actor by ID.""" |
@@ -172,3 +188,243 @@ async def test_actor_validate_input(client: ApifyClient | ApifyClientAsync) -> N |
172 | 188 | # Valid input (hello-world accepts empty input or simple input) |
173 | 189 | is_valid = await maybe_await(actor_client.validate_input({})) |
174 | 190 | assert is_valid is True |
| 191 | + |
| 192 | + |
| 193 | +async def test_get_nonexistent_actor_returns_none(client: ApifyClient | ApifyClientAsync) -> None: |
| 194 | + """Test that getting a non-existent Actor returns None.""" |
| 195 | + actor = await maybe_await(client.actor('this-actor/does-not-exist-anywhere').get()) |
| 196 | + assert actor is None |
| 197 | + |
| 198 | + |
| 199 | +async def test_list_actors_desc_ascending(client: ApifyClient | ApifyClientAsync) -> None: |
| 200 | + """Test listing Actors sorted ascending (desc=False).""" |
| 201 | + actors_page = await maybe_await(client.actors().list(limit=10, desc=False, sort_by='stats.lastRunStartedAt')) |
| 202 | + assert isinstance(actors_page, ListOfActors) |
| 203 | + assert actors_page.items is not None |
| 204 | + |
| 205 | + # The API and Python may break ties on identical timestamps differently, so just verify the |
| 206 | + # sort key is monotonically non-decreasing rather than comparing to a locally re-sorted list. |
| 207 | + min_dt = datetime.min.replace(tzinfo=UTC) |
| 208 | + keys = [(a.stats.last_run_started_at if a.stats else None) or min_dt for a in actors_page.items] |
| 209 | + assert keys == sorted(keys) |
| 210 | + |
| 211 | + |
| 212 | +async def test_actors_iterate(client: ApifyClient | ApifyClientAsync, *, is_async: bool) -> None: |
| 213 | + """Test paginated iteration over user's Actors.""" |
| 214 | + iterator = client.actors().iterate(my=True, limit=10) |
| 215 | + collected: list[ActorShort] = [] |
| 216 | + if is_async: |
| 217 | + assert isinstance(iterator, AsyncIterator) |
| 218 | + async for a in iterator: |
| 219 | + assert isinstance(a, ActorShort) |
| 220 | + collected.append(a) |
| 221 | + else: |
| 222 | + assert isinstance(iterator, Iterator) |
| 223 | + for a in iterator: |
| 224 | + assert isinstance(a, ActorShort) |
| 225 | + collected.append(a) |
| 226 | + |
| 227 | + assert len(collected) <= 10 |
| 228 | + for actor in collected: |
| 229 | + assert actor.id is not None |
| 230 | + |
| 231 | + |
| 232 | +async def test_actor_start_with_options(client: ApifyClient | ApifyClientAsync) -> None: |
| 233 | + """Test starting an Actor with explicit run options.""" |
| 234 | + actor_client = client.actor('apify/hello-world') |
| 235 | + |
| 236 | + # Start the run with explicit build tag, memory, and timeout overrides |
| 237 | + run = await maybe_await( |
| 238 | + actor_client.start( |
| 239 | + build='latest', |
| 240 | + memory_mbytes=256, |
| 241 | + run_timeout=timedelta(seconds=120), |
| 242 | + wait_for_finish=60, |
| 243 | + ) |
| 244 | + ) |
| 245 | + assert isinstance(run, Run) |
| 246 | + assert run.id is not None |
| 247 | + assert run.options is not None |
| 248 | + assert run.options.memory_mbytes == 256 |
| 249 | + assert run.options.timeout_secs == 120 |
| 250 | + |
| 251 | + try: |
| 252 | + # Any terminal-or-in-progress status returned by the platform is acceptable here — |
| 253 | + # under load the run can briefly land in `TIMING-OUT`, `FAILED`, `ABORTING`, or `ABORTED` |
| 254 | + # without indicating a client-side bug. |
| 255 | + assert run.status in ( |
| 256 | + 'READY', |
| 257 | + 'RUNNING', |
| 258 | + 'SUCCEEDED', |
| 259 | + 'TIMED-OUT', |
| 260 | + 'TIMING-OUT', |
| 261 | + 'FAILED', |
| 262 | + 'ABORTING', |
| 263 | + 'ABORTED', |
| 264 | + ) |
| 265 | + finally: |
| 266 | + # Wait for run to finish before cleanup |
| 267 | + await maybe_await(client.run(run.id).wait_for_finish()) |
| 268 | + await maybe_await(client.run(run.id).delete()) |
| 269 | + |
| 270 | + |
| 271 | +async def test_actor_start_with_run_input(client: ApifyClient | ApifyClientAsync) -> None: |
| 272 | + """Test starting an Actor with a JSON run input.""" |
| 273 | + actor_client = client.actor('apify/hello-world') |
| 274 | + |
| 275 | + # Pass a custom input - hello-world accepts arbitrary input and echoes it in logs |
| 276 | + run = await maybe_await(actor_client.start(run_input={'message': 'integration-test-input'})) |
| 277 | + assert isinstance(run, Run) |
| 278 | + assert run.id is not None |
| 279 | + |
| 280 | + run_client = client.run(run.id) |
| 281 | + try: |
| 282 | + finished_run = await maybe_await(run_client.wait_for_finish()) |
| 283 | + assert isinstance(finished_run, Run) |
| 284 | + assert finished_run.status == 'SUCCEEDED' |
| 285 | + finally: |
| 286 | + await maybe_await(run_client.delete()) |
| 287 | + |
| 288 | + |
| 289 | +async def test_actor_call_with_input_and_build(client: ApifyClient | ApifyClientAsync) -> None: |
| 290 | + """Test calling an Actor with input and a specific build tag.""" |
| 291 | + actor_client = client.actor('apify/hello-world') |
| 292 | + |
| 293 | + run = await maybe_await( |
| 294 | + actor_client.call( |
| 295 | + run_input={'message': 'integration-test'}, |
| 296 | + build='latest', |
| 297 | + memory_mbytes=256, |
| 298 | + ) |
| 299 | + ) |
| 300 | + assert isinstance(run, Run) |
| 301 | + |
| 302 | + try: |
| 303 | + assert run.status == 'SUCCEEDED' |
| 304 | + finally: |
| 305 | + await maybe_await(client.run(run.id).delete()) |
| 306 | + |
| 307 | + |
| 308 | +async def test_actor_update_categories(client: ApifyClient | ApifyClientAsync) -> None: |
| 309 | + """Test updating an Actor's categories and seo_title fields.""" |
| 310 | + actor_name = get_random_resource_name('actor') |
| 311 | + |
| 312 | + created_actor = await maybe_await( |
| 313 | + client.actors().create( |
| 314 | + name=actor_name, |
| 315 | + title='Test Actor for Categories', |
| 316 | + ) |
| 317 | + ) |
| 318 | + assert isinstance(created_actor, Actor) |
| 319 | + actor_client = client.actor(created_actor.id) |
| 320 | + |
| 321 | + try: |
| 322 | + updated = await maybe_await( |
| 323 | + actor_client.update( |
| 324 | + categories=['MARKETING'], |
| 325 | + seo_title='SEO Test Title', |
| 326 | + seo_description='SEO Test Description', |
| 327 | + ) |
| 328 | + ) |
| 329 | + assert isinstance(updated, Actor) |
| 330 | + # `categories` and `seo_title` are not declared fields on the Actor model but are returned via |
| 331 | + # `extra='allow'` so we read them from the dumped representation. |
| 332 | + dumped = updated.model_dump(by_alias=True) |
| 333 | + assert dumped.get('categories') == ['MARKETING'] |
| 334 | + assert dumped.get('seoTitle') == 'SEO Test Title' |
| 335 | + finally: |
| 336 | + await maybe_await(actor_client.delete()) |
| 337 | + |
| 338 | + |
| 339 | +async def test_actor_webhooks(client: ApifyClient | ApifyClientAsync) -> None: |
| 340 | + """Test listing webhooks attached to an Actor.""" |
| 341 | + actor_name = get_random_resource_name('actor') |
| 342 | + |
| 343 | + created_actor = await maybe_await( |
| 344 | + client.actors().create( |
| 345 | + name=actor_name, |
| 346 | + title='Test Actor for Webhooks', |
| 347 | + ) |
| 348 | + ) |
| 349 | + assert isinstance(created_actor, Actor) |
| 350 | + actor_client = client.actor(created_actor.id) |
| 351 | + |
| 352 | + try: |
| 353 | + webhooks_page = await maybe_await(actor_client.webhooks().list()) |
| 354 | + assert isinstance(webhooks_page, ListOfWebhooks) |
| 355 | + # A fresh Actor has no webhooks attached. |
| 356 | + assert webhooks_page.items == [] |
| 357 | + finally: |
| 358 | + await maybe_await(actor_client.delete()) |
| 359 | + |
| 360 | + |
| 361 | +async def test_actor_default_build_wait_for_finish(client: ApifyClient | ApifyClientAsync) -> None: |
| 362 | + """Test default_build with explicit wait_for_finish parameter.""" |
| 363 | + actor_client = client.actor('apify/hello-world') |
| 364 | + |
| 365 | + build_client = await maybe_await(actor_client.default_build(wait_for_finish=1)) |
| 366 | + assert isinstance(build_client, BuildClient | BuildClientAsync) |
| 367 | + build = await maybe_await(build_client.get()) |
| 368 | + assert isinstance(build, Build) |
| 369 | + |
| 370 | + |
| 371 | +async def test_actor_get_parses_tiered_price_per_dataset_item(client: ApifyClient | ApifyClientAsync) -> None: |
| 372 | + """Test that actor.get() parses PRICE_PER_DATASET_ITEM entries with tieredPricing.""" |
| 373 | + actor = await maybe_await(client.actor(ALL_PRICING_VARIANTS_ACTOR).get()) |
| 374 | + assert isinstance(actor, Actor) |
| 375 | + assert actor.pricing_infos |
| 376 | + |
| 377 | + tiered_ppd_entries = [ |
| 378 | + info |
| 379 | + for info in actor.pricing_infos |
| 380 | + if isinstance(info, PricePerDatasetItemActorPricingInfo) and info.tiered_pricing is not None |
| 381 | + ] |
| 382 | + assert tiered_ppd_entries, ( |
| 383 | + f'{ALL_PRICING_VARIANTS_ACTOR} should have at least one tiered PRICE_PER_DATASET_ITEM entry — ' |
| 384 | + 'pick a different actor if pricing changed.' |
| 385 | + ) |
| 386 | + |
| 387 | + # Fixture-drift guard: tiered pricing is only meaningful when it has more than one tier |
| 388 | + # and the tiers actually differ in price. A degenerate single-tier or all-zero payload |
| 389 | + # would silently look like flat pricing. |
| 390 | + for entry in tiered_ppd_entries: |
| 391 | + assert entry.tiered_pricing is not None |
| 392 | + assert len(entry.tiered_pricing) >= 2, ( |
| 393 | + f'{ALL_PRICING_VARIANTS_ACTOR} tiered PPD entry has only {len(entry.tiered_pricing)} tier(s); ' |
| 394 | + 'expected multiple tiers (e.g. FREE/BRONZE/SILVER/GOLD/PLATINUM/DIAMOND).' |
| 395 | + ) |
| 396 | + distinct_prices = {t.tiered_price_per_unit_usd for t in entry.tiered_pricing.values()} |
| 397 | + assert len(distinct_prices) >= 2, ( |
| 398 | + f'{ALL_PRICING_VARIANTS_ACTOR} tiered PPD entry has all-identical prices ({distinct_prices}); ' |
| 399 | + 'tiers should differ.' |
| 400 | + ) |
| 401 | + |
| 402 | + |
| 403 | +async def test_actor_get_parses_tiered_pay_per_event(client: ApifyClient | ApifyClientAsync) -> None: |
| 404 | + """Test that actor.get() parses tiered PAY_PER_EVENT events with isPrimaryEvent and isOneTimeEvent flags.""" |
| 405 | + actor = await maybe_await(client.actor(ALL_PRICING_VARIANTS_ACTOR).get()) |
| 406 | + assert isinstance(actor, Actor) |
| 407 | + assert actor.pricing_infos |
| 408 | + |
| 409 | + tiered_ppe_events: list[ActorChargeEvent] = [] |
| 410 | + for info in actor.pricing_infos: |
| 411 | + if not isinstance(info, PayPerEventActorPricingInfo): |
| 412 | + continue |
| 413 | + events = info.pricing_per_event.actor_charge_events or {} |
| 414 | + tiered_ppe_events.extend(event for event in events.values() if event.event_tiered_pricing_usd is not None) |
| 415 | + |
| 416 | + assert tiered_ppe_events, ( |
| 417 | + f'{ALL_PRICING_VARIANTS_ACTOR} should have at least one tiered PAY_PER_EVENT event — ' |
| 418 | + 'pick a different actor if pricing changed.' |
| 419 | + ) |
| 420 | + # Because every model uses `extra='allow'`, a regenerator that drops either alias would |
| 421 | + # silently absorb the JSON key into `model_extra`. Asserting the typed attribute is |
| 422 | + # populated catches that drift. |
| 423 | + assert any(event.is_primary_event is True for event in tiered_ppe_events), ( |
| 424 | + f'{ALL_PRICING_VARIANTS_ACTOR}: no tiered PPE event has is_primary_event == True. ' |
| 425 | + 'The isPrimaryEvent alias may have been dropped from the model.' |
| 426 | + ) |
| 427 | + assert any(event.is_one_time_event is not None for event in tiered_ppe_events), ( |
| 428 | + f'{ALL_PRICING_VARIANTS_ACTOR}: no tiered PPE event has is_one_time_event populated. ' |
| 429 | + 'The isOneTimeEvent alias may have been dropped from the model.' |
| 430 | + ) |
0 commit comments