From a48c7e2f7551f9dcb1139bb7524067003b58c3ac Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Fri, 16 Oct 2026 12:25:29 +0200 Subject: [PATCH 1/4] fix: keep earlier exit code and status message in Actor.exit() and Actor.fail() --- src/apify/_actor.py | 52 ++++++++++++++------ tests/unit/actor/test_actor_lifecycle.py | 62 ++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 15 deletions(-) diff --git a/src/apify/_actor.py b/src/apify/_actor.py index 150cf924..0168e768 100644 --- a/src/apify/_actor.py +++ b/src/apify/_actor.py @@ -7,6 +7,7 @@ import warnings from dataclasses import asdict from datetime import UTC, datetime, timedelta +from enum import Enum from functools import cached_property, partial from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast, overload @@ -68,6 +69,15 @@ _ensure_context = ensure_context('_active') +class _Unset(Enum): + """Default of `exit()` and `fail()` arguments, marking an argument the caller didn't pass.""" + + UNSET = 'UNSET' + + +_UNSET = _Unset.UNSET + + def _warn_max_items_deprecated() -> None: warnings.warn( '`max_items` is deprecated and will be removed in version 5.0.0. It works only with legacy pay-per-result ' @@ -511,46 +521,58 @@ 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, + status_message: str | _Unset | None = _UNSET, + event_listeners_timeout: timedelta | _Unset | None = _UNSET, + cleanup_timeout: timedelta | _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, + exit_code: int | _Unset = _UNSET, exception: BaseException | None = None, - status_message: str | None = None, + status_message: str | _Unset | None = _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__()`. Omitted arguments keep their current values, except that an exit code of `0` + becomes `1`. Args: - exit_code: The exit code with which the Actor should fail (defaults to `1`). + exit_code: The exit code with which the Actor should fail. Defaults to the current exit code if it is + non-zero, or `1` otherwise. 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(exit_code, _Unset): + self.exit_code = exit_code + elif self.exit_code == 0: + self.exit_code = 1 + 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/tests/unit/actor/test_actor_lifecycle.py b/tests/unit/actor/test_actor_lifecycle.py index 19e4ea99..f46d8b28 100644 --- a/tests/unit/actor/test_actor_lifecycle.py +++ b/tests/unit/actor/test_actor_lifecycle.py @@ -219,6 +219,68 @@ 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) + + +@pytest.mark.parametrize( + ('earlier_exit_code', 'expected_exit_code'), + [ + pytest.param(0, 1, id='zero becomes 1'), + pytest.param(5, 5, id='non-zero is kept'), + ], +) +async def test_fail_without_arguments_exit_code(earlier_exit_code: int, expected_exit_code: int) -> None: + """A bare `fail()` exits with 1 unless a non-zero exit code was set earlier.""" + actor = Actor(exit_process=False) + await actor.init() + actor.exit_code = earlier_exit_code + actor.status_message = 'Set earlier' + await actor.fail() + assert actor.exit_code == expected_exit_code + 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) From 959d383a0497f63dd3de2cf20e026f9f1fc2985d Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Fri, 16 Oct 2026 12:43:58 +0200 Subject: [PATCH 2/4] refactor: move the Unset argument marker to _utils --- src/apify/_actor.py | 36 +++++++++++++----------------------- src/apify/_utils.py | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/src/apify/_actor.py b/src/apify/_actor.py index 0168e768..64724f84 100644 --- a/src/apify/_actor.py +++ b/src/apify/_actor.py @@ -7,7 +7,6 @@ import warnings from dataclasses import asdict from datetime import UTC, datetime, timedelta -from enum import Enum from functools import cached_property, partial from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast, overload @@ -41,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, 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 @@ -69,15 +68,6 @@ _ensure_context = ensure_context('_active') -class _Unset(Enum): - """Default of `exit()` and `fail()` arguments, marking an argument the caller didn't pass.""" - - UNSET = 'UNSET' - - -_UNSET = _Unset.UNSET - - def _warn_max_items_deprecated() -> None: warnings.warn( '`max_items` is deprecated and will be removed in version 5.0.0. It works only with legacy pay-per-result ' @@ -521,10 +511,10 @@ async def init(self) -> None: async def exit( self, *, - exit_code: int | _Unset = _UNSET, - status_message: str | _Unset | None = _UNSET, - event_listeners_timeout: timedelta | _Unset | None = _UNSET, - cleanup_timeout: timedelta | _Unset = _UNSET, + exit_code: int | Unset = UNSET, + status_message: str | Unset | None = UNSET, + event_listeners_timeout: timedelta | Unset | None = UNSET, + cleanup_timeout: timedelta | Unset = UNSET, ) -> None: """Exit the Actor without using context-manager syntax. @@ -538,22 +528,22 @@ async def exit( `None` means not waiting for them. cleanup_timeout: Maximum time to wait for cleanup tasks to finish. """ - if not isinstance(exit_code, _Unset): + if not isinstance(exit_code, Unset): self.exit_code = exit_code - if not isinstance(status_message, _Unset): + if not isinstance(status_message, Unset): self.status_message = status_message - if not isinstance(event_listeners_timeout, _Unset): + if not isinstance(event_listeners_timeout, Unset): self._event_listeners_timeout = event_listeners_timeout - if not isinstance(cleanup_timeout, _Unset): + if not isinstance(cleanup_timeout, Unset): self._cleanup_timeout = cleanup_timeout await self.__aexit__(None, None, None) async def fail( self, *, - exit_code: int | _Unset = _UNSET, + exit_code: int | Unset = UNSET, exception: BaseException | None = None, - status_message: str | _Unset | None = _UNSET, + status_message: str | Unset | None = UNSET, ) -> None: """Fail the Actor instance without using context-manager syntax. @@ -567,11 +557,11 @@ async def fail( exception: The exception with which the Actor failed. status_message: The final status message that the Actor should display. """ - if not isinstance(exit_code, _Unset): + if not isinstance(exit_code, Unset): self.exit_code = exit_code elif self.exit_code == 0: self.exit_code = 1 - if not isinstance(status_message, _Unset): + if not isinstance(status_message, Unset): self.status_message = status_message await self.__aexit__( diff --git a/src/apify/_utils.py b/src/apify/_utils.py index 323bd915..f1a64e71 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,19 @@ 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' + + +UNSET = Unset.UNSET From 8178386eb0f44293da46adcfac961f0023d3b92c Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Fri, 16 Oct 2026 13:11:02 +0200 Subject: [PATCH 3/4] refactor: use Unset.UNSET directly as the default of exit() and fail() arguments --- src/apify/_actor.py | 14 +++++++------- src/apify/_utils.py | 3 --- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/apify/_actor.py b/src/apify/_actor.py index 64724f84..7fcf5ad4 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 UNSET, Unset, 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,10 +511,10 @@ async def init(self) -> None: async def exit( self, *, - exit_code: int | Unset = UNSET, - status_message: str | Unset | None = UNSET, - event_listeners_timeout: timedelta | Unset | None = UNSET, - cleanup_timeout: timedelta | Unset = UNSET, + 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. @@ -541,9 +541,9 @@ async def exit( async def fail( self, *, - exit_code: int | Unset = UNSET, + exit_code: int | Unset = Unset.UNSET, exception: BaseException | None = None, - status_message: str | Unset | None = UNSET, + status_message: str | Unset | None = Unset.UNSET, ) -> None: """Fail the Actor instance without using context-manager syntax. diff --git a/src/apify/_utils.py b/src/apify/_utils.py index f1a64e71..1492a13e 100644 --- a/src/apify/_utils.py +++ b/src/apify/_utils.py @@ -156,6 +156,3 @@ class Unset(Enum): """ UNSET = 'UNSET' - - -UNSET = Unset.UNSET From 7786fda4a7c3023b95a6bf821662ad637776064a Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Fri, 16 Oct 2026 13:13:25 +0200 Subject: [PATCH 4/4] refactor: keep the plain exit code default of 1 in Actor.fail() --- src/apify/_actor.py | 13 ++++--------- tests/unit/actor/test_actor_lifecycle.py | 15 ++++----------- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/src/apify/_actor.py b/src/apify/_actor.py index 7fcf5ad4..525e9c01 100644 --- a/src/apify/_actor.py +++ b/src/apify/_actor.py @@ -541,26 +541,21 @@ async def exit( async def fail( self, *, - exit_code: int | Unset = Unset.UNSET, + exit_code: int = 1, exception: BaseException | 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__()`. Omitted arguments keep their current values, except that an exit code of `0` - becomes `1`. + `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 the current exit code if it is - non-zero, or `1` otherwise. + 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. """ - if not isinstance(exit_code, Unset): - self.exit_code = exit_code - elif self.exit_code == 0: - self.exit_code = 1 + self.exit_code = exit_code if not isinstance(status_message, Unset): self.status_message = status_message diff --git a/tests/unit/actor/test_actor_lifecycle.py b/tests/unit/actor/test_actor_lifecycle.py index f46d8b28..4ad2cbf1 100644 --- a/tests/unit/actor/test_actor_lifecycle.py +++ b/tests/unit/actor/test_actor_lifecycle.py @@ -263,21 +263,14 @@ async def test_exit_arguments_override_earlier_values() -> None: assert actor._cleanup_timeout == timedelta(seconds=5) -@pytest.mark.parametrize( - ('earlier_exit_code', 'expected_exit_code'), - [ - pytest.param(0, 1, id='zero becomes 1'), - pytest.param(5, 5, id='non-zero is kept'), - ], -) -async def test_fail_without_arguments_exit_code(earlier_exit_code: int, expected_exit_code: int) -> None: - """A bare `fail()` exits with 1 unless a non-zero exit code was set earlier.""" +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 = earlier_exit_code + actor.exit_code = 5 actor.status_message = 'Set earlier' await actor.fail() - assert actor.exit_code == expected_exit_code + assert actor.exit_code == 1 assert actor.status_message == 'Set earlier'