diff --git a/.gitignore b/.gitignore index 17abd6d..48aaab2 100644 --- a/.gitignore +++ b/.gitignore @@ -173,3 +173,5 @@ cython_debug/ # PyPI configuration file .pypirc .aider* + +.vscode/ diff --git a/README.md b/README.md index 2e5413d..4c754d1 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ import cvec from datetime import datetime ``` -Construct the CVec client. The host, tenant, and api_key can be given through parameters to the constructor or from the environment variables CVEC_HOST, CVEC_TENANT, and CVEC_API_KEY: +Construct the CVec client. The host, tenant, and api_key can be given through parameters to the constructor or from the environment variables CVEC_HOST, and CVEC_API_KEY: ``` cvec = cvec.CVec() @@ -48,7 +48,7 @@ The newest span for a metric does not have an end time, since it has not ended y To get the spans on `my_tag_name` since 2025-05-14 10am, run: ``` -for span in cvec.get_spans("mygroup/myedge/mode", start_at=datetime(2025, 5, 14, 10, 0, 0)): +for span in cvec.get_spans("mygroup/myedge/node", start_at=datetime(2025, 5, 14, 10, 0, 0)): print("%s\t%s" % (span.value, span.raw_start_at)) ``` @@ -115,13 +115,41 @@ Example output: [46257 rows x 4 columns] ``` +### Adding Metric Data + +To add new metric data points, you create a list of `MetricDataPoint` objects and pass them to `add_metric_data`. Each `MetricDataPoint` should have a `name`, a `time`, and either a `value_double` (for numeric values) or a `value_string` (for string values). + +```python +from datetime import datetime +from cvec.models import MetricDataPoint + +# Assuming 'cvec' client is already initialized + +# Create some data points +data_points = [ + MetricDataPoint( + name="mygroup/myedge/compressor01/stage1/temp_out/c", + time=datetime(2025, 7, 29, 10, 0, 0), + value_double=25.5, + ), + MetricDataPoint( + name="mygroup/myedge/compressor01/status", + time=datetime(2025, 7, 29, 10, 0, 5), + value_string="running", + ), +] + +# Add the data points to CVec +cvec.add_metric_data(data_points) +``` + # CVec Class The SDK provides an API client class named `CVec` with the following functions. ## `__init__(?host, ?tenant, ?api_key, ?default_start_at, ?default_end_at)` -Setup the SDK with the given host and API Key. The host and API key are loaded from environment variables CVEC_HOST, CVEC_TENANT, CVEC_API_KEY, if they are not given as arguments to the constructor. The `default_start_at` and `default_end_at` can provide a default query time interval for API methods. +Setup the SDK with the given host and API Key. The host and API key are loaded from environment variables CVEC_HOST, CVEC_API_KEY, if they are not given as arguments to the constructor. The `default_start_at` and `default_end_at` can provide a default query time interval for API methods. ## `get_spans(name, ?start_at, ?end_at, ?limit)` @@ -143,6 +171,13 @@ If no relevant value changes are found, an empty list is returned. Return all data-points within a given [`start_at`, `end_at`) interval, optionally selecting a given list of metric names. The return value is a Pandas DataFrame with four columns: name, time, value_double, value_string. One row is returned for each metric value transition. +## `add_metric_data(data_points, ?use_arrow)` + +Add multiple metric data points to the database. + +- `data_points`: A list of `MetricDataPoint` objects to add. +- `use_arrow`: An optional boolean. If `True`, data is sent to the server using the more efficient Apache Arrow format. This is recommended for large datasets. Defaults to `False`. + ## `get_metrics(?start_at, ?end_at)` Return a list of metrics that had at least one transition in the given [`start_at`, `end_at`) interval. All metrics are returned if no `start_at` and `end_at` are given. diff --git a/examples/add_metric_data_arrow_example.py b/examples/add_metric_data_arrow_example.py index e1cb1a1..efefbbf 100644 --- a/examples/add_metric_data_arrow_example.py +++ b/examples/add_metric_data_arrow_example.py @@ -8,11 +8,7 @@ def main() -> None: cvec = CVec( host=os.environ.get("CVEC_HOST", "https://your-subdomain.cvector.dev"), - email=os.environ.get("CVEC_EMAIL", "your-email@cvector.app"), - password=os.environ.get("CVEC_PASSWORD", "your-password"), - publishable_key=os.environ.get( - "CVEC_PUBLISHABLE_KEY", "your-cvec-publishable-key" - ), + api_key=os.environ.get("CVEC_API_KEY", "your-api-key"), ) test_metric_name = "python-sdk/test" print("\nAdding new metric data using Arrow...") diff --git a/examples/add_metric_data_example.py b/examples/add_metric_data_example.py index e81fe42..068684a 100644 --- a/examples/add_metric_data_example.py +++ b/examples/add_metric_data_example.py @@ -8,11 +8,7 @@ def main() -> None: cvec = CVec( host=os.environ.get("CVEC_HOST", "https://your-subdomain.cvector.dev"), - email=os.environ.get("CVEC_EMAIL", "your-email@cvector.app"), - password=os.environ.get("CVEC_PASSWORD", "your-password"), - publishable_key=os.environ.get( - "CVEC_PUBLISHABLE_KEY", "your-cvec-publishable-key" - ), + api_key=os.environ.get("CVEC_API_KEY", "your-api-key"), ) test_metric_name = "python-sdk/test" print("\nAdding new metric data...") diff --git a/examples/add_multiple_metrics_and_get_spans_for_them.py b/examples/add_multiple_metrics_and_get_spans_for_them.py new file mode 100644 index 0000000..f666ef1 --- /dev/null +++ b/examples/add_multiple_metrics_and_get_spans_for_them.py @@ -0,0 +1,127 @@ +import random +from cvec import CVec +from datetime import datetime, timedelta, timezone +import os +import io +import pyarrow.ipc as ipc # type: ignore[import-untyped] + +from cvec.models.metric import MetricDataPoint + + +def main() -> None: + cvec = CVec( + host=os.environ.get("CVEC_HOST", "https://your-subdomain.cvector.dev"), + api_key=os.environ.get("CVEC_API_KEY", "your-api-key"), + ) + test_metric_name = "" + + # fetch & pick metrics + metrics = cvec.get_metrics( + start_at=datetime(2025, 7, 14, 10, 0, 0), + end_at=datetime(2025, 7, 14, 11, 0, 0), + ) + print(f"Found {len(metrics)} metrics") + for metric in metrics: + print(f"- {metric.name} - {metric.id}") + if metrics: + found_metric_name = next((m.name for m in metrics if "Sensor_" in m.name)) + assert found_metric_name, "No suitable metric found" + test_metric_name = found_metric_name + print(f"\nUsing metric: {test_metric_name}") + + # Add metric non-Arrow data + random_number_nonarrow = random.randint(10000, 20000) + print( + f"\nAdding new metric data point with non-Arrow format for metric " + f"'{test_metric_name}' and values {random_number_nonarrow}..." + ) + new_data = [ + MetricDataPoint( + name=test_metric_name, + time=datetime.now(timezone.utc), + value_double=random_number_nonarrow, + value_string=None, + ), + MetricDataPoint( + name=test_metric_name, + time=datetime.now(timezone.utc), + value_double=None, + value_string=str(random_number_nonarrow), + ), + ] + cvec.add_metric_data(new_data, use_arrow=False) + print("Non-Arrow Data added successfully") + + # Add metric Arrow data + + random_number_arrow = random.randint(10000, 20000) + print( + f"\nAdding new metric data point with Arrow format for metric " + f"'{test_metric_name}' and value {random_number_arrow}..." + ) + new_data = [ + MetricDataPoint( + name=test_metric_name, + time=datetime.now(timezone.utc), + value_double=random_number_arrow, + value_string=None, + ), + ] + cvec.add_metric_data(new_data, use_arrow=True) + print("Arrow Data added successfully") + + # Fetch and print metric data - non-Arrow + data_points = cvec.get_metric_data( + start_at=datetime.now(timezone.utc) - timedelta(minutes=1), + end_at=datetime.now(timezone.utc), + names=[test_metric_name], + ) + assert len(data_points) > 0, "No data points found for the metric" + assert any(dp.value_double == random_number_nonarrow for dp in data_points), ( + "No data point found with the expected non-Arrow value" + ) + assert any(dp.value_string == str(random_number_nonarrow) for dp in data_points), ( + "No data point found with the expected non-Arrow string value" + ) + assert any(dp.value_double == random_number_arrow for dp in data_points), ( + "No data point found with the expected Arrow value" + ) + print(f"\nFound {len(data_points)} data points for metric '{test_metric_name}'") + for point in data_points: + print( + f"- {point.name}: {point.value_double or point.value_string} at {point.time}" + ) + + # Fetch and print metric data - Arrow + arrow_data = cvec.get_metric_arrow( + start_at=datetime.now(timezone.utc) - timedelta(minutes=1), + end_at=datetime.now(timezone.utc), + names=[test_metric_name], + ) + reader = ipc.open_file(io.BytesIO(arrow_data)) + table = reader.read_all() + assert len(table) > 0, "No data found in Arrow format" + print(f"Arrow table shape: {len(table)} rows") + print("\nFirst few rows:") + for i in range(min(5, len(table))): + print( + f"- {table['name'][i].as_py()}: {table['value_double'][i].as_py() or table['value_string'][i].as_py()} at {table['time'][i].as_py()}" + ) + + # spans + spans = cvec.get_spans( + start_at=datetime.now(timezone.utc) - timedelta(minutes=1), + end_at=datetime.now(timezone.utc), + name=test_metric_name, + limit=5, + ) + assert len(spans) > 0, "No spans found for the metric" + print(f"Found {len(spans)} spans") + for span in spans: + print(f"- Value: {span.value} from {span.raw_start_at} to {span.raw_end_at}") + + print("\nAll operations completed successfully.") + + +if __name__ == "__main__": + main() diff --git a/examples/get_metric_arrow_example.py b/examples/get_metric_arrow_example.py index 7a5bb52..aa65887 100644 --- a/examples/get_metric_arrow_example.py +++ b/examples/get_metric_arrow_example.py @@ -7,11 +7,7 @@ def main() -> None: cvec = CVec( host=os.environ.get("CVEC_HOST", "https://your-subdomain.cvector.dev"), - email=os.environ.get("CVEC_EMAIL", "your-email@cvector.app"), - password=os.environ.get("CVEC_PASSWORD", "your-password"), - publishable_key=os.environ.get( - "CVEC_PUBLISHABLE_KEY", "your-cvec-publishable-key" - ), + api_key=os.environ.get("CVEC_API_KEY", "your-api-key"), ) test_metric_name = "python-sdk/test" print("\nGetting metric data as Arrow...") diff --git a/examples/get_metric_data_objects_example.py b/examples/get_metric_data_objects_example.py index 8fabf05..56a2054 100644 --- a/examples/get_metric_data_objects_example.py +++ b/examples/get_metric_data_objects_example.py @@ -5,11 +5,7 @@ def main() -> None: cvec = CVec( host=os.environ.get("CVEC_HOST", "https://your-subdomain.cvector.dev"), - email=os.environ.get("CVEC_EMAIL", "your-email@cvector.app"), - password=os.environ.get("CVEC_PASSWORD", "your-password"), - publishable_key=os.environ.get( - "CVEC_PUBLISHABLE_KEY", "your-cvec-publishable-key" - ), + api_key=os.environ.get("CVEC_API_KEY", "your-api-key"), ) test_metric_name = "python-sdk/test" print("\nGetting metric data as objects...") diff --git a/examples/get_metrics_example.py b/examples/get_metrics_example.py index 62a6478..d5bcb79 100644 --- a/examples/get_metrics_example.py +++ b/examples/get_metrics_example.py @@ -7,11 +7,7 @@ def main() -> None: host=os.environ.get( "CVEC_HOST", "https://your-subdomain.cvector.dev" ), # Replace with your API host - email=os.environ.get("CVEC_EMAIL", "your-email@cvector.app"), - password=os.environ.get("CVEC_PASSWORD", "your-password"), - publishable_key=os.environ.get( - "CVEC_PUBLISHABLE_KEY", "your-cvec-publishable-key" - ), + api_key=os.environ.get("CVEC_API_KEY", "your-api-key"), ) print("\nGetting available metrics...") metrics = cvec.get_metrics() diff --git a/examples/get_spans_example.py b/examples/get_spans_example.py index 3e4c0dd..fb9c010 100644 --- a/examples/get_spans_example.py +++ b/examples/get_spans_example.py @@ -5,11 +5,7 @@ def main() -> None: cvec = CVec( host=os.environ.get("CVEC_HOST", "https://your-subdomain.cvector.dev"), - email=os.environ.get("CVEC_EMAIL", "your-email@cvector.app"), - password=os.environ.get("CVEC_PASSWORD", "your-password"), - publishable_key=os.environ.get( - "CVEC_PUBLISHABLE_KEY", "your-cvec-publishable-key" - ), + api_key=os.environ.get("CVEC_API_KEY", "your-api-key"), ) metrics = cvec.get_metrics() if metrics: diff --git a/src/cvec/cvec.py b/src/cvec/cvec.py index 91a2fc8..5590515 100644 --- a/src/cvec/cvec.py +++ b/src/cvec/cvec.py @@ -1,6 +1,6 @@ import os from datetime import datetime -from typing import Any, List, Optional, Dict +from typing import Any, Dict, List, Optional from urllib.parse import urljoin import requests # type: ignore[import-untyped] @@ -25,15 +25,14 @@ class CVec: _access_token: Optional[str] _refresh_token: Optional[str] _publishable_key: Optional[str] + _api_key: Optional[str] def __init__( self, host: Optional[str] = None, default_start_at: Optional[datetime] = None, default_end_at: Optional[datetime] = None, - email: Optional[str] = None, - password: Optional[str] = None, - publishable_key: Optional[str] = None, + api_key: Optional[str] = None, ) -> None: self.host = host or os.environ.get("CVEC_HOST") self.default_start_at = default_start_at @@ -42,26 +41,47 @@ def __init__( # Supabase authentication self._access_token = None self._refresh_token = None - self._publishable_key = publishable_key or os.environ.get( - "CVEC_PUBLISHABLE_KEY" - ) + self._publishable_key = None + self._api_key = api_key or os.environ.get("CVEC_API_KEY") if not self.host: raise ValueError( "CVEC_HOST must be set either as an argument or environment variable" ) - if not self._publishable_key: + if not self._api_key: raise ValueError( - "CVEC_PUBLISHABLE_KEY must be set either as an argument or environment variable" + "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() + # Handle authentication - if email and password: - self._login_with_supabase(email, password) - else: - raise ValueError( - "Email and password must be provided for Supabase authentication" - ) + email = self._construct_email_from_api_key() + self._login_with_supabase(email, self._api_key) + + def _construct_email_from_api_key(self) -> str: + """ + Construct email from API key using the pattern cva+@cvector.app + + Returns: + The constructed email address + + Raises: + ValueError: If the API key doesn't match the expected pattern + """ + if not self._api_key: + raise ValueError("API key is not set") + + if not self._api_key.startswith("cva_"): + raise ValueError("API key must start with 'cva_'") + + if len(self._api_key) != 40: # cva_ + 36 62-base encoded symbols + raise ValueError("API key invalid length. Expected cva_ + 36 symbols.") + + # Extract 4 characters after "cva_" + key_id = self._api_key[4:8] + return f"cva+{key_id}@cvector.app" def _get_headers(self) -> Dict[str, str]: """Helper method to get request headers.""" @@ -238,7 +258,9 @@ def get_metric_arrow( return result def get_metrics( - self, start_at: Optional[datetime] = None, end_at: Optional[datetime] = None + self, + start_at: Optional[datetime] = None, + end_at: Optional[datetime] = None, ) -> List[Metric]: """ Return a list of metrics that had at least one transition in the given [start_at, end_at) interval. @@ -295,7 +317,10 @@ def _login_with_supabase(self, email: str, password: str) -> None: payload = {"email": email, "password": password} - headers = {"Content-Type": "application/json", "apikey": self._publishable_key} + headers = { + "Content-Type": "application/json", + "apikey": self._publishable_key, + } response = requests.post(supabase_url, json=payload, headers=headers) response.raise_for_status() @@ -316,7 +341,10 @@ def _refresh_supabase_token(self) -> None: payload = {"refresh_token": self._refresh_token} - headers = {"Content-Type": "application/json", "apikey": self._publishable_key} + headers = { + "Content-Type": "application/json", + "apikey": self._publishable_key, + } response = requests.post(supabase_url, json=payload, headers=headers) response.raise_for_status() @@ -324,3 +352,31 @@ def _refresh_supabase_token(self) -> None: data = response.json() self._access_token = data["access_token"] self._refresh_token = data["refresh_token"] + + def _fetch_publishable_key(self) -> str: + """ + Fetch the publishable key from the host's config endpoint. + + Returns: + The publishable key from the config response + + Raises: + ValueError: If the config endpoint is not accessible or doesn't contain the key + """ + try: + config_url = f"{self.host}/config" + response = requests.get(config_url) + response.raise_for_status() + + config_data = response.json() + publishable_key = config_data.get("supabasePublishableKey") + + if not publishable_key: + raise ValueError(f"Configuration fetched from {config_url} is invalid") + + return str(publishable_key) + + except requests.RequestException as e: + raise ValueError(f"Failed to fetch config from {self.host}/config: {e}") + except (KeyError, ValueError) as e: + raise ValueError(f"Invalid config response: {e}") diff --git a/tests/test_cvec.py b/tests/test_cvec.py index de88528..31378bd 100644 --- a/tests/test_cvec.py +++ b/tests/test_cvec.py @@ -12,108 +12,84 @@ class TestCVecConstructor: @patch.object(CVec, "_login_with_supabase", return_value=None) - def test_constructor_with_arguments(self, mock_login: Any) -> None: + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_constructor_with_arguments( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: """Test CVec constructor with all arguments provided.""" client = CVec( host="test_host", default_start_at=datetime(2023, 1, 1, 0, 0, 0), default_end_at=datetime(2023, 1, 2, 0, 0, 0), - email="user@example.com", - password="password123", - publishable_key="test_publishable_key", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", ) assert client.host == "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="env_publishable_key") @patch.dict( os.environ, { "CVEC_HOST": "env_host", - "CVEC_PUBLISHABLE_KEY": "env_publishable_key", + "CVEC_API_KEY": "cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", }, clear=True, ) - def test_constructor_with_env_vars(self, mock_login: Any) -> None: + def test_constructor_with_env_vars( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: """Test CVec constructor with environment variables.""" client = CVec( default_start_at=datetime(2023, 2, 1, 0, 0, 0), default_end_at=datetime(2023, 2, 2, 0, 0, 0), - email="user@example.com", - password="password123", ) assert client.host == "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) 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.dict(os.environ, {}, clear=True) - def test_constructor_missing_host_raises_value_error(self, mock_login: Any) -> None: + def test_constructor_missing_host_raises_value_error( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: """Test CVec constructor raises ValueError if host is missing.""" with pytest.raises( ValueError, match="CVEC_HOST must be set either as an argument or environment variable", ): - CVec( - email="user@example.com", - password="password123", - publishable_key="test_publishable_key", - ) + 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.dict(os.environ, {}, clear=True) - def test_constructor_missing_publishable_key_raises_value_error( - self, mock_login: Any + def test_constructor_missing_api_key_raises_value_error( + self, mock_fetch_key: Any, mock_login: Any ) -> None: - """Test CVec constructor raises ValueError if publishable_key is missing.""" + """Test CVec constructor raises ValueError if api_key is missing.""" with pytest.raises( ValueError, - match="CVEC_PUBLISHABLE_KEY must be set either as an argument or environment variable", + match="CVEC_API_KEY must be set either as an argument or environment variable", ): - CVec(host="test_host", email="user@example.com", password="password123") + CVec(host="test_host") @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.dict(os.environ, {}, clear=True) - def test_constructor_missing_email_password_raises_value_error( - self, mock_login: Any + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_constructor_args_override_env_vars( + self, mock_fetch_key: Any, mock_login: Any ) -> None: - """Test CVec constructor raises ValueError if email or password is missing.""" - with pytest.raises( - ValueError, - match="Email and password must be provided for Supabase authentication", - ): - CVec(host="test_host", publishable_key="test_publishable_key") - - @patch.object(CVec, "_login_with_supabase", return_value=None) - @patch.dict( - os.environ, - { - "CVEC_HOST": "env_host", - # CVEC_PUBLISHABLE_KEY is missing - }, - clear=True, - ) - def test_constructor_missing_publishable_key_env_var_raises_value_error( - self, mock_login: Any - ) -> None: - """Test CVec constructor raises ValueError if CVEC_PUBLISHABLE_KEY env var is missing.""" - with pytest.raises( - ValueError, - match="CVEC_PUBLISHABLE_KEY must be set either as an argument or environment variable", - ): - CVec(email="user@example.com", password="password123") - - @patch.object(CVec, "_login_with_supabase", return_value=None) - def test_constructor_args_override_env_vars(self, mock_login: Any) -> None: """Test CVec constructor arguments override environment variables.""" with patch.dict( os.environ, { "CVEC_HOST": "env_host", - "CVEC_PUBLISHABLE_KEY": "env_publishable_key", + "CVEC_API_KEY": "cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", }, clear=True, ): @@ -121,19 +97,61 @@ def test_constructor_args_override_env_vars(self, mock_login: Any) -> None: host="arg_host", default_start_at=datetime(2023, 3, 1, 0, 0, 0), default_end_at=datetime(2023, 3, 2, 0, 0, 0), - email="user@example.com", - password="password123", - publishable_key="arg_publishable_key", + api_key="cva_differentKeyKALxMnxUdI9hanF0TBPvvvr1", ) assert client.host == "arg_host" - assert client._publishable_key == "arg_publishable_key" + 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) + @patch.object(CVec, "_login_with_supabase", return_value=None) + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_construct_email_from_api_key( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: + """Test email construction from API key.""" + client = CVec( + host="test_host", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", + ) + email = client._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") + def test_construct_email_from_api_key_invalid_format( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: + """Test email construction with invalid API key format.""" + client = CVec( + host="test_host", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", + ) + client._api_key = "invalid_key" + with pytest.raises(ValueError, match="API key must start with 'cva_'"): + 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") + def test_construct_email_from_api_key_invalid_length( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: + """Test email construction with invalid API key length.""" + client = CVec( + host="test_host", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", + ) + client._api_key = "cva_short" + with pytest.raises( + ValueError, match="API key invalid length. Expected cva_ \\+ 36 symbols." + ): + client._construct_email_from_api_key() + class TestCVecGetSpans: @patch.object(CVec, "_login_with_supabase", return_value=None) - def test_get_spans_basic_case(self, mock_login: Any) -> None: + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_get_spans_basic_case(self, mock_fetch_key: Any, mock_login: Any) -> None: # Simulate backend response response_data = [ { @@ -157,9 +175,7 @@ def test_get_spans_basic_case(self, mock_login: Any) -> None: ] client = CVec( host="test_host", - email="user@example.com", - password="password123", - publishable_key="test_publishable_key", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", ) client._make_request = lambda *args, **kwargs: response_data # type: ignore[method-assign] spans = client.get_spans(name="test_tag") @@ -174,7 +190,10 @@ def test_get_spans_basic_case(self, mock_login: Any) -> None: class TestCVecGetMetrics: @patch.object(CVec, "_login_with_supabase", return_value=None) - def test_get_metrics_no_interval(self, mock_login: Any) -> None: + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_get_metrics_no_interval( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: response_data = [ { "id": 1, @@ -191,9 +210,7 @@ def test_get_metrics_no_interval(self, mock_login: Any) -> None: ] client = CVec( host="test_host", - email="user@example.com", - password="password123", - publishable_key="test_publishable_key", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", ) client._make_request = lambda *args, **kwargs: response_data # type: ignore[method-assign] metrics = client.get_metrics() @@ -205,7 +222,10 @@ def test_get_metrics_no_interval(self, mock_login: Any) -> None: assert metrics[1].name == "metric2" @patch.object(CVec, "_login_with_supabase", return_value=None) - def test_get_metrics_with_interval(self, mock_login: Any) -> None: + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_get_metrics_with_interval( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: response_data = [ { "id": 1, @@ -216,9 +236,7 @@ def test_get_metrics_with_interval(self, mock_login: Any) -> None: ] client = CVec( host="test_host", - email="user@example.com", - password="password123", - publishable_key="test_publishable_key", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", ) client._make_request = lambda *args, **kwargs: response_data # type: ignore[method-assign] metrics = client.get_metrics( @@ -229,12 +247,13 @@ def test_get_metrics_with_interval(self, mock_login: Any) -> None: assert metrics[0].name == "metric_in_interval" @patch.object(CVec, "_login_with_supabase", return_value=None) - def test_get_metrics_no_data_found(self, mock_login: Any) -> None: + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_get_metrics_no_data_found( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: client = CVec( host="test_host", - email="user@example.com", - password="password123", - publishable_key="test_publishable_key", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", ) client._make_request = lambda *args, **kwargs: [] # type: ignore[method-assign] metrics = client.get_metrics( @@ -245,7 +264,10 @@ def test_get_metrics_no_data_found(self, mock_login: Any) -> None: class TestCVecGetMetricData: @patch.object(CVec, "_login_with_supabase", return_value=None) - def test_get_metric_data_basic_case(self, mock_login: Any) -> None: + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_get_metric_data_basic_case( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: # Simulate backend response time1 = datetime(2023, 1, 1, 10, 0, 0) time2 = datetime(2023, 1, 1, 11, 0, 0) @@ -262,9 +284,7 @@ def test_get_metric_data_basic_case(self, mock_login: Any) -> None: ] client = CVec( host="test_host", - email="user@example.com", - password="password123", - publishable_key="test_publishable_key", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", ) client._make_request = lambda *args, **kwargs: response_data # type: ignore[method-assign] data_points = client.get_metric_data(names=["tag1", "tag2"]) @@ -279,19 +299,23 @@ def test_get_metric_data_basic_case(self, mock_login: Any) -> None: assert data_points[2].value_string == "val_str" @patch.object(CVec, "_login_with_supabase", return_value=None) - def test_get_metric_data_no_data_points(self, mock_login: Any) -> None: + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_get_metric_data_no_data_points( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: client = CVec( host="test_host", - email="user@example.com", - password="password123", - publishable_key="test_publishable_key", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", ) client._make_request = lambda *args, **kwargs: [] # type: ignore[method-assign] data_points = client.get_metric_data(names=["non_existent_tag"]) assert data_points == [] @patch.object(CVec, "_login_with_supabase", return_value=None) - def test_get_metric_arrow_basic_case(self, mock_login: Any) -> None: + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_get_metric_arrow_basic_case( + self, mock_fetch_key: Any, mock_login: Any + ) -> None: # Prepare Arrow table names = ["tag1", "tag1", "tag2"] times = [ @@ -315,9 +339,7 @@ def test_get_metric_arrow_basic_case(self, mock_login: Any) -> None: arrow_bytes = sink.getvalue().to_pybytes() client = CVec( host="test_host", - email="user@example.com", - password="password123", - publishable_key="test_publishable_key", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", ) client._make_request = lambda *args, **kwargs: arrow_bytes # type: ignore[method-assign] result = client.get_metric_arrow(names=["tag1", "tag2"]) @@ -333,7 +355,8 @@ def test_get_metric_arrow_basic_case(self, mock_login: Any) -> None: ] @patch.object(CVec, "_login_with_supabase", return_value=None) - def test_get_metric_arrow_empty(self, mock_login: Any) -> None: + @patch.object(CVec, "_fetch_publishable_key", return_value="test_publishable_key") + def test_get_metric_arrow_empty(self, mock_fetch_key: Any, mock_login: Any) -> None: table = pa.table( { "name": pa.array([], type=pa.string()), @@ -348,9 +371,7 @@ def test_get_metric_arrow_empty(self, mock_login: Any) -> None: arrow_bytes = sink.getvalue().to_pybytes() client = CVec( host="test_host", - email="user@example.com", - password="password123", - publishable_key="test_publishable_key", + api_key="cva_hHs0CbkKALxMnxUdI9hanF0TBPvvvr1HjG6O", ) client._make_request = lambda *args, **kwargs: arrow_bytes # type: ignore[method-assign] result = client.get_metric_arrow(names=["non_existent_tag"])