From cbbfe9c450e8e5834a0362b2df1e56902cdd13cc Mon Sep 17 00:00:00 2001 From: Axel Marquez Date: Fri, 12 Sep 2025 00:15:15 -0600 Subject: [PATCH] [DMS-803] Add filtered read test suite --- src/edfi-paging-test/README.md | 28 ++-- .../edfi_paging_test/.env.example | 3 + .../edfi_paging_test/api/request_client.py | 49 +++++-- .../edfi_paging_test/helpers/api_metadata.py | 79 ++++++----- .../edfi_paging_test/helpers/argparser.py | 20 +++ .../helpers/main_arguments.py | 3 + .../helpers/resource_entries_cache.py | 62 ++++++++ .../edfi_paging_test/helpers/test_type.py | 20 +++ .../edfi_paging_test/performance_tester.py | 132 ++++++++++++++---- .../reporter/filtered_read_measurement.py | 10 ++ .../reporter/filtered_read_request_logger.py | 94 +++++++++++++ .../{measurement.py => paging_measurement.py} | 2 +- .../reporter/paging_request_logger.py | 101 ++++++++++++++ .../edfi_paging_test/reporter/reporter.py | 38 +---- .../reporter/request_logger.py | 69 --------- .../edfi_paging_test/reporter/summary.py | 1 + .../tests/api/test_request_client.py | 3 +- .../tests/helpers/test_api_metadata.py | 88 ++++++++---- .../tests/reporter/test_reporter.py | 22 ++- .../tests/reporter/test_request_logger.py | 38 +++-- 20 files changed, 615 insertions(+), 247 deletions(-) create mode 100644 src/edfi-paging-test/edfi_paging_test/helpers/resource_entries_cache.py create mode 100644 src/edfi-paging-test/edfi_paging_test/helpers/test_type.py create mode 100644 src/edfi-paging-test/edfi_paging_test/reporter/filtered_read_measurement.py create mode 100644 src/edfi-paging-test/edfi_paging_test/reporter/filtered_read_request_logger.py rename src/edfi-paging-test/edfi_paging_test/reporter/{measurement.py => paging_measurement.py} (88%) create mode 100644 src/edfi-paging-test/edfi_paging_test/reporter/paging_request_logger.py delete mode 100644 src/edfi-paging-test/edfi_paging_test/reporter/request_logger.py diff --git a/src/edfi-paging-test/README.md b/src/edfi-paging-test/README.md index 53564019..68d81adb 100644 --- a/src/edfi-paging-test/README.md +++ b/src/edfi-paging-test/README.md @@ -26,19 +26,21 @@ poetry run python edfi_paging_test -b "https://localhost:54746" -k "testkey" -s ### Supported arguments -| Command Line Argument | Required | Description | -| ------------------------------------ | ------------------------------------ | --------------------------------------------------------------------------------------------- | -| `-b` or `--baseUrl` | yes (no default) | ​The base url used to derived api, metadata, oauth, and dependency urls (e.g., http://server) | -| `-k` or `--key` | yes (no default) | The web API OAuth key | -| `-s` or `--secret` | yes (no default) | The web API OAuth secret | -| `-i` or `--ignoreCertificateErrors` | no (default: false) | Ignore certificate errors | -| `-c` or `--connectionLimit` | no (default: 5) | Maximum concurrent connections to api | -| `-o` or `--output` | no (default: out) | Directory for writing results | -| `-t` or `--contentType` | no (default: csv) | Output file content type: CSV, JSON | -| `-r` or `--resourceList` | no (no default) | (Optional) List of resources to test - if not provided, all resources will be retrieved | -| `-p` or `--pageSize` | no (default: 100) | The page size to request. Max: 500. | -| `-l` or `--logLevel` | no (default: INFO) | Override the console output log level: VERBOSE, DEBUG, INFO, WARN, ERROR | -| `-d` or `--description` | no (default: Paging Volume Test Run) | Description for the test run | +| Command Line Argument | Required | Description | +| ------------------------------------ | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------| +| `-b` or `--baseUrl` | yes (no default) | ​The base url used to derived api, metadata, oauth, and dependency urls (e.g., http://server) | +| `-k` or `--key` | yes (no default) | The web API OAuth key | +| `-s` or `--secret` | yes (no default) | The web API OAuth secret | +| `-i` or `--ignoreCertificateErrors` | no (default: false) | Ignore certificate errors | +| `-c` or `--connectionLimit` | no (default: 5) | Maximum concurrent connections to api | +| `-o` or `--output` | no (default: out) | Directory for writing results | +| `-t` or `--contentType` | no (default: csv) | Output file content type: CSV, JSON | +| `-r` or `--resourceList` | no (no default) | (Optional) List of resources to test - if not provided, all resources will be retrieved | +| `-p` or `--pageSize` | no (default: 100) | The page size to request. Max: 500. | +| `-l` or `--logLevel` | no (default: INFO) | Override the console output log level: VERBOSE, DEBUG, INFO, WARN, ERROR | +| `-d` or `--description` | no (default: Paging Volume Test Run) | Description for the test run | +| `-e` or `--testType` | no (default: DEEP_PAGING) | Type of test to run: DEEP_PAGING, FILTERED_READ | +| `-f` or `--combinationSizeLimit` | no (default: 6) | The maximumum size of the filter combinations to test per resource. For use with the 'FILTERED_READ' testType only | Each argument can also be set by environment variable, or by using as `.env` file. See [.env.example](edfi_paging_test/.env.example). Arguments provided at diff --git a/src/edfi-paging-test/edfi_paging_test/.env.example b/src/edfi-paging-test/edfi_paging_test/.env.example index f49bf8d1..b3b5036c 100644 --- a/src/edfi-paging-test/edfi_paging_test/.env.example +++ b/src/edfi-paging-test/edfi_paging_test/.env.example @@ -14,3 +14,6 @@ PERF_RESOURCE_LIST="["students", "studentEducationOrganizationAssociations"]" # Set to true when using HTTP instead of HTTPS or when using self-signed certificates IGNORE_TLS_CERTIFICATE=false + +PERF_PAGING_TEST_TYPE=DEEP_PAGING +PERF_FILTER_COMBINATION_SIZE_LIMIT=6 diff --git a/src/edfi-paging-test/edfi_paging_test/api/request_client.py b/src/edfi-paging-test/edfi_paging_test/api/request_client.py index 7e1ad5c2..2a78d357 100644 --- a/src/edfi-paging-test/edfi_paging_test/api/request_client.py +++ b/src/edfi-paging-test/edfi_paging_test/api/request_client.py @@ -19,8 +19,10 @@ from edfi_paging_test.api.paginated_result import PaginatedResult from edfi_paging_test.api.api_info import APIInfo from edfi_paging_test.helpers.argparser import MainArguments -from edfi_paging_test.reporter.request_logger import log_request +from edfi_paging_test.reporter.paging_request_logger import PaggingRequestLogger +from edfi_paging_test.reporter.filtered_read_request_logger import FilteredReadRequestLogger from edfi_paging_test.helpers.api_metadata import get_base_api_response +from urllib.parse import quote EDFI_DATA_MODEL_NAME = "ed-fi" @@ -110,7 +112,7 @@ def _authorize(self) -> None: self.oauth.fetch_token( self._get_api_info().oauth_url, auth=self.auth, verify=self.verify_cert - ) + ) def _urljoin(self, base_url: str, relative_url: str) -> str: """ @@ -214,7 +216,7 @@ def get_total(self, resource: str) -> int: ) return 0 - def get_page(self, resource: str, page: int = 1) -> PaginatedResult: + def get_page(self, resource: str, pagingRequestLogger: PaggingRequestLogger, page: int = 1) -> PaginatedResult: """Send an HTTP GET request for the next page. Returns @@ -230,9 +232,10 @@ def get_page(self, resource: str, page: int = 1) -> PaginatedResult: logger.debug(f"GET {next_url}") elapsed, response = timeit(lambda: self._get(next_url)) - items = response.json() if len(response.text) > 0 else [] + items = response.json() if len( + response.text) > 0 and response.status_code == HTTPStatus.OK else [] - log_request( + pagingRequestLogger.log_request( resource, next_url, page, @@ -250,7 +253,7 @@ def get_page(self, resource: str, page: int = 1) -> PaginatedResult: status_code=response.status_code, ) - def get_all(self, resource: str) -> List[Dict[str, Any]]: + def get_all(self, resource: str, pagingRequestLogger: PaggingRequestLogger) -> List[Dict[str, Any]]: """ Send an HTTP GET request for all pages of a resource. @@ -267,14 +270,14 @@ def get_all(self, resource: str) -> List[Dict[str, Any]]: logger.info(f"Retrieving all {resource} records...") - pagination_result = self.get_page(resource, 1) + pagination_result = self.get_page(resource, pagingRequestLogger, 1) page_items = pagination_result.current_page_items # Assign to empty list if result is not a list, e.g. an error response from the API items: List[Any] = page_items if (isinstance(page_items, list)) else [] while True: pagination_result = self.get_page( - resource, pagination_result.current_page + 1 + resource, pagingRequestLogger, pagination_result.current_page + 1 ) items.extend(pagination_result.current_page_items) @@ -282,3 +285,33 @@ def get_all(self, resource: str) -> List[Dict[str, Any]]: break return items + + def filtered_get(self, resource_name: str, filters: Dict[str, str], limit: int, filteredReadRequestLogger: FilteredReadRequestLogger) -> List[Dict[str, Any]]: + """ + Sends an HTTP GET request to the given resource applying the provider filters. + + Args: + resource_name: Name of the resource to query + filters: Dictionary of filter name and value + limit: Number of entries to retrieve + + Returns: + List containing the matching resource entries + """ + + query_string = '&'.join([f"{key}={quote(str(value))}" for key, value in filters.items()]) + url = f"{self._build_url_for_resource(resource_name)}?limit={limit}&{query_string}" + + elapsed, response = timeit(lambda: self._get(url)) + + items = response.json() if len(response.text) > 0 and response.status_code == HTTPStatus.OK else [] + + filteredReadRequestLogger.log_request( + resource_name, + url, + len(filters), + elapsed, + response.status_code, + ) + + return items diff --git a/src/edfi-paging-test/edfi_paging_test/helpers/api_metadata.py b/src/edfi-paging-test/edfi_paging_test/helpers/api_metadata.py index a7c7e878..809ebb13 100644 --- a/src/edfi-paging-test/edfi_paging_test/helpers/api_metadata.py +++ b/src/edfi-paging-test/edfi_paging_test/helpers/api_metadata.py @@ -35,7 +35,7 @@ def get_base_api_response(api_base_url: str, verify_cert: bool = True) -> Dict[s return requests.get( api_base_url, verify=verify_cert - ).json() + ).json() except requests.exceptions.RequestException as e: raise RuntimeError(f"Error: {e}.") from e @@ -61,7 +61,7 @@ def get_openapi_metadata_response(api_base_url: str, verify_cert: bool = True) - return requests.get( base_api_response["urls"]["openApiMetadata"], verify=verify_cert - ).json() + ).json() @cache @@ -88,44 +88,48 @@ def get_resource_metadata_response(api_base_url: str, verify_cert: bool = True) return requests.get( resource_metadata["endpointUri"], verify=verify_cert - ).json() + ).json() -def get_resource_paths(api_base_url: str, verify_cert: bool = True) -> List[str]: +def get_filters_by_resource_name(api_base_url: str, verify_cert: bool = True) -> Dict[str, List[str]]: """ - Gets the resources for the API as relative paths, including the - project/extension prefix. + Analyzes the OpenAPI metadata to determine which filters are available + for each resource endpoint. - Parameters - ---------- - api_base_url : str - The base URL of the API. + Args: + api_base_url (str): Base URL of the Ed-Fi API + verify_cert (bool): Whether to verify SSL certificates - Returns - ------- - List[str] - A list of resource relative paths, including the extension prefix if - relevant. For example: ["schools", "tpdm/candidates"] + Returns: + Dict[str, List[str]]: Dictionary mapping resource names to their + available filters """ - resource_metadata_response: Dict[ - str, Dict[str, str] - ] = get_resource_metadata_response(api_base_url, verify_cert) - all_paths: List[str] = list(resource_metadata_response["paths"].keys()) + + resource_metadata_response: Dict[str, Dict[str, Any] + ] = get_resource_metadata_response(api_base_url, verify_cert) + # filter out paths that are for get by id, deletes, keyChanges or partitions - return list( - filter( - lambda p: ( - "{id}" not in p - and "/deletes" not in p - and "/keyChanges" not in p - and "/partitions" not in p - ) - , all_paths - ) - ) + operations_by_resource = [(normalize_resource_path(path), operations) for path, operations in resource_metadata_response["paths"].items() + if "{id}" not in path + and "/deletes" not in path + and "/keyChanges" not in path + and "/partitions" not in path] + + filters_by_resource = {resource: get_filters( + operations) for resource, operations in operations_by_resource} + return filters_by_resource -def normalize_resource_paths(resource_paths: List[str]) -> List[str]: + +def get_filters(operations) -> List[str]: + pagination_params = {'limit', 'offset', 'totalCount'} + return [param['name'] for param in operations['get']['parameters'] + if 'in' in param and param['in'] == 'query' + and param['name'] != 'id' + and param['name'] not in pagination_params] + + +def normalize_resource_path(resource_path: str) -> str: """ Takes a list of resource relative paths and normalizes to lowercase and with the "ed-fi" namespace prefix removed. @@ -137,13 +141,8 @@ def normalize_resource_paths(resource_paths: List[str]) -> List[str]: Returns ------- - List[str] - A list of normalized resource relative paths. - For example: ["studentschoolassociations", "tpdm/candidates"] + str + A normalized resource relative path. + For example: "studentschoolassociations", "tpdm/candidates" """ - return list( - map( - lambda r: r.removeprefix("/").removeprefix("ed-fi/"), - resource_paths, - ) - ) + return resource_path.removeprefix("/").removeprefix("ed-fi/") diff --git a/src/edfi-paging-test/edfi_paging_test/helpers/argparser.py b/src/edfi-paging-test/edfi_paging_test/helpers/argparser.py index b58b3341..6ecf0663 100644 --- a/src/edfi-paging-test/edfi_paging_test/helpers/argparser.py +++ b/src/edfi-paging-test/edfi_paging_test/helpers/argparser.py @@ -8,6 +8,7 @@ from edfi_paging_test.helpers.output_format import OutputFormat from edfi_paging_test.helpers.log_level import LogLevel from edfi_paging_test.helpers.main_arguments import MainArguments +from edfi_paging_test.helpers.test_type import TestType def parse_main_arguments() -> MainArguments: @@ -113,6 +114,23 @@ def parse_main_arguments() -> MainArguments: default="Paging Volume Test Run", env_var="PERF_DESCRIPTION", ) + parser.add( # type: ignore + "-e", + "--testType", + help="Type of test to run: DEEP_PAGING, FILTERED_READ", + type=TestType, + choices=list(TestType), + default=TestType.DEEP_PAGING, + env_var="PERF_PAGING_TEST_TYPE", + ) + parser.add( # type: ignore + "-f", + "--combinationSizeLimit", + help="The maximumum size of the filter combinations to test per resource. For use with the 'FILTERED_READ' testType only", + type=int, + default=6, + env_var="PERF_FILTER_COMBINATION_SIZE_LIMIT", + ) args_parsed = parser.parse_args() @@ -128,6 +146,8 @@ def parse_main_arguments() -> MainArguments: args_parsed.resourceList or [], args_parsed.pageSize, args_parsed.logLevel, + args_parsed.testType, + args_parsed.combinationSizeLimit, ) return arguments diff --git a/src/edfi-paging-test/edfi_paging_test/helpers/main_arguments.py b/src/edfi-paging-test/edfi_paging_test/helpers/main_arguments.py index 52c9f9ac..fdfa02a2 100644 --- a/src/edfi-paging-test/edfi_paging_test/helpers/main_arguments.py +++ b/src/edfi-paging-test/edfi_paging_test/helpers/main_arguments.py @@ -8,6 +8,7 @@ from edfi_paging_test.helpers.output_format import OutputFormat from edfi_paging_test.helpers.log_level import LogLevel +from edfi_paging_test.helpers.test_type import TestType @dataclass @@ -27,3 +28,5 @@ class MainArguments: resourceList: List[str] pageSize: int = 100 log_level: LogLevel = LogLevel.INFO + test_type: TestType = TestType.DEEP_PAGING + combination_size_limit: int = 6 diff --git a/src/edfi-paging-test/edfi_paging_test/helpers/resource_entries_cache.py b/src/edfi-paging-test/edfi_paging_test/helpers/resource_entries_cache.py new file mode 100644 index 00000000..51db93ad --- /dev/null +++ b/src/edfi-paging-test/edfi_paging_test/helpers/resource_entries_cache.py @@ -0,0 +1,62 @@ +# SPDX-License-Identifier: Apache-2.0 +# Licensed to the Ed-Fi Alliance under one or more agreements. +# The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. +# See the LICENSE and NOTICES files in the project root for more information. + +import random +from typing import Any, Dict, List, Tuple + + +class ResourceEntriesCache: + + def __init__(self, entries: List[Dict[str, Any]], filters: List[str]): + """ + Stores the given resource entries in a columnar fashion to speed up local searches. + + Args: + entries: Resource entries + filters: List of filters that a resource supports + """ + + # Create a dictionary where the key is the filter name and the value is a list of entries + # with a non-null value for the given filter + self.resources_by_filter_name: Dict[str, list[Dict[str, Any]]] = {filter: [] for filter in filters} + + for entry in entries: + for filter in filters: + if filter in entry and entry[filter] is not None: + self.resources_by_filter_name[filter].append(entry) + + def get_entries_with_non_null_filters(self, filters: Tuple[str, ...], count: int) -> List[Dict[str, Any]]: + """ + Searches through the resource entries to find the ones that have non-null values + for all the specified filters, up to the requested count. + + Args: + filters: The required filters + count: Maximum number of matching entries to return + """ + + # Get the filter with the highest cardinality + entries = list(self.resources_by_filter_name.items())[0][1] + for filter in filters: + if len(self.resources_by_filter_name[filter]) < len(entries): + entries = self.resources_by_filter_name[filter] + + random.shuffle(entries) + + result: List[Dict[str, Any]] = [] + for entry in entries: + has_all_non_null_filters = True + for filter in filters: + if filter not in entry or entry[filter] is None: + has_all_non_null_filters = False + break + + if has_all_non_null_filters: + result.append(entry) + + if len(result) >= count: + break + + return result diff --git a/src/edfi-paging-test/edfi_paging_test/helpers/test_type.py b/src/edfi-paging-test/edfi_paging_test/helpers/test_type.py new file mode 100644 index 00000000..f221c334 --- /dev/null +++ b/src/edfi-paging-test/edfi_paging_test/helpers/test_type.py @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: Apache-2.0 +# Licensed to the Ed-Fi Alliance under one or more agreements. +# The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. +# See the LICENSE and NOTICES files in the project root for more information. + +from edfi_paging_test.helpers.case_insensitive_enum import CaseInsensitiveEnum + + +class TestType(CaseInsensitiveEnum): + DEEP_PAGING = "DEEP_PAGING" + FILTERED_READ = "FILTERED_READ" + + def __eq__(self, other: object) -> bool: + try: + return self.value == TestType(other).value + except: # noqa: E722 + return False + + # Prevent pytest from trying to discover tests in this class + __test__ = False diff --git a/src/edfi-paging-test/edfi_paging_test/performance_tester.py b/src/edfi-paging-test/edfi_paging_test/performance_tester.py index 6212d786..85167552 100644 --- a/src/edfi-paging-test/edfi_paging_test/performance_tester.py +++ b/src/edfi-paging-test/edfi_paging_test/performance_tester.py @@ -6,28 +6,29 @@ import asyncio from concurrent.futures import ThreadPoolExecutor from datetime import datetime +from itertools import chain, combinations import logging import time -from typing import List +from typing import Any, Dict, List, Tuple + +from pandas import DataFrame, Series from edfi_paging_test.api.request_client import RequestClient -from edfi_paging_test.reporter import request_logger from edfi_paging_test.reporter import reporter from edfi_paging_test.helpers.main_arguments import MainArguments from edfi_paging_test.helpers.output_format import OutputFormat -from edfi_paging_test.helpers.api_metadata import ( - get_resource_paths, - normalize_resource_paths, -) +from edfi_paging_test.helpers.api_metadata import get_filters_by_resource_name from edfi_paging_test.reporter.summary import Summary +from edfi_paging_test.helpers.test_type import TestType +from edfi_paging_test.helpers.resource_entries_cache import ResourceEntriesCache +from edfi_paging_test.reporter.paging_request_logger import PaggingRequestLogger +from edfi_paging_test.reporter.filtered_read_request_logger import FilteredReadRequestLogger logger = logging.getLogger(__name__) -def _generate_output_reports(args: MainArguments) -> None: - df = request_logger.get_DataFrame() - +def _generate_output_reports(args: MainArguments, df: DataFrame, statistics: Series) -> None: run_name = datetime.now().strftime("%Y-%m-%d-%H-%M-%S") create_detail_out = { OutputFormat.CSV: reporter.create_detail_csv, @@ -39,22 +40,38 @@ def _generate_output_reports(args: MainArguments) -> None: OutputFormat.CSV: reporter.create_statistics_csv, OutputFormat.JSON: reporter.create_statistics_json, } - create_statistics_out[args.contentType](df, args.output, run_name) + create_statistics_out[args.contentType](statistics, args.output, run_name) summary = Summary( - run_name=run_name, run_configration=args - ) + run_name=run_name, run_configration=args + ) reporter.create_summary_json(summary.get_DataFrame(), args.output, run_name) -def fetch_resource(request_client: RequestClient, target_resource: str) -> None: - resources = request_client.get_all(target_resource) - total_count = request_client.get_total(target_resource) +def fetch_resource_entries(request_client: RequestClient, resource_name: str, pagingRequestLogger: PaggingRequestLogger) -> Tuple[str, List[Dict[str, Any]]]: + entries = request_client.get_all(resource_name, pagingRequestLogger) + total_count = request_client.get_total(resource_name) - if len(resources) != total_count: + if len(entries) != total_count: logger.warn( - f"{target_resource}: expected {total_count} results, got: {len(resources)}" + f"{resource_name}: expected {total_count} results, got: {len(entries)}" ) + return (resource_name, entries) + + +def fetch_filtered_resources_entries(request_client: RequestClient, resource_name: str, resource: Dict[str, Any], filters: Tuple[str, ...], filteredReadRequestLogger: FilteredReadRequestLogger): + """ + Retrieves the first 100 entries that match the given filters. + The filter values are taken from the provided resource entry. + """ + + params = {filter: resource[filter] for filter in filters} + entries = request_client.filtered_get(resource_name, params, 100, filteredReadRequestLogger) + + if len(entries) == 0: + logger.warning( + f'Expected at least 1 entry to be returned while filtering the resource "{resource_name}"') + def invalid_resources( openapi_resources: List[str], resources_to_check: List[str] @@ -62,20 +79,42 @@ def invalid_resources( return [r for r in resources_to_check if r not in openapi_resources] +def capitalize_first(text): + return text[0].upper() + text[1:] + + +def get_filter_combinations(filters: List[str], combination_size_limit): + """ + Generates combinations of filters. + + Args: + filters: List of filters that a resource supports + combination_size_limit: The generated combinations will be of size 1 up to this limit + + Returns: + List containing different combinations of filters + """ + + return list(chain(*[combinations(filters, i + 1) for i in range(combination_size_limit)])) + + async def run(args: MainArguments) -> None: try: logger.info("Starting paging volume test...") start = time.time() + paggingRequestLogger = PaggingRequestLogger() + filteredReadRequestLogger = FilteredReadRequestLogger() - normalized_resources: List[str] = normalize_resource_paths( - get_resource_paths(args.baseUrl, not args.ignoreCertificateErrors) - ) + filters_by_resource_name = get_filters_by_resource_name( + args.baseUrl, not args.ignoreCertificateErrors) + + resource_names: List[str] = list(filters_by_resource_name.keys()) if len(args.resourceList) == 0: - args.resourceList = normalized_resources + args.resourceList = resource_names else: invalid_env_resources: List[str] = invalid_resources( - openapi_resources=normalized_resources, + openapi_resources=resource_names, resources_to_check=args.resourceList, ) if len(invalid_env_resources) != 0: @@ -92,14 +131,57 @@ async def run(args: MainArguments) -> None: fetch_resource_calls: List[asyncio.Future] = [ loop.run_in_executor( - executor, fetch_resource, request_client, target_resource + executor, fetch_resource_entries, request_client, target_resource, paggingRequestLogger ) for target_resource in args.resourceList ] - await asyncio.wait(fetch_resource_calls) + completed_fetch_resource_calls, _ = await asyncio.wait(fetch_resource_calls) + + if args.test_type == TestType.FILTERED_READ: + logger.info("Starting filtered read tests...") + + entries_by_resource_name: Dict[str, List[Dict[str, Any]]] = { + call.result()[0]: call.result()[1] for call in completed_fetch_resource_calls} + + entries_to_query: List[Tuple[str, Dict[str, Any], Tuple[str, ...]]] = [] + + for resource_name, entries in entries_by_resource_name.items(): + if len(entries) == 0: + continue + + supported_filters = filters_by_resource_name[resource_name] + filter_combinations = get_filter_combinations(supported_filters, args.combination_size_limit) + + entriesCache = ResourceEntriesCache(entries, supported_filters) + + for filter_combination in filter_combinations: + + # Limit to 10 entries per combination (configurable in future versions) + entries_with_non_null_filters = entriesCache.get_entries_with_non_null_filters( + filter_combination, 10) + + if len(entries_with_non_null_filters) == 0: + continue + + entries_to_query.extend([(resource_name, entry, filter_combination) + for entry in entries_with_non_null_filters]) + + query_resource_calls: List[asyncio.Future] = [ + loop.run_in_executor( + executor, fetch_filtered_resources_entries, request_client, resource_name, entry, filter_combination, filteredReadRequestLogger + ) + for (resource_name, entry, filter_combination) in entries_to_query + ] + + await asyncio.wait(query_resource_calls) + + dt = paggingRequestLogger.get_DataFrame( + ) if args.test_type == TestType.DEEP_PAGING else filteredReadRequestLogger.get_DataFrame() + statistics = paggingRequestLogger.get_statistics( + ) if args.test_type == TestType.DEEP_PAGING else filteredReadRequestLogger.get_statistics() - _generate_output_reports(args) + _generate_output_reports(args, dt, statistics) logger.info( f"Finished with paging volume test in {time.time() - start} seconds." ) diff --git a/src/edfi-paging-test/edfi_paging_test/reporter/filtered_read_measurement.py b/src/edfi-paging-test/edfi_paging_test/reporter/filtered_read_measurement.py new file mode 100644 index 00000000..5ef80bda --- /dev/null +++ b/src/edfi-paging-test/edfi_paging_test/reporter/filtered_read_measurement.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + + +@dataclass +class FilteredReadMeasurement: + resource: str + URL: str + filter_count: int + elapsed_time: float + http_status_code: int diff --git a/src/edfi-paging-test/edfi_paging_test/reporter/filtered_read_request_logger.py b/src/edfi-paging-test/edfi_paging_test/reporter/filtered_read_request_logger.py new file mode 100644 index 00000000..3d8ea455 --- /dev/null +++ b/src/edfi-paging-test/edfi_paging_test/reporter/filtered_read_request_logger.py @@ -0,0 +1,94 @@ +from typing import List + +from pandas import DataFrame, Series + +from edfi_paging_test.reporter.filtered_read_measurement import FilteredReadMeasurement + + +class FilteredReadRequestLogger: + + def __init__(self): + self.request_log: List[FilteredReadMeasurement] = [] + + def log_request( + self, + resource: str, + base_url: str, + filter_count: int, + elapsed: float, + status_code: int, + ) -> None: + m = FilteredReadMeasurement( + resource, + base_url, + filter_count, + elapsed, + status_code, + ) + + self.request_log.append(m) + + def get_DataFrame(self) -> DataFrame: + """ + Converts or appends a list of Measurements to a DataFrame. + + Parameters + ---------- + measurements: List[Measurement] + A list of Measurements that will be loaded into the DataFrame + df: DataFrame + An optional initial DataFrame + + Returns + ------- + DataFrame + A new DataFrame, or the modified Dataframe if `df` is provided + """ + if len(self.request_log) == 0: + raise RuntimeError( + "No measurements have been captured, therefore cannot create a DataFrame" + ) + + df = DataFrame(data=self.request_log) + df.rename( + { + "resource": "Resource", + "filter_count": "NumberOfFilters", + "elapsed_time": "ElapsedTime", + "http_status_code": "StatusCode", + }, + inplace=True, + axis=1, + ) + + df.sort_values(by=["Resource"], inplace=True) + + return df + + def get_statistics(self): + summary = self.get_DataFrame() + + # Need multiple columns in order to perform three different aggregations + summary["MeanTime"] = summary["ElapsedTime"] + summary["StDeviation"] = summary["ElapsedTime"] + + # This is a fascinating capability. Found mention in Medium article, + # though not at all clear from the Pandas documentation. Type check + # does not like it, because not covered in the type stub + summary = summary.groupby(by=["Resource"], as_index=False).apply( + lambda s: Series( + { + "NumberOfRecords": len(s), + "TotalTime": s["ElapsedTime"].sum(), + "MeanTime": s["MeanTime"].mean(), + # "Unbiased" estimate of standard deviation for a sample + # (ddof=1, panda's default). Equivalent of Excel STDEV.S() + "StDeviation": s["StDeviation"].std().round(6), # type: ignore + "NumberOfErrors": s[(s["StatusCode"] >= 400)]["StatusCode"].count(), + } + ) + ) # type: ignore + + summary.fillna(value=0, inplace=True) + + return summary diff --git a/src/edfi-paging-test/edfi_paging_test/reporter/measurement.py b/src/edfi-paging-test/edfi_paging_test/reporter/paging_measurement.py similarity index 88% rename from src/edfi-paging-test/edfi_paging_test/reporter/measurement.py rename to src/edfi-paging-test/edfi_paging_test/reporter/paging_measurement.py index bf69df29..5a876204 100644 --- a/src/edfi-paging-test/edfi_paging_test/reporter/measurement.py +++ b/src/edfi-paging-test/edfi_paging_test/reporter/paging_measurement.py @@ -2,7 +2,7 @@ @dataclass -class Measurement: +class PagingMeasurement: resource: str URL: str page_number: int diff --git a/src/edfi-paging-test/edfi_paging_test/reporter/paging_request_logger.py b/src/edfi-paging-test/edfi_paging_test/reporter/paging_request_logger.py new file mode 100644 index 00000000..0f691c46 --- /dev/null +++ b/src/edfi-paging-test/edfi_paging_test/reporter/paging_request_logger.py @@ -0,0 +1,101 @@ +from typing import List + +from pandas import DataFrame, Series + +from edfi_paging_test.reporter.paging_measurement import PagingMeasurement + + +class PaggingRequestLogger: + + def __init__(self): + self.request_log: List[PagingMeasurement] = [] + + def log_request( + self, + resource: str, + base_url: str, + page: int, + page_size: int, + number_of_records: int, + elapsed: float, + status_code: int, + ) -> None: + m = PagingMeasurement( + resource, + base_url, + page, + page_size, + number_of_records, + elapsed, + status_code, + ) + + self.request_log.append(m) + + def get_DataFrame(self) -> DataFrame: + """ + Converts or appends a list of Measurements to a DataFrame. + + Parameters + ---------- + measurements: List[Measurement] + A list of Measurements that will be loaded into the DataFrame + df: DataFrame + An optional initial DataFrame + + Returns + ------- + DataFrame + A new DataFrame, or the modified Dataframe if `df` is provided + """ + if len(self.request_log) == 0: + raise RuntimeError( + "No measurements have been captured, therefore cannot create a DataFrame" + ) + + df = DataFrame(data=self.request_log) + df.rename( + { + "resource": "Resource", + "page_number": "PageNumber", + "page_size": "PageSize", + "number_of_records": "NumberOfRecords", + "elapsed_time": "ElapsedTime", + "http_status_code": "StatusCode", + }, + inplace=True, + axis=1, + ) + + df.sort_values(by=["Resource", "PageNumber"], inplace=True) + + return df + + def get_statistics(self): + summary = self.get_DataFrame() + + # Need multiple columns in order to perform three different aggregations + summary["MeanTime"] = summary["ElapsedTime"] + summary["StDeviation"] = summary["ElapsedTime"] + + # This is a fascinating capability. Found mention in Medium article, + # though not at all clear from the Pandas documentation. Type check + # does not like it, because not covered in the type stub + summary = summary.groupby(by=["Resource", "PageSize"], as_index=False).apply( + lambda s: Series( + { + "NumberOfPages": s[s["StatusCode"] < 400]["PageNumber"].count(), + "NumberOfRecords": s["NumberOfRecords"].sum(), + "TotalTime": s["ElapsedTime"].sum(), + "MeanTime": s["MeanTime"].mean(), + # "Unbiased" estimate of standard deviation for a sample + # (ddof=1, panda's default). Equivalent of Excel STDEV.S() + "StDeviation": s["StDeviation"].std().round(6), # type: ignore + "NumberOfErrors": s[(s["StatusCode"] >= 400)]["StatusCode"].count(), + } + ) + ) # type: ignore + + summary.fillna(value=0, inplace=True) + + return summary diff --git a/src/edfi-paging-test/edfi_paging_test/reporter/reporter.py b/src/edfi-paging-test/edfi_paging_test/reporter/reporter.py index 1987deb6..1a4ec99b 100644 --- a/src/edfi-paging-test/edfi_paging_test/reporter/reporter.py +++ b/src/edfi-paging-test/edfi_paging_test/reporter/reporter.py @@ -25,52 +25,22 @@ def create_detail_json(df: DataFrame, output_dir: str, run_name: str) -> None: df.to_json(file_path, orient="records") # type: ignore -def _calculate_statistics(df: DataFrame) -> DataFrame: - summary = df.copy() - - # Need multiple columns in order to perform three different aggregations - summary["MeanTime"] = summary["ElapsedTime"] - summary["StDeviation"] = summary["ElapsedTime"] - - # This is a fascinating capability. Found mention in Medium article, - # though not at all clear from the Pandas documentation. Type check - # does not like it, because not covered in the type stub - summary = summary.groupby(by=["Resource", "PageSize"], as_index=False).apply( - lambda s: Series( - { - "NumberOfPages": s[s["StatusCode"] < 400]["PageNumber"].count(), - "NumberOfRecords": s["NumberOfRecords"].sum(), - "TotalTime": s["ElapsedTime"].sum(), - "MeanTime": s["MeanTime"].mean(), - # "Unbiased" estimate of standard deviation for a sample - # (ddof=1, panda's default). Equivalent of Excel STDEV.S() - "StDeviation": s["StDeviation"].std().round(6), # type: ignore - "NumberOfErrors": s[(s["StatusCode"] >= 400)]["StatusCode"].count(), - } - ) - ) # type: ignore - - summary.fillna(value=0, inplace=True) - - return summary - - -def create_statistics_csv(df: DataFrame, output_dir: str, run_name: str) -> None: +def create_statistics_csv(statistics: Series, output_dir: str, run_name: str) -> None: run_dir = path.join(output_dir, run_name) _create_if_not_exists(run_dir) file_path = path.join(run_dir, "statistics.csv") - _calculate_statistics(df).to_csv(file_path, index=False) + statistics.to_csv(file_path, index=False) -def create_statistics_json(df: DataFrame, output_dir: str, run_name: str) -> None: +def create_statistics_json(statistics: Series, output_dir: str, run_name: str) -> None: run_dir = path.join(output_dir, run_name) _create_if_not_exists(run_dir) file_path = path.join(run_dir, "statistics.json") # Apparently to_json is not in the type stub - _calculate_statistics(df).to_json(file_path, orient="records") # type: ignore + statistics.to_json(file_path, orient="records") # type: ignore def create_summary_json(df: DataFrame, output_dir: str, run_name: str) -> None: diff --git a/src/edfi-paging-test/edfi_paging_test/reporter/request_logger.py b/src/edfi-paging-test/edfi_paging_test/reporter/request_logger.py deleted file mode 100644 index 58a42e57..00000000 --- a/src/edfi-paging-test/edfi_paging_test/reporter/request_logger.py +++ /dev/null @@ -1,69 +0,0 @@ -from typing import List - -from pandas import DataFrame - -from edfi_paging_test.reporter.measurement import Measurement - -_request_log: List[Measurement] = [] - - -def log_request( - resource: str, - base_url: str, - page: int, - page_size: int, - number_of_records: int, - elapsed: float, - status_code: int, -) -> None: - m = Measurement( - resource, - base_url, - page, - page_size, - number_of_records, - elapsed, - status_code, - ) - - _request_log.append(m) - - -def get_DataFrame() -> DataFrame: - """ - Converts or appends a list of Measurements to a DataFrame. - - Parameters - ---------- - measurements: List[Measurement] - A list of Measurements that will be loaded into the DataFrame - df: DataFrame - An optional initial DataFrame - - Returns - ------- - DataFrame - A new DataFrame, or the modified Dataframe if `df` is provided - """ - if len(_request_log) == 0: - raise RuntimeError( - "No measurements have been captured, therefore cannot create a DataFrame" - ) - - df = DataFrame(data=_request_log) - df.rename( - { - "resource": "Resource", - "page_number": "PageNumber", - "page_size": "PageSize", - "number_of_records": "NumberOfRecords", - "elapsed_time": "ElapsedTime", - "http_status_code": "StatusCode", - }, - inplace=True, - axis=1, - ) - - df.sort_values(by=["Resource", "PageNumber"], inplace=True) - - return df diff --git a/src/edfi-paging-test/edfi_paging_test/reporter/summary.py b/src/edfi-paging-test/edfi_paging_test/reporter/summary.py index 4461d21c..2ee0db29 100644 --- a/src/edfi-paging-test/edfi_paging_test/reporter/summary.py +++ b/src/edfi-paging-test/edfi_paging_test/reporter/summary.py @@ -22,6 +22,7 @@ def __post_init__(self): self.run_configration.resourceList = (self.run_configration.resourceList or ["all"]) self.run_configration.contentType = self.run_configration.contentType.value # type: ignore self.run_configration.log_level = self.run_configration.log_level.value # type: ignore + self.run_configration.test_type = self.run_configration.test_type.value # type: ignore def get_DataFrame(self) -> DataFrame: """ diff --git a/src/edfi-paging-test/tests/api/test_request_client.py b/src/edfi-paging-test/tests/api/test_request_client.py index b60f795a..dc26e206 100644 --- a/src/edfi-paging-test/tests/api/test_request_client.py +++ b/src/edfi-paging-test/tests/api/test_request_client.py @@ -14,6 +14,7 @@ from edfi_paging_test.api.request_client import RequestClient, timeit from edfi_paging_test.helpers.argparser import MainArguments from edfi_paging_test.helpers.output_format import OutputFormat +from edfi_paging_test.reporter.paging_request_logger import PaggingRequestLogger FAKE_KEY = "TEST_KEY" @@ -176,7 +177,7 @@ def it_should_return_all_available_items(default_request_client: RequestClient): status_code=HTTPStatus.OK, text="[]", ) - result = default_request_client.get_all(FAKE_ENDPOINT) + result = default_request_client.get_all(FAKE_ENDPOINT, PaggingRequestLogger()) assert len(result) == TOTAL_COUNT diff --git a/src/edfi-paging-test/tests/helpers/test_api_metadata.py b/src/edfi-paging-test/tests/helpers/test_api_metadata.py index afe8ee08..dda06751 100644 --- a/src/edfi-paging-test/tests/helpers/test_api_metadata.py +++ b/src/edfi-paging-test/tests/helpers/test_api_metadata.py @@ -4,13 +4,13 @@ # # See the LICENSE and NOTICES files in the project root for more information. -from typing import List +from typing import Dict, List import pytest from pathlib import Path import pook from edfi_paging_test.helpers.api_metadata import ( - get_resource_paths, - normalize_resource_paths, + get_filters_by_resource_name, + normalize_resource_path, ) MOCK_BASE_URL: str = "https://example.com/v5.3/api" @@ -28,7 +28,7 @@ def describe_when_requesting_resource_paths(): @pytest.fixture - def resource_paths() -> List[str]: + def resource_paths() -> Dict[str, List[str]]: # arrange pook.activate() pook.get( @@ -48,38 +48,68 @@ def resource_paths() -> List[str]: ) # act - return get_resource_paths(MOCK_BASE_URL, True) + return get_filters_by_resource_name(MOCK_BASE_URL, True) def it_should_have_correct_length(resource_paths): assert len(resource_paths) == 129 def it_should_have_a_correct_sampling_of_resources(resource_paths): - assert resource_paths[0] == "/ed-fi/academicWeeks" - assert resource_paths[12] == "/tpdm/candidates" - assert resource_paths[50] == "/ed-fi/learningStandards" - assert ( - resource_paths[128] == "/ed-fi/surveySectionResponseStaffTargetAssociations" - ) - - -def describe_when_normalizing_paths(): - @pytest.fixture - def resource_paths() -> List[str]: - # arrange - paths = [ - "/ed-fi/student", - "/ed-fi/studentSchoolAssociation", - "/tpdm/candidates", + assert resource_paths['academicWeeks'] == ['weekIdentifier', + 'schoolId', 'beginDate', 'endDate', 'totalInstructionalDays'] + assert resource_paths['tpdm/candidates'] == [ + 'candidateIdentifier', + 'personId', + 'sourceSystemDescriptor', + 'birthCountryDescriptor', + 'englishLanguageExamDescriptor', + 'genderDescriptor', + 'limitedEnglishProficiencyDescriptor', + 'sexDescriptor', + 'birthSexDescriptor', + 'birthStateAbbreviationDescriptor', + 'birthCity', + 'birthDate', + 'birthInternationalProvince', + 'dateEnteredUS', + 'displacementStatus', + 'economicDisadvantaged', + 'firstGenerationStudent', + 'firstName', + 'generationCodeSuffix', + 'hispanicLatinoEthnicity', + 'lastSurname', + 'maidenName', + 'middleName', + 'multipleBirthStatus', + 'personalTitlePrefix', + ] + assert resource_paths['learningStandards'] == [ + 'learningStandardId', + 'parentLearningStandardId', + 'learningStandardCategoryDescriptor', + 'learningStandardScopeDescriptor', + 'courseTitle', + 'description', + 'learningStandardItemCode', + 'namespace', + 'successCriteria', + 'uri', + ] + assert resource_paths['surveySectionResponseStaffTargetAssociations'] == [ + 'staffUniqueId', + 'namespace', + 'surveyIdentifier', + 'surveyResponseIdentifier', + 'surveySectionTitle', ] - # act - return normalize_resource_paths(paths) - def it_should_remove_edfi_prefix(resource_paths): - assert resource_paths[0] == "student" +def describe_when_normalizing_paths(): + def it_should_remove_edfi_prefix(): + assert normalize_resource_path("/ed-fi/student") == "student" - def it_should_remove_edfi_prefix_and_preserve_case(resource_paths): - assert resource_paths[1] == "studentSchoolAssociation" + def it_should_remove_edfi_prefix_and_preserve_case(): + assert normalize_resource_path("/ed-fi/studentSchoolAssociation") == "studentSchoolAssociation" - def it_should_remove_leading_slash(resource_paths): - assert resource_paths[2] == "tpdm/candidates" + def it_should_remove_leading_slash(): + assert normalize_resource_path("/tpdm/candidates") == "tpdm/candidates" diff --git a/src/edfi-paging-test/tests/reporter/test_reporter.py b/src/edfi-paging-test/tests/reporter/test_reporter.py index 5e69325f..93a59cd3 100644 --- a/src/edfi-paging-test/tests/reporter/test_reporter.py +++ b/src/edfi-paging-test/tests/reporter/test_reporter.py @@ -15,6 +15,8 @@ create_statistics_json, create_summary_json, ) +from edfi_paging_test.reporter.paging_request_logger import PaggingRequestLogger +from edfi_paging_test.helpers.test_type import TestType def describe_when_creating_detail_csv() -> None: @@ -80,7 +82,7 @@ def init_fs(fs: FakeFilesystem) -> None: def describe_given_a_valid_DataFrame() -> None: @pytest.fixture(autouse=True) - def act(): + def act(mocker): df = DataFrame( [ { @@ -143,7 +145,10 @@ def act(): ] ) - create_statistics_csv(df, OUTPUT_DIRECTORY, RUN_NAME) + pagingRequestLogger = PaggingRequestLogger() + mocker.patch.object(pagingRequestLogger, 'get_DataFrame', return_value=df) + + create_statistics_csv(pagingRequestLogger.get_statistics(), OUTPUT_DIRECTORY, RUN_NAME) def it_creates_the_file(fs) -> None: assert fs.exists(EXPECTED_FILE) @@ -278,7 +283,7 @@ def init_fs(fs: FakeFilesystem) -> None: def describe_given_a_valid_DataFrame() -> None: @pytest.fixture(autouse=True) - def act(): + def act(mocker): df = DataFrame( [ { @@ -341,7 +346,10 @@ def act(): ] ) - create_statistics_json(df, OUTPUT_DIRECTORY, RUN_NAME) + pagingRequestLogger = PaggingRequestLogger() + mocker.patch.object(pagingRequestLogger, 'get_DataFrame', return_value=df) + + create_statistics_json(pagingRequestLogger.get_statistics(), OUTPUT_DIRECTORY, RUN_NAME) def it_creates_the_file(fs) -> None: assert fs.exists(EXPECTED_FILE) @@ -441,7 +449,9 @@ def describe_given_a_valid_DataFrame() -> None: "RunConfigration.Contenttype":"CSV", "RunConfigration.Resourcelist":["a","b"], "RunConfigration.Pagesize":100, - "RunConfigration.LogLevel":"DEBUG" + "RunConfigration.LogLevel":"DEBUG", + "RunConfigration.TestType":"DEEP_PAGING", + "RunConfigration.CombinationSizeLimit":1 }]""" @pytest.fixture(autouse=True) @@ -462,6 +472,8 @@ def act(): ["a", "b"], 100, LogLevel.DEBUG, + TestType.DEEP_PAGING, + 1 ) ) df = summary.get_DataFrame() diff --git a/src/edfi-paging-test/tests/reporter/test_request_logger.py b/src/edfi-paging-test/tests/reporter/test_request_logger.py index 9bd5ac90..c9ff5059 100644 --- a/src/edfi-paging-test/tests/reporter/test_request_logger.py +++ b/src/edfi-paging-test/tests/reporter/test_request_logger.py @@ -3,11 +3,11 @@ from pandas import DataFrame import pytest -from edfi_paging_test.reporter.measurement import Measurement -from edfi_paging_test.reporter.request_logger import get_DataFrame, log_request +from edfi_paging_test.reporter.paging_measurement import PagingMeasurement +from edfi_paging_test.reporter.paging_request_logger import PaggingRequestLogger -MEASUREMENT_1 = Measurement( +MEASUREMENT_1 = PagingMeasurement( "academicWeeks", "https://localhost/WebApi/data/v3/", 1, @@ -16,7 +16,7 @@ 40.0, 200, ) -MEASUREMENT_2 = Measurement( +MEASUREMENT_2 = PagingMeasurement( "assignments", "https://localhost/WebApi/data/v3/", 3, @@ -30,13 +30,11 @@ def describe_when_logging_a_request() -> None: def describe_given_there_is_already_an_item() -> None: @pytest.fixture() - def logs(mocker) -> List[Measurement]: - request_log = [MEASUREMENT_1] - mocker.patch( - "edfi_paging_test.reporter.request_logger._request_log", request_log - ) + def logs(mocker) -> List[PagingMeasurement]: + pagingRequestLogger = PaggingRequestLogger() + pagingRequestLogger.request_log = [MEASUREMENT_1] - log_request( + pagingRequestLogger.log_request( MEASUREMENT_2.resource, MEASUREMENT_2.URL, MEASUREMENT_2.page_number, @@ -46,36 +44,32 @@ def logs(mocker) -> List[Measurement]: MEASUREMENT_2.http_status_code ) - return request_log + return pagingRequestLogger.request_log - def it_appends_new_log_to_end_of_list(logs: List[Measurement]) -> None: + def it_appends_new_log_to_end_of_list(logs: List[PagingMeasurement]) -> None: assert logs[-1] == MEASUREMENT_2 def it_leaves_the_original_entry_at_beginning_of_list( - logs: List[Measurement], + logs: List[PagingMeasurement], ) -> None: assert logs[0] == MEASUREMENT_1 def describe_when_converting_to_a_DataFrame() -> None: - def describe_given_given_an_empty_list() -> None: + def describe_given_given_an_empty_request_log() -> None: def it_raises_a_RuntimeError(mocker) -> None: - mocker.patch("edfi_paging_test.reporter.request_logger._request_log", []) - with pytest.raises(RuntimeError): - get_DataFrame() + PaggingRequestLogger().get_DataFrame() def describe_given_two_items() -> None: @pytest.fixture() def df(mocker) -> DataFrame: # Arrange - request_log = [MEASUREMENT_1, MEASUREMENT_2] - mocker.patch( - "edfi_paging_test.reporter.request_logger._request_log", request_log - ) + paggingRequestLogger = PaggingRequestLogger() + paggingRequestLogger.request_log = [MEASUREMENT_1, MEASUREMENT_2] # Act - return get_DataFrame() + return paggingRequestLogger.get_DataFrame() def it_has_two_rows(df: DataFrame) -> None: assert df.shape[0] == 2