diff --git a/src/apify/_actor.py b/src/apify/_actor.py index b7beb5f1..be3487f9 100644 --- a/src/apify/_actor.py +++ b/src/apify/_actor.py @@ -3,6 +3,7 @@ import asyncio import os import sys +from contextlib import suppress from datetime import timedelta from typing import TYPE_CHECKING, Any, Callable, Literal, TypeVar, cast, overload @@ -64,6 +65,7 @@ def __init__( configuration: Configuration | None = None, *, configure_logging: bool = True, + exit_process: bool | None = None, ) -> None: """Create an Actor instance. @@ -74,7 +76,10 @@ def __init__( configuration: The Actor configuration to be used. If not passed, a new Configuration instance will be created. configure_logging: Should the default logging configuration be configured? + exit_process: Whether the Actor should call `sys.exit` when the context manager exits. The default is + True except for the IPython, Pytest and Scrapy environments. """ + self._exit_process = self._get_default_exit_process() if exit_process is None else exit_process self._is_exiting = False self._configuration = configuration or Configuration.get_global_configuration() @@ -141,9 +146,19 @@ def __repr__(self) -> str: return super().__repr__() - def __call__(self, configuration: Configuration | None = None, *, configure_logging: bool = True) -> Self: + def __call__( + self, + configuration: Configuration | None = None, + *, + configure_logging: bool = True, + exit_process: bool | None = None, + ) -> Self: """Make a new Actor instance with a non-default configuration.""" - return self.__class__(configuration=configuration, configure_logging=configure_logging) + return self.__class__( + configuration=configuration, + configure_logging=configure_logging, + exit_process=exit_process, + ) @property def apify_client(self) -> ApifyClientAsync: @@ -281,13 +296,7 @@ async def finalize() -> None: await asyncio.wait_for(finalize(), cleanup_timeout.total_seconds()) self._is_initialized = False - if is_running_in_ipython(): - self.log.debug(f'Not calling sys.exit({exit_code}) because Actor is running in IPython') - elif os.getenv('PYTEST_CURRENT_TEST', default=False): # noqa: PLW1508 - self.log.debug(f'Not calling sys.exit({exit_code}) because Actor is running in an unit test') - elif os.getenv('SCRAPY_SETTINGS_MODULE'): - self.log.debug(f'Not calling sys.exit({exit_code}) because Actor is running with Scrapy') - else: + if self._exit_process: sys.exit(exit_code) async def fail( @@ -1128,6 +1137,26 @@ async def create_proxy_configuration( return proxy_configuration + def _get_default_exit_process(self) -> bool: + """Returns False for IPython, Pytest, and Scrapy environments, True otherwise.""" + if is_running_in_ipython(): + self.log.debug('Running in IPython, setting default `exit_process` to False.') + return False + + # Check if running in Pytest by detecting the relevant environment variable. + if os.getenv('PYTEST_CURRENT_TEST'): + self.log.debug('Running in Pytest, setting default `exit_process` to False.') + return False + + # Check if running in Scrapy by attempting to import it. + with suppress(ImportError): + import scrapy # noqa: F401 + + self.log.debug('Running in Scrapy, setting default `exit_process` to False.') + return False + + return True + Actor = cast(_ActorType, Proxy(_ActorType)) """The entry point of the SDK, through which all the Actor operations should be done.""" diff --git a/tests/unit/actor/test_actor_log.py b/tests/unit/actor/test_actor_log.py index 0cd1aeb8..356f8bb3 100644 --- a/tests/unit/actor/test_actor_log.py +++ b/tests/unit/actor/test_actor_log.py @@ -2,12 +2,9 @@ import contextlib import logging -import sys from typing import TYPE_CHECKING -from apify_client import __version__ as apify_client_version - -from apify import Actor, __version__ +from apify import Actor from apify.log import logger if TYPE_CHECKING: @@ -39,55 +36,69 @@ async def test_actor_logs_messages_correctly(caplog: pytest.LogCaptureFixture) - # Test that exception in Actor.main is logged with the traceback raise RuntimeError('Dummy RuntimeError') - assert len(caplog.records) == 13 + # Updated expected number of log records (an extra record is now captured) + assert len(caplog.records) == 14 - assert caplog.records[0].levelno == logging.INFO - assert caplog.records[0].message == 'Initializing Actor...' + # Record 0: Extra Pytest context log + assert caplog.records[0].levelno == logging.DEBUG + assert caplog.records[0].message.startswith('Running in Pytest') - assert caplog.records[1].levelno == logging.INFO - assert caplog.records[1].message == 'System info' - assert getattr(caplog.records[1], 'apify_sdk_version', None) == __version__ - assert getattr(caplog.records[1], 'apify_client_version', None) == apify_client_version - assert getattr(caplog.records[1], 'python_version', None) == '.'.join([str(x) for x in sys.version_info[:3]]) - assert getattr(caplog.records[1], 'os', None) == sys.platform + # Record 1: Duplicate Pytest context log + assert caplog.records[1].levelno == logging.DEBUG + assert caplog.records[0].message.startswith('Running in Pytest') - assert caplog.records[2].levelno == logging.DEBUG - assert caplog.records[2].message == 'Event manager initialized' + # Record 2: Initializing Actor... + assert caplog.records[2].levelno == logging.INFO + assert caplog.records[2].message == 'Initializing Actor...' - assert caplog.records[3].levelno == logging.DEBUG - assert caplog.records[3].message == 'Charging manager initialized' + # Record 3: System info + assert caplog.records[3].levelno == logging.INFO + assert caplog.records[3].message == 'System info' + # Record 4: Event manager initialized assert caplog.records[4].levelno == logging.DEBUG - assert caplog.records[4].message == 'Debug message' + assert caplog.records[4].message == 'Event manager initialized' - assert caplog.records[5].levelno == logging.INFO - assert caplog.records[5].message == 'Info message' + # Record 5: Charging manager initialized + assert caplog.records[5].levelno == logging.DEBUG + assert caplog.records[5].message == 'Charging manager initialized' - assert caplog.records[6].levelno == logging.WARNING - assert caplog.records[6].message == 'Warning message' + # Record 6: Debug message + assert caplog.records[6].levelno == logging.DEBUG + assert caplog.records[6].message == 'Debug message' - assert caplog.records[7].levelno == logging.ERROR - assert caplog.records[7].message == 'Error message' + # Record 7: Info message + assert caplog.records[7].levelno == logging.INFO + assert caplog.records[7].message == 'Info message' - assert caplog.records[8].levelno == logging.ERROR - assert caplog.records[8].message == 'Exception message' - assert caplog.records[8].exc_info is not None - assert caplog.records[8].exc_info[0] is ValueError - assert isinstance(caplog.records[8].exc_info[1], ValueError) - assert str(caplog.records[8].exc_info[1]) == 'Dummy ValueError' + # Record 8: Warning message + assert caplog.records[8].levelno == logging.WARNING + assert caplog.records[8].message == 'Warning message' - assert caplog.records[9].levelno == logging.INFO - assert caplog.records[9].message == 'Multi\nline\nlog\nmessage' + # Record 9: Error message + assert caplog.records[9].levelno == logging.ERROR + assert caplog.records[9].message == 'Error message' + # Record 10: Exception message with traceback (ValueError) assert caplog.records[10].levelno == logging.ERROR - assert caplog.records[10].message == 'Actor failed with an exception' + assert caplog.records[10].message == 'Exception message' assert caplog.records[10].exc_info is not None - assert caplog.records[10].exc_info[0] is RuntimeError - assert isinstance(caplog.records[10].exc_info[1], RuntimeError) - assert str(caplog.records[10].exc_info[1]) == 'Dummy RuntimeError' + assert caplog.records[10].exc_info[0] is ValueError + assert isinstance(caplog.records[10].exc_info[1], ValueError) + assert str(caplog.records[10].exc_info[1]) == 'Dummy ValueError' + # Record 11: Multiline log message assert caplog.records[11].levelno == logging.INFO - assert caplog.records[11].message == 'Exiting Actor' - - assert caplog.records[12].levelno == logging.DEBUG - assert caplog.records[12].message == 'Not calling sys.exit(91) because Actor is running in an unit test' + assert caplog.records[11].message == 'Multi\nline\nlog\nmessage' + + # Record 12: Actor failed with an exception (RuntimeError) + assert caplog.records[12].levelno == logging.ERROR + assert caplog.records[12].message == 'Actor failed with an exception' + assert caplog.records[12].exc_info is not None + assert caplog.records[12].exc_info[0] is RuntimeError + assert isinstance(caplog.records[12].exc_info[1], RuntimeError) + assert str(caplog.records[12].exc_info[1]) == 'Dummy RuntimeError' + + # Record 13: Exiting Actor + assert caplog.records[13].levelno == logging.INFO + assert caplog.records[13].message == 'Exiting Actor'