|
1 | 1 | import asyncio
|
2 | 2 | import json
|
3 | 3 | import logging
|
4 |
| -import time |
5 | 4 | from collections.abc import AsyncIterator
|
6 | 5 |
|
7 | 6 | import httpx
|
| 7 | +import pytest |
8 | 8 | import respx
|
9 |
| - |
| 9 | +from _pytest.logging import LogCaptureFixture |
| 10 | +from apify_shared.consts import ActorJobStatus |
10 | 11 |
|
11 | 12 | from apify_client import ApifyClientAsync
|
12 |
| -from apify_client.clients import RunClientAsync |
| 13 | +from apify_client._logging import RedirectLogFormatter |
| 14 | +from apify_client.clients.resource_clients.log import StreamedLogAsync |
13 | 15 |
|
| 16 | +_MOCKED_API_URL = 'https://example.com' |
| 17 | +_MOCKED_RUN_ID = 'mocked_run_id' |
| 18 | +_MOCKED_ACTOR_NAME = 'mocked_actor_name' |
| 19 | +_MOCKED_ACTOR_ID = 'mocked_actor_id' |
| 20 | +_MOCKED_ACTOR_LOGS = ( |
| 21 | + b'2025-05-13T07:24:12.588Z ACTOR: Pulling Docker image of build.\n' |
| 22 | + b'2025-05-13T07:24:12.686Z ACTOR: Creating Docker container.\n' |
| 23 | + b'2025-05-13T07:24:12.745Z ACTOR: Starting Docker container.', # Several logs merged into one message |
| 24 | + b'2025-05-13T07:24:14.132Z [apify] INFO multiline \n log', |
| 25 | + b'2025-05-13T07:25:14.132Z [apify] WARNING some warning', |
| 26 | + b'2025-05-13T07:26:14.132Z [apify] DEBUG c', |
| 27 | +) |
14 | 28 |
|
15 |
| -@respx.mock |
16 |
| -async def test_redirected_logs(caplog) -> None: |
17 |
| - """Test that redirected logs are formatted correctly.""" |
18 |
| - mocked_actor_logs_logs = ( |
19 |
| - b"2025-05-13T07:24:12.588Z ACTOR: Pulling Docker image of build.\n" |
20 |
| - b"2025-05-13T07:24:12.686Z ACTOR: Creating Docker container.\n" |
21 |
| - b"2025-05-13T07:24:12.745Z ACTOR: Starting Docker container.", # Several logs merged into one message |
22 |
| - b"2025-05-13T07:24:14.132Z [apify] INFO multiline \n log", |
23 |
| - b"2025-05-13T07:25:14.132Z [apify] WARNING some warning", |
24 |
| - b"2025-05-13T07:26:14.132Z [apify] DEBUG c") |
25 |
| - mocked_actor_name = "mocked_actor" |
26 |
| - mocked_run_id = "mocked_run_id" |
27 |
| - |
28 |
| - expected_logs_and_levels = [ |
29 |
| - ("2025-05-13T07:24:12.588Z ACTOR: Pulling Docker image of build.", logging.INFO), |
30 |
| - ("2025-05-13T07:24:12.686Z ACTOR: Creating Docker container.", logging.INFO), |
31 |
| - ("2025-05-13T07:24:12.745Z ACTOR: Starting Docker container.", logging.INFO), |
32 |
| - ("2025-05-13T07:24:14.132Z [apify] INFO multiline \n log", logging.INFO), |
33 |
| - ("2025-05-13T07:25:14.132Z [apify] WARNING some warning", logging.WARNING), |
34 |
| - ("2025-05-13T07:26:14.132Z [apify] DEBUG c", logging.DEBUG), |
35 |
| - ] |
36 |
| - |
37 |
| - class AsyncByteStream: |
| 29 | +_EXPECTED_MESSAGES_AND_LEVELS = ( |
| 30 | + ('2025-05-13T07:24:12.588Z ACTOR: Pulling Docker image of build.', logging.INFO), |
| 31 | + ('2025-05-13T07:24:12.686Z ACTOR: Creating Docker container.', logging.INFO), |
| 32 | + ('2025-05-13T07:24:12.745Z ACTOR: Starting Docker container.', logging.INFO), |
| 33 | + ('2025-05-13T07:24:14.132Z [apify] INFO multiline \n log', logging.INFO), |
| 34 | + ('2025-05-13T07:25:14.132Z [apify] WARNING some warning', logging.WARNING), |
| 35 | + ('2025-05-13T07:26:14.132Z [apify] DEBUG c', logging.DEBUG), |
| 36 | +) |
| 37 | + |
| 38 | + |
| 39 | +@pytest.fixture |
| 40 | +def mock_api() -> None: |
| 41 | + class AsyncByteStream(httpx._types.AsyncByteStream): |
38 | 42 | async def __aiter__(self) -> AsyncIterator[bytes]:
|
39 |
| - for i in mocked_actor_logs_logs: |
| 43 | + for i in _MOCKED_ACTOR_LOGS: |
40 | 44 | yield i
|
41 | 45 | await asyncio.sleep(0.1)
|
42 | 46 |
|
43 | 47 | async def aclose(self) -> None:
|
44 | 48 | pass
|
45 | 49 |
|
46 |
| - respx.get(url=f'https://example.com/v2/actor-runs/{mocked_run_id}').mock( |
47 |
| - return_value=httpx.Response(content=json.dumps({"data":{'id': mocked_run_id}}),status_code=200)) |
48 |
| - respx.get(url=f'https://example.com/v2/actor-runs/{mocked_run_id}/log?stream=1').mock( |
49 |
| - return_value=httpx.Response(stream=AsyncByteStream(), status_code=200)) |
| 50 | + actor_runs_responses = iter( |
| 51 | + ( |
| 52 | + httpx.Response( |
| 53 | + content=json.dumps({'data': {'id': _MOCKED_RUN_ID, 'status': ActorJobStatus.RUNNING}}), status_code=200 |
| 54 | + ), |
| 55 | + httpx.Response( |
| 56 | + content=json.dumps({'data': {'id': _MOCKED_RUN_ID, 'status': ActorJobStatus.RUNNING}}), status_code=200 |
| 57 | + ), |
| 58 | + httpx.Response( |
| 59 | + content=json.dumps({'data': {'id': _MOCKED_RUN_ID, 'status': ActorJobStatus.SUCCEEDED}}), |
| 60 | + status_code=200, |
| 61 | + ), |
| 62 | + ) |
| 63 | + ) |
| 64 | + |
| 65 | + async def actor_runs_side_effect(_: httpx.Request) -> httpx.Response: |
| 66 | + await asyncio.sleep(0.5) |
| 67 | + return next(actor_runs_responses) |
| 68 | + |
| 69 | + respx.get(url=f'{_MOCKED_API_URL}/v2/actor-runs/{_MOCKED_RUN_ID}').mock(side_effect=actor_runs_side_effect) |
| 70 | + |
| 71 | + respx.get(url=f'{_MOCKED_API_URL}/v2/acts/{_MOCKED_ACTOR_ID}').mock( |
| 72 | + return_value=httpx.Response(content=json.dumps({'data': {'name': _MOCKED_ACTOR_NAME}}), status_code=200) |
| 73 | + ) |
| 74 | + |
| 75 | + respx.post(url=f'{_MOCKED_API_URL}/v2/acts/{_MOCKED_ACTOR_ID}/runs').mock( |
| 76 | + return_value=httpx.Response(content=json.dumps({'data': {'id': _MOCKED_RUN_ID}}), status_code=200) |
| 77 | + ) |
50 | 78 |
|
51 |
| - run_client = ApifyClientAsync(token="mocked_token", api_url='https://example.com').run(run_id=mocked_run_id) |
52 |
| - streamed_log = await run_client.get_streamed_log(actor_name=mocked_actor_name) |
| 79 | + respx.get(url=f'{_MOCKED_API_URL}/v2/actor-runs/{_MOCKED_RUN_ID}/log?stream=1').mock( |
| 80 | + return_value=httpx.Response(stream=AsyncByteStream(), status_code=200) |
| 81 | + ) |
| 82 | + |
| 83 | + |
| 84 | +@pytest.fixture |
| 85 | +def propagate_stream_logs() -> None: |
| 86 | + StreamedLogAsync._force_propagate = True # Enable propagation of logs to the caplog fixture |
| 87 | + logging.getLogger(f'apify.{_MOCKED_ACTOR_NAME}-{_MOCKED_RUN_ID}').setLevel(logging.DEBUG) |
| 88 | + |
| 89 | + |
| 90 | +@respx.mock |
| 91 | +async def test_redirected_logs( |
| 92 | + caplog: LogCaptureFixture, |
| 93 | + mock_api: None, # noqa: ARG001, fixture |
| 94 | + propagate_stream_logs: None, # noqa: ARG001, fixture |
| 95 | +) -> None: |
| 96 | + """Test that redirected logs are formatted correctly.""" |
| 97 | + |
| 98 | + run_client = ApifyClientAsync(token='mocked_token', api_url=_MOCKED_API_URL).run(run_id=_MOCKED_RUN_ID) |
| 99 | + streamed_log = await run_client.get_streamed_log(actor_name=_MOCKED_ACTOR_NAME) |
53 | 100 |
|
54 | 101 | # Set `propagate=True` during the tests, so that caplog can see the logs..
|
55 |
| - logger_name = f"apify.{mocked_actor_name}-{mocked_run_id}" |
56 |
| - logging.getLogger(logger_name).propagate = True |
| 102 | + logger_name = f'apify.{_MOCKED_ACTOR_NAME}-{_MOCKED_RUN_ID}' |
57 | 103 |
|
58 | 104 | with caplog.at_level(logging.DEBUG, logger=logger_name):
|
59 | 105 | async with streamed_log:
|
60 | 106 | # Do stuff while the log from the other actor is being redirected to the logs.
|
61 | 107 | await asyncio.sleep(1)
|
62 | 108 |
|
63 |
| - records = caplog.records |
64 |
| - assert len(records) == 6 |
65 |
| - for expected_log_and_level, record in zip(expected_logs_and_levels, records): |
66 |
| - assert expected_log_and_level[0] == record.message |
67 |
| - assert expected_log_and_level[1] == record.levelno |
| 109 | + assert len(caplog.records) == 6 |
| 110 | + for expected_message_and_level, record in zip(_EXPECTED_MESSAGES_AND_LEVELS, caplog.records): |
| 111 | + assert expected_message_and_level[0] == record.message |
| 112 | + assert expected_message_and_level[1] == record.levelno |
| 113 | + |
| 114 | + |
| 115 | +@respx.mock |
| 116 | +async def test_actor_call_redirect_logs_to_default_logger( |
| 117 | + caplog: LogCaptureFixture, |
| 118 | + mock_api: None, # noqa: ARG001, fixture |
| 119 | + propagate_stream_logs: None, # noqa: ARG001, fixture |
| 120 | +) -> None: |
| 121 | + """Test that logs are redirected correctly to the default logger. |
| 122 | +
|
| 123 | + Caplog contains logs before formatting, so formatting is not included in the test expectations.""" |
| 124 | + logger_name = f'apify.{_MOCKED_ACTOR_NAME}-{_MOCKED_RUN_ID}' |
| 125 | + logger = logging.getLogger(logger_name) |
| 126 | + run_client = ApifyClientAsync(token='mocked_token', api_url=_MOCKED_API_URL).actor(actor_id=_MOCKED_ACTOR_ID) |
| 127 | + |
| 128 | + with caplog.at_level(logging.DEBUG, logger=logger_name): |
| 129 | + await run_client.call() |
| 130 | + |
| 131 | + # Ensure expected handler and formater |
| 132 | + assert isinstance(logger.handlers[0].formatter, RedirectLogFormatter) |
| 133 | + assert isinstance(logger.handlers[0], logging.StreamHandler) |
| 134 | + |
| 135 | + # Ensure logs are propagated |
| 136 | + assert len(caplog.records) == 6 |
| 137 | + for expected_message_and_level, record in zip(_EXPECTED_MESSAGES_AND_LEVELS, caplog.records): |
| 138 | + assert expected_message_and_level[0] == record.message |
| 139 | + assert expected_message_and_level[1] == record.levelno |
| 140 | + |
| 141 | + |
| 142 | +@respx.mock |
| 143 | +async def test_actor_call_no_redirect_logs( |
| 144 | + caplog: LogCaptureFixture, |
| 145 | + mock_api: None, # noqa: ARG001, fixture |
| 146 | + propagate_stream_logs: None, # noqa: ARG001, fixture |
| 147 | +) -> None: |
| 148 | + logger_name = f'apify.{_MOCKED_ACTOR_NAME}-{_MOCKED_RUN_ID}' |
| 149 | + run_client = ApifyClientAsync(token='mocked_token', api_url=_MOCKED_API_URL).actor(actor_id=_MOCKED_ACTOR_ID) |
| 150 | + |
| 151 | + with caplog.at_level(logging.DEBUG, logger=logger_name): |
| 152 | + await run_client.call(logger=None) |
| 153 | + |
| 154 | + assert len(caplog.records) == 0 |
| 155 | + |
| 156 | + |
| 157 | +@respx.mock |
| 158 | +async def test_actor_call_redirect_logs_to_custom_logger( |
| 159 | + caplog: LogCaptureFixture, |
| 160 | + mock_api: None, # noqa: ARG001, fixture |
| 161 | + propagate_stream_logs: None, # noqa: ARG001, fixture |
| 162 | +) -> None: |
| 163 | + """Test that logs are redirected correctly to the custom logger.""" |
| 164 | + logger_name = 'custom_logger' |
| 165 | + logger = logging.getLogger(logger_name) |
| 166 | + run_client = ApifyClientAsync(token='mocked_token', api_url=_MOCKED_API_URL).actor(actor_id=_MOCKED_ACTOR_ID) |
| 167 | + |
| 168 | + with caplog.at_level(logging.DEBUG, logger=logger_name): |
| 169 | + await run_client.call(logger=logger) |
| 170 | + |
| 171 | + assert len(caplog.records) == 6 |
| 172 | + for expected_message_and_level, record in zip(_EXPECTED_MESSAGES_AND_LEVELS, caplog.records): |
| 173 | + assert expected_message_and_level[0] == record.message |
| 174 | + assert expected_message_and_level[1] == record.levelno |
0 commit comments