From da71183ad894245d9724302b8db26f1442db404b Mon Sep 17 00:00:00 2001 From: SergioSim Date: Thu, 16 Nov 2023 16:19:24 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=85(tests)=20unify=20sync/async=20tests?= =?UTF-8?q?=20for=20lrs=20data=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The behavior of the sync and async versions of the lrs data backend are very similar. Thus, to keep both implementations inline, we tests both of them in the same file. --- src/ralph/backends/data/lrs.py | 27 +-- tests/backends/data/test_async_lrs.py | 239 +++++++++++++++++--------- tests/backends/data/test_lrs.py | 65 ------- tests/conftest.py | 2 +- tests/fixtures/backends.py | 91 +++++++--- tests/test_dependencies.py | 12 +- 6 files changed, 231 insertions(+), 205 deletions(-) delete mode 100644 tests/backends/data/test_lrs.py diff --git a/src/ralph/backends/data/lrs.py b/src/ralph/backends/data/lrs.py index a93d60847..de4238f42 100644 --- a/src/ralph/backends/data/lrs.py +++ b/src/ralph/backends/data/lrs.py @@ -18,7 +18,7 @@ from ralph.backends.lrs.base import LRSStatementsQuery from ralph.conf import BaseSettingsConfig, HeadersParameters from ralph.exceptions import BackendException -from ralph.utils import async_parse_dict_to_bytes, iter_by_batch, parse_to_dict +from ralph.utils import iter_by_batch, parse_dict_to_bytes, parse_to_dict class LRSHeaders(HeadersParameters): @@ -159,7 +159,7 @@ def _read_bytes( ) -> Iterator[bytes]: """Method called by `self.read` yielding bytes. See `self.read`.""" statements = self._read_dicts(query, target, chunk_size, ignore_errors) - yield from async_parse_dict_to_bytes( + yield from parse_dict_to_bytes( statements, self.settings.LOCALE_ENCODING, ignore_errors, self.logger ) @@ -202,9 +202,9 @@ def _read_dicts( for statement in statements_generator: yield statement except HTTPError as error: - msg = "Failed to fetch statements." - self.logger.error("%s. %s", msg, error) - raise BackendException(msg, *error.args) from error + msg = "Failed to fetch statements: %s" + self.logger.error(msg, error) + raise BackendException(msg % (error,)) from error def write( # noqa: PLR0913 self, @@ -213,8 +213,6 @@ def write( # noqa: PLR0913 chunk_size: Optional[int] = None, ignore_errors: bool = False, operation_type: Optional[BaseOperationType] = None, - simultaneous: bool = False, - max_num_simultaneous: Optional[int] = None, ) -> int: """Write `data` records to the `target` endpoint and return their count. @@ -231,21 +229,8 @@ def write( # noqa: PLR0913 operation_type (BaseOperationType or None): The mode of the write operation. If `operation_type` is `None`, the `default_operation_type` is used instead. See `BaseOperationType`. - simultaneous (bool): If `True`, chunks requests will be made concurrently. - If `False` (default), chunks will be sent sequentially - max_num_simultaneous (int or None): If simultaneous is `True`, the maximum - number of chunks to POST concurrently. If `None` (default), no limit is - set. """ - return super().write( - data, - target, - chunk_size, - ignore_errors, - operation_type, - simultaneous, - max_num_simultaneous, - ) + return super().write(data, target, chunk_size, ignore_errors, operation_type) def _write_bytes( # noqa: PLR0913 self, diff --git a/tests/backends/data/test_async_lrs.py b/tests/backends/data/test_async_lrs.py index ceda1277d..94f45310a 100644 --- a/tests/backends/data/test_async_lrs.py +++ b/tests/backends/data/test_async_lrs.py @@ -16,14 +16,14 @@ from ralph.backends.data.async_lrs import AsyncLRSDataBackend from ralph.backends.data.base import BaseOperationType, DataBackendStatus -from ralph.backends.data.lrs import LRSDataBackendSettings, LRSHeaders +from ralph.backends.data.lrs import LRSDataBackend, LRSDataBackendSettings, LRSHeaders from ralph.backends.lrs.base import LRSStatementsQuery from ralph.exceptions import BackendException, BackendParameterException from ...helpers import mock_statement -def test_backends_http_async_lrs_default_instantiation(monkeypatch, fs): +def test_backends_data_async_lrs_default_instantiation(monkeypatch, fs, lrs_backend): """Test the `LRSDataBackend` default instantiation.""" fs.create_file(".env") backend_settings_names = [ @@ -41,8 +41,10 @@ def test_backends_http_async_lrs_default_instantiation(monkeypatch, fs): monkeypatch.delenv(f"RALPH_BACKENDS__DATA__LRS__{name}", raising=False) assert AsyncLRSDataBackend.name == "async_lrs" - assert AsyncLRSDataBackend.settings_class == LRSDataBackendSettings - backend = AsyncLRSDataBackend() + assert LRSDataBackend.name == "lrs" + backend_class = lrs_backend().__class__ + assert backend_class.settings_class == LRSDataBackendSettings + backend = backend_class() assert backend.query_class == LRSStatementsQuery assert backend.base_url == parse_obj_as(AnyHttpUrl, "http://0.0.0.0:8100") assert backend.auth == ("ralph", "secret") @@ -55,11 +57,11 @@ def test_backends_http_async_lrs_default_instantiation(monkeypatch, fs): # Test overriding default values with environment variables. monkeypatch.setenv("RALPH_BACKENDS__DATA__LRS__USERNAME", "foo") - backend = AsyncLRSDataBackend() + backend = backend_class() assert backend.auth == ("foo", "secret") -def test_backends_http_async_lrs_instantiation_with_settings(): +def test_backends_data_async_lrs_instantiation_with_settings(lrs_backend): """Test the LRS backend instantiation with settings.""" headers = LRSHeaders( @@ -77,7 +79,6 @@ def test_backends_http_async_lrs_instantiation_with_settings(): WRITE_CHUNK_SIZE=5000, ) - assert AsyncLRSDataBackend.name == "async_lrs" assert AsyncLRSDataBackend.settings_class == LRSDataBackendSettings backend = AsyncLRSDataBackend(settings) assert backend.query_class == LRSStatementsQuery @@ -93,65 +94,68 @@ def test_backends_http_async_lrs_instantiation_with_settings(): @pytest.mark.anyio -async def test_backends_http_async_lrs_status_with_successful_request( - httpx_mock: HTTPXMock, async_lrs_backend +async def test_backends_data_async_lrs_status_with_successful_request( + httpx_mock: HTTPXMock, lrs_backend ): """Test the LRS backend status method returns `OK` when the request is successful. """ - backend: AsyncLRSDataBackend = async_lrs_backend() + backend: AsyncLRSDataBackend = lrs_backend() # Mock GET response of HTTPX url = "http://fake-lrs.com/__heartbeat__" httpx_mock.add_response(url=url, method="GET", status_code=200) status = await backend.status() assert status == DataBackendStatus.OK + await backend.close() @pytest.mark.anyio -async def test_backends_http_async_lrs_status_with_request_error( - httpx_mock: HTTPXMock, async_lrs_backend, caplog +async def test_backends_data_async_lrs_status_with_request_error( + httpx_mock: HTTPXMock, lrs_backend, caplog ): """Test the LRS backend status method returns `AWAY` when a `RequestError` exception is caught. """ - backend: AsyncLRSDataBackend = async_lrs_backend() + backend: AsyncLRSDataBackend = lrs_backend() httpx_mock.add_exception(RequestError("Test Request Error")) with caplog.at_level(logging.ERROR): status = await backend.status() assert ( - "ralph.backends.data.async_lrs", + f"ralph.backends.data.{backend.name}", logging.ERROR, "Unable to request the server", ) in caplog.record_tuples assert status == DataBackendStatus.AWAY + await backend.close() @pytest.mark.anyio -async def test_backends_http_async_lrs_status_with_http_status_error( - httpx_mock: HTTPXMock, async_lrs_backend, caplog +async def test_backends_data_async_lrs_status_with_http_status_error( + httpx_mock: HTTPXMock, lrs_backend, caplog ): """Test the LRS backend status method returns `ERROR` when an `HTTPStatusError` is caught. """ - backend: AsyncLRSDataBackend = async_lrs_backend() + backend: AsyncLRSDataBackend = lrs_backend() exception = HTTPStatusError("Test HTTP Status Error", request=None, response=None) httpx_mock.add_exception(exception) with caplog.at_level(logging.ERROR): status = await backend.status() assert ( - "ralph.backends.data.async_lrs", + f"ralph.backends.data.{backend.name}", logging.ERROR, "Response raised an HTTP status of 4xx or 5xx", ) in caplog.record_tuples assert status == DataBackendStatus.ERROR + await backend.close() @pytest.mark.parametrize("max_statements", [None, 2, 4, 8]) @pytest.mark.anyio -async def test_backends_http_async_lrs_read_max_statements( - httpx_mock: HTTPXMock, max_statements: int, async_lrs_backend +async def test_backends_data_async_lrs_read_max_statements( + httpx_mock: HTTPXMock, max_statements: int, lrs_backend ): """Test the LRS backend `read` method `max_statements` argument.""" statements = [mock_statement() for _ in range(3)] @@ -172,37 +176,39 @@ async def test_backends_http_async_lrs_read_max_statements( url = "http://fake-lrs.com/xAPI/statements/?limit=500&pit_id=pit_id" httpx_mock.add_response(url=url, method="GET", json=more_response) - backend: AsyncLRSDataBackend = async_lrs_backend() + backend: AsyncLRSDataBackend = lrs_backend() result = [x async for x in backend.read(max_statements=max_statements)] # Assert that result is of the proper length assert result == all_statements[:max_statements] + await backend.close() @pytest.mark.anyio @pytest.mark.parametrize("greedy", [False, True]) -async def test_backends_http_async_lrs_read_without_target( - greedy: bool, httpx_mock: HTTPXMock, async_lrs_backend +async def test_backends_data_async_lrs_read_without_target( + greedy: bool, httpx_mock: HTTPXMock, lrs_backend ): """Test that the LRS backend `read` method without target parameter value fetches statements from '/xAPI/statements/' default endpoint. """ - backend: AsyncLRSDataBackend = async_lrs_backend() - response = {"statements": [mock_statement() for _ in range(3)]} + backend: AsyncLRSDataBackend = lrs_backend() + response = {"statements": mock_statement()} url = "http://fake-lrs.com/xAPI/statements/?limit=500" httpx_mock.add_response(url=url, method="GET", json=response) result = [x async for x in backend.read(greedy=greedy)] - assert result == response["statements"] + assert result == [response["statements"]] + await backend.close() @pytest.mark.anyio @pytest.mark.parametrize("greedy", [False, True]) -async def test_backends_http_async_lrs_read_backend_error( - httpx_mock: HTTPXMock, caplog, greedy: bool, async_lrs_backend +async def test_backends_data_async_lrs_read_backend_error( + httpx_mock: HTTPXMock, caplog, greedy: bool, lrs_backend ): """Test the LRS backend `read` method raises a `BackendException` when the server returns an error. """ - backend: AsyncLRSDataBackend = async_lrs_backend() + backend: AsyncLRSDataBackend = lrs_backend() url = "http://fake-lrs.com/xAPI/statements/?limit=500" httpx_mock.add_response(url=url, method="GET", status_code=500) error = ( @@ -215,21 +221,22 @@ async def test_backends_http_async_lrs_read_backend_error( _ = [x async for x in backend.read(greedy=greedy)] assert ( - "ralph.backends.data.async_lrs", + f"ralph.backends.data.{backend.name}", logging.ERROR, error, ) in caplog.record_tuples + await backend.close() @pytest.mark.anyio @pytest.mark.parametrize("greedy", [False, True]) -async def test_backends_http_async_lrs_read_without_pagination( - httpx_mock: HTTPXMock, greedy: bool, async_lrs_backend +async def test_backends_data_async_lrs_read_without_pagination( + httpx_mock: HTTPXMock, greedy: bool, lrs_backend ): """Test the LRS backend `read` method when the request on the target endpoint returns statements without pagination. """ - backend: AsyncLRSDataBackend = async_lrs_backend() + backend: AsyncLRSDataBackend = lrs_backend() statements = [ mock_statement(verb={"id": "https://w3id.org/xapi/video/verbs/played"}), mock_statement(verb={"id": "https://w3id.org/xapi/video/verbs/played"}), @@ -250,17 +257,18 @@ async def test_backends_http_async_lrs_read_without_pagination( assert result == [ f"{json.dumps(statement)}\n".encode("utf-8") for statement in statements ] + await backend.close() @pytest.mark.anyio @pytest.mark.parametrize("greedy", [False, True]) -async def test_backends_http_async_lrs_read_without_pagination_with_query( - httpx_mock: HTTPXMock, greedy: bool, async_lrs_backend +async def test_backends_data_async_lrs_read_without_pagination_with_query( + httpx_mock: HTTPXMock, greedy: bool, lrs_backend ): """Test the LRS backend `read` method with a query when the request on the target endpoint returns statements without pagination. """ - backend: AsyncLRSDataBackend = async_lrs_backend() + backend: AsyncLRSDataBackend = lrs_backend() verb_id = "https://w3id.org/xapi/video/verbs/played" query = LRSStatementsQuery(verb=verb_id) statements = [ @@ -282,16 +290,17 @@ async def test_backends_http_async_lrs_read_without_pagination_with_query( assert result == [ f"{json.dumps(statement)}\n".encode("utf-8") for statement in statements ] + await backend.close() @pytest.mark.anyio -async def test_backends_http_async_lrs_read_with_pagination( - httpx_mock: HTTPXMock, async_lrs_backend +async def test_backends_data_async_lrs_read_with_pagination( + httpx_mock: HTTPXMock, lrs_backend ): """Test the LRS backend `read` method when the request on the target endpoint returns statements with pagination. """ - backend: AsyncLRSDataBackend = async_lrs_backend() + backend: AsyncLRSDataBackend = lrs_backend() statements = [ mock_statement(verb={"id": "https://w3id.org/xapi/video/verbs/played"}), mock_statement(verb={"id": "https://w3id.org/xapi/video/verbs/initialized"}), @@ -320,16 +329,17 @@ async def test_backends_http_async_lrs_read_with_pagination( assert result == [ f"{json.dumps(statement)}\n".encode("utf-8") for statement in all_statements ] + await backend.close() @pytest.mark.anyio -async def test_backends_http_async_lrs_read_with_pagination_with_query( - httpx_mock: HTTPXMock, async_lrs_backend +async def test_backends_data_async_lrs_read_with_pagination_with_query( + httpx_mock: HTTPXMock, lrs_backend ): """Test the LRS backend `read` method with a query when the request on the target endpoint returns statements with pagination. """ - backend: AsyncLRSDataBackend = async_lrs_backend() + backend: AsyncLRSDataBackend = lrs_backend() verb_id = "https://w3id.org/xapi/video/verbs/played" query = LRSStatementsQuery(verb=verb_id) statements = [mock_statement(verb={"id": verb_id})] @@ -357,6 +367,7 @@ async def test_backends_http_async_lrs_read_with_pagination_with_query( assert result == [ f"{json.dumps(statement)}\n".encode("utf-8") for statement in all_statements ] + await backend.close() @pytest.mark.anyio @@ -375,9 +386,9 @@ async def test_backends_http_async_lrs_read_with_pagination_with_query( (2, True, None, [6]), ], ) -async def test_backends_http_async_lrs_write_without_operation( # noqa: PLR0913 +async def test_backends_data_async_lrs_write_without_operation( # noqa: PLR0913 httpx_mock: HTTPXMock, - async_lrs_backend, + lrs_backend, caplog, chunk_size, simultaneous, @@ -387,7 +398,7 @@ async def test_backends_http_async_lrs_write_without_operation( # noqa: PLR0913 """Test the LRS backend `write` method, given no operation_type should POST to the LRS server. """ - backend: AsyncLRSDataBackend = async_lrs_backend() + backend: AsyncLRSDataBackend = lrs_backend() statements = [mock_statement() for _ in range(6)] # Mock HTTPX POST @@ -407,39 +418,43 @@ async def test_backends_http_async_lrs_write_without_operation( # noqa: PLR0913 chunk_size = 500 assert ( - "ralph.backends.data.async_lrs", + f"ralph.backends.data.{backend.name}", logging.DEBUG, "Start writing to the http://fake-lrs.com/xAPI/statements/ endpoint " f"(chunk size: {chunk_size})", ) in caplog.record_tuples - log_records = list(caplog.record_tuples) - for statement_count_log in statement_count_logs: - log_records.remove( - ( - "ralph.backends.data.async_lrs", - logging.DEBUG, - f"Posted {statement_count_log} statements", + if isinstance(backend, AsyncLRSDataBackend): + # Only async backends support `simultaneous` and `max_num_simultaneous`. + log_records = list(caplog.record_tuples) + for statement_count_log in statement_count_logs: + log_records.remove( + ( + f"ralph.backends.data.{backend.name}", + logging.DEBUG, + f"Posted {statement_count_log} statements", + ) ) - ) + await backend.close() @pytest.mark.anyio -async def test_backends_http_async_lrs_write_without_data(caplog, async_lrs_backend): +async def test_backends_data_async_lrs_write_without_data(caplog, lrs_backend): """Test the LRS backend `write` method returns null when no data to write in the target endpoint are given. """ - backend: AsyncLRSDataBackend = async_lrs_backend() + backend: AsyncLRSDataBackend = lrs_backend() with caplog.at_level(logging.INFO): result = await backend.write([]) assert ( - "ralph.backends.data.async_lrs", + f"ralph.backends.data.{backend.name}", logging.INFO, "Data Iterator is empty; skipping write to target", ) in caplog.record_tuples assert result == 0 + await backend.close() @pytest.mark.parametrize( @@ -451,32 +466,38 @@ async def test_backends_http_async_lrs_write_without_data(caplog, async_lrs_back ], ) @pytest.mark.anyio -async def test_backends_http_async_lrs_write_with_unsupported_operation( - async_lrs_backend, operation_type, caplog, error_msg +async def test_backends_data_async_lrs_write_with_unsupported_operation( + lrs_backend, operation_type, caplog, error_msg ): """Test the LRS backend `write` method, given an unsupported` `operation_type`, should raise a `BackendParameterException`. """ - backend: AsyncLRSDataBackend = async_lrs_backend() + backend: AsyncLRSDataBackend = lrs_backend() with pytest.raises(BackendParameterException, match=error_msg): with caplog.at_level(logging.ERROR): await backend.write(data=[b"foo"], operation_type=operation_type) assert ( - "ralph.backends.data.async_lrs", + f"ralph.backends.data.{backend.name}", logging.ERROR, error_msg, ) in caplog.record_tuples + await backend.close() @pytest.mark.anyio -async def test_backends_https_async_lrs_write_with_invalid_parameters( - httpx_mock: HTTPXMock, async_lrs_backend, caplog +async def test_backends_data_async_lrs_write_with_invalid_parameters( + httpx_mock: HTTPXMock, lrs_backend, caplog ): """Test the LRS backend `write` method, given invalid_parameters should raise a `BackendParameterException`. """ - backend: AsyncLRSDataBackend = async_lrs_backend() + backend: AsyncLRSDataBackend = lrs_backend() + if not isinstance(backend, AsyncLRSDataBackend): + # Only async backends support `simultaneous` and `max_num_simultaneous`. + await backend.close() + return + error = "max_num_simultaneous must be a strictly positive integer" with pytest.raises(BackendParameterException, match=error): with caplog.at_level(logging.ERROR): @@ -487,7 +508,7 @@ async def test_backends_https_async_lrs_write_with_invalid_parameters( ) assert ( - "ralph.backends.data.async_lrs", + f"ralph.backends.data.{backend.name}", logging.ERROR, error, ) in caplog.record_tuples @@ -503,20 +524,21 @@ async def test_backends_https_async_lrs_write_with_invalid_parameters( assert await backend.write([{}], max_num_simultaneous=-1) == 1 assert ( - "ralph.backends.data.async_lrs", + f"ralph.backends.data.{backend.name}", logging.WARNING, error, ) in caplog.record_tuples + await backend.close() @pytest.mark.anyio -async def test_backends_https_async_lrs_write_with_target( - httpx_mock: HTTPXMock, async_lrs_backend, caplog +async def test_backends_data_async_lrs_write_with_target( + httpx_mock: HTTPXMock, lrs_backend, caplog ): """Test the LRS backend `write` method with a target parameter value writes statements to the target endpoint. """ - backend: AsyncLRSDataBackend = async_lrs_backend() + backend: AsyncLRSDataBackend = lrs_backend() data = [mock_statement() for _ in range(3)] # Mock HTTPX POST @@ -531,10 +553,44 @@ async def test_backends_https_async_lrs_write_with_target( assert await backend.write(data=data, target="/not-xAPI/not-statements/") == 3 assert ( - "ralph.backends.data.async_lrs", + f"ralph.backends.data.{backend.name}", logging.DEBUG, f"Start writing to the {url} endpoint (chunk size: 500)", ) in caplog.record_tuples + await backend.close() + + +@pytest.mark.anyio +async def test_backends_data_async_lrs_write_with_target_and_binary_data( + httpx_mock: HTTPXMock, lrs_backend, caplog +): + """Test the LRS backend `write` method with a target parameter value given + binary statements, writes them to the target endpoint. + """ + backend: AsyncLRSDataBackend = lrs_backend() + data = [mock_statement() for _ in range(3)] + bytes_data = [json.dumps(d).encode("utf-8") for d in data] + + # Mock HTTPX POST + url = "http://fake-lrs.com/not-xAPI/not-statements/" + httpx_mock.add_response( + url=url, + method="POST", + json=data, + ) + + with caplog.at_level(logging.DEBUG): + assert ( + await backend.write(data=bytes_data, target="/not-xAPI/not-statements/") + == 3 + ) + + assert ( + f"ralph.backends.data.{backend.name}", + logging.DEBUG, + f"Start writing to the {url} endpoint (chunk size: 500)", + ) in caplog.record_tuples + await backend.close() @pytest.mark.anyio @@ -542,13 +598,13 @@ async def test_backends_https_async_lrs_write_with_target( "operation_type", [BaseOperationType.CREATE, BaseOperationType.INDEX], ) -async def test_backends_https_async_lrs_write_with_create_or_index_operation( - operation_type, httpx_mock: HTTPXMock, async_lrs_backend, caplog +async def test_backends_data_async_lrs_write_with_create_or_index_operation( + operation_type, httpx_mock: HTTPXMock, lrs_backend, caplog ): """Test the `LRSHTTP.write` method with `CREATE` or `INDEX` operation_type writes statements to the given target endpoint. """ - backend: AsyncLRSDataBackend = async_lrs_backend() + backend: AsyncLRSDataBackend = lrs_backend() data = [mock_statement() for _ in range(3)] # Mock HTTPX POST @@ -559,20 +615,21 @@ async def test_backends_https_async_lrs_write_with_create_or_index_operation( assert await backend.write(data=data, operation_type=operation_type) == 3 assert ( - "ralph.backends.data.async_lrs", + f"ralph.backends.data.{backend.name}", logging.DEBUG, "Posted 3 statements", ) in caplog.record_tuples + await backend.close() @pytest.mark.anyio -async def test_backends_https_async_lrs_write_backend_exception( +async def test_backends_data_async_lrs_write_with_post_exception( httpx_mock: HTTPXMock, - async_lrs_backend, + lrs_backend, caplog, ): """Test the `LRSHTTP.write` method with HTTP error.""" - backend: AsyncLRSDataBackend = async_lrs_backend() + backend: AsyncLRSDataBackend = lrs_backend() data = [mock_statement()] # Mock HTTPX POST @@ -582,13 +639,27 @@ async def test_backends_https_async_lrs_write_backend_exception( with caplog.at_level(logging.ERROR): await backend.write(data=data) - assert ( - "ralph.backends.data.async_lrs", - logging.ERROR, + msg = ( "Failed to post statements: Server error '500 Internal Server Error' for url " "'http://fake-lrs.com/xAPI/statements/'\nFor more information check: " - "https://httpstatuses.com/500", + "https://httpstatuses.com/500" + ) + assert ( + f"ralph.backends.data.{backend.name}", + logging.ERROR, + msg, + ) in caplog.record_tuples + + # Given `ignore_errors=True` the `write` method should log a warning message. + with caplog.at_level(logging.WARNING): + assert not (await backend.write(data=data, ignore_errors=True)) + + assert ( + f"ralph.backends.data.{backend.name}", + logging.WARNING, + msg, ) in caplog.record_tuples + await backend.close() # Asynchronicity tests for dev purposes (skip in CI) @@ -599,7 +670,7 @@ async def test_backends_https_async_lrs_write_backend_exception( @pytest.mark.parametrize( "num_pages,chunk_size,network_latency_time", [(3, 3, 0.2), (10, 3, 0.2)] ) -async def test_backends_https_async_lrs_read_concurrency( +async def test_backends_data_async_lrs_read_concurrency( httpx_mock: HTTPXMock, num_pages, chunk_size, network_latency_time ): """Test concurrency performances in `read`, for development use. @@ -694,8 +765,8 @@ async def _simulate_slow_processing(): @pytest.mark.skip(reason="Timing based tests are too unstable to run in CI") @pytest.mark.anyio -async def test_backends_https_async_lrs_write_concurrency( - httpx_mock: HTTPXMock, async_lrs_backend +async def test_backends_data_async_lrs_write_concurrency( + httpx_mock: HTTPXMock, lrs_backend ): """Test concurrency performances in `write`, for development use.""" diff --git a/tests/backends/data/test_lrs.py b/tests/backends/data/test_lrs.py deleted file mode 100644 index 28629af6c..000000000 --- a/tests/backends/data/test_lrs.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Tests for Ralph Async LRS HTTP backend.""" - -import pytest -from pydantic import AnyHttpUrl, parse_obj_as - -from ralph.backends.data.lrs import LRSDataBackend, LRSDataBackendSettings, LRSHeaders -from ralph.backends.lrs.base import LRSStatementsQuery - - -@pytest.mark.anyio -def test_backends_http_lrs_default_instantiation(monkeypatch, fs): - """Test the `LRSDataBackend` default instantiation.""" - fs.create_file(".env") - backend_settings_names = [ - "BASE_URL", - "USERNAME", - "PASSWORD", - "HEADERS", - "STATUS_ENDPOINT", - "STATEMENTS_ENDPOINT", - ] - for name in backend_settings_names: - monkeypatch.delenv(f"RALPH_BACKENDS__DATA__LRS__{name}", raising=False) - - assert LRSDataBackend.name == "lrs" - assert LRSDataBackend.settings_class == LRSDataBackendSettings - backend = LRSDataBackend() - assert backend.query_class == LRSStatementsQuery - assert backend.base_url == parse_obj_as(AnyHttpUrl, "http://0.0.0.0:8100") - assert backend.auth == ("ralph", "secret") - assert backend.settings.HEADERS == LRSHeaders() - assert backend.settings.STATUS_ENDPOINT == "/__heartbeat__" - assert backend.settings.STATEMENTS_ENDPOINT == "/xAPI/statements" - - # Test overriding default values with environment variables. - monkeypatch.setenv("RALPH_BACKENDS__DATA__LRS__USERNAME", "foo") - backend = LRSDataBackend() - assert backend.auth == ("foo", "secret") - - -def test_backends_http_lrs_instantiation_with_settings(): - """Test the LRS backend default instantiation.""" - - headers = LRSHeaders( - X_EXPERIENCE_API_VERSION="1.0.3", CONTENT_TYPE="application/json" - ) - settings = LRSDataBackendSettings( - BASE_URL="http://fake-lrs.com", - USERNAME="user", - PASSWORD="pass", - HEADERS=headers, - STATUS_ENDPOINT="/fake-status-endpoint", - STATEMENTS_ENDPOINT="/xAPI/statements", - ) - - assert LRSDataBackend.name == "lrs" - assert LRSDataBackend.settings_class == LRSDataBackendSettings - backend = LRSDataBackend(settings) - assert backend.query_class == LRSStatementsQuery - assert isinstance(backend.base_url, AnyHttpUrl) - assert backend.auth == ("user", "pass") - assert backend.settings.HEADERS.CONTENT_TYPE == "application/json" - assert backend.settings.HEADERS.X_EXPERIENCE_API_VERSION == "1.0.3" - assert backend.settings.STATUS_ENDPOINT == "/fake-status-endpoint" - assert backend.settings.STATEMENTS_ENDPOINT == "/xAPI/statements" diff --git a/tests/conftest.py b/tests/conftest.py index 51dde8d4d..67e409130 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,7 +18,6 @@ anyio_backend, async_es_backend, async_es_lrs_backend, - async_lrs_backend, async_mongo_backend, async_mongo_lrs_backend, clickhouse, @@ -34,6 +33,7 @@ fs_lrs_backend, ldp_backend, lrs, + lrs_backend, mongo, mongo_backend, mongo_forwarding, diff --git a/tests/fixtures/backends.py b/tests/fixtures/backends.py index f7d01239d..76108306e 100644 --- a/tests/fixtures/backends.py +++ b/tests/fixtures/backends.py @@ -6,10 +6,10 @@ import random import time from contextlib import asynccontextmanager -from functools import lru_cache +from functools import lru_cache, wraps from multiprocessing import Process from pathlib import Path -from typing import Callable +from typing import Callable, Optional, TypeVar, Union import boto3 import botocore @@ -19,6 +19,7 @@ import websockets from elasticsearch import BadRequestError, Elasticsearch from httpx import AsyncClient, ConnectError +from pydantic import AnyHttpUrl, parse_obj_as from pymongo import MongoClient from pymongo.errors import CollectionInvalid @@ -32,6 +33,7 @@ from ralph.backends.data.es import ESDataBackend from ralph.backends.data.fs import FSDataBackend from ralph.backends.data.ldp import LDPDataBackend +from ralph.backends.data.lrs import LRSDataBackend, LRSHeaders from ralph.backends.data.mongo import MongoDataBackend from ralph.backends.data.s3 import S3DataBackend from ralph.backends.data.swift import SwiftDataBackend @@ -133,26 +135,7 @@ def get_async_es_test_backend(index: str = ES_TEST_INDEX): return AsyncESLRSBackend(settings) -@lru_cache -def get_async_lrs_test_backend( - base_url: str = "http://fake-lrs.com", -) -> AsyncLRSDataBackend: - """Return an AsyncESLRSBackend backend instance using test defaults.""" - settings = AsyncLRSDataBackend.settings_class( - BASE_URL=base_url, - USERNAME="user", - PASSWORD="pass", - HEADERS={ - "X_EXPERIENCE_API_VERSION": "1.0.3", - "CONTENT_TYPE": "application/json", - }, - LOCALE_ENCODING="utf8", - STATUS_ENDPOINT="/__heartbeat__", - STATEMENTS_ENDPOINT="/xAPI/statements/", - READ_CHUNK_SIZE=500, - WRITE_CHUNK_SIZE=500, - ) - return AsyncLRSDataBackend(settings) +LrsTestBackend = TypeVar("LrsTestBackend", LRSDataBackend, AsyncLRSDataBackend) @lru_cache @@ -265,10 +248,66 @@ def anyio_backend(): return "asyncio" -@pytest.fixture() -def async_lrs_backend() -> Callable[[], AsyncLRSDataBackend]: - """Return the `get_async_lrs_test_backend` function.""" - return get_async_lrs_test_backend +@pytest.mark.anyio +@pytest.fixture(params=["sync", "async"]) +def lrs_backend( + request, +) -> Callable[[Optional[str]], Union[LRSDataBackend, AsyncLRSDataBackend]]: + """Return the `get_lrs_test_backend` function.""" + backend_class = LRSDataBackend if request.param == "sync" else AsyncLRSDataBackend + + def make_awaitable(sync_func): + """Make a synchronous callable awaitable.""" + + @wraps(sync_func) + async def async_func(*args, **kwargs): + kwargs.pop("simultaneous", None) + kwargs.pop("max_num_simultaneous", None) + return sync_func(*args, **kwargs) + + return async_func + + def make_awaitable_generator(sync_func): + """Make a synchronous generator awaitable.""" + + @wraps(sync_func) + async def async_func(*args, **kwargs): + kwargs.pop("greedy", None) + for item in sync_func(*args, **kwargs): + yield item + + return async_func + + def _get_lrs_test_backend( + base_url: Optional[str] = "http://fake-lrs.com", + ) -> Union[LRSDataBackend, AsyncLRSDataBackend]: + """Return an (Async)LRSDataBackend backend instance using test defaults.""" + headers = { + "X_EXPERIENCE_API_VERSION": "1.0.3", + "CONTENT_TYPE": "application/json", + } + settings = backend_class.settings_class( + BASE_URL=parse_obj_as(AnyHttpUrl, base_url), + USERNAME="user", + PASSWORD="pass", + HEADERS=LRSHeaders.parse_obj(headers), + LOCALE_ENCODING="utf8", + STATUS_ENDPOINT="/__heartbeat__", + STATEMENTS_ENDPOINT="/xAPI/statements/", + READ_CHUNK_SIZE=500, + WRITE_CHUNK_SIZE=500, + ) + backend = backend_class(settings) + + if isinstance(backend, LRSDataBackend): + backend.status = make_awaitable(backend.status) # type: ignore + backend.read = make_awaitable_generator(backend.read) # type: ignore + backend.write = make_awaitable(backend.write) # type: ignore + backend.close = make_awaitable(backend.close) # type: ignore + + return backend + + return _get_lrs_test_backend @pytest.fixture diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index 88730ced5..1e2adb742 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -7,6 +7,8 @@ import pytest from click.testing import CliRunner +from ralph import cli + def test_dependencies_ralph_command_requires_click(monkeypatch): """Test Click module installation while executing the ralph command.""" @@ -30,16 +32,10 @@ def test_dependencies_ralph_command_requires_click(monkeypatch): def test_dependencies_runserver_subcommand_requires_uvicorn(monkeypatch): """Test Uvicorn module installation while executing the runserver sub command.""" - monkeypatch.setitem(sys.modules, "uvicorn", None) - - # Force ralph.cli reload now that uvicorn is considered as missing - if "ralph.cli" in sys.modules: - del sys.modules["ralph.cli"] - cli = importlib.import_module("ralph.cli") - + monkeypatch.delattr(cli, "uvicorn") + monkeypatch.setattr(cli, "configure_logging", lambda: None) runner = CliRunner() result = runner.invoke(cli.cli, "runserver -b es".split()) - assert isinstance(result.exception, ModuleNotFoundError) assert str(result.exception) == ( "You need to install 'lrs' optional dependencies to use the runserver "