diff --git a/README.md b/README.md index fb42f6b..7062868 100644 --- a/README.md +++ b/README.md @@ -660,6 +660,30 @@ This strategy ensures that: 3. Tests remain reliable even when remote APIs are unavailable 4. Response schema validation stays current with actual API behavior +#### Performance Testing + +HURL includes comprehensive performance tests to validate httpx performance features. See [Performance Testing Documentation](docs/PERFORMANCE_TESTING.md) for detailed information. + +```bash +# Run performance tests against local FastAPI test server +python -m pytest tests/performance/ --performance-local + +# Run specific performance test categories +python -m pytest tests/performance/test_connection_features.py --performance-local +python -m pytest tests/performance/test_concurrency_features.py --performance-local + +# Performance tests are opt-in only and never run by default +python -m pytest tests/performance/ # These will be skipped without --performance-local +``` + +Performance tests validate: +- Connection keep-alive vs new connections +- Connection pooling configurations +- HTTP/1.1 vs HTTP/2 protocol performance +- Sync vs async client performance +- Concurrency scaling behavior +- Timeout and retry configuration effects + ### Code Style This project uses: diff --git a/docs/PERFORMANCE_TESTING.md b/docs/PERFORMANCE_TESTING.md new file mode 100644 index 0000000..ef6edcc --- /dev/null +++ b/docs/PERFORMANCE_TESTING.md @@ -0,0 +1,188 @@ +# Performance Testing for HURL + +This document describes the performance testing infrastructure implemented for HURL to test httpx performance features. + +## Overview + +HURL now includes comprehensive performance tests that demonstrate and validate the performance impact of various httpx features including: + +- Connection keep-alive vs new connections +- Connection pooling configurations +- HTTP/1.1 vs HTTP/2 protocol performance +- Sync vs async client performance +- Concurrency scaling behavior +- Timeout and retry configuration effects + +## Test Environment Strategies + +### 1. Local Test Server (FastAPI) +- **Purpose**: Performance testing with controlled, local environment +- **Implementation**: FastAPI-based server serving mock Hilltop XML responses +- **Configuration**: Configurable delays, error simulation, and fixture serving +- **Usage**: Enabled with `--performance-local` flag + +### 2. Remote Server Testing +- **Purpose**: Real-world performance validation against internet-accessible APIs +- **Configuration**: Via environment variables for security +- **Usage**: Enabled with `--performance-remote` flag (not yet implemented) + +### 3. Mocked Responses (Existing) +- **Purpose**: Correctness validation without network dependency +- **Implementation**: Existing pytest-httpx fixture cache system +- **Usage**: Default behavior for CI/CD + +## Running Performance Tests + +### Prerequisites + +Install performance testing dependencies: +```bash +pip install pytest-benchmark fastapi uvicorn +``` + +### Local Performance Tests + +Run performance tests against local FastAPI test server: + +```bash +# Run all local performance tests +python -m pytest tests/performance/ --performance-local + +# Run specific test categories +python -m pytest tests/performance/test_connection_features.py --performance-local +python -m pytest tests/performance/test_concurrency_features.py --performance-local + +# Run with verbose output +python -m pytest tests/performance/ --performance-local -v -s +``` + +### Configuration + +Performance tests can be configured via environment variables: + +```bash +# Local test server configuration +export TEST_SERVER_PORT=8001 # Server port (default: 8001) +export TEST_SERVER_DELAY=0.01 # Artificial delay in seconds (default: 0.01) +export TEST_SERVER_ERROR_RATE=0.0 # Error simulation rate 0.0-1.0 (default: 0.0) + +# Remote testing configuration (for future implementation) +export HILLTOP_PERFORMANCE_BASE_URL=https://your-test-server.com +export HILLTOP_PERFORMANCE_HTS_ENDPOINT=test.hts +``` + +## Test Categories + +### Connection Management Tests +- **File**: `tests/performance/test_connection_features.py` +- **Features**: Keep-alive connections, connection pooling, pool limits +- **Example Results**: + ``` + Keep-alive time: 0.1176s + New connection time: 0.1233s + Performance improvement: 4.6% + ``` + +### Protocol Performance Tests +- **File**: `tests/performance/test_protocol_features.py` +- **Features**: HTTP/1.1 vs HTTP/2, protocol negotiation +- **Metrics**: Request latency, throughput, protocol version validation + +### Concurrency Tests +- **File**: `tests/performance/test_concurrency_features.py` +- **Features**: Sync vs async clients, concurrent request handling, scaling +- **Example Results**: + ``` + Concurrency 1: 64.87 RPS + Concurrency 2: 140.36 RPS + Concurrency 5: 268.32 RPS + Concurrency 10: 398.80 RPS + ``` + +### Timeout and Retry Tests +- **File**: `tests/performance/test_timeout_retry.py` +- **Features**: Timeout configurations, retry behavior, error handling +- **Metrics**: Error handling overhead, retry performance impact + +## Benchmark Output + +Performance tests use `pytest-benchmark` to provide detailed metrics: + +``` +Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations +-------------------------------------------------------------------------------------------------------------------------------------- +test_sequential_requests_with_keep_alive 57.1432 58.3662 57.6593 0.2948 57.6226 0.2820 6;1 17.3433 17 1 +``` + +## Design Principles + +### Opt-in Testing +- Performance tests **never run by default** +- Require explicit CLI flags (`--performance-local`, `--performance-remote`) +- Skipped with clear messages when flags not provided + +### CI/CD Integration +- Only mocked tests run in CI (no network dependencies) +- Performance tests can be added to CI if fast enough +- Slow tests marked with `@pytest.mark.slow` for selective exclusion + +### Configurability +- All server URLs, delays, and error rates configurable via environment variables +- Deterministic behavior (no random values) +- Sensible defaults for local development + +### TDD Approach +- Tests designed before implementation changes +- Focus on demonstrating clear performance impact +- Quantitative metrics over qualitative assessments + +## Local Test Server Details + +The FastAPI-based local test server: + +- **Endpoint**: `http://127.0.0.1:8001/foo.hts` +- **Health Check**: `http://127.0.0.1:8001/health` +- **XML Format**: Proper HilltopServer format compatible with response parsers +- **Fixture Support**: Serves cached fixture data when available +- **Error Simulation**: Configurable error rates and artificial delays + +### Mock Response Example + +```xml + + + Test + MockServer-1.0 + foo.hts + 12345 + + test.dsn + 1 + + +``` + +## Future Enhancements + +1. **Remote Performance Testing**: Complete implementation of `--performance-remote` flag +2. **HTTP/2 Support**: Enhanced protocol testing when server supports HTTP/2 +3. **Historical Benchmarking**: Store and compare performance results over time +4. **Load Testing**: Stress testing scenarios with high concurrency +5. **Configuration-based Fixture Mapping**: Replace hardcoded mapping with config files + +## Troubleshooting + +### Server Startup Issues +- Check port availability (default 8001) +- Verify FastAPI and uvicorn installation +- Check firewall/security restrictions + +### Test Failures +- Ensure performance flags are provided (`--performance-local`) +- Check environment variable configuration +- Verify network connectivity for remote tests + +### Slow Performance +- Check `TEST_SERVER_DELAY` configuration +- Verify system resources aren't constrained +- Consider reducing test iteration counts for development \ No newline at end of file diff --git a/hurl/__init__.py b/hurl/__init__.py index 44245ee..71c3aed 100644 --- a/hurl/__init__.py +++ b/hurl/__init__.py @@ -1,5 +1,9 @@ """Top-level package for HURL.""" +from .client import HilltopClient, AsyncHilltopClient + __author__ = """Nic Mostert""" __email__ = "nicolas.mostert@horizons.govt.nz" __version__ = "0.1.0" + +__all__ = ["HilltopClient", "AsyncHilltopClient"] diff --git a/hurl/client.py b/hurl/client.py index 06dcafd..f935399 100644 --- a/hurl/client.py +++ b/hurl/client.py @@ -1,6 +1,8 @@ """Hilltop Client Module.""" +import asyncio import os +from typing import Optional import httpx import certifi @@ -30,14 +32,28 @@ def __init__( base_url: str | None = None, hts_endpoint: str | None = None, timeout: int = 60, + max_connections: int = 10, + max_keepalive_connections: int = 5, + http2: bool = False, + verify_ssl: bool = False, # Keep as False for backward compatibility ): self.base_url = base_url or os.getenv("HILLTOP_BASE_URL") self.hts_endpoint = hts_endpoint or os.getenv("HILLTOP_HTS_ENDPOINT") self.timeout = timeout + self.max_connections = max_connections + self.max_keepalive_connections = max_keepalive_connections + self.http2 = http2 + self.verify_ssl = verify_ssl + + # Create httpx session with configurable options self.session = httpx.Client( timeout=httpx.Timeout(timeout=timeout), - limits=httpx.Limits(max_connections=10), - verify=False, # TEMPORARY! # Disable SSL verification for testing + limits=httpx.Limits( + max_connections=max_connections, + max_keepalive_connections=max_keepalive_connections + ), + http2=http2, + verify=verify_ssl, follow_redirects=True, ) @@ -167,3 +183,164 @@ def __enter__(self): def __exit__(self, exc_type, exc_value, traceback): """Exit the runtime context related to this object.""" self.close() + + +class AsyncHilltopClient: + """An async client for interacting with Hilltop Server.""" + + def __init__( + self, + base_url: str | None = None, + hts_endpoint: str | None = None, + timeout: int = 60, + max_connections: int = 10, + max_keepalive_connections: int = 5, + http2: bool = False, + verify_ssl: bool = False, + ): + self.base_url = base_url or os.getenv("HILLTOP_BASE_URL") + self.hts_endpoint = hts_endpoint or os.getenv("HILLTOP_HTS_ENDPOINT") + self.timeout = timeout + self.max_connections = max_connections + self.max_keepalive_connections = max_keepalive_connections + self.http2 = http2 + self.verify_ssl = verify_ssl + + # Create async httpx session + self.session = httpx.AsyncClient( + timeout=httpx.Timeout(timeout=timeout), + limits=httpx.Limits( + max_connections=max_connections, + max_keepalive_connections=max_keepalive_connections + ), + http2=http2, + verify=verify_ssl, + follow_redirects=True, + ) + + if not self.base_url: + raise HilltopConfigError( + "Base URL must be provided or set in environment variables." + ) + + if not self.hts_endpoint: + raise HilltopConfigError( + "Hilltop HTS endpoint must be provided or set in environment variables." + ) + + async def _validate_response(self, response: httpx.Response) -> None: + """Raise HilltopResponseError if the response is not successful.""" + print(f"Response status code: {response.status_code}") + try: + response.raise_for_status() + except httpx.HTTPStatusError as e: + raise HilltopResponseError( + f"HTTP error occurred: {e.response.status_code} - {e.response.text}", + url=str(e.request.url), + raw_response=e.response.text, + ) from e + + async def get_collection_list(self, **kwargs) -> CollectionListResponse: + """Fetch the collection list from Hilltop Server.""" + request = CollectionListRequest( + base_url=self.base_url, + hts_endpoint=self.hts_endpoint, + **kwargs, + ) + response = await self.session.get(request.gen_url()) + await self._validate_response(response) + result = CollectionListResponse.from_xml(response.text) + result.request = request + return result + + async def get_data(self, **kwargs) -> GetDataResponse: + """Fetch data from Hilltop Server.""" + request = GetDataRequest( + base_url=self.base_url, + hts_endpoint=self.hts_endpoint, + **kwargs, + ) + response = await self.session.get(request.gen_url()) + await self._validate_response(response) + result = GetDataResponse.from_xml(response.text) + result.request = request + return result + + async def get_measurement_list(self, **kwargs) -> MeasurementListResponse: + """Fetch the measurement list from Hilltop Server.""" + request = MeasurementListRequest( + base_url=self.base_url, + hts_endpoint=self.hts_endpoint, + **kwargs, + ) + print(request.gen_url()) + response = await self.session.get(request.gen_url()) + await self._validate_response(response) + result = MeasurementListResponse.from_xml(response.text) + result.request = request + return result + + async def get_site_info(self, **kwargs) -> SiteInfoResponse: + """Fetch the site info from Hilltop Server.""" + request = SiteInfoRequest( + base_url=self.base_url, + hts_endpoint=self.hts_endpoint, + **kwargs, + ) + response = await self.session.get(request.gen_url()) + await self._validate_response(response) + result = SiteInfoResponse.from_xml(response.text) + result.request = request + return result + + async def get_site_list(self, **kwargs) -> SiteListResponse: + """Fetch the site list from Hilltop Server.""" + request = SiteListRequest( + base_url=self.base_url, + hts_endpoint=self.hts_endpoint, + **kwargs, + ) + response = await self.session.get(request.gen_url()) + await self._validate_response(response) + result = SiteListResponse.from_xml(response.text) + result.request = request + return result + + async def get_status(self, **kwargs) -> StatusResponse: + """Fetch the status from Hilltop Server.""" + request = StatusRequest( + base_url=self.base_url, + hts_endpoint=self.hts_endpoint, + **kwargs, + ) + print(request.gen_url()) + response = await self.session.get(request.gen_url()) + await self._validate_response(response) + result = StatusResponse.from_xml(response.text) + result.request = request + return result + + async def get_time_range(self, **kwargs) -> TimeRangeResponse: + """Fetch the TimeRange from Hilltop Server.""" + request = TimeRangeRequest( + base_url=self.base_url, + hts_endpoint=self.hts_endpoint, + **kwargs, + ) + response = await self.session.get(request.gen_url()) + await self._validate_response(response) + result = TimeRangeResponse.from_xml(response.text) + result.request = request + return result + + async def close(self): + """Close the HTTP session.""" + await self.session.aclose() + + async def __aenter__(self): + """Enter the async runtime context related to this object.""" + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + """Exit the async runtime context related to this object.""" + await self.close() diff --git a/hurl/schemas/responses/status.py b/hurl/schemas/responses/status.py index ef0bc26..34517f3 100644 --- a/hurl/schemas/responses/status.py +++ b/hurl/schemas/responses/status.py @@ -5,6 +5,7 @@ import xmltodict from pydantic import BaseModel, Field, field_validator +from hurl.exceptions import HilltopParseError from hurl.schemas.mixins import ModelReprMixin from hurl.schemas.requests import StatusRequest diff --git a/poetry.lock b/poetry.lock index e4759a0..0dced8e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -44,6 +44,21 @@ files = [ {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, ] +[[package]] +name = "click" +version = "8.2.1" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, + {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + [[package]] name = "colorama" version = "0.4.6" @@ -51,12 +66,135 @@ description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["main"] -markers = "sys_platform == \"win32\"" +markers = "sys_platform == \"win32\" or platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "coverage" +version = "7.10.6" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "coverage-7.10.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70e7bfbd57126b5554aa482691145f798d7df77489a177a6bef80de78860a356"}, + {file = "coverage-7.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301"}, + {file = "coverage-7.10.6-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c61fc91ab80b23f5fddbee342d19662f3d3328173229caded831aa0bd7595460"}, + {file = "coverage-7.10.6-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10356fdd33a7cc06e8051413140bbdc6f972137508a3572e3f59f805cd2832fd"}, + {file = "coverage-7.10.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80b1695cf7c5ebe7b44bf2521221b9bb8cdf69b1f24231149a7e3eb1ae5fa2fb"}, + {file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2e4c33e6378b9d52d3454bd08847a8651f4ed23ddbb4a0520227bd346382bbc6"}, + {file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c8a3ec16e34ef980a46f60dc6ad86ec60f763c3f2fa0db6d261e6e754f72e945"}, + {file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7d79dabc0a56f5af990cc6da9ad1e40766e82773c075f09cc571e2076fef882e"}, + {file = "coverage-7.10.6-cp310-cp310-win32.whl", hash = "sha256:86b9b59f2b16e981906e9d6383eb6446d5b46c278460ae2c36487667717eccf1"}, + {file = "coverage-7.10.6-cp310-cp310-win_amd64.whl", hash = "sha256:e132b9152749bd33534e5bd8565c7576f135f157b4029b975e15ee184325f528"}, + {file = "coverage-7.10.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c706db3cabb7ceef779de68270150665e710b46d56372455cd741184f3868d8f"}, + {file = "coverage-7.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e0c38dc289e0508ef68ec95834cb5d2e96fdbe792eaccaa1bccac3966bbadcc"}, + {file = "coverage-7.10.6-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:752a3005a1ded28f2f3a6e8787e24f28d6abe176ca64677bcd8d53d6fe2ec08a"}, + {file = "coverage-7.10.6-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:689920ecfd60f992cafca4f5477d55720466ad2c7fa29bb56ac8d44a1ac2b47a"}, + {file = "coverage-7.10.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec98435796d2624d6905820a42f82149ee9fc4f2d45c2c5bc5a44481cc50db62"}, + {file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b37201ce4a458c7a758ecc4efa92fa8ed783c66e0fa3c42ae19fc454a0792153"}, + {file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2904271c80898663c810a6b067920a61dd8d38341244a3605bd31ab55250dad5"}, + {file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5aea98383463d6e1fa4e95416d8de66f2d0cb588774ee20ae1b28df826bcb619"}, + {file = "coverage-7.10.6-cp311-cp311-win32.whl", hash = "sha256:e3fb1fa01d3598002777dd259c0c2e6d9d5e10e7222976fc8e03992f972a2cba"}, + {file = "coverage-7.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:f35ed9d945bece26553d5b4c8630453169672bea0050a564456eb88bdffd927e"}, + {file = "coverage-7.10.6-cp311-cp311-win_arm64.whl", hash = "sha256:99e1a305c7765631d74b98bf7dbf54eeea931f975e80f115437d23848ee8c27c"}, + {file = "coverage-7.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b2dd6059938063a2c9fee1af729d4f2af28fd1a545e9b7652861f0d752ebcea"}, + {file = "coverage-7.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:388d80e56191bf846c485c14ae2bc8898aa3124d9d35903fef7d907780477634"}, + {file = "coverage-7.10.6-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:90cb5b1a4670662719591aa92d0095bb41714970c0b065b02a2610172dbf0af6"}, + {file = "coverage-7.10.6-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:961834e2f2b863a0e14260a9a273aff07ff7818ab6e66d2addf5628590c628f9"}, + {file = "coverage-7.10.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf9a19f5012dab774628491659646335b1928cfc931bf8d97b0d5918dd58033c"}, + {file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:99c4283e2a0e147b9c9cc6bc9c96124de9419d6044837e9799763a0e29a7321a"}, + {file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:282b1b20f45df57cc508c1e033403f02283adfb67d4c9c35a90281d81e5c52c5"}, + {file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cdbe264f11afd69841bd8c0d83ca10b5b32853263ee62e6ac6a0ab63895f972"}, + {file = "coverage-7.10.6-cp312-cp312-win32.whl", hash = "sha256:a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d"}, + {file = "coverage-7.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:856986eadf41f52b214176d894a7de05331117f6035a28ac0016c0f63d887629"}, + {file = "coverage-7.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80"}, + {file = "coverage-7.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6"}, + {file = "coverage-7.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80"}, + {file = "coverage-7.10.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003"}, + {file = "coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27"}, + {file = "coverage-7.10.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4"}, + {file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d"}, + {file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc"}, + {file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc"}, + {file = "coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e"}, + {file = "coverage-7.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32"}, + {file = "coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2"}, + {file = "coverage-7.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b"}, + {file = "coverage-7.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393"}, + {file = "coverage-7.10.6-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27"}, + {file = "coverage-7.10.6-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df"}, + {file = "coverage-7.10.6-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb"}, + {file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282"}, + {file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4"}, + {file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21"}, + {file = "coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0"}, + {file = "coverage-7.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5"}, + {file = "coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b"}, + {file = "coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e"}, + {file = "coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb"}, + {file = "coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034"}, + {file = "coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1"}, + {file = "coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a"}, + {file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb"}, + {file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d"}, + {file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747"}, + {file = "coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5"}, + {file = "coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713"}, + {file = "coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32"}, + {file = "coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65"}, + {file = "coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6"}, + {file = "coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0"}, + {file = "coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e"}, + {file = "coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5"}, + {file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7"}, + {file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5"}, + {file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0"}, + {file = "coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7"}, + {file = "coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930"}, + {file = "coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b"}, + {file = "coverage-7.10.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90558c35af64971d65fbd935c32010f9a2f52776103a259f1dee865fe8259352"}, + {file = "coverage-7.10.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8953746d371e5695405806c46d705a3cd170b9cc2b9f93953ad838f6c1e58612"}, + {file = "coverage-7.10.6-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c83f6afb480eae0313114297d29d7c295670a41c11b274e6bca0c64540c1ce7b"}, + {file = "coverage-7.10.6-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7eb68d356ba0cc158ca535ce1381dbf2037fa8cb5b1ae5ddfc302e7317d04144"}, + {file = "coverage-7.10.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b15a87265e96307482746d86995f4bff282f14b027db75469c446da6127433b"}, + {file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fc53ba868875bfbb66ee447d64d6413c2db91fddcfca57025a0e7ab5b07d5862"}, + {file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:efeda443000aa23f276f4df973cb82beca682fd800bb119d19e80504ffe53ec2"}, + {file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9702b59d582ff1e184945d8b501ffdd08d2cee38d93a2206aa5f1365ce0b8d78"}, + {file = "coverage-7.10.6-cp39-cp39-win32.whl", hash = "sha256:2195f8e16ba1a44651ca684db2ea2b2d4b5345da12f07d9c22a395202a05b23c"}, + {file = "coverage-7.10.6-cp39-cp39-win_amd64.whl", hash = "sha256:f32ff80e7ef6a5b5b606ea69a36e97b219cd9dc799bcf2963018a4d8f788cfbf"}, + {file = "coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3"}, + {file = "coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90"}, +] + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + +[[package]] +name = "fastapi" +version = "0.116.1" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565"}, + {file = "fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143"}, +] + +[package.dependencies] +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.40.0,<0.48.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] + [[package]] name = "h11" version = "0.16.0" @@ -69,6 +207,34 @@ files = [ {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, ] +[[package]] +name = "h2" +version = "4.3.0" +description = "Pure-Python HTTP/2 protocol implementation" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd"}, + {file = "h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1"}, +] + +[package.dependencies] +hpack = ">=4.1,<5" +hyperframe = ">=6.1,<7" + +[[package]] +name = "hpack" +version = "4.1.0" +description = "Pure-Python HPACK header encoding" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496"}, + {file = "hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca"}, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -116,6 +282,18 @@ http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "hyperframe" +version = "6.1.0" +description = "Pure-Python HTTP/2 framing" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5"}, + {file = "hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08"}, +] + [[package]] name = "idna" version = "3.10" @@ -485,6 +663,18 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "py-cpuinfo" +version = "9.0.0" +description = "Get CPU info with pure Python" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690"}, + {file = "py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5"}, +] + [[package]] name = "pydantic" version = "2.11.9" @@ -656,6 +846,67 @@ pygments = ">=2.7.2" [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99"}, + {file = "pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57"}, +] + +[package.dependencies] +pytest = ">=8.2,<9" +typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "pytest-benchmark" +version = "5.1.0" +description = "A ``pytest`` fixture for benchmarking code. It will group the tests into rounds that are calibrated to the chosen timer." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pytest-benchmark-5.1.0.tar.gz", hash = "sha256:9ea661cdc292e8231f7cd4c10b0319e56a2118e2c09d9f50e1b3d150d2aca105"}, + {file = "pytest_benchmark-5.1.0-py3-none-any.whl", hash = "sha256:922de2dfa3033c227c96da942d1878191afa135a29485fb942e85dff1c592c89"}, +] + +[package.dependencies] +py-cpuinfo = "*" +pytest = ">=8.1" + +[package.extras] +aspect = ["aspectlib"] +elasticsearch = ["elasticsearch"] +histogram = ["pygal", "pygaljs", "setuptools"] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, + {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, +] + +[package.dependencies] +coverage = {version = ">=7.10.6", extras = ["toml"]} +pluggy = ">=1.2" +pytest = ">=7" + +[package.extras] +testing = ["process-tests", "pytest-xdist", "virtualenv"] + [[package]] name = "pytest-httpx" version = "0.35.0" @@ -822,6 +1073,25 @@ files = [ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] +[[package]] +name = "starlette" +version = "0.47.3" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51"}, + {file = "starlette-0.47.3.tar.gz", hash = "sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9"}, +] + +[package.dependencies] +anyio = ">=3.6.2,<5" +typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} + +[package.extras] +full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -861,6 +1131,25 @@ files = [ {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, ] +[[package]] +name = "uvicorn" +version = "0.35.0" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"}, + {file = "uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01"}, +] + +[package.dependencies] +click = ">=7.0" +h11 = ">=0.8" + +[package.extras] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] + [[package]] name = "xmltodict" version = "1.0.0" @@ -876,4 +1165,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "ba73f48dd659f82db52bed7f6ef4ec824cd0dfba142544891edc3a3f09511325" +content-hash = "1b6383f9e3fe8590fa927fd6460968d04d286448d721eb02d5ec56a26add07d8" diff --git a/pyproject.toml b/pyproject.toml index 9acf97f..1ae41cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,14 @@ dependencies = [ "isodate (>=0.7.2,<0.8.0)", "xmltodict (>=1.0.0,<2.0.0)", "certifi (>=2025.8.3,<2026.0.0)", - "pyyaml (>=6.0.2,<7.0.0)" + "pyyaml (>=6.0.2,<7.0.0)", + "fastapi (>=0.116.1,<0.117.0)", + "uvicorn (>=0.35.0,<0.36.0)", + "pytest-benchmark (>=5.1.0,<6.0.0)", + "anyio (>=4.10.0,<5.0.0)", + "pytest-asyncio (>=1.2.0,<2.0.0)", + "h2 (>=4.3.0,<5.0.0)", + "pytest-cov (>=7.0.0,<8.0.0)" ] @@ -29,10 +36,13 @@ requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" [tool.pytest.ini_options] +asyncio_mode = "auto" markers = [ "slow: marks tests as slow (deselect as '-m \"not slow\"')", "remote: marks tests as remote (deselect as '-m \"not remote\"')", + "update: updates the api response data cache (deselect as '-m \"not update\"')", "unit: marks tests as unit tests (deselect as '-m \"not unit\"')", "integration: marks tests as integration tests (deselect as '-m \"not integration\"')", "performance: marks tests as performance tests (deselect as '-m \"not performance\"')", ] +addopts = "--cov=hurl --cov-report=term-missing" diff --git a/tests/conftest.py b/tests/conftest.py index cf84df2..af1fecd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,9 @@ """Shared fixtures for testing.""" +import asyncio +import os +import threading +import time from pathlib import Path import pytest @@ -78,13 +82,16 @@ def pytest_collection_modifyitems(config, items): if "unit" in item.keywords or "performance" in item.keywords: item.add_marker( pytest.mark.skip( - reason="Only running integration tests in unit mode" + reason="Only running integration tests in integration mode" ) ) if config.getoption("--mode") == "performance": - raise pytest.PytestConfigWarning( - "Performance tests are not yet implemented." - ) + if "integration" in item.keywords or "unit" in item.keywords: + item.add_marker( + pytest.mark.skip( + reason="Only running performance tests in performance mode" + ) + ) if config.getoption("--update"): if config.getoption("--mode") not in ["all", "integration"]: @@ -155,3 +162,102 @@ def _factory(response_xml="payload", status_code=200): } return _factory + + +@pytest.fixture(scope="session") +def local_test_server(): + """Start a local FastAPI test server for performance testing.""" + import threading + import time + + import httpx + import uvicorn + + from tests.performance.local_server import create_test_server + + # Configuration from environment variables + host = "127.0.0.1" + port = int(os.getenv("TEST_SERVER_PORT", "8001")) # Use 8001 to avoid conflicts + delay = float( + os.getenv("TEST_SERVER_DELAY", "0.01") + ) # Small default delay for testing + error_rate = float(os.getenv("TEST_SERVER_ERROR_RATE", "0.0")) + + server = create_test_server( + host=host, port=port, delay=delay, error_rate=error_rate + ) + + # Start server in a separate thread + server_thread = threading.Thread( + target=server.run, kwargs={"access_log": False}, daemon=True + ) + server_thread.start() + + # Wait for server to start + server_url = f"http://{host}:{port}" + max_retries = 50 # Increase retries + for i in range(max_retries): + try: + response = httpx.get(f"{server_url}/health", timeout=2.0) + if response.status_code == 200: + break + except (httpx.RequestError, httpx.TimeoutException): + pass + time.sleep(0.1) + else: + pytest.fail(f"Local test server failed to start after {max_retries * 0.1}s") + + yield { + "base_url": server_url, + "hts_endpoint": "foo.hts", + "host": host, + "port": port, + "delay": delay, + "error_rate": error_rate, + } + + # Server cleanup happens automatically when thread ends + + +@pytest.fixture(scope="session") +def performance_remote_client(): + """Create a remote client for performance testing.""" + from hurl.client import HilltopClient + + # Check if remote testing is enabled and environment is configured + remote_base_url = os.getenv("HILLTOP_PERFORMANCE_BASE_URL") + remote_hts_endpoint = os.getenv("HILLTOP_PERFORMANCE_HTS_ENDPOINT") + + if not remote_base_url or not remote_hts_endpoint: + pytest.skip( + "Remote performance testing requires HILLTOP_PERFORMANCE_BASE_URL " + "and HILLTOP_PERFORMANCE_HTS_ENDPOINT environment variables" + ) + + client = HilltopClient( + base_url=remote_base_url, + hts_endpoint=remote_hts_endpoint, + timeout=30, # Longer timeout for performance tests + ) + + try: + yield client + finally: + client.close() + + +@pytest.fixture +def performance_local_client(local_test_server): + """Create a client configured for local performance testing.""" + from hurl.client import HilltopClient + + client = HilltopClient( + base_url=local_test_server["base_url"], + hts_endpoint=local_test_server["hts_endpoint"], + timeout=30, + ) + + try: + yield client + finally: + client.close() diff --git a/tests/performance/__init__.py b/tests/performance/__init__.py new file mode 100644 index 0000000..47dca00 --- /dev/null +++ b/tests/performance/__init__.py @@ -0,0 +1 @@ +"""Performance testing module for HURL httpx features.""" diff --git a/tests/performance/local_server.py b/tests/performance/local_server.py new file mode 100644 index 0000000..a596a31 --- /dev/null +++ b/tests/performance/local_server.py @@ -0,0 +1,203 @@ +"""Local FastAPI test server for performance testing. + +This module provides a FastAPI-based test server that serves fixture data +from the cache with configurable delays and error simulation for testing +httpx performance features like connection pooling, keep-alive, HTTP/2, etc. +""" + +import asyncio +import os +import time +from pathlib import Path +from typing import Optional + +import uvicorn +from fastapi import FastAPI, HTTPException, Response +from fastapi.responses import PlainTextResponse + + +class LocalTestServer: + """FastAPI-based local test server for performance testing.""" + + def __init__( + self, + host: str = "127.0.0.1", + port: int = 8000, + mocked_data_path: Optional[Path] = None, + default_delay: float = 0.0, + error_rate: float = 0.0, + ): + """Initialize the local test server. + + Args: + ---- + host: Host to bind to (default: 127.0.0.1) + port: Port to bind to (default: 8000) + mocked_data_path: Path to fixture cache directory + default_delay: Default artificial delay in seconds + error_rate: Rate of errors to simulate (0.0-1.0) + """ + self.host = host + self.port = port + self.default_delay = default_delay + self.error_rate = error_rate + + # Set fixture cache path + if mocked_data_path is None: + self.mocked_data_path = Path(__file__).parent.parent / "mocked_data" + else: + self.mocked_data_path = mocked_data_path + + self.app = FastAPI( + title="HURL Performance Test Server", + description="Local test server for httpx performance testing", + version="1.0.0", + ) + + # Hardcoded fixture mapping (can be extended to config-based later) + self.fixture_mapping = { + # StatusRequest endpoint + "/foo.hts": { + "Service=Hilltop&Request=Status": "status/response.xml", + }, + # SiteListRequest endpoint + "/foo.hts": { + "Service=Hilltop&Request=SiteList": "site_list/all_response.xml", + }, + # MeasurementListRequest endpoint + "/foo.hts": { + "Service=Hilltop&Request=MeasurementList": "measurement_list/all_response.xml", + }, + # GetDataRequest endpoint + "/foo.hts": { + "Service=Hilltop&Request=GetData": "get_data/basic_response.xml", + }, + } + + self._setup_routes() + + def _setup_routes(self): + """Setup FastAPI routes.""" + + @self.app.get("/health") + async def health_check(): + """Health check endpoint.""" + return {"status": "healthy", "server": "hurl-performance-test"} + + @self.app.get("/foo.hts") + async def hilltop_endpoint(service: str = None, request: str = None): + """Provide Main Hilltop-compatible endpoint.""" + # Simulate artificial delay if configured + if self.default_delay > 0: + await asyncio.sleep(self.default_delay) + + # Simulate errors if configured + if self.error_rate > 0 and time.time() % 1.0 < self.error_rate: + raise HTTPException(status_code=500, detail="Simulated server error") + + # Handle case where parameters might be case-insensitive + if not service: + service = "Hilltop" # Default service + if not request: + request = "Status" # Default request + + # Create query key for fixture mapping + query_key = f"Service={service}&Request={request}" + + # Find matching fixture + fixture_file = None + if query_key in self.fixture_mapping.get("/foo.hts", {}): + fixture_file = self.fixture_mapping["/foo.hts"][query_key] + else: + # Default fallback - try to find any matching fixture + if request == "Status": + fixture_file = "status/response.xml" + elif request == "SiteList": + fixture_file = "site_list/all_response.xml" + elif request == "MeasurementList": + fixture_file = "measurement_list/all_response.xml" + elif request == "GetData": + fixture_file = "get_data/basic_response.xml" + + if not fixture_file: + # Fallback to default response + fixture_file = "status/response.xml" + + # Load fixture content + fixture_path = self.mocked_data_path / fixture_file + if not fixture_path.exists(): + # Create a minimal default response if fixture doesn't exist + default_xml = f""" + + Test + MockServer-1.0 + foo.hts + 12345 + + test.dsn + 1 + +""" + return PlainTextResponse(content=default_xml, media_type="text/xml") + + try: + xml_content = fixture_path.read_text(encoding="utf-8") + return PlainTextResponse(content=xml_content, media_type="text/xml") + except Exception as e: + # Fallback to default XML on error + default_xml = f""" + + Test + MockServer-1.0 + foo.hts + 12345 + + test.dsn + 1 + +""" + return PlainTextResponse(content=default_xml, media_type="text/xml") + + def run(self, **kwargs): + """Run the server.""" + # Set default values but allow override + run_kwargs = { + "host": self.host, + "port": self.port, + "log_level": "warning", # Reduce noise during testing + } + run_kwargs.update(kwargs) + + uvicorn.run(self.app, **run_kwargs) + + async def run_async(self, **kwargs): + """Run the server asynchronously.""" + config = uvicorn.Config( + self.app, host=self.host, port=self.port, log_level="warning", **kwargs + ) + server = uvicorn.Server(config) + await server.serve() + + +def create_test_server( + host: str = "127.0.0.1", + port: int = 8000, + delay: float = 0.0, + error_rate: float = 0.0, +) -> LocalTestServer: + """Create a test server with specified configuration (Factory function).""" + return LocalTestServer( + host=host, port=port, default_delay=delay, error_rate=error_rate + ) + + +if __name__ == "__main__": + # Allow running server directly for manual testing + delay = float(os.getenv("TEST_SERVER_DELAY", "0.0")) + error_rate = float(os.getenv("TEST_SERVER_ERROR_RATE", "0.0")) + port = int(os.getenv("TEST_SERVER_PORT", "8000")) + + server = create_test_server(delay=delay, error_rate=error_rate, port=port) + print(f"Starting HURL Performance Test Server on http://127.0.0.1:{port}") + print(f"Configuration: delay={delay}s, error_rate={error_rate}") + server.run() diff --git a/tests/performance/test_concurrency_features.py b/tests/performance/test_concurrency_features.py new file mode 100644 index 0000000..a59d9a5 --- /dev/null +++ b/tests/performance/test_concurrency_features.py @@ -0,0 +1,376 @@ +"""Tests for httpx concurrency features. + +This module tests httpx performance features related to concurrency: +- Sync vs async client performance +- Concurrent request handling +- Async context manager usage +""" + +import asyncio +import time +from concurrent.futures import ThreadPoolExecutor + +import httpx +import pytest + +from hurl.client import HilltopClient + + +class TestSyncVsAsyncPerformance: + """Compare synchronous vs asynchronous client performance.""" + + @pytest.mark.performance + def test_sync_sequential_requests(self, benchmark, local_test_server): + """Test synchronous sequential request performance.""" + + def make_sync_requests(): + """Make sequential requests using sync client.""" + with httpx.Client( + base_url=local_test_server["base_url"], timeout=30.0 + ) as client: + results = [] + for _ in range(5): + response = client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + results.append(response) + return results + + results = benchmark(make_sync_requests) + + # Verify all requests succeeded + assert len(results) == 5 + for result in results: + assert result.status_code == 200 + + @pytest.mark.performance + def test_async_sequential_requests(self, benchmark, local_test_server): + """Test asynchronous sequential request performance.""" + + async def make_async_requests(): + """Make sequential requests using async client.""" + async with httpx.AsyncClient( + base_url=local_test_server["base_url"], timeout=30.0 + ) as client: + results = [] + for _ in range(5): + response = await client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + results.append(response) + return results + + def run_async_test(): + """Wrapper to run the async test.""" + return asyncio.run(make_async_requests()) + + # Use pytest-benchmark's async support + results = benchmark.pedantic(run_async_test, rounds=3, iterations=1) + + # Verify all requests succeeded + assert len(results) == 5 + for result in results: + assert result.status_code == 200 + + @pytest.mark.performance + def test_sync_vs_async_comparison(self, local_test_server): + """Compare sync vs async performance for the same workload.""" + + # Sync performance + start_time = time.perf_counter() + with httpx.Client( + base_url=local_test_server["base_url"], timeout=30.0 + ) as client: + sync_results = [] + for _ in range(10): + response = client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + sync_results.append(response) + sync_time = time.perf_counter() - start_time + + # Async performance (sequential) + async def async_sequential(): + async with httpx.AsyncClient( + base_url=local_test_server["base_url"], timeout=30.0 + ) as client: + async_results = [] + for _ in range(10): + response = await client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + async_results.append(response) + return async_results + + start_time = time.perf_counter() + async_results = asyncio.run(async_sequential()) + async_time = time.perf_counter() - start_time + + # Verify both approaches work + assert len(sync_results) == 10 + assert len(async_results) == 10 + + for result in sync_results + async_results: + assert result.status_code == 200 + + # Record performance metrics + print(f"Sync sequential time: {sync_time:.4f}s") + print(f"Async sequential time: {async_time:.4f}s") + print(f"Sync RPS: {10/sync_time:.2f}") + print(f"Async RPS: {10/async_time:.2f}") + + +class TestConcurrentRequests: + """Test concurrent request performance.""" + + @pytest.mark.performance + def test_sync_concurrent_with_threads(self, benchmark, local_test_server): + """Test sync client with thread-based concurrency.""" + + def make_single_request(): + """Make a single request.""" + with httpx.Client( + base_url=local_test_server["base_url"], timeout=30.0 + ) as client: + response = client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + return response + + def make_concurrent_sync_requests(): + """Make concurrent requests using ThreadPoolExecutor.""" + with ThreadPoolExecutor(max_workers=5) as executor: + futures = [executor.submit(make_single_request) for _ in range(10)] + results = [future.result() for future in futures] + return results + + results = benchmark(make_concurrent_sync_requests) + + # Verify all requests succeeded + assert len(results) == 10 + for result in results: + assert result.status_code == 200 + + @pytest.mark.performance + def test_async_concurrent_requests(self, benchmark, local_test_server): + """Test async client with asyncio concurrency.""" + + async def make_async_concurrent_requests(): + """Make concurrent requests using asyncio.gather.""" + async with httpx.AsyncClient( + base_url=local_test_server["base_url"], + limits=httpx.Limits(max_connections=10), + timeout=30.0, + ) as client: + tasks = [ + client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + for _ in range(10) + ] + results = await asyncio.gather(*tasks) + return results + + def run_async_test(): + """Wrapper to run the async test.""" + return asyncio.run(make_async_concurrent_requests()) + + results = benchmark.pedantic(run_async_test, rounds=3, iterations=1) + + # Verify all requests succeeded + assert len(results) == 10 + for result in results: + assert result.status_code == 200 + + @pytest.mark.performance + def test_concurrency_scaling(self, local_test_server): + """Test how performance scales with concurrency level.""" + + async def test_concurrency_level(concurrency): + """Test performance at a specific concurrency level.""" + async with httpx.AsyncClient( + base_url=local_test_server["base_url"], + limits=httpx.Limits(max_connections=concurrency), + timeout=30.0, + ) as client: + start_time = time.perf_counter() + tasks = [ + client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + for _ in range(concurrency * 2) # 2x requests vs connections + ] + results = await asyncio.gather(*tasks) + end_time = time.perf_counter() + + return { + "concurrency": concurrency, + "requests": len(results), + "time": end_time - start_time, + "rps": len(results) / (end_time - start_time), + "success": all(r.status_code == 200 for r in results), + } + + # Test different concurrency levels + concurrency_levels = [1, 2, 5, 10] + results = [] + + for level in concurrency_levels: + result = asyncio.run(test_concurrency_level(level)) + results.append(result) + + # Verify all tests succeeded + for result in results: + assert result["success"] + print( + f"Concurrency {result['concurrency']}: {result['rps']:.2f} RPS ({result['time']:.4f}s for {result['requests']} requests)" + ) + + # Check that concurrency generally improves performance + # (though in test environment this might not always be true) + assert all(r["rps"] > 0 for r in results) + + +class TestAsyncContextManagement: + """Test async context manager performance.""" + + @pytest.mark.performance + async def test_async_context_manager_reuse(self, local_test_server): + """Test reusing async client context manager.""" + + async with httpx.AsyncClient( + base_url=local_test_server["base_url"], timeout=30.0 + ) as client: + # Multiple requests within same context + start_time = time.perf_counter() + results = [] + for _ in range(5): + response = await client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + results.append(response) + end_time = time.perf_counter() + + # Verify all succeeded + assert len(results) == 5 + for result in results: + assert result.status_code == 200 + + reuse_time = end_time - start_time + print(f"Context reuse time: {reuse_time:.4f}s") + + # Compare with creating new context for each request + start_time = time.perf_counter() + new_context_results = [] + for _ in range(5): + async with httpx.AsyncClient( + base_url=local_test_server["base_url"], timeout=30.0 + ) as client: + response = await client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + new_context_results.append(response) + end_time = time.perf_counter() + + new_context_time = end_time - start_time + print(f"New context time: {new_context_time:.4f}s") + print( + f"Context reuse improvement: {((new_context_time - reuse_time) / new_context_time * 100):.1f}%" + ) + + # Verify both approaches work + assert len(new_context_results) == 5 + for result in new_context_results: + assert result.status_code == 200 + + +class TestHilltopClientConcurrency: + """Test HilltopClient in concurrent scenarios.""" + + @pytest.mark.performance + def test_hilltop_client_thread_safety(self, local_test_server): + """Test HilltopClient thread safety (though it's not designed for it).""" + + # Note: HilltopClient is not designed to be thread-safe + # This test demonstrates the recommended pattern: one client per thread + + def make_requests_with_own_client(): + """Each thread creates its own client.""" + with HilltopClient( + base_url=local_test_server["base_url"], + hts_endpoint=local_test_server["hts_endpoint"], + timeout=30, + ) as client: + results = [] + for _ in range(3): + result = client.get_status() + results.append(result) + return results + + # Use thread pool with separate clients + with ThreadPoolExecutor(max_workers=3) as executor: + futures = [executor.submit(make_requests_with_own_client) for _ in range(3)] + all_results = [future.result() for future in futures] + + # Verify all threads succeeded + assert len(all_results) == 3 + for thread_results in all_results: + assert len(thread_results) == 3 + for result in thread_results: + assert hasattr(result, "request") + + @pytest.mark.performance + def test_multiple_hilltop_clients(self, local_test_server): + """Test performance with multiple HilltopClient instances.""" + + start_time = time.perf_counter() + results = [] + + # Create multiple clients and use them + for i in range(5): + with HilltopClient( + base_url=local_test_server["base_url"], + hts_endpoint=local_test_server["hts_endpoint"], + timeout=30, + ) as client: + result = client.get_status() + results.append(result) + + end_time = time.perf_counter() + + # Verify all succeeded + assert len(results) == 5 + for result in results: + assert hasattr(result, "request") + + multiple_clients_time = end_time - start_time + print(f"Multiple clients time: {multiple_clients_time:.4f}s") + print(f"Multiple clients RPS: {5/multiple_clients_time:.2f}") + + # Compare with single client reuse + start_time = time.perf_counter() + with HilltopClient( + base_url=local_test_server["base_url"], + hts_endpoint=local_test_server["hts_endpoint"], + timeout=30, + ) as client: + single_client_results = [] + for _ in range(5): + result = client.get_status() + single_client_results.append(result) + end_time = time.perf_counter() + + single_client_time = end_time - start_time + print(f"Single client time: {single_client_time:.4f}s") + print(f"Single client RPS: {5/single_client_time:.2f}") + + # Verify both approaches work + assert len(single_client_results) == 5 + for result in single_client_results: + assert hasattr(result, "request") + + # Single client reuse should typically be faster + print( + f"Single client improvement: {((multiple_clients_time - single_client_time) / multiple_clients_time * 100):.1f}%" + ) + diff --git a/tests/performance/test_connection_features.py b/tests/performance/test_connection_features.py new file mode 100644 index 0000000..3807e32 --- /dev/null +++ b/tests/performance/test_connection_features.py @@ -0,0 +1,260 @@ +"""Tests for httpx connection management features. + +This module tests httpx performance features related to connection management: +- Keep-alive connections vs new connections +- Connection pooling vs non-pooled connections +- Connection reuse patterns +""" + +import asyncio +import time +from unittest.mock import patch + +import httpx +import pytest + +from hurl.client import HilltopClient + + +class TestConnectionKeepAlive: + """Test connection keep-alive functionality.""" + + @pytest.mark.performance + def test_sequential_requests_with_keep_alive( + self, benchmark, performance_local_client + ): + """Test performance of sequential requests with keep-alive enabled.""" + + def make_requests(): + """Make multiple sequential requests using the same client session.""" + results = [] + for _ in range(5): + response = performance_local_client.get_status() + results.append(response) + return results + + # Benchmark with keep-alive (default httpx behavior) + results = benchmark(make_requests) + + # Verify all requests succeeded + assert len(results) == 5 + for result in results: + assert hasattr(result, "request") + + @pytest.mark.performance + def test_sequential_requests_without_keep_alive(self, benchmark, local_test_server): + """Test performance of sequential requests without keep-alive.""" + + def make_requests_new_client(): + """Make multiple requests, creating a new client for each.""" + results = [] + for _ in range(5): + # Create new client for each request (no connection reuse) + with HilltopClient( + base_url=local_test_server["base_url"], + hts_endpoint=local_test_server["hts_endpoint"], + timeout=30, + ) as client: + result = client.get_status() + results.append(result) + return results + + # Benchmark without keep-alive (new client each time) + results = benchmark(make_requests_new_client) + + # Verify all requests succeeded + assert len(results) == 5 + for result in results: + assert hasattr(result, "request") + + @pytest.mark.performance + def test_keep_alive_vs_new_connections_comparison(self, local_test_server): + """Compare keep-alive vs new connections performance.""" + + # Test with keep-alive (reuse connection) + start_time = time.perf_counter() + with HilltopClient( + base_url=local_test_server["base_url"], + hts_endpoint=local_test_server["hts_endpoint"], + timeout=30, + ) as client: + for _ in range(10): + client.get_status() + keep_alive_time = time.perf_counter() - start_time + + # Test without keep-alive (new connection each time) + start_time = time.perf_counter() + for _ in range(10): + with HilltopClient( + base_url=local_test_server["base_url"], + hts_endpoint=local_test_server["hts_endpoint"], + timeout=30, + ) as client: + client.get_status() + new_connection_time = time.perf_counter() - start_time + + # Keep-alive should be faster + # (but allow some tolerance for test environment variability) + print(f"Keep-alive time: {keep_alive_time:.4f}s") + print(f"New connection time: {new_connection_time:.4f}s") + print( + f"Performance improvement: {((new_connection_time - keep_alive_time) / new_connection_time * 100):.1f}%" + ) + + # Keep-alive should typically be faster, but in test environments this might be minimal + # So we just verify both approaches work and record the metrics + assert keep_alive_time > 0 + assert new_connection_time > 0 + + +class TestConnectionPooling: + """Test connection pooling features.""" + + @pytest.mark.performance + def test_pooled_vs_non_pooled_requests(self, local_test_server): + """Compare pooled vs non-pooled connection performance.""" + + # Test pooled requests + pooled_start = time.perf_counter() + with httpx.Client( + base_url=local_test_server["base_url"], + limits=httpx.Limits(max_connections=10, max_keepalive_connections=5), + timeout=30.0, + ) as client: + pooled_results = [] + for _ in range(5): + response = client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + pooled_results.append(response) + pooled_time = time.perf_counter() - pooled_start + + # Test non-pooled requests + non_pooled_start = time.perf_counter() + with httpx.Client( + base_url=local_test_server["base_url"], + limits=httpx.Limits(max_connections=1, max_keepalive_connections=0), + timeout=30.0, + ) as client: + non_pooled_results = [] + for _ in range(5): + response = client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + non_pooled_results.append(response) + non_pooled_time = time.perf_counter() - non_pooled_start + + # Verify both approaches work + assert len(pooled_results) == 5 + assert len(non_pooled_results) == 5 + + for result in pooled_results + non_pooled_results: + assert result.status_code == 200 + + print(f"Pooled requests time: {pooled_time:.4f}s") + print(f"Non-pooled requests time: {non_pooled_time:.4f}s") + + # Both should complete successfully + assert pooled_time > 0 + assert non_pooled_time > 0 + + @pytest.mark.performance + def test_connection_pool_limits(self, local_test_server): + """Test behavior with different connection pool limits.""" + + # Test with small pool + small_pool_times = [] + with httpx.Client( + base_url=local_test_server["base_url"], + limits=httpx.Limits(max_connections=2, max_keepalive_connections=1), + timeout=30.0, + ) as client: + for _ in range(5): + start = time.perf_counter() + response = client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + end = time.perf_counter() + small_pool_times.append(end - start) + assert response.status_code == 200 + + # Test with large pool + large_pool_times = [] + with httpx.Client( + base_url=local_test_server["base_url"], + limits=httpx.Limits(max_connections=20, max_keepalive_connections=10), + timeout=30.0, + ) as client: + for _ in range(5): + start = time.perf_counter() + response = client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + end = time.perf_counter() + large_pool_times.append(end - start) + assert response.status_code == 200 + + # Record metrics for analysis + avg_small = sum(small_pool_times) / len(small_pool_times) + avg_large = sum(large_pool_times) / len(large_pool_times) + + print(f"Small pool average time: {avg_small:.4f}s") + print(f"Large pool average time: {avg_large:.4f}s") + + # Both should complete successfully + assert avg_small > 0 + assert avg_large > 0 + + +class TestConnectionConfiguration: + """Test different httpx connection configurations.""" + + @pytest.mark.performance + def test_hilltop_client_connection_config(self, local_test_server): + """Test HilltopClient's default connection configuration.""" + + with HilltopClient( + base_url=local_test_server["base_url"], + hts_endpoint=local_test_server["hts_endpoint"], + timeout=30, + ) as client: + # Verify the client session has expected configuration + assert client.session.timeout.connect == 30 + assert client.session.timeout.read == 30 + assert client.session.follow_redirects is True + + # Test that connections work + response = client.get_status() + assert hasattr(response, "request") + + # Verify the client timeout configuration + assert client.timeout == 30 + + print(f"Client timeout: {client.timeout}s") + print(f"Session timeout: {client.session.timeout}") + print(f"Follow redirects: {client.session.follow_redirects}") + print(f"Base URL: {client.base_url}") + print(f"HTS Endpoint: {client.hts_endpoint}") + + @pytest.mark.performance + def test_custom_connection_configuration(self, local_test_server): + """Test custom httpx connection configurations.""" + + # Test various configurations + configs = [ + {"max_connections": 5, "max_keepalive_connections": 2}, + {"max_connections": 20, "max_keepalive_connections": 10}, + {"max_connections": 1, "max_keepalive_connections": 0}, + ] + + for config in configs: + with httpx.Client( + base_url=local_test_server["base_url"], + limits=httpx.Limits(**config), + timeout=30.0, + ) as client: + response = client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + assert response.status_code == 200 + assert "Test Council" in response.text # Basic XML validation diff --git a/tests/performance/test_protocol_features.py b/tests/performance/test_protocol_features.py new file mode 100644 index 0000000..21dd403 --- /dev/null +++ b/tests/performance/test_protocol_features.py @@ -0,0 +1,241 @@ +"""Tests for httpx protocol features. + +This module tests httpx performance features related to HTTP protocols: +- HTTP/1.1 vs HTTP/2 performance +- Protocol negotiation +- Protocol-specific optimizations +""" + +import asyncio +import time + +import httpx +import pytest + +from hurl.client import HilltopClient + + +class TestHTTPProtocolPerformance: + """Test HTTP protocol performance characteristics.""" + + @pytest.mark.performance + def test_http1_sequential_requests(self, benchmark, local_test_server): + """Test HTTP/1.1 sequential request performance.""" + + def make_http1_requests(): + """Make requests using HTTP/1.1 explicitly.""" + with httpx.Client( + base_url=local_test_server["base_url"], + http2=False, # Force HTTP/1.1 + limits=httpx.Limits(max_connections=10), + timeout=30.0, + ) as client: + results = [] + for _ in range(5): + response = client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + results.append(response) + return results + + results = benchmark(make_http1_requests) + + # Verify all requests succeeded + assert len(results) == 5 + for result in results: + assert result.status_code == 200 + # Verify we're using HTTP/1.1 + assert result.http_version == "HTTP/1.1" + + @pytest.mark.performance + def test_http2_sequential_requests(self, benchmark, local_test_server): + """Test HTTP/2 sequential request performance.""" + + def make_http2_requests(): + """Make requests using HTTP/2 if available.""" + with httpx.Client( + base_url=local_test_server["base_url"], + http2=True, # Enable HTTP/2 + limits=httpx.Limits(max_connections=10), + timeout=30.0, + ) as client: + results = [] + for _ in range(5): + response = client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + results.append(response) + return results + + results = benchmark(make_http2_requests) + + # Verify all requests succeeded + assert len(results) == 5 + for result in results: + assert result.status_code == 200 + # Note: Local FastAPI server may not support HTTP/2, so we don't assert version + + @pytest.mark.performance + def test_protocol_comparison(self, local_test_server): + """Compare HTTP/1.1 vs HTTP/2 performance characteristics.""" + + # Test HTTP/1.1 performance + start_time = time.perf_counter() + with httpx.Client( + base_url=local_test_server["base_url"], + http2=False, + limits=httpx.Limits(max_connections=5), + timeout=30.0, + ) as client: + http1_responses = [] + for _ in range(10): + response = client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + http1_responses.append(response) + http1_time = time.perf_counter() - start_time + + # Test HTTP/2 performance + start_time = time.perf_counter() + with httpx.Client( + base_url=local_test_server["base_url"], + http2=True, + limits=httpx.Limits(max_connections=5), + timeout=30.0, + ) as client: + http2_responses = [] + for _ in range(10): + response = client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + http2_responses.append(response) + http2_time = time.perf_counter() - start_time + + # Verify both protocols work + assert len(http1_responses) == 10 + assert len(http2_responses) == 10 + + for response in http1_responses: + assert response.status_code == 200 + assert response.http_version == "HTTP/1.1" + + for response in http2_responses: + assert response.status_code == 200 + # HTTP version may be 1.1 or 2.0 depending on server support + + # Record performance metrics + print(f"HTTP/1.1 time: {http1_time:.4f}s") + print(f"HTTP/2 time: {http2_time:.4f}s") + print(f"Requests per second (HTTP/1.1): {10/http1_time:.2f}") + print(f"Requests per second (HTTP/2): {10/http2_time:.2f}") + + +class TestConcurrentProtocolRequests: + """Test concurrent request performance with different protocols.""" + + @pytest.mark.performance + async def test_concurrent_http1_requests(self, local_test_server): + """Test concurrent HTTP/1.1 requests.""" + + async def make_request(client): + """Make a single async request.""" + response = await client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + return response + + async with httpx.AsyncClient( + base_url=local_test_server["base_url"], + http2=False, + limits=httpx.Limits(max_connections=10), + timeout=30.0, + ) as client: + # Make 10 concurrent requests + start_time = time.perf_counter() + tasks = [make_request(client) for _ in range(10)] + responses = await asyncio.gather(*tasks) + end_time = time.perf_counter() + + # Verify all requests succeeded + assert len(responses) == 10 + for response in responses: + assert response.status_code == 200 + assert response.http_version == "HTTP/1.1" + + concurrent_time = end_time - start_time + print(f"Concurrent HTTP/1.1 time: {concurrent_time:.4f}s") + print(f"Concurrent requests per second: {10/concurrent_time:.2f}") + + @pytest.mark.performance + async def test_concurrent_http2_requests(self, local_test_server): + """Test concurrent HTTP/2 requests.""" + + async def make_request(client): + """Make a single async request.""" + response = await client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + return response + + async with httpx.AsyncClient( + base_url=local_test_server["base_url"], + http2=True, + limits=httpx.Limits(max_connections=10), + timeout=30.0, + ) as client: + # Make 10 concurrent requests + start_time = time.perf_counter() + tasks = [make_request(client) for _ in range(10)] + responses = await asyncio.gather(*tasks) + end_time = time.perf_counter() + + # Verify all requests succeeded + assert len(responses) == 10 + for response in responses: + assert response.status_code == 200 + # HTTP version depends on server support + + concurrent_time = end_time - start_time + print(f"Concurrent HTTP/2 time: {concurrent_time:.4f}s") + print(f"Concurrent requests per second: {10/concurrent_time:.2f}") + + +class TestProtocolConfiguration: + """Test protocol configuration in HilltopClient.""" + + @pytest.mark.performance + def test_hilltop_client_default_protocol(self, local_test_server): + """Test HilltopClient's default protocol configuration.""" + + with HilltopClient( + base_url=local_test_server["base_url"], + hts_endpoint=local_test_server["hts_endpoint"], + timeout=30, + ) as client: + # Make a request to see what protocol is used + response = client.get_status() + assert hasattr(response, "request") + + # Check the underlying session configuration + # HilltopClient uses httpx.Client which defaults to HTTP/1.1 unless http2=True + + # NIC: Not sure what copilot is trying to assert here. + # This is not an attribute. + + # assert hasattr(client.session, "limits") + + @pytest.mark.performance + def test_protocol_fallback_behavior(self, local_test_server): + """Test protocol fallback behavior.""" + + # Test that HTTP/2 client can fallback to HTTP/1.1 if needed + with httpx.Client( + base_url=local_test_server["base_url"], + http2=True, # Enable HTTP/2 but allow fallback + timeout=30.0, + ) as client: + response = client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + assert response.status_code == 200 + # Should work regardless of what protocol the server actually supports diff --git a/tests/performance/test_timeout_retry.py b/tests/performance/test_timeout_retry.py new file mode 100644 index 0000000..24e55ac --- /dev/null +++ b/tests/performance/test_timeout_retry.py @@ -0,0 +1,429 @@ +"""Tests for httpx timeout and retry configuration effects. + +This module tests httpx performance characteristics related to: +- Different timeout configurations +- Retry behavior and performance impact +- Error handling and recovery +""" + +import asyncio +import time +from unittest.mock import patch + +import httpx +import pytest + +from hurl.client import HilltopClient + + +class TestTimeoutConfiguration: + """Test different timeout configuration effects.""" + + @pytest.mark.performance + def test_short_timeout_performance(self, benchmark, local_test_server): + """Test performance with short timeout values.""" + + def make_requests_short_timeout(): + """Make requests with short timeout.""" + with httpx.Client( + base_url=local_test_server["base_url"], + timeout=1.0, # Very short timeout + ) as client: + results = [] + for _ in range(3): + try: + response = client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + results.append(response) + except httpx.TimeoutException: + # Count timeouts as results too + results.append(None) + return results + + results = benchmark(make_requests_short_timeout) + + # Should have attempted all requests + assert len(results) == 3 + + # Count successful vs timeout responses + successful = [r for r in results if r is not None and r.status_code == 200] + timeouts = [r for r in results if r is None] + + print(f"Successful requests: {len(successful)}") + print(f"Timeout requests: {len(timeouts)}") + + @pytest.mark.performance + def test_long_timeout_performance(self, benchmark, local_test_server): + """Test performance with long timeout values.""" + + def make_requests_long_timeout(): + """Make requests with long timeout.""" + with httpx.Client( + base_url=local_test_server["base_url"], + timeout=30.0, # Long timeout + ) as client: + results = [] + for _ in range(3): + response = client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + results.append(response) + return results + + results = benchmark(make_requests_long_timeout) + + # All should succeed with long timeout + assert len(results) == 3 + for result in results: + assert result.status_code == 200 + + @pytest.mark.performance + def test_timeout_granularity(self, local_test_server): + """Test different timeout granularity settings.""" + + # Test fine-grained timeout configuration + timeout_config = httpx.Timeout( + connect=5.0, # Connection timeout + read=10.0, # Read timeout + write=5.0, # Write timeout + pool=15.0, # Pool timeout + ) + + start_time = time.perf_counter() + with httpx.Client( + base_url=local_test_server["base_url"], timeout=timeout_config + ) as client: + results = [] + for _ in range(5): + response = client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + results.append(response) + end_time = time.perf_counter() + + # Verify all succeeded + assert len(results) == 5 + for result in results: + assert result.status_code == 200 + + granular_time = end_time - start_time + print(f"Granular timeout time: {granular_time:.4f}s") + + # Compare with simple timeout + start_time = time.perf_counter() + with httpx.Client( + base_url=local_test_server["base_url"], timeout=30.0 # Simple timeout + ) as client: + simple_results = [] + for _ in range(5): + response = client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + simple_results.append(response) + end_time = time.perf_counter() + + simple_time = end_time - start_time + print(f"Simple timeout time: {simple_time:.4f}s") + + # Both should work similarly for successful requests + assert len(simple_results) == 5 + for result in simple_results: + assert result.status_code == 200 + + +class TestRetryBehavior: + """Test retry behavior and its performance impact.""" + + @pytest.mark.performance + def test_no_retry_performance(self, benchmark, local_test_server): + """Test performance without retry logic.""" + + def make_requests_no_retry(): + """Make requests without any retry logic.""" + with httpx.Client( + base_url=local_test_server["base_url"], timeout=30.0 + ) as client: + results = [] + for _ in range(5): + response = client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + results.append(response) + return results + + results = benchmark(make_requests_no_retry) + + assert len(results) == 5 + for result in results: + assert result.status_code == 200 + + @pytest.mark.performance + def test_manual_retry_performance(self, benchmark, local_test_server): + """Test performance with manual retry logic.""" + + def make_requests_with_retry(): + """Make requests with manual retry logic.""" + with httpx.Client( + base_url=local_test_server["base_url"], timeout=5.0 + ) as client: + results = [] + for _ in range(3): # Fewer requests since we're adding retry overhead + max_retries = 3 + for attempt in range(max_retries): + try: + response = client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + results.append(response) + break # Success, no need to retry + except (httpx.TimeoutException, httpx.RequestError) as e: + if attempt == max_retries - 1: + # Last attempt failed, record the failure + results.append(None) + else: + # Wait before retry + time.sleep(0.1 * (attempt + 1)) # Exponential backoff + return results + + results = benchmark(make_requests_with_retry) + + assert len(results) == 3 + successful = [r for r in results if r is not None and r.status_code == 200] + failed = [r for r in results if r is None] + + print(f"Successful with retry: {len(successful)}") + print(f"Failed after retries: {len(failed)}") + + @pytest.mark.performance + def test_retry_vs_no_retry_comparison(self, local_test_server): + """Compare performance with and without retry logic.""" + + # Test without retry + start_time = time.perf_counter() + with httpx.Client( + base_url=local_test_server["base_url"], timeout=30.0 + ) as client: + no_retry_results = [] + for _ in range(5): + response = client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + no_retry_results.append(response) + no_retry_time = time.perf_counter() - start_time + + # Test with retry logic + start_time = time.perf_counter() + with httpx.Client( + base_url=local_test_server["base_url"], timeout=30.0 + ) as client: + retry_results = [] + for _ in range(5): + max_retries = 2 + for attempt in range(max_retries): + try: + response = client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + retry_results.append(response) + break + except (httpx.TimeoutException, httpx.RequestError): + if attempt == max_retries - 1: + retry_results.append(None) + else: + time.sleep(0.05) # Small delay between retries + retry_time = time.perf_counter() - start_time + + # Both should succeed in this case (no network errors expected) + assert len(no_retry_results) == 5 + assert len(retry_results) == 5 + + for result in no_retry_results + retry_results: + if result is not None: + assert result.status_code == 200 + + print(f"No retry time: {no_retry_time:.4f}s") + print(f"With retry time: {retry_time:.4f}s") + print( + f"Retry overhead: {((retry_time - no_retry_time) / no_retry_time * 100):.1f}%" + ) + + +class TestErrorHandlingPerformance: + """Test error handling and recovery performance.""" + + @pytest.mark.performance + def test_connection_error_handling(self, local_test_server): + """Test performance when handling connection errors.""" + + # Test requests to a non-existent endpoint + invalid_url = local_test_server["base_url"].replace( + "8001", "9999" + ) # Wrong port + + start_time = time.perf_counter() + error_count = 0 + with httpx.Client(timeout=1.0) as client: + for _ in range(3): + try: + response = client.get( + f"{invalid_url}/foo.hts?Service=Hilltop&Request=Status" + ) + except (httpx.ConnectError, httpx.TimeoutException): + error_count += 1 + error_handling_time = time.perf_counter() - start_time + + print(f"Error handling time: {error_handling_time:.4f}s") + print(f"Errors encountered: {error_count}") + + # Should have encountered connection errors + assert error_count > 0 + + @pytest.mark.performance + def test_mixed_success_failure_performance(self, local_test_server): + """Test performance with mixed successful and failed requests.""" + + # Simulate a scenario with some failing requests + start_time = time.perf_counter() + results = [] + + with httpx.Client(timeout=2.0) as client: + # Mix of valid and invalid requests + requests = [ + f"{local_test_server['base_url']}/foo.hts?Service=Hilltop&Request=Status", # Valid + f"{local_test_server['base_url']}/invalid?Service=Hilltop&Request=Status", # Invalid endpoint + f"{local_test_server['base_url']}/foo.hts?Service=Hilltop&Request=Status", # Valid + f"{local_test_server['base_url']}/foo.hts?Invalid=Request", # Invalid params + f"{local_test_server['base_url']}/foo.hts?Service=Hilltop&Request=Status", # Valid + ] + + for url in requests: + try: + response = client.get(url) + results.append(("success", response.status_code)) + except httpx.RequestError as e: + results.append(("error", str(type(e).__name__))) + + mixed_time = time.perf_counter() - start_time + + # Count successes and failures + successes = [r for r in results if r[0] == "success" and r[1] == 200] + failures = [ + r for r in results if r[0] == "error" or (r[0] == "success" and r[1] != 200) + ] + + print(f"Mixed requests time: {mixed_time:.4f}s") + print(f"Successful requests: {len(successes)}") + print(f"Failed requests: {len(failures)}") + + # Should have a mix of successes and failures + assert len(results) == 5 + assert len(successes) > 0 # Some requests should succeed + assert len(failures) > 0 # Some requests should fail + + +class TestHilltopClientTimeoutBehavior: + """Test HilltopClient timeout behavior.""" + + @pytest.mark.performance + def test_hilltop_client_default_timeout(self, local_test_server): + """Test HilltopClient with default timeout configuration.""" + + start_time = time.perf_counter() + with HilltopClient( + base_url=local_test_server["base_url"], + hts_endpoint=local_test_server["hts_endpoint"], + timeout=60, # Default timeout + ) as client: + results = [] + for _ in range(3): + result = client.get_status() + results.append(result) + default_timeout_time = time.perf_counter() - start_time + + assert len(results) == 3 + for result in results: + assert hasattr(result, "request") + + print(f"Default timeout (60s) time: {default_timeout_time:.4f}s") + + @pytest.mark.performance + def test_hilltop_client_custom_timeout(self, local_test_server): + """Test HilltopClient with custom timeout configuration.""" + + # Test with shorter timeout + start_time = time.perf_counter() + with HilltopClient( + base_url=local_test_server["base_url"], + hts_endpoint=local_test_server["hts_endpoint"], + timeout=10, # Custom shorter timeout + ) as client: + results = [] + for _ in range(3): + result = client.get_status() + results.append(result) + custom_timeout_time = time.perf_counter() - start_time + + assert len(results) == 3 + for result in results: + assert hasattr(result, "request") + + print(f"Custom timeout (10s) time: {custom_timeout_time:.4f}s") + + # For successful requests, timeout value shouldn't significantly affect performance + # (the actual network operations are the same) + assert custom_timeout_time > 0 + + @pytest.mark.performance + def test_hilltop_client_timeout_configuration(self, local_test_server): + """Test that HilltopClient correctly configures httpx timeout.""" + + timeout_value = 15 + with HilltopClient( + base_url=local_test_server["base_url"], + hts_endpoint=local_test_server["hts_endpoint"], + timeout=timeout_value, + ) as client: + # Verify the timeout is correctly configured in the underlying session + assert client.timeout == timeout_value + assert client.session.timeout.connect == timeout_value + assert client.session.timeout.read == timeout_value + assert client.session.timeout.write == timeout_value + assert client.session.timeout.pool == timeout_value + + # Make a request to ensure it works + result = client.get_status() + assert hasattr(result, "request") + + +class TestAsyncTimeoutBehavior: + """Test async client timeout behavior.""" + + @pytest.mark.performance + async def test_async_client_timeout_performance(self, local_test_server): + """Test async client timeout performance.""" + + # Test with various timeout configurations + timeout_configs = [5.0, 10.0, 30.0] + + for timeout in timeout_configs: + start_time = time.perf_counter() + async with httpx.AsyncClient( + base_url=local_test_server["base_url"], timeout=timeout + ) as client: + tasks = [ + client.get( + f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status" + ) + for _ in range(3) + ] + results = await asyncio.gather(*tasks) + end_time = time.perf_counter() + + # Verify all succeeded + assert len(results) == 3 + for result in results: + assert result.status_code == 200 + + async_time = end_time - start_time + print(f"Async timeout {timeout}s time: {async_time:.4f}s") diff --git a/tests/test_schemas/test_responses/test_collection_list_response.py b/tests/test_schemas/test_responses/test_collection_list_response.py index 48e1099..ff48135 100644 --- a/tests/test_schemas/test_responses/test_collection_list_response.py +++ b/tests/test_schemas/test_responses/test_collection_list_response.py @@ -73,7 +73,7 @@ def fixture_func(): class TestRemoteFixtures: @pytest.mark.remote - @pytest.mark.update + @pytest.mark.integration def test_basic_response_xml_fixture( self, remote_client, diff --git a/tests/test_schemas/test_responses/test_get_data_response.py b/tests/test_schemas/test_responses/test_get_data_response.py index 6387c96..b586510 100644 --- a/tests/test_schemas/test_responses/test_get_data_response.py +++ b/tests/test_schemas/test_responses/test_get_data_response.py @@ -127,7 +127,7 @@ def fixture_func(): class TestRemoteFixtures: @pytest.mark.remote - @pytest.mark.update + @pytest.mark.integration def test_basic_response_xml_fixture( self, remote_client, httpx_mock, basic_response_xml_cached ): @@ -164,7 +164,7 @@ def test_basic_response_xml_fixture( assert basic_response_xml_cleaned == remote_xml_cleaned @pytest.mark.remote - @pytest.mark.update + @pytest.mark.integration def test_one_point_response_xml_fixture( self, remote_client, httpx_mock, one_point_response_xml_cached ): @@ -201,7 +201,7 @@ def test_one_point_response_xml_fixture( assert one_point_response_xml_cleaned == remote_xml_cleaned @pytest.mark.remote - @pytest.mark.update + @pytest.mark.integration def test_collection_response_xml_fixture( self, remote_client, httpx_mock, collection_response_xml_cached ): @@ -245,7 +245,7 @@ def test_collection_response_xml_fixture( assert collection_response_xml_cleaned == remote_xml_cleaned @pytest.mark.remote - @pytest.mark.update + @pytest.mark.integration def test_time_interval_response_xml_fixture( self, remote_client, httpx_mock, time_interval_response_xml_cached ): @@ -283,7 +283,7 @@ def test_time_interval_response_xml_fixture( assert time_interval_response_xml_cleaned == remote_xml_cleaned @pytest.mark.remote - @pytest.mark.update + @pytest.mark.integration def test_time_interval_complex_response_xml_fixture( self, remote_client, httpx_mock, time_interval_complex_response_xml_cached ): @@ -322,7 +322,7 @@ def test_time_interval_complex_response_xml_fixture( assert time_interval_complex_response_xml_cleaned == remote_xml_cleaned @pytest.mark.remote - @pytest.mark.update + @pytest.mark.integration def test_date_only_response_xml_fixture( self, remote_client, httpx_mock, date_only_response_xml_cached ): diff --git a/tests/test_schemas/test_responses/test_measurement_list_response.py b/tests/test_schemas/test_responses/test_measurement_list_response.py index 186f653..7803c33 100644 --- a/tests/test_schemas/test_responses/test_measurement_list_response.py +++ b/tests/test_schemas/test_responses/test_measurement_list_response.py @@ -85,6 +85,7 @@ def fixture_func(): class VerifyCachedFixtures: @pytest.mark.remote + @pytest.mark.integration def test_multi_response_xml_cache( self, remote_client, @@ -125,6 +126,7 @@ def test_multi_response_xml_cache( assert remote_xml_cleaned == multi_response_xml_cleaned @pytest.mark.remote + @pytest.mark.integration def test_all_response_xml_fixture( self, remote_client, @@ -153,6 +155,7 @@ def test_all_response_xml_fixture( assert remote_xml == cached_xml @pytest.mark.remote + @pytest.mark.integration def test_error_response_xml_fixture( self, remote_client, @@ -182,6 +185,7 @@ def test_error_response_xml_fixture( assert remote_xml == cached_xml @pytest.mark.remote + @pytest.mark.integration def test_units_response_xml_fixture( self, remote_client, diff --git a/tests/test_schemas/test_responses/test_site_info_response.py b/tests/test_schemas/test_responses/test_site_info_response.py index 04e56ba..dce4260 100644 --- a/tests/test_schemas/test_responses/test_site_info_response.py +++ b/tests/test_schemas/test_responses/test_site_info_response.py @@ -77,7 +77,7 @@ def fixture_func(): class TestRemoteFixtures: @pytest.mark.remote - @pytest.mark.update + @pytest.mark.integration def test_basic_response_xml_fixture( self, remote_client, @@ -115,7 +115,7 @@ def test_basic_response_xml_fixture( assert remote_xml_cleaned == basic_response_xml_cleaned @pytest.mark.remote - @pytest.mark.update + @pytest.mark.integration def test_collection_response_xml_fixture( self, remote_client, diff --git a/tests/test_schemas/test_responses/test_site_list_response.py b/tests/test_schemas/test_responses/test_site_list_response.py index 2c0134a..8982652 100644 --- a/tests/test_schemas/test_responses/test_site_list_response.py +++ b/tests/test_schemas/test_responses/test_site_list_response.py @@ -70,7 +70,7 @@ def fixture_func(): class TestRemoteFixtures: @pytest.mark.remote - @pytest.mark.update + @pytest.mark.integration def test_all_response_xml_fixture( self, remote_client, httpx_mock, all_response_xml_cached ): diff --git a/tests/test_schemas/test_responses/test_status_response.py b/tests/test_schemas/test_responses/test_status_response.py index 319f0de..4276977 100644 --- a/tests/test_schemas/test_responses/test_status_response.py +++ b/tests/test_schemas/test_responses/test_status_response.py @@ -4,7 +4,6 @@ import pytest import pytest_httpx - from dotenv import load_dotenv load_dotenv() @@ -22,10 +21,7 @@ def fixture_func(request, httpx_mock, remote_client): from hurl.schemas.requests import StatusRequest path = ( - Path(__file__).parent.parent.parent - / "fixture_cache" - / "status" - / filename + Path(__file__).parent.parent.parent / "fixture_cache" / "status" / filename ) if request.config.getoption("--update"): # Switch off httpx mock so that cached request can go through. @@ -42,6 +38,7 @@ def fixture_func(request, httpx_mock, remote_client): path.write_text(cached_xml, encoding="utf-8") raw_xml = path.read_text(encoding="utf-8") return raw_xml + return fixture_func @@ -53,14 +50,10 @@ def fixture_func(): """Load test XML once per test session.""" from pathlib import Path - path = ( - Path(__file__).parent.parent.parent - / "mocked_data" - / "status" - / filename - ) + path = Path(__file__).parent.parent.parent / "mocked_data" / "status" / filename raw_xml = path.read_text(encoding="utf-8") return raw_xml + return fixture_func @@ -73,8 +66,10 @@ def fixture_func(): class TestRemoteFixtures: @pytest.mark.remote - @pytest.mark.update - def test_response_xml_fixture(self, httpx_mock, remote_client, status_response_xml_cached): + @pytest.mark.integration + def test_response_xml_fixture( + self, httpx_mock, remote_client, status_response_xml_cached + ): """Validate the status response XML fixture.""" from urllib.parse import urlparse diff --git a/tests/test_schemas/test_responses/test_time_range_response.py b/tests/test_schemas/test_responses/test_time_range_response.py index b93040d..9fab877 100644 --- a/tests/test_schemas/test_responses/test_time_range_response.py +++ b/tests/test_schemas/test_responses/test_time_range_response.py @@ -76,7 +76,7 @@ def fixture_func(): class TestRemoteFixtures: @pytest.mark.remote - @pytest.mark.update + @pytest.mark.integration def test_basic_response_xml_fixture( self, remote_client, diff --git a/tests/test_utils.py b/tests/test_utils.py index a6073a1..bfca556 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,10 +1,11 @@ import pytest +@pytest.mark.unit def test_validate_hilltop_interval_notation(): """Test validate_hilltop_interval_notation function.""" - from hurl.utils import validate_hilltop_interval_notation from hurl.exceptions import HilltopRequestError + from hurl.utils import validate_hilltop_interval_notation # Valid cases assert validate_hilltop_interval_notation("10 seconds") == "10 seconds"