diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f31e7cc..de88b4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: - name: Checkout uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: CVector-Energy/python-test@main + - uses: CVector-Energy/python-test@uv with: python-version: ${{ matrix.python-version }} src-dirs: . diff --git a/.github/workflows/license-check-python.yml b/.github/workflows/license-check-python.yml new file mode 100644 index 0000000..b2ae716 --- /dev/null +++ b/.github/workflows/license-check-python.yml @@ -0,0 +1,17 @@ +name: Python License Check + +on: + push: + +jobs: + license-check: + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + + - name: Check Python licenses + uses: CVector-Energy/pyproject-license-check@main + with: + app-id: ${{ vars.APP_ID }} + app-private-key: ${{ secrets.APP_PRIVATE_KEY }} diff --git a/pyproject.toml b/pyproject.toml index 53282e0..5524c05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cvec" -version = "1.4.3" +version = "1.5.0" description = "SDK for CVector Energy" authors = [{ name = "CVector", email = "support@cvector.energy" }] readme = "README.md" @@ -8,6 +8,7 @@ requires-python = ">=3.10" dependencies = [ "pydantic>=2.12.0", "pyarrow>=22.0.0", + "brotli>=1.2.0,<2", ] license = "MIT" license-files = ['LICENSE'] diff --git a/src/cvec/cvec.py b/src/cvec/cvec.py index d4df873..f2f5177 100644 --- a/src/cvec/cvec.py +++ b/src/cvec/cvec.py @@ -1,12 +1,19 @@ +import gzip import json import logging import os +import time +import zlib + +import brotli # type: ignore[import-untyped] from datetime import datetime from typing import Any, Dict, List, Optional from urllib.error import HTTPError, URLError from urllib.parse import urlencode, urljoin from urllib.request import Request, urlopen +from cvec.http_cache import MAX_CACHE_ENTRIES, CacheEntry, parse_max_age + from cvec.models.agent_post import AgentPost, AgentPostRecommendation, AgentPostTag from cvec.models.eav_column import EAVColumn from cvec.models.eav_filter import EAVFilter @@ -53,6 +60,9 @@ def __init__( self._publishable_key = None self._api_key = api_key or os.environ.get("CVEC_API_KEY") + # HTTP cache for GET requests + self._cache: Dict[str, CacheEntry] = {} + if not self.host: raise ValueError( "CVEC_HOST must be set either as an argument or environment variable" @@ -105,8 +115,58 @@ def _get_headers(self) -> Dict[str, str]: "Authorization": f"Bearer {self._access_token}", "Content-Type": "application/json", "Accept": "application/json", + "Accept-Encoding": "br, gzip, deflate", } + @staticmethod + def _read_response(response: Any) -> tuple[bytes, str]: + """Read and decompress response body. + + Returns: + Tuple of (decompressed data, content type) + """ + raw = response.read() + encoding = response.headers.get("Content-Encoding", "") + if encoding == "br": + raw = brotli.decompress(raw) + elif encoding == "gzip": + raw = gzip.decompress(raw) + elif encoding == "deflate": + raw = zlib.decompress(raw) + content_type: str = response.headers.get("content-type", "") + return raw, content_type + + @staticmethod + def _parse_response_body(response_data: bytes, content_type: str) -> Any: + """Parse response body based on content type.""" + if content_type == "application/vnd.apache.arrow.stream": + return response_data + return json.loads(response_data.decode("utf-8")) + + def _process_response(self, response: Any, url: str, method: str) -> Any: + """Read, decompress, parse, and optionally cache a response.""" + response_data, content_type = self._read_response(response) + parsed = self._parse_response_body(response_data, content_type) + + if method == "GET": + cache_control = response.headers.get("Cache-Control", "") + max_age = parse_max_age(cache_control) + if max_age is not None: + if url not in self._cache and len(self._cache) >= MAX_CACHE_ENTRIES: + worst_url = min( + self._cache, key=lambda u: self._cache[u].expires_at + ) + del self._cache[worst_url] + etag = response.headers.get("ETag", "") or None + self._cache[url] = CacheEntry( + data=parsed, + etag=etag, + max_age=max_age, + stored_at=time.monotonic(), + ) + + return parsed + def _make_request( self, method: str, @@ -124,6 +184,17 @@ def _make_request( if filtered_params: url = f"{url}?{urlencode(filtered_params)}" + # Check cache for GET requests + if method == "GET" and url in self._cache: + entry = self._cache[url] + if time.monotonic() - entry.stored_at < entry.max_age: + return entry.data + # Stale entry with ETag: use conditional request + if entry.etag: + if headers is None: + headers = {} + headers["If-None-Match"] = entry.etag + request_headers = self._get_headers() if headers: request_headers.update(headers) @@ -140,16 +211,16 @@ def make_http_request() -> Any: url, data=request_body, headers=request_headers, method=method ) with urlopen(req) as response: - response_data = response.read() - content_type = response.headers.get("content-type", "") - - if content_type == "application/vnd.apache.arrow.stream": - return response_data - return json.loads(response_data.decode("utf-8")) + return self._process_response(response, url, method) try: return make_http_request() except HTTPError as e: + # Handle 304 Not Modified + if e.code == 304 and method == "GET" and url in self._cache: + entry = self._cache[url] + entry.stored_at = time.monotonic() + return entry.data # Handle 401 Unauthorized with token refresh if e.code == 401 and self._access_token and self._refresh_token: try: @@ -164,12 +235,7 @@ def make_http_request() -> Any: url, data=request_body, headers=request_headers, method=method ) with urlopen(req) as response: - response_data = response.read() - content_type = response.headers.get("content-type", "") - - if content_type == "application/vnd.apache.arrow.stream": - return response_data - return json.loads(response_data.decode("utf-8")) + return self._process_response(response, url, method) except (HTTPError, URLError, ValueError, KeyError) as refresh_error: logger.warning( "Token refresh failed, continuing with original request: %s", diff --git a/src/cvec/http_cache.py b/src/cvec/http_cache.py new file mode 100644 index 0000000..174296b --- /dev/null +++ b/src/cvec/http_cache.py @@ -0,0 +1,40 @@ +"""In-memory HTTP cache for GET requests with Cache-Control and ETag support.""" + +from dataclasses import dataclass +from typing import Any, Optional + + +MAX_CACHE_ENTRIES = 100 + + +@dataclass +class CacheEntry: + """A cached HTTP response.""" + + data: Any + etag: Optional[str] + max_age: int + stored_at: float + + @property + def expires_at(self) -> float: + """Monotonic time when this entry expires.""" + return self.stored_at + self.max_age + + +def parse_max_age(header: Optional[str]) -> Optional[int]: + """Parse max-age value from a Cache-Control header. + + Returns: + The max-age value in seconds, or None if not present. + """ + if header is None: + return None + for directive in header.split(","): + directive = directive.strip() + if directive.startswith("max-age="): + try: + return int(directive[len("max-age=") :]) + except ValueError: + return None + return None diff --git a/tests/test_http_cache.py b/tests/test_http_cache.py new file mode 100644 index 0000000..9a1d15c --- /dev/null +++ b/tests/test_http_cache.py @@ -0,0 +1,407 @@ +"""Tests for HTTP caching (Cache-Control, ETag, If-None-Match).""" + +import json +import time +from typing import Any +from unittest.mock import Mock, patch +from urllib.error import HTTPError + +from cvec import CVec +from cvec.http_cache import CacheEntry, parse_max_age + + +def mock_fetch_config_side_effect(instance: CVec) -> str: + """Side effect for _fetch_config mock that sets tenant_id.""" + instance._tenant_id = 1 + return "test_publishable_key" + + +def _make_mock_response( + body: bytes, + content_type: str = "application/json", + etag: str = "", + cache_control: str = "", +) -> Mock: + """Create a mock HTTP response.""" + mock_response = Mock() + mock_response.read.return_value = body + + headers: dict[str, str] = {"content-type": content_type} + if etag: + headers["ETag"] = etag + if cache_control: + headers["Cache-Control"] = cache_control + mock_response.headers = headers + mock_response.__enter__ = Mock(return_value=mock_response) + mock_response.__exit__ = Mock(return_value=False) + return mock_response + + +def _create_client() -> CVec: + """Create a CVec client with mocked auth.""" + client = CVec( + host="https://test.example.com", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", + ) + client._access_token = "test_token" + return client + + +class TestParseMaxAge: + """Tests for parse_max_age helper.""" + + def test_basic(self) -> None: + assert parse_max_age("max-age=300") == 300 + + def test_with_other_directives(self) -> None: + assert parse_max_age("public, max-age=600, must-revalidate") == 600 + + def test_none_header(self) -> None: + assert parse_max_age(None) is None + + def test_no_max_age_directive(self) -> None: + assert parse_max_age("public, no-cache") is None + + def test_zero_max_age(self) -> None: + assert parse_max_age("max-age=0") == 0 + + +class TestCacheEntry: + """Tests for CacheEntry dataclass.""" + + def test_creation(self) -> None: + entry = CacheEntry( + data={"key": "value"}, + etag='"abc123"', + max_age=300, + stored_at=100.0, + ) + assert entry.data == {"key": "value"} + assert entry.etag == '"abc123"' + assert entry.max_age == 300 + assert entry.stored_at == 100.0 + + def test_no_etag(self) -> None: + entry = CacheEntry(data=[], etag=None, max_age=60, stored_at=0.0) + assert entry.etag is None + + def test_expires_at(self) -> None: + entry = CacheEntry(data=[], etag=None, max_age=300, stored_at=100.0) + assert entry.expires_at == 400.0 + + +class TestHttpCache: + """Integration tests for caching in _make_request.""" + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object( + CVec, + "_fetch_config", + autospec=True, + side_effect=mock_fetch_config_side_effect, + ) + @patch("cvec.cvec.urlopen") + def test_cache_stores_new_response( + self, + mock_urlopen: Any, + mock_fetch_key: Any, + mock_login: Any, + ) -> None: + """First GET stores the response in cache with correct max-age and etag.""" + client = _create_client() + + data = [{"id": 1, "name": "metric1"}] + mock_response = _make_mock_response( + json.dumps(data).encode("utf-8"), + etag='"etag1"', + cache_control="max-age=300", + ) + mock_urlopen.return_value = mock_response + + client.get_metrics() + + assert len(client._cache) == 1 + url = next(iter(client._cache)) + entry = client._cache[url] + assert entry.etag == '"etag1"' + assert entry.max_age == 300 + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object( + CVec, + "_fetch_config", + autospec=True, + side_effect=mock_fetch_config_side_effect, + ) + @patch("cvec.cvec.urlopen") + def test_fresh_cache_hit_returns_cached_data( + self, + mock_urlopen: Any, + mock_fetch_key: Any, + mock_login: Any, + ) -> None: + """GET request with fresh cache entry returns data without HTTP call.""" + client = _create_client() + + data = [{"id": 1, "name": "metric1"}] + mock_response = _make_mock_response( + json.dumps(data).encode("utf-8"), + etag='"etag1"', + cache_control="max-age=300", + ) + mock_urlopen.return_value = mock_response + + # First call: populates cache + result1 = client.get_metrics() + assert mock_urlopen.call_count == 1 + + # Second call: should use cache, no HTTP call + result2 = client.get_metrics() + assert mock_urlopen.call_count == 1 # no additional call + assert result1 == result2 + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object( + CVec, + "_fetch_config", + autospec=True, + side_effect=mock_fetch_config_side_effect, + ) + @patch("cvec.cvec.urlopen") + def test_stale_cache_with_etag_sends_if_none_match( + self, + mock_urlopen: Any, + mock_fetch_key: Any, + mock_login: Any, + ) -> None: + """Stale entry triggers conditional request with If-None-Match.""" + client = _create_client() + + data = [{"id": 1, "name": "metric1"}] + mock_response = _make_mock_response( + json.dumps(data).encode("utf-8"), + etag='"etag1"', + cache_control="max-age=300", + ) + mock_urlopen.return_value = mock_response + + # Populate cache + client.get_metrics() + + # Make cache stale by backdating stored_at + url = next(iter(client._cache)) + client._cache[url].stored_at = time.monotonic() - 400 + + # New response for the stale request + new_data = [{"id": 1, "name": "metric1_updated"}] + mock_response2 = _make_mock_response( + json.dumps(new_data).encode("utf-8"), + etag='"etag2"', + cache_control="max-age=300", + ) + mock_urlopen.return_value = mock_response2 + + client.get_metrics() + + # Check that If-None-Match was sent + req = mock_urlopen.call_args[0][0] + assert req.get_header("If-none-match") == '"etag1"' + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object( + CVec, + "_fetch_config", + autospec=True, + side_effect=mock_fetch_config_side_effect, + ) + @patch("cvec.cvec.urlopen") + def test_304_response_returns_cached_data( + self, + mock_urlopen: Any, + mock_fetch_key: Any, + mock_login: Any, + ) -> None: + """304 response returns cached data and refreshes stored_at.""" + client = _create_client() + + data = [{"id": 1, "name": "metric1"}] + mock_response = _make_mock_response( + json.dumps(data).encode("utf-8"), + etag='"etag1"', + cache_control="max-age=300", + ) + mock_urlopen.return_value = mock_response + + # Populate cache + result1 = client.get_metrics() + + # Make cache stale + url = next(iter(client._cache)) + client._cache[url].stored_at = time.monotonic() - 400 + old_stored_at = client._cache[url].stored_at + + # Return 304 + http_304 = HTTPError( + url="https://test.example.com/api/metrics/", + code=304, + msg="Not Modified", + hdrs={"Cache-Control": "max-age=300"}, # type: ignore[arg-type] + fp=None, + ) + mock_urlopen.side_effect = http_304 + + result2 = client.get_metrics() + + # Should return same cached data + assert result1 == result2 + # stored_at should be refreshed + assert client._cache[url].stored_at > old_stored_at + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object( + CVec, + "_fetch_config", + autospec=True, + side_effect=mock_fetch_config_side_effect, + ) + @patch("cvec.cvec.urlopen") + def test_post_request_not_cached( + self, + mock_urlopen: Any, + mock_fetch_key: Any, + mock_login: Any, + ) -> None: + """POST request bypasses cache entirely.""" + client = _create_client() + + mock_response = _make_mock_response( + b"null", + cache_control="max-age=300", + etag='"etag1"', + ) + mock_urlopen.return_value = mock_response + + client._make_request("POST", "/api/metrics/data", json_data={}) + + assert len(client._cache) == 0 + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object( + CVec, + "_fetch_config", + autospec=True, + side_effect=mock_fetch_config_side_effect, + ) + @patch("cvec.cvec.urlopen") + def test_no_cache_control_header_not_cached( + self, + mock_urlopen: Any, + mock_fetch_key: Any, + mock_login: Any, + ) -> None: + """Response without Cache-Control max-age is not cached.""" + client = _create_client() + + data = [{"id": 1, "name": "metric1"}] + mock_response = _make_mock_response( + json.dumps(data).encode("utf-8"), + ) + mock_urlopen.return_value = mock_response + + client.get_metrics() + + assert len(client._cache) == 0 + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object( + CVec, + "_fetch_config", + autospec=True, + side_effect=mock_fetch_config_side_effect, + ) + @patch("cvec.cvec.urlopen") + def test_different_urls_cached_separately( + self, + mock_urlopen: Any, + mock_fetch_key: Any, + mock_login: Any, + ) -> None: + """Two different URLs get separate cache entries.""" + client = _create_client() + + data1 = [{"id": 1, "name": "m1"}] + data2 = [ + { + "name": "m1", + "time": "2024-01-01T00:00:00", + "value_double": 1.0, + } + ] + + mock_response1 = _make_mock_response( + json.dumps(data1).encode("utf-8"), + etag='"etag1"', + cache_control="max-age=300", + ) + mock_response2 = _make_mock_response( + json.dumps(data2).encode("utf-8"), + etag='"etag2"', + cache_control="max-age=300", + ) + + mock_urlopen.side_effect = [mock_response1, mock_response2] + + client.get_metrics() + client.get_metric_data(names=["m1"]) + + assert len(client._cache) == 2 + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object( + CVec, + "_fetch_config", + autospec=True, + side_effect=mock_fetch_config_side_effect, + ) + @patch("cvec.cvec.urlopen") + def test_cache_evicts_when_full( + self, + mock_urlopen: Any, + mock_fetch_key: Any, + mock_login: Any, + ) -> None: + """When cache is full, the entry with earliest expiration is evicted.""" + client = _create_client() + + now = time.monotonic() + + # Pre-fill cache to max size with entries that expire at different times + for i in range(100): + url = f"https://test.example.com/api/item/{i}" + client._cache[url] = CacheEntry( + data={"id": i}, + etag=f'"etag{i}"', + max_age=300, + # Entry 50 expires first (stored earliest) + stored_at=now - 200 if i == 50 else now, + ) + + assert len(client._cache) == 100 + + # Add one more entry via a real request + data = [{"id": 999, "name": "new_metric"}] + mock_response = _make_mock_response( + json.dumps(data).encode("utf-8"), + etag='"etag_new"', + cache_control="max-age=300", + ) + mock_urlopen.return_value = mock_response + + client.get_metrics() + + # Cache should still be at 100 (evicted one to make room) + assert len(client._cache) == 100 + # Entry 50 (earliest expiration) should have been evicted + assert "https://test.example.com/api/item/50" not in client._cache + # New entry should be present + assert any("metrics" in url for url in client._cache) diff --git a/tests/test_http_compression.py b/tests/test_http_compression.py new file mode 100644 index 0000000..c6a2ea2 --- /dev/null +++ b/tests/test_http_compression.py @@ -0,0 +1,226 @@ +"""Tests for HTTP compression support (gzip/deflate/brotli).""" + +import gzip +import json +import zlib +from typing import Any +from unittest.mock import Mock, patch + +import brotli # type: ignore[import-untyped] + +from cvec import CVec + + +def mock_fetch_config_side_effect(instance: CVec) -> str: + """Side effect for _fetch_config mock that sets tenant_id.""" + instance._tenant_id = 1 + return "test_publishable_key" + + +def _make_mock_response( + body: bytes, + content_type: str = "application/json", + content_encoding: str = "", + extra_headers: dict[str, str] | None = None, +) -> Mock: + """Create a mock HTTP response with the given body and headers.""" + mock_response = Mock() + mock_response.read.return_value = body + + headers: dict[str, str] = {"content-type": content_type} + if content_encoding: + headers["Content-Encoding"] = content_encoding + if extra_headers: + headers.update(extra_headers) + mock_response.headers = headers + mock_response.__enter__ = Mock(return_value=mock_response) + mock_response.__exit__ = Mock(return_value=False) + return mock_response + + +def _create_client() -> CVec: + """Create a CVec client with mocked auth.""" + client = CVec( + host="https://test.example.com", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", + ) + client._access_token = "test_token" + return client + + +class TestHttpCompression: + """Test cases for HTTP compression support.""" + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object( + CVec, + "_fetch_config", + autospec=True, + side_effect=mock_fetch_config_side_effect, + ) + @patch("cvec.cvec.urlopen") + def test_accept_encoding_header_sent( + self, + mock_urlopen: Any, + mock_fetch_key: Any, + mock_login: Any, + ) -> None: + """Verify Accept-Encoding includes br, gzip, deflate.""" + client = _create_client() + + mock_response = _make_mock_response(b"[]") + mock_urlopen.return_value = mock_response + + client.get_metrics() + + req = mock_urlopen.call_args[0][0] + assert req.get_header("Accept-encoding") == "br, gzip, deflate" + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object( + CVec, + "_fetch_config", + autospec=True, + side_effect=mock_fetch_config_side_effect, + ) + @patch("cvec.cvec.urlopen") + def test_gzip_response_decompressed( + self, + mock_urlopen: Any, + mock_fetch_key: Any, + mock_login: Any, + ) -> None: + """Mock a gzip-compressed JSON response, verify it is decompressed.""" + client = _create_client() + + data = [{"id": 1, "name": "metric1"}] + compressed = gzip.compress(json.dumps(data).encode("utf-8")) + mock_response = _make_mock_response(compressed, content_encoding="gzip") + mock_urlopen.return_value = mock_response + + result = client.get_metrics() + assert len(result) == 1 + assert result[0].name == "metric1" + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object( + CVec, + "_fetch_config", + autospec=True, + side_effect=mock_fetch_config_side_effect, + ) + @patch("cvec.cvec.urlopen") + def test_deflate_response_decompressed( + self, + mock_urlopen: Any, + mock_fetch_key: Any, + mock_login: Any, + ) -> None: + """Mock a deflate-compressed JSON response, verify decompression.""" + client = _create_client() + + data = [{"id": 2, "name": "metric2"}] + raw = json.dumps(data).encode("utf-8") + compressed = zlib.compress(raw) + mock_response = _make_mock_response(compressed, content_encoding="deflate") + mock_urlopen.return_value = mock_response + + result = client.get_metrics() + assert len(result) == 1 + assert result[0].name == "metric2" + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object( + CVec, + "_fetch_config", + autospec=True, + side_effect=mock_fetch_config_side_effect, + ) + @patch("cvec.cvec.urlopen") + def test_uncompressed_response_unchanged( + self, + mock_urlopen: Any, + mock_fetch_key: Any, + mock_login: Any, + ) -> None: + """Verify responses without Content-Encoding still work.""" + client = _create_client() + + data = [{"id": 3, "name": "metric3"}] + mock_response = _make_mock_response(json.dumps(data).encode("utf-8")) + mock_urlopen.return_value = mock_response + + result = client.get_metrics() + assert len(result) == 1 + assert result[0].name == "metric3" + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object( + CVec, + "_fetch_config", + autospec=True, + side_effect=mock_fetch_config_side_effect, + ) + @patch("cvec.cvec.urlopen") + def test_gzip_arrow_response_decompressed( + self, + mock_urlopen: Any, + mock_fetch_key: Any, + mock_login: Any, + ) -> None: + """Verify binary Arrow responses with gzip encoding are decompressed.""" + client = _create_client() + + import pyarrow as pa # type: ignore[import-untyped] + import pyarrow.ipc as ipc # type: ignore[import-untyped] + + table = pa.table( + { + "name": ["m1"], + "timestamp": [pa.scalar(1000000, type=pa.timestamp("us"))], + "value": [1.0], + } + ) + sink = pa.BufferOutputStream() + writer = ipc.new_file(sink, table.schema) + writer.write_table(table) + writer.close() + arrow_bytes = sink.getvalue().to_pybytes() + + compressed = gzip.compress(arrow_bytes) + mock_response = _make_mock_response( + compressed, + content_type="application/vnd.apache.arrow.stream", + content_encoding="gzip", + ) + mock_urlopen.return_value = mock_response + + result = client._make_request("GET", "/api/metrics/data/arrow") + assert isinstance(result, bytes) + assert result == arrow_bytes + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object( + CVec, + "_fetch_config", + autospec=True, + side_effect=mock_fetch_config_side_effect, + ) + @patch("cvec.cvec.urlopen") + def test_brotli_response_decompressed( + self, + mock_urlopen: Any, + mock_fetch_key: Any, + mock_login: Any, + ) -> None: + """Mock a brotli-compressed JSON response, verify it is decompressed.""" + client = _create_client() + + data = [{"id": 4, "name": "metric4"}] + compressed = brotli.compress(json.dumps(data).encode("utf-8")) + mock_response = _make_mock_response(compressed, content_encoding="br") + mock_urlopen.return_value = mock_response + + result = client.get_metrics() + assert len(result) == 1 + assert result[0].name == "metric4" diff --git a/uv.lock b/uv.lock index bd34c28..f224352 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/10/a090475284fc4a71aed40a96f32e44a7fe5bda39687353dd977720b211b6/brotli-1.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3b90b767916ac44e93a8e28ce6adf8d551e43affb512f2377c732d486ac6514e", size = 863089, upload-time = "2025-11-05T18:38:01.181Z" }, + { url = "https://files.pythonhosted.org/packages/03/41/17416630e46c07ac21e378c3464815dd2e120b441e641bc516ac32cc51d2/brotli-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6be67c19e0b0c56365c6a76e393b932fb0e78b3b56b711d180dd7013cb1fd984", size = 445442, upload-time = "2025-11-05T18:38:02.434Z" }, + { url = "https://files.pythonhosted.org/packages/24/31/90cc06584deb5d4fcafc0985e37741fc6b9717926a78674bbb3ce018957e/brotli-1.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bbd5b5ccd157ae7913750476d48099aaf507a79841c0d04a9db4415b14842de", size = 1532658, upload-time = "2025-11-05T18:38:03.588Z" }, + { url = "https://files.pythonhosted.org/packages/62/17/33bf0c83bcbc96756dfd712201d87342732fad70bb3472c27e833a44a4f9/brotli-1.2.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f3c908bcc404c90c77d5a073e55271a0a498f4e0756e48127c35d91cf155947", size = 1631241, upload-time = "2025-11-05T18:38:04.582Z" }, + { url = "https://files.pythonhosted.org/packages/48/10/f47854a1917b62efe29bc98ac18e5d4f71df03f629184575b862ef2e743b/brotli-1.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1b557b29782a643420e08d75aea889462a4a8796e9a6cf5621ab05a3f7da8ef2", size = 1424307, upload-time = "2025-11-05T18:38:05.587Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b7/f88eb461719259c17483484ea8456925ee057897f8e64487d76e24e5e38d/brotli-1.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81da1b229b1889f25adadc929aeb9dbc4e922bd18561b65b08dd9343cfccca84", size = 1488208, upload-time = "2025-11-05T18:38:06.613Z" }, + { url = "https://files.pythonhosted.org/packages/26/59/41bbcb983a0c48b0b8004203e74706c6b6e99a04f3c7ca6f4f41f364db50/brotli-1.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ff09cd8c5eec3b9d02d2408db41be150d8891c5566addce57513bf546e3d6c6d", size = 1597574, upload-time = "2025-11-05T18:38:07.838Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e6/8c89c3bdabbe802febb4c5c6ca224a395e97913b5df0dff11b54f23c1788/brotli-1.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a1778532b978d2536e79c05dac2d8cd857f6c55cd0c95ace5b03740824e0e2f1", size = 1492109, upload-time = "2025-11-05T18:38:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/ed/9a/4b19d4310b2dbd545c0c33f176b0528fa68c3cd0754e34b2f2bcf56548ae/brotli-1.2.0-cp310-cp310-win32.whl", hash = "sha256:b232029d100d393ae3c603c8ffd7e3fe6f798c5e28ddca5feabb8e8fdb732997", size = 334461, upload-time = "2025-11-05T18:38:10.729Z" }, + { url = "https://files.pythonhosted.org/packages/ac/39/70981d9f47705e3c2b95c0847dfa3e7a37aa3b7c6030aedc4873081ed005/brotli-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:ef87b8ab2704da227e83a246356a2b179ef826f550f794b2c52cddb4efbd0196", size = 369035, upload-time = "2025-11-05T18:38:11.827Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" }, + { url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619, upload-time = "2025-11-05T18:38:16.094Z" }, + { url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" }, + { url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150, upload-time = "2025-11-05T18:38:19.792Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" }, + { url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451, upload-time = "2025-11-05T18:38:21.94Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035, upload-time = "2025-11-05T18:38:22.941Z" }, + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -22,9 +80,10 @@ wheels = [ [[package]] name = "cvec" -version = "1.4.3" +version = "1.5.0" source = { editable = "." } dependencies = [ + { name = "brotli" }, { name = "pyarrow" }, { name = "pydantic" }, ] @@ -39,6 +98,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "brotli", specifier = ">=1.2.0,<2" }, { name = "pyarrow", specifier = ">=22.0.0" }, { name = "pydantic", specifier = ">=2.12.0" }, ]