Skip to content
Closed
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
33 changes: 20 additions & 13 deletions src/apify/_actor.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
from apify._consts import EVENT_LISTENERS_TIMEOUT, EXIT_CODE_ERROR_USER_FUNCTION_THREW, ActorEnvVars, ApifyEnvVars
from apify._crypto import decrypt_input_secrets, load_private_key
from apify._proxy_configuration import ProxyConfiguration
from apify._utils import docs_group, docs_name, ensure_context, get_system_info, is_running_in_ipython
from apify._utils import Unset, docs_group, docs_name, ensure_context, get_system_info, is_running_in_ipython
from apify._webhook import to_client_representations
from apify.events import ApifyEventManager, EventManager, LocalEventManager
from apify.log import _configure_logging, logger
Expand Down Expand Up @@ -511,46 +511,53 @@ async def init(self) -> None:
async def exit(
self,
*,
exit_code: int = 0,
status_message: str | None = None,
event_listeners_timeout: timedelta | None = EVENT_LISTENERS_TIMEOUT,
cleanup_timeout: timedelta = timedelta(seconds=30),
exit_code: int | Unset = Unset.UNSET,
status_message: str | Unset | None = Unset.UNSET,
event_listeners_timeout: timedelta | Unset | None = Unset.UNSET,
cleanup_timeout: timedelta | Unset = Unset.UNSET,
) -> None:
"""Exit the Actor without using context-manager syntax.

Equivalent to `await Actor.__aexit__()`.
Equivalent to `await Actor.__aexit__()`. Omitted arguments keep their current values, set through the
constructor or the `exit_code` and `status_message` properties.

Args:
exit_code: The exit code the Actor should use when exiting.
status_message: Final status message to display upon Actor termination.
event_listeners_timeout: Maximum time to wait for Actor event listeners to complete before exiting.
`None` means not waiting for them.
cleanup_timeout: Maximum time to wait for cleanup tasks to finish.
"""
self.exit_code = exit_code
self.status_message = status_message
self._event_listeners_timeout = event_listeners_timeout
self._cleanup_timeout = cleanup_timeout
if not isinstance(exit_code, Unset):
self.exit_code = exit_code
if not isinstance(status_message, Unset):
self.status_message = status_message
if not isinstance(event_listeners_timeout, Unset):
self._event_listeners_timeout = event_listeners_timeout
if not isinstance(cleanup_timeout, Unset):
self._cleanup_timeout = cleanup_timeout
await self.__aexit__(None, None, None)

async def fail(
self,
*,
exit_code: int = 1,
exception: BaseException | None = None,
status_message: str | None = None,
status_message: str | Unset | None = Unset.UNSET,
) -> None:
"""Fail the Actor instance without using context-manager syntax.

Equivalent to setting the `self.exit_code` and `self.status_message` properties and using
`await Actor.__aexit__()`.
`await Actor.__aexit__()`. An omitted `status_message` keeps its current value.

Args:
exit_code: The exit code with which the Actor should fail (defaults to `1`).
exception: The exception with which the Actor failed.
status_message: The final status message that the Actor should display.
"""
self.exit_code = exit_code
self.status_message = status_message
if not isinstance(status_message, Unset):
self.status_message = status_message

await self.__aexit__(
exc_type=type(exception) if exception else None,
Expand Down
14 changes: 14 additions & 0 deletions src/apify/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import sys
from collections.abc import AsyncGenerator, Callable
from contextlib import asynccontextmanager
from enum import Enum
from functools import wraps
from importlib import metadata
from typing import Any, Literal, TypeVar, cast
Expand Down Expand Up @@ -142,3 +143,16 @@ async def __call__(self) -> AsyncGenerator[None]:
yield
finally:
self._owner = None


class Unset(Enum):
"""Marker for an argument the caller didn't pass, used as its default value.

It tells "not passed" apart from every value a caller can pass. `None` can't be the marker where `None` is itself
a meaningful argument, such as `event_listeners_timeout=None` meaning "don't wait for listeners". A string such as
`'UNSET'` can't be either, since a caller could pass that same string for a `str` argument. A single-member enum
compares equal to nothing else, and type checkers narrow `isinstance(value, Unset)`, so the rest of the function
sees the argument's real type.
"""

UNSET = 'UNSET'
55 changes: 55 additions & 0 deletions tests/unit/actor/test_actor_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,61 @@ async def test_fail_sets_custom_exit_code_and_status_message(actor: _ActorType)
assert actor.status_message == 'Failing with code 99'


async def test_exit_keeps_values_from_constructor() -> None:
"""A bare `exit()` keeps the exit code, status message and timeouts passed to the constructor."""
actor = Actor(
exit_process=False,
exit_code=3,
status_message='Set in constructor',
event_listeners_timeout=None,
cleanup_timeout=timedelta(seconds=5),
)
await actor.init()
await actor.exit()
assert actor.exit_code == 3
assert actor.status_message == 'Set in constructor'
assert actor._event_listeners_timeout is None
assert actor._cleanup_timeout == timedelta(seconds=5)


async def test_exit_keeps_values_set_through_properties() -> None:
"""A bare `exit()` keeps the exit code and status message set through the properties."""
actor = Actor(exit_process=False)
await actor.init()
actor.exit_code = 3
actor.status_message = 'Scraped 120 items'
await actor.exit()
assert actor.exit_code == 3
assert actor.status_message == 'Scraped 120 items'


async def test_exit_arguments_override_earlier_values() -> None:
"""Arguments passed to `exit()` replace the values set earlier."""
actor = Actor(exit_process=False, exit_code=3, status_message='Set in constructor')
await actor.init()
await actor.exit(
exit_code=0,
status_message=None,
event_listeners_timeout=None,
cleanup_timeout=timedelta(seconds=5),
)
assert actor.exit_code == 0
assert actor.status_message is None
assert actor._event_listeners_timeout is None
assert actor._cleanup_timeout == timedelta(seconds=5)


async def test_fail_without_arguments_keeps_status_message() -> None:
"""A bare `fail()` exits with 1 and keeps the status message set earlier."""
actor = Actor(exit_process=False)
await actor.init()
actor.exit_code = 5
actor.status_message = 'Set earlier'
await actor.fail()
assert actor.exit_code == 1
assert actor.status_message == 'Set earlier'


async def test_unhandled_exception_sets_error_exit_code() -> None:
"""Test that unhandled exceptions in context manager set the error exit code."""
actor = Actor(exit_process=False)
Expand Down
Loading