From 1aa904df94a3ca61dbf5e63e645704ceeab17071 Mon Sep 17 00:00:00 2001 From: Joshua Napoli Date: Mon, 1 Dec 2025 12:36:55 -0500 Subject: [PATCH 1/6] feat: select pivoted EAV data --- CLAUDE.md | 1 + examples/eav_example.py | 30 ++++ src/cvec/__init__.py | 3 +- src/cvec/cvec.py | 139 ++++++++++++++++++ src/cvec/models/__init__.py | 2 + src/cvec/models/eav_filter.py | 20 +++ tests/test_cvec.py | 259 ++++++++++++++++++++++++++++++++-- 7 files changed, 443 insertions(+), 11 deletions(-) create mode 100644 CLAUDE.md create mode 100644 examples/eav_example.py create mode 100644 src/cvec/models/eav_filter.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d7fc12c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +- Name files for their main export, transforming Python names from PascalCase to underscore_case. For example, the file that defines the EAVFilter class should be named eav_filter.py. \ No newline at end of file diff --git a/examples/eav_example.py b/examples/eav_example.py new file mode 100644 index 0000000..02e512e --- /dev/null +++ b/examples/eav_example.py @@ -0,0 +1,30 @@ +import os + +from cvec import CVec, EAVFilter + + +def main() -> None: + cvec = CVec( + host=os.environ.get( + "CVEC_HOST", "https://your-subdomain.cvector.dev" + ), # Replace with your API host + api_key=os.environ.get("CVEC_API_KEY", "your-api-key"), + ) + + # Example: Query with numeric range filter + print("\nQuerying with numeric range filter...") + rows = cvec.select_from_eav( + tenant_id=5, + table_id="916310b2-2eab-4538-b179-98fe77c0c24d", # Maintenance Entries + column_ids=["date", "operator", "pipeline"], + filters=[ + EAVFilter(column_id="date", numeric_min=45992, numeric_max=45993), + ], + ) + print(f"Found {len(rows)} rows with date in range [45992, 45993)") + for row in rows: + print(f"- {row}") + + +if __name__ == "__main__": + main() diff --git a/src/cvec/__init__.py b/src/cvec/__init__.py index e25fa13..d4cd057 100644 --- a/src/cvec/__init__.py +++ b/src/cvec/__init__.py @@ -1,3 +1,4 @@ from .cvec import CVec +from .models import EAVFilter -__all__ = ["CVec"] +__all__ = ["CVec", "EAVFilter"] diff --git a/src/cvec/cvec.py b/src/cvec/cvec.py index eacb6a7..905bb15 100644 --- a/src/cvec/cvec.py +++ b/src/cvec/cvec.py @@ -7,6 +7,7 @@ from urllib.parse import urlencode, urljoin from urllib.request import Request, urlopen +from cvec.models.eav_filter import EAVFilter from cvec.models.metric import Metric, MetricDataPoint from cvec.models.span import Span from cvec.utils.arrow_converter import ( @@ -52,6 +53,10 @@ def __init__( raise ValueError( "CVEC_HOST must be set either as an argument or environment variable" ) + + # Add https:// scheme if not provided + if not self.host.startswith("http://") and not self.host.startswith("https://"): + self.host = f"https://{self.host}" if not self._api_key: raise ValueError( "CVEC_API_KEY must be set either as an argument or environment variable" @@ -507,3 +512,137 @@ def _fetch_publishable_key(self) -> str: raise ValueError(f"Failed to fetch config from {self.host}/config: {e}") except (KeyError, ValueError) as e: raise ValueError(f"Invalid config response: {e}") + + def _call_rpc( + self, + function_name: str, + params: Optional[Dict[str, Any]] = None, + ) -> Any: + """ + Call a Supabase RPC function. + + Args: + function_name: The name of the RPC function to call + params: Optional dictionary of parameters to pass to the function + + Returns: + The response data from the RPC call + """ + if not self._access_token: + raise ValueError("No access token available. Please login first.") + if not self._publishable_key: + raise ValueError("Publishable key not available") + + url = f"{self.host}/supabase/rest/v1/rpc/{function_name}" + + headers = { + "Accept": "application/json", + "Apikey": self._publishable_key, + "Authorization": f"Bearer {self._access_token}", + "Content-Profile": "app_data", + "Content-Type": "application/json", + } + + request_body = json.dumps(params or {}).encode("utf-8") + + def make_rpc_request() -> Any: + """Inner function to make the actual RPC request.""" + req = Request(url, data=request_body, headers=headers, method="POST") + with urlopen(req) as response: + response_data = response.read() + return json.loads(response_data.decode("utf-8")) + + try: + return make_rpc_request() + except HTTPError as e: + # Handle 401 Unauthorized with token refresh + if e.code == 401 and self._access_token and self._refresh_token: + try: + self._refresh_supabase_token() + # Update headers with new token + headers["Authorization"] = f"Bearer {self._access_token}" + + # Retry the request + req = Request( + url, data=request_body, headers=headers, method="POST" + ) + with urlopen(req) as response: + response_data = response.read() + return json.loads(response_data.decode("utf-8")) + except (HTTPError, URLError, ValueError, KeyError) as refresh_error: + logger.warning( + "Token refresh failed, continuing with original request: %s", + refresh_error, + exc_info=True, + ) + # If refresh fails, re-raise the original 401 error + raise e + raise + + def select_from_eav( + self, + tenant_id: int, + table_id: str, + column_ids: Optional[List[str]] = None, + filters: Optional[List[EAVFilter]] = None, + ) -> List[Dict[str, Any]]: + """ + Query pivoted data from EAV (Entity-Attribute-Value) tables. + + This method calls the app_data.select_from_eav Supabase function to retrieve + data from EAV tables with optional column selection and filtering. + + Args: + tenant_id: The tenant ID to query data for + table_id: The UUID of the EAV table to query + column_ids: Optional list of column IDs to include in the result. + If None, all columns are returned. + filters: Optional list of EAVFilter objects to filter the results. + Each filter can specify: + - column_id: The EAV column ID to filter on (required) + - numeric_min: Minimum numeric value (inclusive) + - numeric_max: Maximum numeric value (exclusive) + - string_value: Exact string value to match + - boolean_value: Boolean value to match + + Returns: + List of dictionaries, each representing a row with column values. + Each row contains an 'id' field plus fields for each column_id + with their corresponding values (number, string, or boolean). + + Example: + >>> filters = [ + ... EAVFilter(column_id="timestamp", numeric_min=100, numeric_max=200), + ... EAVFilter(column_id="status", string_value="ACTIVE"), + ... ] + >>> rows = client.select_from_eav( + ... tenant_id=123, + ... table_id="73d3845f-5c0e-4d20-8df7-6f8880c24eb4", + ... column_ids=["timestamp", "status", "voltage"], + ... filters=filters, + ... ) + """ + # Convert EAVFilter objects to dictionaries, excluding None values + filters_json: List[Dict[str, Any]] = [] + if filters: + for f in filters: + filter_dict: Dict[str, Any] = {"column_id": f.column_id} + if f.numeric_min is not None: + filter_dict["numeric_min"] = f.numeric_min + if f.numeric_max is not None: + filter_dict["numeric_max"] = f.numeric_max + if f.string_value is not None: + filter_dict["string_value"] = f.string_value + if f.boolean_value is not None: + filter_dict["boolean_value"] = f.boolean_value + filters_json.append(filter_dict) + + params: Dict[str, Any] = { + "tenant_id": tenant_id, + "table_id": table_id, + "column_ids": column_ids, + "filters": filters_json, + } + + response_data = self._call_rpc("select_from_eav", params) + return list(response_data) if response_data else [] diff --git a/src/cvec/models/__init__.py b/src/cvec/models/__init__.py index e3172fd..d4c8cef 100644 --- a/src/cvec/models/__init__.py +++ b/src/cvec/models/__init__.py @@ -1,7 +1,9 @@ +from .eav_filter import EAVFilter from .metric import Metric, MetricDataPoint from .span import Span __all__ = [ + "EAVFilter", "Metric", "MetricDataPoint", "Span", diff --git a/src/cvec/models/eav_filter.py b/src/cvec/models/eav_filter.py new file mode 100644 index 0000000..d8ab9e5 --- /dev/null +++ b/src/cvec/models/eav_filter.py @@ -0,0 +1,20 @@ +from typing import Optional, Union + +from pydantic import BaseModel + + +class EAVFilter(BaseModel): + """ + Represents a filter for querying EAV data. + + Filters are used to narrow down results based on column values: + - Use numeric_min/numeric_max for numeric range filtering (min inclusive, max exclusive) + - Use string_value for exact string matching + - Use boolean_value for boolean matching + """ + + column_id: str + numeric_min: Optional[Union[int, float]] = None + numeric_max: Optional[Union[int, float]] = None + string_value: Optional[str] = None + boolean_value: Optional[bool] = None diff --git a/tests/test_cvec.py b/tests/test_cvec.py index 31378bd..d526eec 100644 --- a/tests/test_cvec.py +++ b/tests/test_cvec.py @@ -1,13 +1,15 @@ -import pytest +import io import os -from unittest.mock import patch from datetime import datetime -from cvec import CVec -from cvec.models.metric import Metric +from typing import Any +from unittest.mock import patch + import pyarrow as pa # type: ignore[import-untyped] import pyarrow.ipc as ipc # type: ignore[import-untyped] -import io -from typing import Any +import pytest + +from cvec import CVec, EAVFilter +from cvec.models.metric import Metric class TestCVecConstructor: @@ -18,17 +20,53 @@ def test_constructor_with_arguments( ) -> None: """Test CVec constructor with all arguments provided.""" client = CVec( - host="test_host", + host="https://test_host", default_start_at=datetime(2023, 1, 1, 0, 0, 0), default_end_at=datetime(2023, 1, 2, 0, 0, 0), api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", ) - assert client.host == "test_host" + assert client.host == "https://test_host" assert client.default_start_at == datetime(2023, 1, 1, 0, 0, 0) assert client.default_end_at == datetime(2023, 1, 2, 0, 0, 0) assert client._publishable_key == "test_publishable_key" assert client._api_key == "cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O" + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_constructor_adds_https_scheme( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: + """Test CVec constructor adds https:// scheme if not provided.""" + client = CVec( + host="example.cvector.dev", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", + ) + assert client.host == "https://example.cvector.dev" + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_constructor_preserves_https_scheme( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: + """Test CVec constructor preserves https:// scheme if already provided.""" + client = CVec( + host="https://example.cvector.dev", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", + ) + assert client.host == "https://example.cvector.dev" + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_constructor_preserves_http_scheme( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: + """Test CVec constructor preserves http:// scheme if provided.""" + client = CVec( + host="http://localhost:3000", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", + ) + assert client.host == "http://localhost:3000" + @patch.object(CVec, "_login_with_supabase", return_value=None) @patch.object(CVec, "_fetch_publishable_key", return_value="env_publishable_key") @patch.dict( @@ -47,7 +85,7 @@ def test_constructor_with_env_vars( default_start_at=datetime(2023, 2, 1, 0, 0, 0), default_end_at=datetime(2023, 2, 2, 0, 0, 0), ) - assert client.host == "env_host" + assert client.host == "https://env_host" assert client._publishable_key == "env_publishable_key" assert client._api_key == "cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O" assert client.default_start_at == datetime(2023, 2, 1, 0, 0, 0) @@ -99,7 +137,7 @@ def test_constructor_args_override_env_vars( default_end_at=datetime(2023, 3, 2, 0, 0, 0), api_key="cva_differentKeyKALxMnxUdI9hanF0TBPvvvr1", ) - assert client.host == "arg_host" + assert client.host == "https://arg_host" assert client._api_key == "cva_differentKeyKALxMnxUdI9hanF0TBPvvvr1" assert client.default_start_at == datetime(2023, 3, 1, 0, 0, 0) assert client.default_end_at == datetime(2023, 3, 2, 0, 0, 0) @@ -381,3 +419,204 @@ def test_get_metric_arrow_empty(self, mock_fetch_key: Any, mock_login: Any) -> N assert result_table.column("name").to_pylist() == [] assert result_table.column("value_double").to_pylist() == [] assert result_table.column("value_string").to_pylist() == [] + + +class TestEAVFilter: + def test_eav_filter_basic(self) -> None: + """Test EAVFilter with only column_id.""" + filter_obj = EAVFilter(column_id="date") + assert filter_obj.column_id == "date" + assert filter_obj.numeric_min is None + assert filter_obj.numeric_max is None + assert filter_obj.string_value is None + assert filter_obj.boolean_value is None + + def test_eav_filter_numeric_range(self) -> None: + """Test EAVFilter with numeric range.""" + filter_obj = EAVFilter(column_id="date", numeric_min=100, numeric_max=200) + assert filter_obj.column_id == "date" + assert filter_obj.numeric_min == 100 + assert filter_obj.numeric_max == 200 + + def test_eav_filter_string_value(self) -> None: + """Test EAVFilter with string value.""" + filter_obj = EAVFilter(column_id="status", string_value="failure") + assert filter_obj.column_id == "status" + assert filter_obj.string_value == "failure" + + def test_eav_filter_boolean_value(self) -> None: + """Test EAVFilter with boolean value.""" + filter_obj = EAVFilter(column_id="ZNAGI", boolean_value=False) + assert filter_obj.column_id == "ZNAGI" + assert filter_obj.boolean_value is False + + +class TestCVecSelectFromEAV: + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_select_from_eav_basic(self, mock_fetch_key: Any, mock_login: Any) -> None: + """Test select_from_eav with no filters.""" + response_data = [ + {"id": "row1", "col1": 100.5, "col2": "value1"}, + {"id": "row2", "col1": 200.0, "col2": "value2"}, + ] + client = CVec( + host="test_host", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", + ) + client._call_rpc = lambda *args, **kwargs: response_data # type: ignore[method-assign] + + result = client.select_from_eav( + tenant_id=1, + table_id="7a80f3a2-6fa1-43ce-8483-76bd00dc93c6", + ) + + assert len(result) == 2 + assert result[0]["id"] == "row1" + assert result[0]["col1"] == 100.5 + assert result[1]["col2"] == "value2" + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_select_from_eav_with_column_ids( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: + """Test select_from_eav with specific column_ids.""" + response_data = [ + {"id": "row1", "col1": 100.5}, + {"id": "row2", "col1": 200.0}, + ] + client = CVec( + host="test_host", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", + ) + + captured_params: dict[str, Any] = {} + + def mock_call_rpc(name: str, params: Any) -> Any: + captured_params.update(params) + return response_data + + client._call_rpc = mock_call_rpc # type: ignore[method-assign] + + result = client.select_from_eav( + tenant_id=1, + table_id="7a80f3a2-6fa1-43ce-8483-76bd00dc93c6", + column_ids=["col1"], + ) + + assert len(result) == 2 + assert captured_params["column_ids"] == ["col1"] + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_select_from_eav_with_filters( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: + """Test select_from_eav with filters.""" + response_data = [ + {"id": "row1", "col1": 150.0, "col2": "ACTIVE"}, + ] + client = CVec( + host="test_host", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", + ) + + captured_params: dict[str, Any] = {} + + def mock_call_rpc(name: str, params: Any) -> Any: + captured_params.update(params) + return response_data + + client._call_rpc = mock_call_rpc # type: ignore[method-assign] + + filters = [ + EAVFilter(column_id="col1", numeric_min=100, numeric_max=200), + EAVFilter(column_id="col2", string_value="ACTIVE"), + ] + + result = client.select_from_eav( + tenant_id=1, + table_id="7a80f3a2-6fa1-43ce-8483-76bd00dc93c6", + filters=filters, + ) + + assert len(result) == 1 + assert result[0]["col1"] == 150.0 + assert captured_params["filters"] == [ + {"column_id": "col1", "numeric_min": 100, "numeric_max": 200}, + {"column_id": "col2", "string_value": "ACTIVE"}, + ] + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_select_from_eav_with_boolean_filter( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: + """Test select_from_eav with boolean filter.""" + response_data = [ + {"id": "row1", "is_active": True}, + ] + client = CVec( + host="test_host", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", + ) + + captured_params: dict[str, Any] = {} + + def mock_call_rpc(name: str, params: Any) -> Any: + captured_params.update(params) + return response_data + + client._call_rpc = mock_call_rpc # type: ignore[method-assign] + + filters = [EAVFilter(column_id="is_active", boolean_value=True)] + + result = client.select_from_eav( + tenant_id=1, + table_id="7a80f3a2-6fa1-43ce-8483-76bd00dc93c6", + filters=filters, + ) + + assert len(result) == 1 + assert captured_params["filters"] == [ + {"column_id": "is_active", "boolean_value": True} + ] + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_select_from_eav_empty_result( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: + """Test select_from_eav with empty result.""" + client = CVec( + host="test_host", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", + ) + client._call_rpc = lambda *args, **kwargs: [] # type: ignore[method-assign] + + result = client.select_from_eav( + tenant_id=1, + table_id="7a80f3a2-6fa1-43ce-8483-76bd00dc93c6", + ) + + assert result == [] + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_select_from_eav_none_result( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: + """Test select_from_eav with None result.""" + client = CVec( + host="test_host", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", + ) + client._call_rpc = lambda *args, **kwargs: None # type: ignore[method-assign] + + result = client.select_from_eav( + tenant_id=1, + table_id="7a80f3a2-6fa1-43ce-8483-76bd00dc93c6", + ) + + assert result == [] From 9f5efd1beca62e6c17ec936cd11b161230fc6735 Mon Sep 17 00:00:00 2001 From: Joshua Napoli Date: Mon, 1 Dec 2025 14:08:55 -0500 Subject: [PATCH 2/6] feat: use readable names rather than IDs --- examples/eav_example.py | 74 ++++++++++++--- src/cvec/__init__.py | 4 +- src/cvec/cvec.py | 171 +++++++++++++++++++++++++++++++--- src/cvec/models/__init__.py | 4 + src/cvec/models/eav_column.py | 18 ++++ src/cvec/models/eav_filter.py | 2 +- src/cvec/models/eav_table.py | 22 +++++ tests/test_cvec.py | 160 +++++++++++++++++++++---------- 8 files changed, 376 insertions(+), 79 deletions(-) create mode 100644 src/cvec/models/eav_column.py create mode 100644 src/cvec/models/eav_table.py diff --git a/examples/eav_example.py b/examples/eav_example.py index 02e512e..fe328d7 100644 --- a/examples/eav_example.py +++ b/examples/eav_example.py @@ -5,26 +5,74 @@ def main() -> None: cvec = CVec( - host=os.environ.get( - "CVEC_HOST", "https://your-subdomain.cvector.dev" - ), # Replace with your API host + host=os.environ.get("CVEC_HOST", "your-subdomain.cvector.dev"), api_key=os.environ.get("CVEC_API_KEY", "your-api-key"), ) - # Example: Query with numeric range filter - print("\nQuerying with numeric range filter...") + tenant_id = 1 # Replace with your tenant ID + + # Example: List available EAV tables + print("\nListing EAV tables...") + tables = cvec.get_eav_tables(tenant_id) + print(f"Found {len(tables)} tables") + for table in tables: + print(f"- {table.name} (id: {table.id})") + + if not tables: + print("No tables found. Exiting.") + return + + # Use the first table for demonstration + table_name = tables[0].name + + # Example: List columns for the table + print(f"\nListing columns for '{table_name}'...") + columns = cvec.get_eav_columns(tables[0].id) + print(f"Found {len(columns)} columns") + for column in columns: + print(f"- {column.name} ({column.type})") + + # Example: Query all rows from the table + print(f"\nQuerying '{table_name}' (all columns, no filters)...") rows = cvec.select_from_eav( - tenant_id=5, - table_id="916310b2-2eab-4538-b179-98fe77c0c24d", # Maintenance Entries - column_ids=["date", "operator", "pipeline"], - filters=[ - EAVFilter(column_id="date", numeric_min=45992, numeric_max=45993), - ], + tenant_id=tenant_id, + table_name=table_name, ) - print(f"Found {len(rows)} rows with date in range [45992, 45993)") - for row in rows: + print(f"Found {len(rows)} rows") + for row in rows[:5]: # Show first 5 rows print(f"- {row}") + # Example: Query with numeric range filter (if there's a numeric column) + numeric_columns = [c for c in columns if c.type == "number"] + if numeric_columns: + col_name = numeric_columns[0].name + print(f"\nQuerying with numeric filter on '{col_name}'...") + rows = cvec.select_from_eav( + tenant_id=tenant_id, + table_name=table_name, + filters=[ + EAVFilter(column_name=col_name, numeric_min=0), + ], + ) + print(f"Found {len(rows)} rows with {col_name} >= 0") + + # Example: Query with string filter (if there's a string column) + string_columns = [c for c in columns if c.type == "string"] + if string_columns and rows: + col_name = string_columns[0].name + # Get a sample value from the first row + sample_value = rows[0].get(col_name) if rows else None + if sample_value: + print(f"\nQuerying with string filter on '{col_name}'...") + rows = cvec.select_from_eav( + tenant_id=tenant_id, + table_name=table_name, + filters=[ + EAVFilter(column_name=col_name, string_value=str(sample_value)), + ], + ) + print(f"Found {len(rows)} rows with {col_name}='{sample_value}'") + if __name__ == "__main__": main() diff --git a/src/cvec/__init__.py b/src/cvec/__init__.py index d4cd057..6631527 100644 --- a/src/cvec/__init__.py +++ b/src/cvec/__init__.py @@ -1,4 +1,4 @@ from .cvec import CVec -from .models import EAVFilter +from .models import EAVColumn, EAVFilter, EAVTable -__all__ = ["CVec", "EAVFilter"] +__all__ = ["CVec", "EAVColumn", "EAVFilter", "EAVTable"] diff --git a/src/cvec/cvec.py b/src/cvec/cvec.py index 905bb15..d713b33 100644 --- a/src/cvec/cvec.py +++ b/src/cvec/cvec.py @@ -7,7 +7,9 @@ from urllib.parse import urlencode, urljoin from urllib.request import Request, urlopen +from cvec.models.eav_column import EAVColumn from cvec.models.eav_filter import EAVFilter +from cvec.models.eav_table import EAVTable from cvec.models.metric import Metric, MetricDataPoint from cvec.models.span import Span from cvec.utils.arrow_converter import ( @@ -579,11 +581,104 @@ def make_rpc_request() -> Any: raise e raise + def _query_table( + self, + table_name: str, + query_params: Optional[str] = None, + ) -> Any: + """ + Query a Supabase table via PostgREST. + + Args: + table_name: The name of the table to query + query_params: Optional PostgREST query parameters (e.g., "name=eq.foo") + + Returns: + The response data from the query + """ + if not self._access_token: + raise ValueError("No access token available. Please login first.") + if not self._publishable_key: + raise ValueError("Publishable key not available") + + url = f"{self.host}/supabase/rest/v1/{table_name}" + if query_params: + url = f"{url}?{query_params}" + + headers = { + "Accept": "application/json", + "Accept-Profile": "app_data", + "Apikey": self._publishable_key, + "Authorization": f"Bearer {self._access_token}", + } + + def make_query_request() -> Any: + """Inner function to make the actual query request.""" + req = Request(url, headers=headers, method="GET") + with urlopen(req) as response: + response_data = response.read() + return json.loads(response_data.decode("utf-8")) + + try: + return make_query_request() + except HTTPError as e: + # Handle 401 Unauthorized with token refresh + if e.code == 401 and self._access_token and self._refresh_token: + try: + self._refresh_supabase_token() + # Update headers with new token + headers["Authorization"] = f"Bearer {self._access_token}" + + # Retry the request + req = Request(url, headers=headers, method="GET") + with urlopen(req) as response: + response_data = response.read() + return json.loads(response_data.decode("utf-8")) + except (HTTPError, URLError, ValueError, KeyError) as refresh_error: + logger.warning( + "Token refresh failed, continuing with original request: %s", + refresh_error, + exc_info=True, + ) + # If refresh fails, re-raise the original 401 error + raise e + raise + + def get_eav_tables(self, tenant_id: int) -> List[EAVTable]: + """ + Get all EAV tables for a tenant. + + Args: + tenant_id: The tenant ID to query tables for + + Returns: + List of EAVTable objects + """ + response_data = self._query_table( + "eav_tables", f"tenant_id=eq.{tenant_id}&order=name" + ) + return [EAVTable.model_validate(table) for table in response_data] + + def get_eav_columns(self, table_id: str) -> List[EAVColumn]: + """ + Get all columns for an EAV table. + + Args: + table_id: The UUID of the EAV table + + Returns: + List of EAVColumn objects + """ + response_data = self._query_table( + "eav_columns", f"eav_table_id=eq.{table_id}&order=name" + ) + return [EAVColumn.model_validate(column) for column in response_data] + def select_from_eav( self, tenant_id: int, - table_id: str, - column_ids: Optional[List[str]] = None, + table_name: str, + column_names: Optional[List[str]] = None, filters: Optional[List[EAVFilter]] = None, ) -> List[Dict[str, Any]]: """ @@ -594,12 +689,12 @@ def select_from_eav( Args: tenant_id: The tenant ID to query data for - table_id: The UUID of the EAV table to query - column_ids: Optional list of column IDs to include in the result. - If None, all columns are returned. + table_name: The name of the EAV table to query + column_names: Optional list of column names to include in the result. + If None, all columns are returned. filters: Optional list of EAVFilter objects to filter the results. Each filter can specify: - - column_id: The EAV column ID to filter on (required) + - column_name: The EAV column name to filter on (required) - numeric_min: Minimum numeric value (inclusive) - numeric_max: Maximum numeric value (exclusive) - string_value: Exact string value to match @@ -607,26 +702,57 @@ def select_from_eav( Returns: List of dictionaries, each representing a row with column values. - Each row contains an 'id' field plus fields for each column_id + Each row contains an 'id' field plus fields for each column name with their corresponding values (number, string, or boolean). Example: >>> filters = [ - ... EAVFilter(column_id="timestamp", numeric_min=100, numeric_max=200), - ... EAVFilter(column_id="status", string_value="ACTIVE"), + ... EAVFilter(column_name="Weight", numeric_min=100, numeric_max=200), + ... EAVFilter(column_name="Status", string_value="ACTIVE"), ... ] >>> rows = client.select_from_eav( - ... tenant_id=123, - ... table_id="73d3845f-5c0e-4d20-8df7-6f8880c24eb4", - ... column_ids=["timestamp", "status", "voltage"], + ... tenant_id=1, + ... table_name="BT/Scrap Entry", + ... column_names=["Weight", "Status", "Is Verified"], ... filters=filters, ... ) """ - # Convert EAVFilter objects to dictionaries, excluding None values + # Look up the table ID from the table name + tables_response = self._query_table( + "eav_tables", + f"tenant_id=eq.{tenant_id}&name=eq.{table_name}&limit=1", + ) + if not tables_response: + raise ValueError(f"Table '{table_name}' not found for tenant {tenant_id}") + table_id = tables_response[0]["id"] + + # Get all columns for the table to build name -> id mapping + columns = self.get_eav_columns(table_id) + column_name_to_id = {col.name: col.eav_column_id for col in columns} + + # Convert column names to column IDs + column_ids: Optional[List[str]] = None + if column_names: + column_ids = [] + for name in column_names: + if name not in column_name_to_id: + raise ValueError( + f"Column '{name}' not found in table '{table_name}'" + ) + column_ids.append(column_name_to_id[name]) + + # Convert EAVFilter objects to dictionaries, translating names to IDs filters_json: List[Dict[str, Any]] = [] if filters: for f in filters: - filter_dict: Dict[str, Any] = {"column_id": f.column_id} + if f.column_name not in column_name_to_id: + raise ValueError( + f"Filter column '{f.column_name}' not found in table " + f"'{table_name}'" + ) + filter_dict: Dict[str, Any] = { + "column_id": column_name_to_id[f.column_name] + } if f.numeric_min is not None: filter_dict["numeric_min"] = f.numeric_min if f.numeric_max is not None: @@ -645,4 +771,19 @@ def select_from_eav( } response_data = self._call_rpc("select_from_eav", params) - return list(response_data) if response_data else [] + + # Convert column IDs back to names in the response + column_id_to_name = {col.eav_column_id: col.name for col in columns} + result: List[Dict[str, Any]] = [] + for row in response_data or []: + converted_row: Dict[str, Any] = {} + for key, value in row.items(): + if key == "id": + converted_row[key] = value + elif key in column_id_to_name: + converted_row[column_id_to_name[key]] = value + else: + converted_row[key] = value + result.append(converted_row) + + return result diff --git a/src/cvec/models/__init__.py b/src/cvec/models/__init__.py index d4c8cef..dd1a3b0 100644 --- a/src/cvec/models/__init__.py +++ b/src/cvec/models/__init__.py @@ -1,9 +1,13 @@ +from .eav_column import EAVColumn from .eav_filter import EAVFilter +from .eav_table import EAVTable from .metric import Metric, MetricDataPoint from .span import Span __all__ = [ + "EAVColumn", "EAVFilter", + "EAVTable", "Metric", "MetricDataPoint", "Span", diff --git a/src/cvec/models/eav_column.py b/src/cvec/models/eav_column.py new file mode 100644 index 0000000..a5222c3 --- /dev/null +++ b/src/cvec/models/eav_column.py @@ -0,0 +1,18 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict + + +class EAVColumn(BaseModel): + """ + Represents an EAV column metadata record. + """ + + eav_table_id: str + eav_column_id: str + name: str + type: str + created_at: Optional[datetime] = None + + model_config = ConfigDict(json_encoders={datetime: lambda dt: dt.isoformat()}) diff --git a/src/cvec/models/eav_filter.py b/src/cvec/models/eav_filter.py index d8ab9e5..45a2d1b 100644 --- a/src/cvec/models/eav_filter.py +++ b/src/cvec/models/eav_filter.py @@ -13,7 +13,7 @@ class EAVFilter(BaseModel): - Use boolean_value for boolean matching """ - column_id: str + column_name: str numeric_min: Optional[Union[int, float]] = None numeric_max: Optional[Union[int, float]] = None string_value: Optional[str] = None diff --git a/src/cvec/models/eav_table.py b/src/cvec/models/eav_table.py new file mode 100644 index 0000000..2e7f023 --- /dev/null +++ b/src/cvec/models/eav_table.py @@ -0,0 +1,22 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict + + +class EAVTable(BaseModel): + """ + Represents an EAV table metadata record. + """ + + id: str + tenant_id: int + name: str + continuation_token: Optional[str] = None + last_sync_at: Optional[datetime] = None + total_rows_synced: Optional[int] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + last_etag: Optional[str] = None + + model_config = ConfigDict(json_encoders={datetime: lambda dt: dt.isoformat()}) diff --git a/tests/test_cvec.py b/tests/test_cvec.py index d526eec..bd0ddfe 100644 --- a/tests/test_cvec.py +++ b/tests/test_cvec.py @@ -423,9 +423,9 @@ def test_get_metric_arrow_empty(self, mock_fetch_key: Any, mock_login: Any) -> N class TestEAVFilter: def test_eav_filter_basic(self) -> None: - """Test EAVFilter with only column_id.""" - filter_obj = EAVFilter(column_id="date") - assert filter_obj.column_id == "date" + """Test EAVFilter with only column_name.""" + filter_obj = EAVFilter(column_name="Date") + assert filter_obj.column_name == "Date" assert filter_obj.numeric_min is None assert filter_obj.numeric_max is None assert filter_obj.string_value is None @@ -433,58 +433,96 @@ def test_eav_filter_basic(self) -> None: def test_eav_filter_numeric_range(self) -> None: """Test EAVFilter with numeric range.""" - filter_obj = EAVFilter(column_id="date", numeric_min=100, numeric_max=200) - assert filter_obj.column_id == "date" + filter_obj = EAVFilter(column_name="Date", numeric_min=100, numeric_max=200) + assert filter_obj.column_name == "Date" assert filter_obj.numeric_min == 100 assert filter_obj.numeric_max == 200 def test_eav_filter_string_value(self) -> None: """Test EAVFilter with string value.""" - filter_obj = EAVFilter(column_id="status", string_value="failure") - assert filter_obj.column_id == "status" + filter_obj = EAVFilter(column_name="Status", string_value="failure") + assert filter_obj.column_name == "Status" assert filter_obj.string_value == "failure" def test_eav_filter_boolean_value(self) -> None: """Test EAVFilter with boolean value.""" - filter_obj = EAVFilter(column_id="ZNAGI", boolean_value=False) - assert filter_obj.column_id == "ZNAGI" + filter_obj = EAVFilter(column_name="Is Active", boolean_value=False) + assert filter_obj.column_name == "Is Active" assert filter_obj.boolean_value is False class TestCVecSelectFromEAV: + """Tests for select_from_eav using table_name and column_names.""" + + def _mock_query_table( + self, table_id: str = "7a80f3a2-6fa1-43ce-8483-76bd00dc93c6" + ) -> Any: + """Create a mock for _query_table that returns table and column data.""" + + def mock_query(table_name: str, query_params: str | None = None) -> Any: + if table_name == "eav_tables": + return [{"id": table_id, "tenant_id": 1, "name": "Test Table"}] + elif table_name == "eav_columns": + return [ + { + "eav_table_id": table_id, + "eav_column_id": "col1_id", + "name": "Column 1", + "type": "number", + }, + { + "eav_table_id": table_id, + "eav_column_id": "col2_id", + "name": "Column 2", + "type": "string", + }, + { + "eav_table_id": table_id, + "eav_column_id": "is_active_id", + "name": "Is Active", + "type": "boolean", + }, + ] + return [] + + return mock_query + @patch.object(CVec, "_login_with_supabase", return_value=None) @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") def test_select_from_eav_basic(self, mock_fetch_key: Any, mock_login: Any) -> None: """Test select_from_eav with no filters.""" - response_data = [ - {"id": "row1", "col1": 100.5, "col2": "value1"}, - {"id": "row2", "col1": 200.0, "col2": "value2"}, + # Response uses column IDs + rpc_response = [ + {"id": "row1", "col1_id": 100.5, "col2_id": "value1"}, + {"id": "row2", "col1_id": 200.0, "col2_id": "value2"}, ] client = CVec( host="test_host", api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", ) - client._call_rpc = lambda *args, **kwargs: response_data # type: ignore[method-assign] + client._query_table = self._mock_query_table() # type: ignore[method-assign] + client._call_rpc = lambda *args, **kwargs: rpc_response # type: ignore[method-assign] result = client.select_from_eav( tenant_id=1, - table_id="7a80f3a2-6fa1-43ce-8483-76bd00dc93c6", + table_name="Test Table", ) + # Result should have column names, not IDs assert len(result) == 2 assert result[0]["id"] == "row1" - assert result[0]["col1"] == 100.5 - assert result[1]["col2"] == "value2" + assert result[0]["Column 1"] == 100.5 + assert result[1]["Column 2"] == "value2" @patch.object(CVec, "_login_with_supabase", return_value=None) @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") - def test_select_from_eav_with_column_ids( + def test_select_from_eav_with_column_names( self, mock_fetch_key: Any, mock_login: Any ) -> None: - """Test select_from_eav with specific column_ids.""" - response_data = [ - {"id": "row1", "col1": 100.5}, - {"id": "row2", "col1": 200.0}, + """Test select_from_eav with specific column_names.""" + rpc_response = [ + {"id": "row1", "col1_id": 100.5}, + {"id": "row2", "col1_id": 200.0}, ] client = CVec( host="test_host", @@ -495,18 +533,20 @@ def test_select_from_eav_with_column_ids( def mock_call_rpc(name: str, params: Any) -> Any: captured_params.update(params) - return response_data + return rpc_response + client._query_table = self._mock_query_table() # type: ignore[method-assign] client._call_rpc = mock_call_rpc # type: ignore[method-assign] result = client.select_from_eav( tenant_id=1, - table_id="7a80f3a2-6fa1-43ce-8483-76bd00dc93c6", - column_ids=["col1"], + table_name="Test Table", + column_names=["Column 1"], ) assert len(result) == 2 - assert captured_params["column_ids"] == ["col1"] + # Should translate column name to column ID for the RPC call + assert captured_params["column_ids"] == ["col1_id"] @patch.object(CVec, "_login_with_supabase", return_value=None) @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") @@ -514,8 +554,8 @@ def test_select_from_eav_with_filters( self, mock_fetch_key: Any, mock_login: Any ) -> None: """Test select_from_eav with filters.""" - response_data = [ - {"id": "row1", "col1": 150.0, "col2": "ACTIVE"}, + rpc_response = [ + {"id": "row1", "col1_id": 150.0, "col2_id": "ACTIVE"}, ] client = CVec( host="test_host", @@ -526,26 +566,28 @@ def test_select_from_eav_with_filters( def mock_call_rpc(name: str, params: Any) -> Any: captured_params.update(params) - return response_data + return rpc_response + client._query_table = self._mock_query_table() # type: ignore[method-assign] client._call_rpc = mock_call_rpc # type: ignore[method-assign] filters = [ - EAVFilter(column_id="col1", numeric_min=100, numeric_max=200), - EAVFilter(column_id="col2", string_value="ACTIVE"), + EAVFilter(column_name="Column 1", numeric_min=100, numeric_max=200), + EAVFilter(column_name="Column 2", string_value="ACTIVE"), ] result = client.select_from_eav( tenant_id=1, - table_id="7a80f3a2-6fa1-43ce-8483-76bd00dc93c6", + table_name="Test Table", filters=filters, ) assert len(result) == 1 - assert result[0]["col1"] == 150.0 + assert result[0]["Column 1"] == 150.0 + # Filters should use column IDs in RPC call assert captured_params["filters"] == [ - {"column_id": "col1", "numeric_min": 100, "numeric_max": 200}, - {"column_id": "col2", "string_value": "ACTIVE"}, + {"column_id": "col1_id", "numeric_min": 100, "numeric_max": 200}, + {"column_id": "col2_id", "string_value": "ACTIVE"}, ] @patch.object(CVec, "_login_with_supabase", return_value=None) @@ -554,8 +596,8 @@ def test_select_from_eav_with_boolean_filter( self, mock_fetch_key: Any, mock_login: Any ) -> None: """Test select_from_eav with boolean filter.""" - response_data = [ - {"id": "row1", "is_active": True}, + rpc_response = [ + {"id": "row1", "is_active_id": True}, ] client = CVec( host="test_host", @@ -566,21 +608,22 @@ def test_select_from_eav_with_boolean_filter( def mock_call_rpc(name: str, params: Any) -> Any: captured_params.update(params) - return response_data + return rpc_response + client._query_table = self._mock_query_table() # type: ignore[method-assign] client._call_rpc = mock_call_rpc # type: ignore[method-assign] - filters = [EAVFilter(column_id="is_active", boolean_value=True)] + filters = [EAVFilter(column_name="Is Active", boolean_value=True)] result = client.select_from_eav( tenant_id=1, - table_id="7a80f3a2-6fa1-43ce-8483-76bd00dc93c6", + table_name="Test Table", filters=filters, ) assert len(result) == 1 assert captured_params["filters"] == [ - {"column_id": "is_active", "boolean_value": True} + {"column_id": "is_active_id", "boolean_value": True} ] @patch.object(CVec, "_login_with_supabase", return_value=None) @@ -593,30 +636,51 @@ def test_select_from_eav_empty_result( host="test_host", api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", ) + client._query_table = self._mock_query_table() # type: ignore[method-assign] client._call_rpc = lambda *args, **kwargs: [] # type: ignore[method-assign] result = client.select_from_eav( tenant_id=1, - table_id="7a80f3a2-6fa1-43ce-8483-76bd00dc93c6", + table_name="Test Table", ) assert result == [] @patch.object(CVec, "_login_with_supabase", return_value=None) @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") - def test_select_from_eav_none_result( + def test_select_from_eav_table_not_found( self, mock_fetch_key: Any, mock_login: Any ) -> None: - """Test select_from_eav with None result.""" + """Test select_from_eav raises error when table not found.""" client = CVec( host="test_host", api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", ) - client._call_rpc = lambda *args, **kwargs: None # type: ignore[method-assign] + client._query_table = lambda *args, **kwargs: [] # type: ignore[method-assign] - result = client.select_from_eav( - tenant_id=1, - table_id="7a80f3a2-6fa1-43ce-8483-76bd00dc93c6", + with pytest.raises(ValueError, match="Table 'Unknown Table' not found"): + client.select_from_eav( + tenant_id=1, + table_name="Unknown Table", + ) + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_select_from_eav_column_not_found( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: + """Test select_from_eav raises error when column not found.""" + client = CVec( + host="test_host", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", ) + client._query_table = self._mock_query_table() # type: ignore[method-assign] - assert result == [] + with pytest.raises( + ValueError, match="Column 'Unknown Column' not found in table 'Test Table'" + ): + client.select_from_eav( + tenant_id=1, + table_name="Test Table", + column_names=["Unknown Column"], + ) From a5acc9bd49d4ad4bd058c12c40ab290336648c4e Mon Sep 17 00:00:00 2001 From: Joshua Napoli Date: Mon, 1 Dec 2025 14:16:22 -0500 Subject: [PATCH 3/6] feat: use names rather than IDs --- src/cvec/cvec.py | 16 ++++++++++------ tests/test_cvec.py | 4 +++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/cvec/cvec.py b/src/cvec/cvec.py index d713b33..d2d44c9 100644 --- a/src/cvec/cvec.py +++ b/src/cvec/cvec.py @@ -584,14 +584,15 @@ def make_rpc_request() -> Any: def _query_table( self, table_name: str, - query_params: Optional[str] = None, + query_params: Optional[Dict[str, str]] = None, ) -> Any: """ Query a Supabase table via PostgREST. Args: table_name: The name of the table to query - query_params: Optional PostgREST query parameters (e.g., "name=eq.foo") + query_params: Optional dict of PostgREST query parameters + (e.g., {"name": "eq.foo", "order": "name"}) Returns: The response data from the query @@ -603,7 +604,8 @@ def _query_table( url = f"{self.host}/supabase/rest/v1/{table_name}" if query_params: - url = f"{url}?{query_params}" + encoded_params = urlencode(query_params) + url = f"{url}?{encoded_params}" headers = { "Accept": "application/json", @@ -655,7 +657,8 @@ def get_eav_tables(self, tenant_id: int) -> List[EAVTable]: List of EAVTable objects """ response_data = self._query_table( - "eav_tables", f"tenant_id=eq.{tenant_id}&order=name" + "eav_tables", + {"tenant_id": f"eq.{tenant_id}", "order": "name"}, ) return [EAVTable.model_validate(table) for table in response_data] @@ -670,7 +673,8 @@ def get_eav_columns(self, table_id: str) -> List[EAVColumn]: List of EAVColumn objects """ response_data = self._query_table( - "eav_columns", f"eav_table_id=eq.{table_id}&order=name" + "eav_columns", + {"eav_table_id": f"eq.{table_id}", "order": "name"}, ) return [EAVColumn.model_validate(column) for column in response_data] @@ -720,7 +724,7 @@ def select_from_eav( # Look up the table ID from the table name tables_response = self._query_table( "eav_tables", - f"tenant_id=eq.{tenant_id}&name=eq.{table_name}&limit=1", + {"tenant_id": f"eq.{tenant_id}", "name": f"eq.{table_name}", "limit": "1"}, ) if not tables_response: raise ValueError(f"Table '{table_name}' not found for tenant {tenant_id}") diff --git a/tests/test_cvec.py b/tests/test_cvec.py index bd0ddfe..5b6d80a 100644 --- a/tests/test_cvec.py +++ b/tests/test_cvec.py @@ -459,7 +459,9 @@ def _mock_query_table( ) -> Any: """Create a mock for _query_table that returns table and column data.""" - def mock_query(table_name: str, query_params: str | None = None) -> Any: + def mock_query( + table_name: str, query_params: dict[str, str] | None = None + ) -> Any: if table_name == "eav_tables": return [{"id": table_id, "tenant_id": 1, "name": "Test Table"}] elif table_name == "eav_columns": From 10c9ebeae5cf95144420978216b3d3740fd90541 Mon Sep 17 00:00:00 2001 From: Joshua Napoli Date: Mon, 1 Dec 2025 14:26:43 -0500 Subject: [PATCH 4/6] feat: allow select_from_eav by either id or name --- src/cvec/cvec.py | 135 ++++++++++++++++++++------ src/cvec/models/eav_filter.py | 17 +++- tests/test_cvec.py | 173 ++++++++++++++++++++++++++++++++-- 3 files changed, 284 insertions(+), 41 deletions(-) diff --git a/src/cvec/cvec.py b/src/cvec/cvec.py index d2d44c9..10a3f7f 100644 --- a/src/cvec/cvec.py +++ b/src/cvec/cvec.py @@ -678,6 +678,79 @@ def get_eav_columns(self, table_id: str) -> List[EAVColumn]: ) return [EAVColumn.model_validate(column) for column in response_data] + def select_from_eav_id( + self, + tenant_id: int, + table_id: str, + column_ids: Optional[List[str]] = None, + filters: Optional[List[EAVFilter]] = None, + ) -> List[Dict[str, Any]]: + """ + Query pivoted data from EAV tables using table and column IDs directly. + + This is the lower-level method that works with IDs. For a more user-friendly + interface using names, see select_from_eav(). + + Args: + tenant_id: The tenant ID to query data for + table_id: The UUID of the EAV table to query + column_ids: Optional list of column IDs to include in the result. + If None, all columns are returned. + filters: Optional list of EAVFilter objects to filter the results. + Each filter must use column_id (not column_name) and can specify: + - column_id: The EAV column ID to filter on (required) + - numeric_min: Minimum numeric value (inclusive) + - numeric_max: Maximum numeric value (exclusive) + - string_value: Exact string value to match + - boolean_value: Boolean value to match + + Returns: + List of dictionaries, each representing a row with column values. + Each row contains an 'id' field plus fields for each column_id + with their corresponding values (number, string, or boolean). + + Example: + >>> filters = [ + ... EAVFilter(column_id="MTnaC", numeric_min=100, numeric_max=200), + ... EAVFilter(column_id="z09PL", string_value="ACTIVE"), + ... ] + >>> rows = client.select_from_eav_id( + ... tenant_id=1, + ... table_id="550e8400-e29b-41d4-a716-446655440000", + ... column_ids=["MTnaC", "z09PL", "ZNAGI"], + ... filters=filters, + ... ) + """ + # Convert EAVFilter objects to dictionaries + filters_json: List[Dict[str, Any]] = [] + if filters: + for f in filters: + if f.column_id is None: + raise ValueError( + "Filters for select_from_eav_id must use column_id, " + "not column_name" + ) + filter_dict: Dict[str, Any] = {"column_id": f.column_id} + if f.numeric_min is not None: + filter_dict["numeric_min"] = f.numeric_min + if f.numeric_max is not None: + filter_dict["numeric_max"] = f.numeric_max + if f.string_value is not None: + filter_dict["string_value"] = f.string_value + if f.boolean_value is not None: + filter_dict["boolean_value"] = f.boolean_value + filters_json.append(filter_dict) + + params: Dict[str, Any] = { + "tenant_id": tenant_id, + "table_id": table_id, + "column_ids": column_ids, + "filters": filters_json, + } + + response_data = self._call_rpc("select_from_eav", params) + return list(response_data) if response_data else [] + def select_from_eav( self, tenant_id: int, @@ -686,10 +759,10 @@ def select_from_eav( filters: Optional[List[EAVFilter]] = None, ) -> List[Dict[str, Any]]: """ - Query pivoted data from EAV (Entity-Attribute-Value) tables. + Query pivoted data from EAV tables using human-readable names. - This method calls the app_data.select_from_eav Supabase function to retrieve - data from EAV tables with optional column selection and filtering. + This method looks up table and column IDs from names, then calls + select_from_eav_id(). For direct ID access, use select_from_eav_id(). Args: tenant_id: The tenant ID to query data for @@ -697,7 +770,7 @@ def select_from_eav( column_names: Optional list of column names to include in the result. If None, all columns are returned. filters: Optional list of EAVFilter objects to filter the results. - Each filter can specify: + Each filter must use column_name (not column_id) and can specify: - column_name: The EAV column name to filter on (required) - numeric_min: Minimum numeric value (inclusive) - numeric_max: Maximum numeric value (exclusive) @@ -730,9 +803,10 @@ def select_from_eav( raise ValueError(f"Table '{table_name}' not found for tenant {tenant_id}") table_id = tables_response[0]["id"] - # Get all columns for the table to build name -> id mapping + # Get all columns for the table to build name <-> id mappings columns = self.get_eav_columns(table_id) column_name_to_id = {col.name: col.eav_column_id for col in columns} + column_id_to_name = {col.eav_column_id: col.name for col in columns} # Convert column names to column IDs column_ids: Optional[List[str]] = None @@ -745,41 +819,42 @@ def select_from_eav( ) column_ids.append(column_name_to_id[name]) - # Convert EAVFilter objects to dictionaries, translating names to IDs - filters_json: List[Dict[str, Any]] = [] + # Convert filters with column_name to filters with column_id + id_filters: Optional[List[EAVFilter]] = None if filters: + id_filters = [] for f in filters: + if f.column_name is None: + raise ValueError( + "Filters for select_from_eav must use column_name, " + "not column_id" + ) if f.column_name not in column_name_to_id: raise ValueError( f"Filter column '{f.column_name}' not found in table " f"'{table_name}'" ) - filter_dict: Dict[str, Any] = { - "column_id": column_name_to_id[f.column_name] - } - if f.numeric_min is not None: - filter_dict["numeric_min"] = f.numeric_min - if f.numeric_max is not None: - filter_dict["numeric_max"] = f.numeric_max - if f.string_value is not None: - filter_dict["string_value"] = f.string_value - if f.boolean_value is not None: - filter_dict["boolean_value"] = f.boolean_value - filters_json.append(filter_dict) - - params: Dict[str, Any] = { - "tenant_id": tenant_id, - "table_id": table_id, - "column_ids": column_ids, - "filters": filters_json, - } - - response_data = self._call_rpc("select_from_eav", params) + id_filters.append( + EAVFilter( + column_id=column_name_to_id[f.column_name], + numeric_min=f.numeric_min, + numeric_max=f.numeric_max, + string_value=f.string_value, + boolean_value=f.boolean_value, + ) + ) + + # Call the ID-based method + response_data = self.select_from_eav_id( + tenant_id=tenant_id, + table_id=table_id, + column_ids=column_ids, + filters=id_filters, + ) # Convert column IDs back to names in the response - column_id_to_name = {col.eav_column_id: col.name for col in columns} result: List[Dict[str, Any]] = [] - for row in response_data or []: + for row in response_data: converted_row: Dict[str, Any] = {} for key, value in row.items(): if key == "id": diff --git a/src/cvec/models/eav_filter.py b/src/cvec/models/eav_filter.py index 45a2d1b..3be49f6 100644 --- a/src/cvec/models/eav_filter.py +++ b/src/cvec/models/eav_filter.py @@ -1,6 +1,6 @@ from typing import Optional, Union -from pydantic import BaseModel +from pydantic import BaseModel, model_validator class EAVFilter(BaseModel): @@ -8,13 +8,26 @@ class EAVFilter(BaseModel): Represents a filter for querying EAV data. Filters are used to narrow down results based on column values: + - Use column_name with select_from_eav() for human-readable column names + - Use column_id with select_from_eav_id() for direct column IDs - Use numeric_min/numeric_max for numeric range filtering (min inclusive, max exclusive) - Use string_value for exact string matching - Use boolean_value for boolean matching + + Exactly one of column_name or column_id must be provided. """ - column_name: str + column_name: Optional[str] = None + column_id: Optional[str] = None numeric_min: Optional[Union[int, float]] = None numeric_max: Optional[Union[int, float]] = None string_value: Optional[str] = None boolean_value: Optional[bool] = None + + @model_validator(mode="after") + def check_column_identifier(self) -> "EAVFilter": + if self.column_name is None and self.column_id is None: + raise ValueError("Either column_name or column_id must be provided") + if self.column_name is not None and self.column_id is not None: + raise ValueError("Only one of column_name or column_id should be provided") + return self diff --git a/tests/test_cvec.py b/tests/test_cvec.py index 5b6d80a..cda8fde 100644 --- a/tests/test_cvec.py +++ b/tests/test_cvec.py @@ -422,14 +422,17 @@ def test_get_metric_arrow_empty(self, mock_fetch_key: Any, mock_login: Any) -> N class TestEAVFilter: - def test_eav_filter_basic(self) -> None: - """Test EAVFilter with only column_name.""" + def test_eav_filter_with_column_name(self) -> None: + """Test EAVFilter with column_name.""" filter_obj = EAVFilter(column_name="Date") assert filter_obj.column_name == "Date" - assert filter_obj.numeric_min is None - assert filter_obj.numeric_max is None - assert filter_obj.string_value is None - assert filter_obj.boolean_value is None + assert filter_obj.column_id is None + + def test_eav_filter_with_column_id(self) -> None: + """Test EAVFilter with column_id.""" + filter_obj = EAVFilter(column_id="MTnaC") + assert filter_obj.column_id == "MTnaC" + assert filter_obj.column_name is None def test_eav_filter_numeric_range(self) -> None: """Test EAVFilter with numeric range.""" @@ -450,6 +453,16 @@ def test_eav_filter_boolean_value(self) -> None: assert filter_obj.column_name == "Is Active" assert filter_obj.boolean_value is False + def test_eav_filter_requires_column_identifier(self) -> None: + """Test EAVFilter raises error when neither column_name nor column_id.""" + with pytest.raises(ValueError, match="Either column_name or column_id"): + EAVFilter(numeric_min=100) + + def test_eav_filter_rejects_both_identifiers(self) -> None: + """Test EAVFilter raises error when both column_name and column_id.""" + with pytest.raises(ValueError, match="Only one of column_name or column_id"): + EAVFilter(column_name="Date", column_id="MTnaC") + class TestCVecSelectFromEAV: """Tests for select_from_eav using table_name and column_names.""" @@ -538,7 +551,7 @@ def mock_call_rpc(name: str, params: Any) -> Any: return rpc_response client._query_table = self._mock_query_table() # type: ignore[method-assign] - client._call_rpc = mock_call_rpc # type: ignore[method-assign] + client._call_rpc = mock_call_rpc # type: ignore[assignment, method-assign] result = client.select_from_eav( tenant_id=1, @@ -571,7 +584,7 @@ def mock_call_rpc(name: str, params: Any) -> Any: return rpc_response client._query_table = self._mock_query_table() # type: ignore[method-assign] - client._call_rpc = mock_call_rpc # type: ignore[method-assign] + client._call_rpc = mock_call_rpc # type: ignore[assignment, method-assign] filters = [ EAVFilter(column_name="Column 1", numeric_min=100, numeric_max=200), @@ -613,7 +626,7 @@ def mock_call_rpc(name: str, params: Any) -> Any: return rpc_response client._query_table = self._mock_query_table() # type: ignore[method-assign] - client._call_rpc = mock_call_rpc # type: ignore[method-assign] + client._call_rpc = mock_call_rpc # type: ignore[assignment, method-assign] filters = [EAVFilter(column_name="Is Active", boolean_value=True)] @@ -686,3 +699,145 @@ def test_select_from_eav_column_not_found( table_name="Test Table", column_names=["Unknown Column"], ) + + +class TestCVecSelectFromEAVId: + """Tests for select_from_eav_id using table_id and column_ids directly.""" + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_select_from_eav_id_basic( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: + """Test select_from_eav_id with no filters.""" + rpc_response = [ + {"id": "row1", "col1_id": 100.5, "col2_id": "value1"}, + {"id": "row2", "col1_id": 200.0, "col2_id": "value2"}, + ] + client = CVec( + host="test_host", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", + ) + client._call_rpc = lambda *args, **kwargs: rpc_response # type: ignore[method-assign] + + result = client.select_from_eav_id( + tenant_id=1, + table_id="7a80f3a2-6fa1-43ce-8483-76bd00dc93c6", + ) + + # Result keeps column IDs (no name translation) + assert len(result) == 2 + assert result[0]["id"] == "row1" + assert result[0]["col1_id"] == 100.5 + assert result[1]["col2_id"] == "value2" + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_select_from_eav_id_with_column_ids( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: + """Test select_from_eav_id with specific column_ids.""" + rpc_response = [ + {"id": "row1", "col1_id": 100.5}, + ] + client = CVec( + host="test_host", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", + ) + + captured_params: dict[str, Any] = {} + + def mock_call_rpc(name: str, params: Any) -> Any: + captured_params.update(params) + return rpc_response + + client._call_rpc = mock_call_rpc # type: ignore[assignment, method-assign] + + result = client.select_from_eav_id( + tenant_id=1, + table_id="7a80f3a2-6fa1-43ce-8483-76bd00dc93c6", + column_ids=["col1_id"], + ) + + assert len(result) == 1 + assert captured_params["column_ids"] == ["col1_id"] + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_select_from_eav_id_with_filters( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: + """Test select_from_eav_id with filters using column_id.""" + rpc_response = [ + {"id": "row1", "col1_id": 150.0, "col2_id": "ACTIVE"}, + ] + client = CVec( + host="test_host", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", + ) + + captured_params: dict[str, Any] = {} + + def mock_call_rpc(name: str, params: Any) -> Any: + captured_params.update(params) + return rpc_response + + client._call_rpc = mock_call_rpc # type: ignore[assignment, method-assign] + + filters = [ + EAVFilter(column_id="col1_id", numeric_min=100, numeric_max=200), + EAVFilter(column_id="col2_id", string_value="ACTIVE"), + ] + + result = client.select_from_eav_id( + tenant_id=1, + table_id="7a80f3a2-6fa1-43ce-8483-76bd00dc93c6", + filters=filters, + ) + + assert len(result) == 1 + assert captured_params["filters"] == [ + {"column_id": "col1_id", "numeric_min": 100, "numeric_max": 200}, + {"column_id": "col2_id", "string_value": "ACTIVE"}, + ] + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_select_from_eav_id_rejects_column_name_filter( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: + """Test select_from_eav_id raises error when filter uses column_name.""" + client = CVec( + host="test_host", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", + ) + + filters = [EAVFilter(column_name="Column 1", numeric_min=100)] + + with pytest.raises( + ValueError, match="Filters for select_from_eav_id must use column_id" + ): + client.select_from_eav_id( + tenant_id=1, + table_id="7a80f3a2-6fa1-43ce-8483-76bd00dc93c6", + filters=filters, + ) + + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_select_from_eav_id_empty_result( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: + """Test select_from_eav_id with empty result.""" + client = CVec( + host="test_host", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", + ) + client._call_rpc = lambda *args, **kwargs: [] # type: ignore[method-assign] + + result = client.select_from_eav_id( + tenant_id=1, + table_id="7a80f3a2-6fa1-43ce-8483-76bd00dc93c6", + ) + + assert result == [] From 21bb4636c8374ea3967688b1ff7d832d88d87838 Mon Sep 17 00:00:00 2001 From: Joshua Napoli Date: Mon, 1 Dec 2025 14:45:42 -0500 Subject: [PATCH 5/6] chore: bump minor version number --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8aade12..f6129e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cvec" -version = "1.2.0" +version = "1.3.0" description = "SDK for CVector Energy" authors = [{ name = "CVector", email = "support@cvector.energy" }] readme = "README.md" From 948ddb167678adb92c1829226285d1280891880e Mon Sep 17 00:00:00 2001 From: Joshua Napoli Date: Mon, 1 Dec 2025 15:19:07 -0500 Subject: [PATCH 6/6] fix: get the tenant_id from the host config --- examples/eav_example.py | 75 ++++--------------- examples/show_eav_schema.py | 27 +++++++ src/cvec/cvec.py | 43 +++++------ tests/test_cvec.py | 144 +++++++++++++++++++++++++----------- tests/test_modeling.py | 45 ++++++----- tests/test_token_refresh.py | 18 ++++- 6 files changed, 198 insertions(+), 154 deletions(-) create mode 100755 examples/show_eav_schema.py diff --git a/examples/eav_example.py b/examples/eav_example.py index fe328d7..1b350c7 100644 --- a/examples/eav_example.py +++ b/examples/eav_example.py @@ -5,74 +5,25 @@ def main() -> None: cvec = CVec( - host=os.environ.get("CVEC_HOST", "your-subdomain.cvector.dev"), + host=os.environ.get( + "CVEC_HOST", "https://your-subdomain.cvector.dev" + ), # Replace with your API host api_key=os.environ.get("CVEC_API_KEY", "your-api-key"), ) - tenant_id = 1 # Replace with your tenant ID - - # Example: List available EAV tables - print("\nListing EAV tables...") - tables = cvec.get_eav_tables(tenant_id) - print(f"Found {len(tables)} tables") - for table in tables: - print(f"- {table.name} (id: {table.id})") - - if not tables: - print("No tables found. Exiting.") - return - - # Use the first table for demonstration - table_name = tables[0].name - - # Example: List columns for the table - print(f"\nListing columns for '{table_name}'...") - columns = cvec.get_eav_columns(tables[0].id) - print(f"Found {len(columns)} columns") - for column in columns: - print(f"- {column.name} ({column.type})") - - # Example: Query all rows from the table - print(f"\nQuerying '{table_name}' (all columns, no filters)...") - rows = cvec.select_from_eav( - tenant_id=tenant_id, - table_name=table_name, + # Example: Query with numeric range filter + print("\nQuerying with numeric range filter...") + rows = cvec.select_from_eav_id( + table_id="00000000-0000-0000-0000-000000000000", + column_ids=["abcd", "defg", "hijk"], + filters=[ + EAVFilter(column_id="abcd", numeric_min=45992, numeric_max=45993), + ], ) - print(f"Found {len(rows)} rows") - for row in rows[:5]: # Show first 5 rows + print(f"Found {len(rows)} rows with abcd in range [45992, 45993)") + for row in rows: print(f"- {row}") - # Example: Query with numeric range filter (if there's a numeric column) - numeric_columns = [c for c in columns if c.type == "number"] - if numeric_columns: - col_name = numeric_columns[0].name - print(f"\nQuerying with numeric filter on '{col_name}'...") - rows = cvec.select_from_eav( - tenant_id=tenant_id, - table_name=table_name, - filters=[ - EAVFilter(column_name=col_name, numeric_min=0), - ], - ) - print(f"Found {len(rows)} rows with {col_name} >= 0") - - # Example: Query with string filter (if there's a string column) - string_columns = [c for c in columns if c.type == "string"] - if string_columns and rows: - col_name = string_columns[0].name - # Get a sample value from the first row - sample_value = rows[0].get(col_name) if rows else None - if sample_value: - print(f"\nQuerying with string filter on '{col_name}'...") - rows = cvec.select_from_eav( - tenant_id=tenant_id, - table_name=table_name, - filters=[ - EAVFilter(column_name=col_name, string_value=str(sample_value)), - ], - ) - print(f"Found {len(rows)} rows with {col_name}='{sample_value}'") - if __name__ == "__main__": main() diff --git a/examples/show_eav_schema.py b/examples/show_eav_schema.py new file mode 100755 index 0000000..4277c07 --- /dev/null +++ b/examples/show_eav_schema.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Show EAV tables and columns.""" + +import os + +from cvec import CVec + + +def main() -> None: + cvec = CVec( + host=os.environ.get("CVEC_HOST", ""), + api_key=os.environ.get("CVEC_API_KEY", ""), + ) + + tables = cvec.get_eav_tables() + print(f"Found {len(tables)} EAV tables\n") + + for table in tables: + print(f"{table.name} (id: {table.id})") + columns = cvec.get_eav_columns(table.id) + for column in columns: + print(f" - {column.name} ({column.type}, id: {column.eav_column_id})") + print() + + +if __name__ == "__main__": + main() diff --git a/src/cvec/cvec.py b/src/cvec/cvec.py index 10a3f7f..b61306e 100644 --- a/src/cvec/cvec.py +++ b/src/cvec/cvec.py @@ -33,6 +33,7 @@ class CVec: _refresh_token: Optional[str] _publishable_key: Optional[str] _api_key: Optional[str] + _tenant_id: int def __init__( self, @@ -64,8 +65,8 @@ def __init__( "CVEC_API_KEY must be set either as an argument or environment variable" ) - # Fetch publishable key from host config - self._publishable_key = self._fetch_publishable_key() + # Fetch config (publishable key and tenant ID) + self._publishable_key = self._fetch_config() # Handle authentication email = self._construct_email_from_api_key() @@ -485,15 +486,17 @@ def _refresh_supabase_token(self) -> None: self._access_token = data["access_token"] self._refresh_token = data["refresh_token"] - def _fetch_publishable_key(self) -> str: + def _fetch_config(self) -> str: """ - Fetch the publishable key from the host's config endpoint. + Fetch configuration from the host's config endpoint. + + Sets the tenant_id on the instance and returns the publishable key. Returns: The publishable key from the config response Raises: - ValueError: If the config endpoint is not accessible or doesn't contain the key + ValueError: If the config endpoint is not accessible or doesn't contain required fields """ try: config_url = f"{self.host}/config" @@ -504,10 +507,14 @@ def _fetch_publishable_key(self) -> str: config_data = json.loads(response_data.decode("utf-8")) publishable_key = config_data.get("supabasePublishableKey") + tenant_id = config_data.get("tenantId") if not publishable_key: raise ValueError(f"Configuration fetched from {config_url} is invalid") + if tenant_id is None: + raise ValueError(f"tenantId not found in config from {config_url}") + self._tenant_id = int(tenant_id) return str(publishable_key) except (HTTPError, URLError) as e: @@ -646,19 +653,16 @@ def make_query_request() -> Any: raise e raise - def get_eav_tables(self, tenant_id: int) -> List[EAVTable]: + def get_eav_tables(self) -> List[EAVTable]: """ - Get all EAV tables for a tenant. - - Args: - tenant_id: The tenant ID to query tables for + Get all EAV tables for the tenant. Returns: List of EAVTable objects """ response_data = self._query_table( "eav_tables", - {"tenant_id": f"eq.{tenant_id}", "order": "name"}, + {"tenant_id": f"eq.{self._tenant_id}", "order": "name"}, ) return [EAVTable.model_validate(table) for table in response_data] @@ -680,7 +684,6 @@ def get_eav_columns(self, table_id: str) -> List[EAVColumn]: def select_from_eav_id( self, - tenant_id: int, table_id: str, column_ids: Optional[List[str]] = None, filters: Optional[List[EAVFilter]] = None, @@ -692,7 +695,6 @@ def select_from_eav_id( interface using names, see select_from_eav(). Args: - tenant_id: The tenant ID to query data for table_id: The UUID of the EAV table to query column_ids: Optional list of column IDs to include in the result. If None, all columns are returned. @@ -715,7 +717,6 @@ def select_from_eav_id( ... EAVFilter(column_id="z09PL", string_value="ACTIVE"), ... ] >>> rows = client.select_from_eav_id( - ... tenant_id=1, ... table_id="550e8400-e29b-41d4-a716-446655440000", ... column_ids=["MTnaC", "z09PL", "ZNAGI"], ... filters=filters, @@ -742,7 +743,7 @@ def select_from_eav_id( filters_json.append(filter_dict) params: Dict[str, Any] = { - "tenant_id": tenant_id, + "tenant_id": self._tenant_id, "table_id": table_id, "column_ids": column_ids, "filters": filters_json, @@ -753,7 +754,6 @@ def select_from_eav_id( def select_from_eav( self, - tenant_id: int, table_name: str, column_names: Optional[List[str]] = None, filters: Optional[List[EAVFilter]] = None, @@ -765,7 +765,6 @@ def select_from_eav( select_from_eav_id(). For direct ID access, use select_from_eav_id(). Args: - tenant_id: The tenant ID to query data for table_name: The name of the EAV table to query column_names: Optional list of column names to include in the result. If None, all columns are returned. @@ -788,7 +787,6 @@ def select_from_eav( ... EAVFilter(column_name="Status", string_value="ACTIVE"), ... ] >>> rows = client.select_from_eav( - ... tenant_id=1, ... table_name="BT/Scrap Entry", ... column_names=["Weight", "Status", "Is Verified"], ... filters=filters, @@ -797,10 +795,14 @@ def select_from_eav( # Look up the table ID from the table name tables_response = self._query_table( "eav_tables", - {"tenant_id": f"eq.{tenant_id}", "name": f"eq.{table_name}", "limit": "1"}, + { + "tenant_id": f"eq.{self._tenant_id}", + "name": f"eq.{table_name}", + "limit": "1", + }, ) if not tables_response: - raise ValueError(f"Table '{table_name}' not found for tenant {tenant_id}") + raise ValueError(f"Table '{table_name}' not found") table_id = tables_response[0]["id"] # Get all columns for the table to build name <-> id mappings @@ -846,7 +848,6 @@ def select_from_eav( # Call the ID-based method response_data = self.select_from_eav_id( - tenant_id=tenant_id, table_id=table_id, column_ids=column_ids, filters=id_filters, diff --git a/tests/test_cvec.py b/tests/test_cvec.py index cda8fde..9acf00d 100644 --- a/tests/test_cvec.py +++ b/tests/test_cvec.py @@ -12,9 +12,17 @@ from cvec.models.metric import Metric +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" + + class TestCVecConstructor: @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_constructor_with_arguments( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -32,7 +40,9 @@ def test_constructor_with_arguments( assert client._api_key == "cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O" @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_constructor_adds_https_scheme( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -44,7 +54,9 @@ def test_constructor_adds_https_scheme( assert client.host == "https://example.cvector.dev" @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_constructor_preserves_https_scheme( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -56,7 +68,9 @@ def test_constructor_preserves_https_scheme( assert client.host == "https://example.cvector.dev" @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_constructor_preserves_http_scheme( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -68,7 +82,9 @@ def test_constructor_preserves_http_scheme( assert client.host == "http://localhost:3000" @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="env_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) @patch.dict( os.environ, { @@ -86,13 +102,15 @@ def test_constructor_with_env_vars( default_end_at=datetime(2023, 2, 2, 0, 0, 0), ) assert client.host == "https://env_host" - assert client._publishable_key == "env_publishable_key" + assert client._publishable_key == "test_publishable_key" assert client._api_key == "cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O" assert client.default_start_at == datetime(2023, 2, 1, 0, 0, 0) assert client.default_end_at == datetime(2023, 2, 2, 0, 0, 0) @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) @patch.dict(os.environ, {}, clear=True) def test_constructor_missing_host_raises_value_error( self, mock_fetch_key: Any, mock_login: Any @@ -105,7 +123,9 @@ def test_constructor_missing_host_raises_value_error( CVec(api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O") @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) @patch.dict(os.environ, {}, clear=True) def test_constructor_missing_api_key_raises_value_error( self, mock_fetch_key: Any, mock_login: Any @@ -118,7 +138,9 @@ def test_constructor_missing_api_key_raises_value_error( CVec(host="test_host") @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_constructor_args_override_env_vars( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -143,7 +165,9 @@ def test_constructor_args_override_env_vars( assert client.default_end_at == datetime(2023, 3, 2, 0, 0, 0) @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_construct_email_from_api_key( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -156,7 +180,9 @@ def test_construct_email_from_api_key( assert email == "cva+hHs0@cvector.app" @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_construct_email_from_api_key_invalid_format( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -170,7 +196,9 @@ def test_construct_email_from_api_key_invalid_format( client._construct_email_from_api_key() @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_construct_email_from_api_key_invalid_length( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -188,7 +216,9 @@ def test_construct_email_from_api_key_invalid_length( class TestCVecGetSpans: @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_get_spans_basic_case(self, mock_fetch_key: Any, mock_login: Any) -> None: # Simulate backend response response_data = [ @@ -228,7 +258,9 @@ def test_get_spans_basic_case(self, mock_fetch_key: Any, mock_login: Any) -> Non class TestCVecGetMetrics: @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_get_metrics_no_interval( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -260,7 +292,9 @@ def test_get_metrics_no_interval( assert metrics[1].name == "metric2" @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_get_metrics_with_interval( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -285,7 +319,9 @@ def test_get_metrics_with_interval( assert metrics[0].name == "metric_in_interval" @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_get_metrics_no_data_found( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -302,7 +338,9 @@ def test_get_metrics_no_data_found( class TestCVecGetMetricData: @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_get_metric_data_basic_case( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -337,7 +375,9 @@ def test_get_metric_data_basic_case( assert data_points[2].value_string == "val_str" @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_get_metric_data_no_data_points( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -350,7 +390,9 @@ def test_get_metric_data_no_data_points( assert data_points == [] @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_get_metric_arrow_basic_case( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -393,7 +435,9 @@ def test_get_metric_arrow_basic_case( ] @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_get_metric_arrow_empty(self, mock_fetch_key: Any, mock_login: Any) -> None: table = pa.table( { @@ -503,7 +547,9 @@ def mock_query( return mock_query @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_select_from_eav_basic(self, mock_fetch_key: Any, mock_login: Any) -> None: """Test select_from_eav with no filters.""" # Response uses column IDs @@ -519,7 +565,6 @@ def test_select_from_eav_basic(self, mock_fetch_key: Any, mock_login: Any) -> No client._call_rpc = lambda *args, **kwargs: rpc_response # type: ignore[method-assign] result = client.select_from_eav( - tenant_id=1, table_name="Test Table", ) @@ -530,7 +575,9 @@ def test_select_from_eav_basic(self, mock_fetch_key: Any, mock_login: Any) -> No assert result[1]["Column 2"] == "value2" @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_select_from_eav_with_column_names( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -554,7 +601,6 @@ def mock_call_rpc(name: str, params: Any) -> Any: client._call_rpc = mock_call_rpc # type: ignore[assignment, method-assign] result = client.select_from_eav( - tenant_id=1, table_name="Test Table", column_names=["Column 1"], ) @@ -564,7 +610,9 @@ def mock_call_rpc(name: str, params: Any) -> Any: assert captured_params["column_ids"] == ["col1_id"] @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_select_from_eav_with_filters( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -592,7 +640,6 @@ def mock_call_rpc(name: str, params: Any) -> Any: ] result = client.select_from_eav( - tenant_id=1, table_name="Test Table", filters=filters, ) @@ -606,7 +653,9 @@ def mock_call_rpc(name: str, params: Any) -> Any: ] @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_select_from_eav_with_boolean_filter( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -631,7 +680,6 @@ def mock_call_rpc(name: str, params: Any) -> Any: filters = [EAVFilter(column_name="Is Active", boolean_value=True)] result = client.select_from_eav( - tenant_id=1, table_name="Test Table", filters=filters, ) @@ -642,7 +690,9 @@ def mock_call_rpc(name: str, params: Any) -> Any: ] @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_select_from_eav_empty_result( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -655,14 +705,15 @@ def test_select_from_eav_empty_result( client._call_rpc = lambda *args, **kwargs: [] # type: ignore[method-assign] result = client.select_from_eav( - tenant_id=1, table_name="Test Table", ) assert result == [] @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_select_from_eav_table_not_found( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -675,12 +726,13 @@ def test_select_from_eav_table_not_found( with pytest.raises(ValueError, match="Table 'Unknown Table' not found"): client.select_from_eav( - tenant_id=1, table_name="Unknown Table", ) @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_select_from_eav_column_not_found( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -695,7 +747,6 @@ def test_select_from_eav_column_not_found( ValueError, match="Column 'Unknown Column' not found in table 'Test Table'" ): client.select_from_eav( - tenant_id=1, table_name="Test Table", column_names=["Unknown Column"], ) @@ -705,7 +756,9 @@ class TestCVecSelectFromEAVId: """Tests for select_from_eav_id using table_id and column_ids directly.""" @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_select_from_eav_id_basic( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -721,7 +774,6 @@ def test_select_from_eav_id_basic( client._call_rpc = lambda *args, **kwargs: rpc_response # type: ignore[method-assign] result = client.select_from_eav_id( - tenant_id=1, table_id="7a80f3a2-6fa1-43ce-8483-76bd00dc93c6", ) @@ -732,7 +784,9 @@ def test_select_from_eav_id_basic( assert result[1]["col2_id"] == "value2" @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_select_from_eav_id_with_column_ids( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -754,7 +808,6 @@ def mock_call_rpc(name: str, params: Any) -> Any: client._call_rpc = mock_call_rpc # type: ignore[assignment, method-assign] result = client.select_from_eav_id( - tenant_id=1, table_id="7a80f3a2-6fa1-43ce-8483-76bd00dc93c6", column_ids=["col1_id"], ) @@ -763,7 +816,9 @@ def mock_call_rpc(name: str, params: Any) -> Any: assert captured_params["column_ids"] == ["col1_id"] @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_select_from_eav_id_with_filters( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -790,7 +845,6 @@ def mock_call_rpc(name: str, params: Any) -> Any: ] result = client.select_from_eav_id( - tenant_id=1, table_id="7a80f3a2-6fa1-43ce-8483-76bd00dc93c6", filters=filters, ) @@ -802,7 +856,9 @@ def mock_call_rpc(name: str, params: Any) -> Any: ] @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_select_from_eav_id_rejects_column_name_filter( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -818,13 +874,14 @@ def test_select_from_eav_id_rejects_column_name_filter( ValueError, match="Filters for select_from_eav_id must use column_id" ): client.select_from_eav_id( - tenant_id=1, table_id="7a80f3a2-6fa1-43ce-8483-76bd00dc93c6", filters=filters, ) @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) def test_select_from_eav_id_empty_result( self, mock_fetch_key: Any, mock_login: Any ) -> None: @@ -836,7 +893,6 @@ def test_select_from_eav_id_empty_result( client._call_rpc = lambda *args, **kwargs: [] # type: ignore[method-assign] result = client.select_from_eav_id( - tenant_id=1, table_id="7a80f3a2-6fa1-43ce-8483-76bd00dc93c6", ) diff --git a/tests/test_modeling.py b/tests/test_modeling.py index 8eea9bd..4924c4e 100644 --- a/tests/test_modeling.py +++ b/tests/test_modeling.py @@ -10,21 +10,24 @@ from cvec.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" + + class TestModelingMethods: """Test the modeling methods in the CVec class.""" - @patch("cvec.cvec.CVec._fetch_publishable_key") - @patch("cvec.cvec.CVec._login_with_supabase") - @patch("cvec.cvec.CVec._make_request") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object(CVec, "_make_request") def test_get_modeling_metrics( self, mock_make_request: Mock, mock_login: Mock, mock_fetch_key: Mock ) -> None: """Test get_modeling_metrics method.""" - # Mock the publishable key fetch - mock_fetch_key.return_value = "test_publishable_key" - - # Mock the login method - mock_login.return_value = None # Mock the response mock_response = [ @@ -64,18 +67,15 @@ def test_get_modeling_metrics( assert call_args[1]["params"]["start_at"] == "2024-01-01T12:00:00" assert call_args[1]["params"]["end_at"] == "2024-01-01T13:00:00" - @patch("cvec.cvec.CVec._fetch_publishable_key") - @patch("cvec.cvec.CVec._login_with_supabase") - @patch("cvec.cvec.CVec._make_request") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object(CVec, "_make_request") def test_get_modeling_metrics_data( self, mock_make_request: Mock, mock_login: Mock, mock_fetch_key: Mock ) -> None: """Test get_modeling_metrics_data method.""" - # Mock the publishable key fetch - mock_fetch_key.return_value = "test_publishable_key" - - # Mock the login method - mock_login.return_value = None # Mock the response mock_response = [ @@ -117,18 +117,15 @@ def test_get_modeling_metrics_data( assert call_args[1]["params"]["start_at"] == "2024-01-01T12:00:00" assert call_args[1]["params"]["end_at"] == "2024-01-01T13:00:00" - @patch("cvec.cvec.CVec._fetch_publishable_key") - @patch("cvec.cvec.CVec._login_with_supabase") - @patch("cvec.cvec.CVec._make_request") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object(CVec, "_make_request") def test_get_modeling_metrics_data_arrow( self, mock_make_request: Mock, mock_login: Mock, mock_fetch_key: Mock ) -> None: """Test get_modeling_metrics_data_arrow method.""" - # Mock the publishable key fetch - mock_fetch_key.return_value = "test_publishable_key" - - # Mock the login method - mock_login.return_value = None # Mock the response (Arrow data as bytes) mock_response = b"fake_arrow_data" diff --git a/tests/test_token_refresh.py b/tests/test_token_refresh.py index 890c06b..c902678 100644 --- a/tests/test_token_refresh.py +++ b/tests/test_token_refresh.py @@ -9,11 +9,19 @@ 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" + + class TestTokenRefresh: """Test cases for automatic token refresh functionality.""" @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) @patch("cvec.cvec.urlopen") def test_token_refresh_on_401( self, @@ -67,7 +75,9 @@ def mock_refresh() -> None: assert result == [] @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) @patch("cvec.cvec.urlopen") def test_token_refresh_handles_network_errors_gracefully( self, @@ -110,7 +120,9 @@ def mock_refresh_with_error() -> None: assert exc_info.value.code == 401 @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + @patch.object( + CVec, "_fetch_config", autospec=True, side_effect=mock_fetch_config_side_effect + ) @patch("cvec.cvec.urlopen") def test_token_refresh_handles_missing_refresh_token( self,