diff --git a/src/apify/_actor.py b/src/apify/_actor.py index 150cf924..525e9c01 100644 --- a/src/apify/_actor.py +++ b/src/apify/_actor.py @@ -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 @@ -511,25 +511,31 @@ 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( @@ -537,12 +543,12 @@ async def fail( *, 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`). @@ -550,7 +556,8 @@ async def fail( 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, diff --git a/src/apify/_utils.py b/src/apify/_utils.py index 323bd915..1492a13e 100644 --- a/src/apify/_utils.py +++ b/src/apify/_utils.py @@ -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 @@ -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' diff --git a/tests/unit/actor/test_actor_lifecycle.py b/tests/unit/actor/test_actor_lifecycle.py index 19e4ea99..4ad2cbf1 100644 --- a/tests/unit/actor/test_actor_lifecycle.py +++ b/tests/unit/actor/test_actor_lifecycle.py @@ -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)