From a04898a1bf693987d36bbd8d9f878d64e906f922 Mon Sep 17 00:00:00 2001 From: haydencarroll-NS1 Date: Tue, 19 Aug 2025 17:46:30 +0100 Subject: [PATCH 1/6] alerting: usage alerts (account) --- CHANGELOG.md | 5 + examples/usage_alerts.py | 138 +++++++++++++++++ ns1/__init__.py | 18 ++- ns1/alerting/__init__.py | 3 + ns1/alerting/usage_alerts.py | 75 +++++++++ tests/unit/test_usage_alerts.py | 265 ++++++++++++++++++++++++++++++++ 6 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 examples/usage_alerts.py create mode 100644 ns1/alerting/__init__.py create mode 100644 ns1/alerting/usage_alerts.py create mode 100644 tests/unit/test_usage_alerts.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3668a9e..93a011f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.25.0 (August 19th, 2025) + +ENHANCEMENTS: +* Add Usage Alerts (account): create/get/patch/delete/list; client-side validation; examples. + ## 0.24.0 (March 20th, 2025) ENHANCEMENTS: diff --git a/examples/usage_alerts.py b/examples/usage_alerts.py new file mode 100644 index 0000000..bdee12e --- /dev/null +++ b/examples/usage_alerts.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +# +# Example of using the Usage Alerts API +# + +import os +import sys +import json + +# Path hackery to ensure we import the local ns1 module +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), + '..'))) + +from ns1 import NS1 + +# Create NS1 client +config = { + 'endpoint': 'https://api.nsone.net', + 'default_key': 'test1', + 'keys': { + 'test1': { + 'key': os.environ.get('NS1_APIKEY', 'test1'), + 'desc': 'test key' + } + } +} + +# Create a config from dictionary and create the client +from ns1.config import Config +c = Config() +c.loadFromDict(config) +client = NS1(config=c) + +# Usage Alerts API Examples +def usage_alerts_example(): + print("\n=== Usage Alerts Examples ===\n") + + # List all usage alerts + print("Listing usage alerts:") + try: + alerts = client.alerting().usage.list(limit=10) + print(f"Total alerts: {alerts.get('total_results', 0)}") + for i, alert in enumerate(alerts.get('results', [])): + print(f" {i+1}. {alert.get('name')} (id: {alert.get('id')})") + except Exception as e: + print(f"Error listing alerts: {e}") + + # Create a usage alert + print("\nCreating a usage alert:") + try: + alert = client.alerting().usage.create( + name="Example query usage alert", + subtype="query_usage", + alert_at_percent=85, + notifier_list_ids=[], + zone_names=[] + ) + alert_id = alert['id'] + print(f"Created alert: {alert['name']} (id: {alert_id})") + print(f"Alert details: {json.dumps(alert, indent=2)}") + except Exception as e: + print(f"Error creating alert: {e}") + return + + # Update the alert + print("\nUpdating the alert threshold to 90%:") + try: + updated = client.alerting().usage.patch( + alert_id, + alert_at_percent=90 + ) + print(f"Updated alert: {updated['name']}") + print(f"New threshold: {updated['data']['alert_at_percent']}%") + except Exception as e: + print(f"Error updating alert: {e}") + + # Get alert details + print("\nGetting alert details:") + try: + details = client.alerting().usage.get(alert_id) + print(f"Alert details: {json.dumps(details, indent=2)}") + except Exception as e: + print(f"Error getting alert: {e}") + + # Delete the alert + print("\nDeleting the alert:") + try: + client.alerting().usage.delete(alert_id) + print(f"Alert {alert_id} deleted successfully") + except Exception as e: + print(f"Error deleting alert: {e}") + +# Test validation failures +def test_validation(): + print("\n=== Validation Tests ===\n") + + # Test invalid subtype + print("Testing invalid subtype:") + try: + client.alerting().usage.create( + name="Test alert", + subtype="invalid_subtype", + alert_at_percent=85 + ) + except ValueError as e: + print(f"Validation error (expected): {e}") + + # Test threshold too low + print("\nTesting threshold too low (0):") + try: + client.alerting().usage.create( + name="Test alert", + subtype="query_usage", + alert_at_percent=0 + ) + except ValueError as e: + print(f"Validation error (expected): {e}") + + # Test threshold too high + print("\nTesting threshold too high (101):") + try: + client.alerting().usage.create( + name="Test alert", + subtype="query_usage", + alert_at_percent=101 + ) + except ValueError as e: + print(f"Validation error (expected): {e}") + +if __name__ == '__main__': + print("Usage Alerts API Examples") + print("-" * 30) + print("Note: To run against the actual API, set the NS1_APIKEY environment variable") + print("Otherwise, this will run against a mock API endpoint") + + # Run examples + usage_alerts_example() + test_validation() diff --git a/ns1/__init__.py b/ns1/__init__.py index e96296e..d566658 100644 --- a/ns1/__init__.py +++ b/ns1/__init__.py @@ -5,7 +5,7 @@ # from .config import Config -version = "0.24.0" +version = "0.25.0" class NS1: @@ -242,6 +242,22 @@ def alerts(self): return ns1.rest.alerts.Alerts(self.config) + def alerting(self): + """ + Return the alerting namespace for accessing alerting features + + :return: Alerting namespace + """ + from ns1.alerting import UsageAlertsAPI + + # Create or reuse the alerting namespace + ns = getattr(self, "_alerting_ns", None) + if ns is None: + ns = type("AlertingNS", (), {})() + ns.usage = UsageAlertsAPI(self) + setattr(self, "_alerting_ns", ns) + return ns + def billing_usage(self): """ Return a new raw REST interface to BillingUsage resources diff --git a/ns1/alerting/__init__.py b/ns1/alerting/__init__.py new file mode 100644 index 0000000..91e2bbe --- /dev/null +++ b/ns1/alerting/__init__.py @@ -0,0 +1,3 @@ +from .usage_alerts import UsageAlertsAPI, USAGE_SUBTYPES + +__all__ = ["UsageAlertsAPI", "USAGE_SUBTYPES"] diff --git a/ns1/alerting/usage_alerts.py b/ns1/alerting/usage_alerts.py new file mode 100644 index 0000000..53dd3ea --- /dev/null +++ b/ns1/alerting/usage_alerts.py @@ -0,0 +1,75 @@ +from typing import List, Optional, Dict, Any + +USAGE_SUBTYPES = { + "query_usage", "record_usage", "china_query_usage", + "rum_decision_usage", "filter_chain_usage", "monitor_usage", +} + +def _validate(name: str, subtype: str, alert_at_percent: int) -> None: + if not name: + raise ValueError("name required") + if subtype not in USAGE_SUBTYPES: + raise ValueError("invalid subtype") + if not isinstance(alert_at_percent, int) or not (1 <= alert_at_percent <= 100): + raise ValueError("data.alert_at_percent must be int in 1..100") + +class UsageAlertsAPI: + """ + Account-scoped usage alerts. + Server rules: type='account'; data.alert_at_percent in 1..100; + PATCH must not include type/subtype; zone_names/notifier_list_ids may be []. + """ + def __init__(self, client) -> None: + self._c = client + + def create(self, *, + name: str, + subtype: str, + alert_at_percent: int, + notifier_list_ids: Optional[List[str]] = None, + zone_names: Optional[List[str]] = None) -> Dict[str, Any]: + _validate(name, subtype, alert_at_percent) + body = { + "name": name, + "type": "account", + "subtype": subtype, + "data": {"alert_at_percent": int(alert_at_percent)}, + "notifier_list_ids": notifier_list_ids or [], + "zone_names": zone_names or [], + } + return self._c._post("/alerting/v1/alerts", json=body) + + def get(self, alert_id: str) -> Dict[str, Any]: + return self._c._get(f"/alerting/v1/alerts/{alert_id}") + + def patch(self, alert_id: str, *, + name: Optional[str] = None, + alert_at_percent: Optional[int] = None, + notifier_list_ids: Optional[List[str]] = None, + zone_names: Optional[List[str]] = None) -> Dict[str, Any]: + patch: Dict[str, Any] = {} + if name is not None: + patch["name"] = name + if alert_at_percent is not None: + if not isinstance(alert_at_percent, int) or not (1 <= alert_at_percent <= 100): + raise ValueError("data.alert_at_percent must be int in 1..100") + patch["data"] = {"alert_at_percent": int(alert_at_percent)} + if notifier_list_ids is not None: + patch["notifier_list_ids"] = notifier_list_ids + if zone_names is not None: + patch["zone_names"] = zone_names + return self._c._patch(f"/alerting/v1/alerts/{alert_id}", json=patch) + + def delete(self, alert_id: str) -> None: + self._c._delete(f"/alerting/v1/alerts/{alert_id}") + + def list(self, *, + limit: int = 50, + next: Optional[str] = None, + order_descending: bool = False) -> Dict[str, Any]: + params: Dict[str, Any] = {"limit": limit} + if next: + params["next"] = next + if order_descending: + params["order_descending"] = "true" + return self._c._get("/alerting/v1/alerts", params=params) diff --git a/tests/unit/test_usage_alerts.py b/tests/unit/test_usage_alerts.py new file mode 100644 index 0000000..029e6b7 --- /dev/null +++ b/tests/unit/test_usage_alerts.py @@ -0,0 +1,265 @@ +import json +import pytest +import responses + +from ns1.alerting import UsageAlertsAPI, USAGE_SUBTYPES + + +@pytest.fixture +def usage_alerts_client(): + from ns1 import NS1 + client = NS1(apiKey="test1") + client.config["endpoint"] = "https://api.nsone.net" + return client + + +@responses.activate +def test_create_usage_alert(usage_alerts_client): + """Test creating a usage alert""" + client = usage_alerts_client + + # Mock response for create + responses.add( + responses.POST, + "https://api.nsone.net/alerting/v1/alerts", + json={ + "id": "a1b2c3", + "name": "Test Alert", + "type": "account", + "subtype": "query_usage", + "data": {"alert_at_percent": 85}, + "notifier_list_ids": ["n1"], + "zone_names": [], + "created_at": 1597937213, + "updated_at": 1597937213 + }, + status=200, + ) + + alert = client.alerting().usage.create( + name="Test Alert", + subtype="query_usage", + alert_at_percent=85, + notifier_list_ids=["n1"] + ) + + assert alert["id"] == "a1b2c3" + assert alert["name"] == "Test Alert" + assert alert["type"] == "account" + assert alert["subtype"] == "query_usage" + assert alert["data"]["alert_at_percent"] == 85 + assert alert["notifier_list_ids"] == ["n1"] + + +@responses.activate +def test_get_usage_alert(usage_alerts_client): + """Test retrieving a usage alert""" + client = usage_alerts_client + alert_id = "a1b2c3" + + # Mock response for get + responses.add( + responses.GET, + f"https://api.nsone.net/alerting/v1/alerts/{alert_id}", + json={ + "id": alert_id, + "name": "Test Alert", + "type": "account", + "subtype": "query_usage", + "data": {"alert_at_percent": 85}, + "notifier_list_ids": ["n1"], + "zone_names": [] + }, + status=200, + ) + + alert = client.alerting().usage.get(alert_id) + + assert alert["id"] == alert_id + assert alert["name"] == "Test Alert" + assert alert["data"]["alert_at_percent"] == 85 + + +@responses.activate +def test_patch_usage_alert(usage_alerts_client): + """Test patching a usage alert - verify type/subtype are not sent""" + client = usage_alerts_client + alert_id = "a1b2c3" + + def request_callback(request): + payload = json.loads(request.body) + # Verify type and subtype are not in the request + assert "type" not in payload + assert "subtype" not in payload + # Verify data contains the right alert_at_percent + assert payload["data"]["alert_at_percent"] == 90 + + resp_body = { + "id": alert_id, + "name": "Updated Alert", + "type": "account", + "subtype": "query_usage", + "data": {"alert_at_percent": 90}, + "notifier_list_ids": ["n1"], + "zone_names": [] + } + return (200, {}, json.dumps(resp_body)) + + responses.add_callback( + responses.PATCH, + f"https://api.nsone.net/alerting/v1/alerts/{alert_id}", + callback=request_callback, + content_type="application/json", + ) + + alert = client.alerting().usage.patch( + alert_id, + name="Updated Alert", + alert_at_percent=90 + ) + + assert alert["id"] == alert_id + assert alert["name"] == "Updated Alert" + assert alert["data"]["alert_at_percent"] == 90 + + +@responses.activate +def test_delete_usage_alert(usage_alerts_client): + """Test deleting a usage alert""" + client = usage_alerts_client + alert_id = "a1b2c3" + + responses.add( + responses.DELETE, + f"https://api.nsone.net/alerting/v1/alerts/{alert_id}", + status=204, + ) + + client.alerting().usage.delete(alert_id) + + # If we got here without exception, the test passes + + +@responses.activate +def test_list_usage_alerts(usage_alerts_client): + """Test listing usage alerts with pagination params""" + client = usage_alerts_client + + responses.add( + responses.GET, + "https://api.nsone.net/alerting/v1/alerts", + json={ + "limit": 1, + "next": "next_token", + "total_results": 2, + "results": [ + { + "id": "a1", + "name": "Alert 1", + "type": "account", + "subtype": "query_usage", + "data": {"alert_at_percent": 80} + } + ] + }, + status=200, + match=[ + responses.matchers.query_param_matcher({ + "limit": "1", + "order_descending": "true" + }) + ] + ) + + response = client.alerting().usage.list( + limit=1, + order_descending=True + ) + + assert "results" in response + assert "next" in response + assert response["next"] == "next_token" + assert response["total_results"] == 2 + assert len(response["results"]) == 1 + assert response["results"][0]["id"] == "a1" + + +def test_validation_threshold_bounds(usage_alerts_client): + """Test validation of alert_at_percent bounds""" + client = usage_alerts_client + + # Test below minimum + with pytest.raises(ValueError) as excinfo: + client.alerting().usage.create( + name="Test Alert", + subtype="query_usage", + alert_at_percent=0 + ) + assert "alert_at_percent must be int in 1..100" in str(excinfo.value) + + # Test above maximum + with pytest.raises(ValueError) as excinfo: + client.alerting().usage.create( + name="Test Alert", + subtype="query_usage", + alert_at_percent=101 + ) + assert "alert_at_percent must be int in 1..100" in str(excinfo.value) + + # Test same validation in patch + with pytest.raises(ValueError) as excinfo: + client.alerting().usage.patch( + "a1", + alert_at_percent=101 + ) + assert "alert_at_percent must be int in 1..100" in str(excinfo.value) + + +def test_validation_subtype(usage_alerts_client): + """Test validation of subtype values""" + client = usage_alerts_client + + # Verify all defined subtypes are accepted + for subtype in USAGE_SUBTYPES: + try: + # Just validate, don't make an actual request + client.alerting().usage._c = None # This will cause an error if validation passes + with pytest.raises(AttributeError): + client.alerting().usage.create( + name="Test Alert", + subtype=subtype, + alert_at_percent=85 + ) + except ValueError: + pytest.fail(f"Valid subtype '{subtype}' was rejected") + + # Test invalid subtype + with pytest.raises(ValueError) as excinfo: + client.alerting().usage.create( + name="Test Alert", + subtype="invalid_subtype", + alert_at_percent=85 + ) + assert "invalid subtype" in str(excinfo.value) + + +@responses.activate +def test_404_error_handling(usage_alerts_client): + """Test proper error handling for 404 responses""" + client = usage_alerts_client + alert_id = "nonexistent" + + # Mock 404 response with error message + responses.add( + responses.GET, + f"https://api.nsone.net/alerting/v1/alerts/{alert_id}", + json={"message": "alert not found"}, + status=404, + ) + + # This should raise an exception through the REST transport + with pytest.raises(Exception) as excinfo: + client.alerting().usage.get(alert_id) + + # Verify error contains the message from the server + assert "alert not found" in str(excinfo.value) From d3c1866a34703a17e2b6d9508ebb41983e2b0870 Mon Sep 17 00:00:00 2001 From: haydencarroll-NS1 Date: Wed, 20 Aug 2025 09:47:50 +0100 Subject: [PATCH 2/6] different tests --- tests/unit/test_usage_alerts.py | 261 +++++++++++++++----------------- 1 file changed, 123 insertions(+), 138 deletions(-) diff --git a/tests/unit/test_usage_alerts.py b/tests/unit/test_usage_alerts.py index 029e6b7..a4dd3bb 100644 --- a/tests/unit/test_usage_alerts.py +++ b/tests/unit/test_usage_alerts.py @@ -1,40 +1,54 @@ -import json +# +# Copyright (c) 2025 NSONE, Inc. +# +# License under The MIT License (MIT). See LICENSE in project root. +# import pytest -import responses -from ns1.alerting import UsageAlertsAPI, USAGE_SUBTYPES +try: # Python 3.3 + + import unittest.mock as mock +except ImportError: + import mock + +import json @pytest.fixture -def usage_alerts_client(): +def usage_alerts_client(config): + config.loadFromDict( + { + "endpoint": "api.nsone.net", + "default_key": "test1", + "keys": { + "test1": { + "key": "key-1", + "desc": "test key number 1", + } + }, + } + ) from ns1 import NS1 - client = NS1(apiKey="test1") - client.config["endpoint"] = "https://api.nsone.net" + client = NS1(config=config) return client -@responses.activate def test_create_usage_alert(usage_alerts_client): """Test creating a usage alert""" client = usage_alerts_client - # Mock response for create - responses.add( - responses.POST, - "https://api.nsone.net/alerting/v1/alerts", - json={ - "id": "a1b2c3", - "name": "Test Alert", - "type": "account", - "subtype": "query_usage", - "data": {"alert_at_percent": 85}, - "notifier_list_ids": ["n1"], - "zone_names": [], - "created_at": 1597937213, - "updated_at": 1597937213 - }, - status=200, - ) + # Create a mock for the _post method in alerting().usage + client.alerting()._c._post = mock.MagicMock() + client.alerting()._c._post.return_value = { + "id": "a1b2c3", + "name": "Test Alert", + "type": "account", + "subtype": "query_usage", + "data": {"alert_at_percent": 85}, + "notifier_list_ids": ["n1"], + "zone_names": [], + "created_at": 1597937213, + "updated_at": 1597937213 + } alert = client.alerting().usage.create( name="Test Alert", @@ -43,74 +57,69 @@ def test_create_usage_alert(usage_alerts_client): notifier_list_ids=["n1"] ) + # Verify _post was called with correct arguments + expected_body = { + "name": "Test Alert", + "type": "account", + "subtype": "query_usage", + "data": {"alert_at_percent": 85}, + "notifier_list_ids": ["n1"], + "zone_names": [] + } + client.alerting()._c._post.assert_called_once_with("/alerting/v1/alerts", json=expected_body) + + # Verify result assert alert["id"] == "a1b2c3" assert alert["name"] == "Test Alert" assert alert["type"] == "account" assert alert["subtype"] == "query_usage" assert alert["data"]["alert_at_percent"] == 85 - assert alert["notifier_list_ids"] == ["n1"] -@responses.activate def test_get_usage_alert(usage_alerts_client): """Test retrieving a usage alert""" client = usage_alerts_client alert_id = "a1b2c3" - # Mock response for get - responses.add( - responses.GET, - f"https://api.nsone.net/alerting/v1/alerts/{alert_id}", - json={ - "id": alert_id, - "name": "Test Alert", - "type": "account", - "subtype": "query_usage", - "data": {"alert_at_percent": 85}, - "notifier_list_ids": ["n1"], - "zone_names": [] - }, - status=200, - ) + # Create a mock for the _get method + client.alerting()._c._get = mock.MagicMock() + client.alerting()._c._get.return_value = { + "id": alert_id, + "name": "Test Alert", + "type": "account", + "subtype": "query_usage", + "data": {"alert_at_percent": 85}, + "notifier_list_ids": ["n1"], + "zone_names": [] + } alert = client.alerting().usage.get(alert_id) + # Verify _get was called with correct URL + client.alerting()._c._get.assert_called_once_with(f"/alerting/v1/alerts/{alert_id}") + + # Verify result assert alert["id"] == alert_id assert alert["name"] == "Test Alert" assert alert["data"]["alert_at_percent"] == 85 -@responses.activate def test_patch_usage_alert(usage_alerts_client): """Test patching a usage alert - verify type/subtype are not sent""" client = usage_alerts_client alert_id = "a1b2c3" - def request_callback(request): - payload = json.loads(request.body) - # Verify type and subtype are not in the request - assert "type" not in payload - assert "subtype" not in payload - # Verify data contains the right alert_at_percent - assert payload["data"]["alert_at_percent"] == 90 - - resp_body = { - "id": alert_id, - "name": "Updated Alert", - "type": "account", - "subtype": "query_usage", - "data": {"alert_at_percent": 90}, - "notifier_list_ids": ["n1"], - "zone_names": [] - } - return (200, {}, json.dumps(resp_body)) - - responses.add_callback( - responses.PATCH, - f"https://api.nsone.net/alerting/v1/alerts/{alert_id}", - callback=request_callback, - content_type="application/json", - ) + # Create a mock for the _patch method + client.alerting()._c._patch = mock.MagicMock() + client.alerting()._c._patch.return_value = { + "id": alert_id, + "name": "Updated Alert", + "type": "account", + "subtype": "query_usage", + "data": {"alert_at_percent": 90}, + "notifier_list_ids": ["n1"], + "zone_names": [] + } alert = client.alerting().usage.patch( alert_id, @@ -118,64 +127,72 @@ def request_callback(request): alert_at_percent=90 ) + # Verify _patch was called with correct arguments + expected_body = { + "name": "Updated Alert", + "data": {"alert_at_percent": 90} + } + client.alerting()._c._patch.assert_called_once_with(f"/alerting/v1/alerts/{alert_id}", json=expected_body) + + # Verify type/subtype are not in the arguments + call_args = client.alerting()._c._patch.call_args[1]["json"] + assert "type" not in call_args + assert "subtype" not in call_args + + # Verify result assert alert["id"] == alert_id assert alert["name"] == "Updated Alert" assert alert["data"]["alert_at_percent"] == 90 -@responses.activate def test_delete_usage_alert(usage_alerts_client): """Test deleting a usage alert""" client = usage_alerts_client alert_id = "a1b2c3" - responses.add( - responses.DELETE, - f"https://api.nsone.net/alerting/v1/alerts/{alert_id}", - status=204, - ) + # Create a mock for the _delete method + client.alerting()._c._delete = mock.MagicMock() client.alerting().usage.delete(alert_id) - # If we got here without exception, the test passes + # Verify _delete was called with correct URL + client.alerting()._c._delete.assert_called_once_with(f"/alerting/v1/alerts/{alert_id}") -@responses.activate def test_list_usage_alerts(usage_alerts_client): """Test listing usage alerts with pagination params""" client = usage_alerts_client - responses.add( - responses.GET, - "https://api.nsone.net/alerting/v1/alerts", - json={ - "limit": 1, - "next": "next_token", - "total_results": 2, - "results": [ - { - "id": "a1", - "name": "Alert 1", - "type": "account", - "subtype": "query_usage", - "data": {"alert_at_percent": 80} - } - ] - }, - status=200, - match=[ - responses.matchers.query_param_matcher({ - "limit": "1", - "order_descending": "true" - }) + # Create a mock for the _get method + client.alerting()._c._get = mock.MagicMock() + client.alerting()._c._get.return_value = { + "limit": 1, + "next": "next_token", + "total_results": 2, + "results": [ + { + "id": "a1", + "name": "Alert 1", + "type": "account", + "subtype": "query_usage", + "data": {"alert_at_percent": 80} + } ] - ) + } response = client.alerting().usage.list( limit=1, order_descending=True ) + # Verify _get was called with correct URL and params + expected_params = { + "limit": 1, + "order_descending": "true" + } + client.alerting()._c._get.assert_called_once_with("/alerting/v1/alerts", params=expected_params) + + # Verify result assert "results" in response assert "next" in response assert response["next"] == "next_token" @@ -215,51 +232,19 @@ def test_validation_threshold_bounds(usage_alerts_client): assert "alert_at_percent must be int in 1..100" in str(excinfo.value) -def test_validation_subtype(usage_alerts_client): +def test_validation_subtype(): """Test validation of subtype values""" - client = usage_alerts_client + from ns1.alerting import USAGE_SUBTYPES + from ns1.alerting.usage_alerts import _validate - # Verify all defined subtypes are accepted + # Valid subtypes should pass validation for subtype in USAGE_SUBTYPES: try: - # Just validate, don't make an actual request - client.alerting().usage._c = None # This will cause an error if validation passes - with pytest.raises(AttributeError): - client.alerting().usage.create( - name="Test Alert", - subtype=subtype, - alert_at_percent=85 - ) + _validate("Test Alert", subtype, 85) except ValueError: pytest.fail(f"Valid subtype '{subtype}' was rejected") - # Test invalid subtype + # Invalid subtype should fail validation with pytest.raises(ValueError) as excinfo: - client.alerting().usage.create( - name="Test Alert", - subtype="invalid_subtype", - alert_at_percent=85 - ) + _validate("Test Alert", "invalid_subtype", 85) assert "invalid subtype" in str(excinfo.value) - - -@responses.activate -def test_404_error_handling(usage_alerts_client): - """Test proper error handling for 404 responses""" - client = usage_alerts_client - alert_id = "nonexistent" - - # Mock 404 response with error message - responses.add( - responses.GET, - f"https://api.nsone.net/alerting/v1/alerts/{alert_id}", - json={"message": "alert not found"}, - status=404, - ) - - # This should raise an exception through the REST transport - with pytest.raises(Exception) as excinfo: - client.alerting().usage.get(alert_id) - - # Verify error contains the message from the server - assert "alert not found" in str(excinfo.value) From 605ad35de4d8312a77e3897ebf30a6dc872d538c Mon Sep 17 00:00:00 2001 From: haydencarroll-NS1 Date: Wed, 20 Aug 2025 10:09:02 +0100 Subject: [PATCH 3/6] fixes --- examples/usage_alerts.py | 13 +++-- ns1/__init__.py | 2 +- ns1/alerting/usage_alerts.py | 2 + tests/unit/test_usage_alerts.py | 84 ++++++++++++++++++++------------- 4 files changed, 62 insertions(+), 39 deletions(-) diff --git a/examples/usage_alerts.py b/examples/usage_alerts.py index bdee12e..fbf3dc8 100644 --- a/examples/usage_alerts.py +++ b/examples/usage_alerts.py @@ -6,12 +6,12 @@ import os import sys import json +from ns1 import NS1 +from ns1.config import Config # Path hackery to ensure we import the local ns1 module sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), - '..'))) - -from ns1 import NS1 + '..'))) # Create NS1 client config = { @@ -26,11 +26,12 @@ } # Create a config from dictionary and create the client -from ns1.config import Config c = Config() c.loadFromDict(config) client = NS1(config=c) + + # Usage Alerts API Examples def usage_alerts_example(): print("\n=== Usage Alerts Examples ===\n") @@ -90,6 +91,8 @@ def usage_alerts_example(): except Exception as e: print(f"Error deleting alert: {e}") + + # Test validation failures def test_validation(): print("\n=== Validation Tests ===\n") @@ -127,6 +130,8 @@ def test_validation(): except ValueError as e: print(f"Validation error (expected): {e}") + + if __name__ == '__main__': print("Usage Alerts API Examples") print("-" * 30) diff --git a/ns1/__init__.py b/ns1/__init__.py index d566658..78a0466 100644 --- a/ns1/__init__.py +++ b/ns1/__init__.py @@ -249,7 +249,7 @@ def alerting(self): :return: Alerting namespace """ from ns1.alerting import UsageAlertsAPI - + # Create or reuse the alerting namespace ns = getattr(self, "_alerting_ns", None) if ns is None: diff --git a/ns1/alerting/usage_alerts.py b/ns1/alerting/usage_alerts.py index 53dd3ea..ecf187b 100644 --- a/ns1/alerting/usage_alerts.py +++ b/ns1/alerting/usage_alerts.py @@ -5,6 +5,7 @@ "rum_decision_usage", "filter_chain_usage", "monitor_usage", } + def _validate(name: str, subtype: str, alert_at_percent: int) -> None: if not name: raise ValueError("name required") @@ -13,6 +14,7 @@ def _validate(name: str, subtype: str, alert_at_percent: int) -> None: if not isinstance(alert_at_percent, int) or not (1 <= alert_at_percent <= 100): raise ValueError("data.alert_at_percent must be int in 1..100") + class UsageAlertsAPI: """ Account-scoped usage alerts. diff --git a/tests/unit/test_usage_alerts.py b/tests/unit/test_usage_alerts.py index a4dd3bb..ebb9c6b 100644 --- a/tests/unit/test_usage_alerts.py +++ b/tests/unit/test_usage_alerts.py @@ -10,7 +10,6 @@ except ImportError: import mock -import json @pytest.fixture @@ -32,13 +31,14 @@ def usage_alerts_client(config): return client + def test_create_usage_alert(usage_alerts_client): """Test creating a usage alert""" client = usage_alerts_client - + # Create a mock for the _post method in alerting().usage - client.alerting()._c._post = mock.MagicMock() - client.alerting()._c._post.return_value = { + client._post = mock.MagicMock() + client._post.return_value = { "id": "a1b2c3", "name": "Test Alert", "type": "account", @@ -50,12 +50,14 @@ def test_create_usage_alert(usage_alerts_client): "updated_at": 1597937213 } - alert = client.alerting().usage.create( - name="Test Alert", - subtype="query_usage", - alert_at_percent=85, - notifier_list_ids=["n1"] - ) + # Patch the client reference + with mock.patch.object(client.alerting().usage, "_c", client): + alert = client.alerting().usage.create( + name="Test Alert", + subtype="query_usage", + alert_at_percent=85, + notifier_list_ids=["n1"] + ) # Verify _post was called with correct arguments expected_body = { @@ -66,7 +68,7 @@ def test_create_usage_alert(usage_alerts_client): "notifier_list_ids": ["n1"], "zone_names": [] } - client.alerting()._c._post.assert_called_once_with("/alerting/v1/alerts", json=expected_body) + client._post.assert_called_once_with("/alerting/v1/alerts", json=expected_body) # Verify result assert alert["id"] == "a1b2c3" @@ -76,14 +78,15 @@ def test_create_usage_alert(usage_alerts_client): assert alert["data"]["alert_at_percent"] == 85 + def test_get_usage_alert(usage_alerts_client): """Test retrieving a usage alert""" client = usage_alerts_client alert_id = "a1b2c3" # Create a mock for the _get method - client.alerting()._c._get = mock.MagicMock() - client.alerting()._c._get.return_value = { + client._get = mock.MagicMock() + client._get.return_value = { "id": alert_id, "name": "Test Alert", "type": "account", @@ -93,10 +96,12 @@ def test_get_usage_alert(usage_alerts_client): "zone_names": [] } - alert = client.alerting().usage.get(alert_id) + # Patch the client reference + with mock.patch.object(client.alerting().usage, "_c", client): + alert = client.alerting().usage.get(alert_id) # Verify _get was called with correct URL - client.alerting()._c._get.assert_called_once_with(f"/alerting/v1/alerts/{alert_id}") + client._get.assert_called_once_with(f"/alerting/v1/alerts/{alert_id}") # Verify result assert alert["id"] == alert_id @@ -104,14 +109,15 @@ def test_get_usage_alert(usage_alerts_client): assert alert["data"]["alert_at_percent"] == 85 + def test_patch_usage_alert(usage_alerts_client): """Test patching a usage alert - verify type/subtype are not sent""" client = usage_alerts_client alert_id = "a1b2c3" # Create a mock for the _patch method - client.alerting()._c._patch = mock.MagicMock() - client.alerting()._c._patch.return_value = { + client._patch = mock.MagicMock() + client._patch.return_value = { "id": alert_id, "name": "Updated Alert", "type": "account", @@ -121,21 +127,23 @@ def test_patch_usage_alert(usage_alerts_client): "zone_names": [] } - alert = client.alerting().usage.patch( - alert_id, - name="Updated Alert", - alert_at_percent=90 - ) + # Patch the client reference + with mock.patch.object(client.alerting().usage, "_c", client): + alert = client.alerting().usage.patch( + alert_id, + name="Updated Alert", + alert_at_percent=90 + ) # Verify _patch was called with correct arguments expected_body = { "name": "Updated Alert", "data": {"alert_at_percent": 90} } - client.alerting()._c._patch.assert_called_once_with(f"/alerting/v1/alerts/{alert_id}", json=expected_body) + client._patch.assert_called_once_with(f"/alerting/v1/alerts/{alert_id}", json=expected_body) # Verify type/subtype are not in the arguments - call_args = client.alerting()._c._patch.call_args[1]["json"] + call_args = client._patch.call_args[1]["json"] assert "type" not in call_args assert "subtype" not in call_args @@ -145,18 +153,22 @@ def test_patch_usage_alert(usage_alerts_client): assert alert["data"]["alert_at_percent"] == 90 + def test_delete_usage_alert(usage_alerts_client): """Test deleting a usage alert""" client = usage_alerts_client alert_id = "a1b2c3" # Create a mock for the _delete method - client.alerting()._c._delete = mock.MagicMock() + client._delete = mock.MagicMock() - client.alerting().usage.delete(alert_id) + # Patch the client reference + with mock.patch.object(client.alerting().usage, "_c", client): + client.alerting().usage.delete(alert_id) # Verify _delete was called with correct URL - client.alerting()._c._delete.assert_called_once_with(f"/alerting/v1/alerts/{alert_id}") + client._delete.assert_called_once_with(f"/alerting/v1/alerts/{alert_id}") + def test_list_usage_alerts(usage_alerts_client): @@ -164,8 +176,8 @@ def test_list_usage_alerts(usage_alerts_client): client = usage_alerts_client # Create a mock for the _get method - client.alerting()._c._get = mock.MagicMock() - client.alerting()._c._get.return_value = { + client._get = mock.MagicMock() + client._get.return_value = { "limit": 1, "next": "next_token", "total_results": 2, @@ -180,17 +192,19 @@ def test_list_usage_alerts(usage_alerts_client): ] } - response = client.alerting().usage.list( - limit=1, - order_descending=True - ) + # Patch the client reference + with mock.patch.object(client.alerting().usage, "_c", client): + response = client.alerting().usage.list( + limit=1, + order_descending=True + ) # Verify _get was called with correct URL and params expected_params = { "limit": 1, "order_descending": "true" } - client.alerting()._c._get.assert_called_once_with("/alerting/v1/alerts", params=expected_params) + client._get.assert_called_once_with("/alerting/v1/alerts", params=expected_params) # Verify result assert "results" in response @@ -201,6 +215,7 @@ def test_list_usage_alerts(usage_alerts_client): assert response["results"][0]["id"] == "a1" + def test_validation_threshold_bounds(usage_alerts_client): """Test validation of alert_at_percent bounds""" client = usage_alerts_client @@ -232,6 +247,7 @@ def test_validation_threshold_bounds(usage_alerts_client): assert "alert_at_percent must be int in 1..100" in str(excinfo.value) + def test_validation_subtype(): """Test validation of subtype values""" from ns1.alerting import USAGE_SUBTYPES From 16c54b75857d4bf2b03efca51bf41e1c6ae5aba4 Mon Sep 17 00:00:00 2001 From: haydencarroll-NS1 Date: Wed, 20 Aug 2025 10:14:03 +0100 Subject: [PATCH 4/6] fix linting --- examples/usage_alerts.py | 21 +++++------ ns1/alerting/usage_alerts.py | 7 +++- tests/unit/test_usage_alerts.py | 65 +++++++++++++++------------------ 3 files changed, 44 insertions(+), 49 deletions(-) diff --git a/examples/usage_alerts.py b/examples/usage_alerts.py index fbf3dc8..8548b20 100644 --- a/examples/usage_alerts.py +++ b/examples/usage_alerts.py @@ -31,11 +31,10 @@ client = NS1(config=c) - # Usage Alerts API Examples def usage_alerts_example(): print("\n=== Usage Alerts Examples ===\n") - + # List all usage alerts print("Listing usage alerts:") try: @@ -45,7 +44,7 @@ def usage_alerts_example(): print(f" {i+1}. {alert.get('name')} (id: {alert.get('id')})") except Exception as e: print(f"Error listing alerts: {e}") - + # Create a usage alert print("\nCreating a usage alert:") try: @@ -62,7 +61,7 @@ def usage_alerts_example(): except Exception as e: print(f"Error creating alert: {e}") return - + # Update the alert print("\nUpdating the alert threshold to 90%:") try: @@ -74,7 +73,7 @@ def usage_alerts_example(): print(f"New threshold: {updated['data']['alert_at_percent']}%") except Exception as e: print(f"Error updating alert: {e}") - + # Get alert details print("\nGetting alert details:") try: @@ -82,7 +81,7 @@ def usage_alerts_example(): print(f"Alert details: {json.dumps(details, indent=2)}") except Exception as e: print(f"Error getting alert: {e}") - + # Delete the alert print("\nDeleting the alert:") try: @@ -92,11 +91,10 @@ def usage_alerts_example(): print(f"Error deleting alert: {e}") - # Test validation failures def test_validation(): print("\n=== Validation Tests ===\n") - + # Test invalid subtype print("Testing invalid subtype:") try: @@ -107,7 +105,7 @@ def test_validation(): ) except ValueError as e: print(f"Validation error (expected): {e}") - + # Test threshold too low print("\nTesting threshold too low (0):") try: @@ -118,7 +116,7 @@ def test_validation(): ) except ValueError as e: print(f"Validation error (expected): {e}") - + # Test threshold too high print("\nTesting threshold too high (101):") try: @@ -131,13 +129,12 @@ def test_validation(): print(f"Validation error (expected): {e}") - if __name__ == '__main__': print("Usage Alerts API Examples") print("-" * 30) print("Note: To run against the actual API, set the NS1_APIKEY environment variable") print("Otherwise, this will run against a mock API endpoint") - + # Run examples usage_alerts_example() test_validation() diff --git a/ns1/alerting/usage_alerts.py b/ns1/alerting/usage_alerts.py index ecf187b..fc18ce8 100644 --- a/ns1/alerting/usage_alerts.py +++ b/ns1/alerting/usage_alerts.py @@ -11,7 +11,8 @@ def _validate(name: str, subtype: str, alert_at_percent: int) -> None: raise ValueError("name required") if subtype not in USAGE_SUBTYPES: raise ValueError("invalid subtype") - if not isinstance(alert_at_percent, int) or not (1 <= alert_at_percent <= 100): + if not isinstance(alert_at_percent, int) or not ( + 1 <= alert_at_percent <= 100): raise ValueError("data.alert_at_percent must be int in 1..100") @@ -21,6 +22,7 @@ class UsageAlertsAPI: Server rules: type='account'; data.alert_at_percent in 1..100; PATCH must not include type/subtype; zone_names/notifier_list_ids may be []. """ + def __init__(self, client) -> None: self._c = client @@ -53,7 +55,8 @@ def patch(self, alert_id: str, *, if name is not None: patch["name"] = name if alert_at_percent is not None: - if not isinstance(alert_at_percent, int) or not (1 <= alert_at_percent <= 100): + if not isinstance(alert_at_percent, int) or not ( + 1 <= alert_at_percent <= 100): raise ValueError("data.alert_at_percent must be int in 1..100") patch["data"] = {"alert_at_percent": int(alert_at_percent)} if notifier_list_ids is not None: diff --git a/tests/unit/test_usage_alerts.py b/tests/unit/test_usage_alerts.py index ebb9c6b..820ada6 100644 --- a/tests/unit/test_usage_alerts.py +++ b/tests/unit/test_usage_alerts.py @@ -11,7 +11,6 @@ import mock - @pytest.fixture def usage_alerts_client(config): config.loadFromDict( @@ -31,7 +30,6 @@ def usage_alerts_client(config): return client - def test_create_usage_alert(usage_alerts_client): """Test creating a usage alert""" client = usage_alerts_client @@ -49,7 +47,7 @@ def test_create_usage_alert(usage_alerts_client): "created_at": 1597937213, "updated_at": 1597937213 } - + # Patch the client reference with mock.patch.object(client.alerting().usage, "_c", client): alert = client.alerting().usage.create( @@ -58,7 +56,7 @@ def test_create_usage_alert(usage_alerts_client): alert_at_percent=85, notifier_list_ids=["n1"] ) - + # Verify _post was called with correct arguments expected_body = { "name": "Test Alert", @@ -68,8 +66,9 @@ def test_create_usage_alert(usage_alerts_client): "notifier_list_ids": ["n1"], "zone_names": [] } - client._post.assert_called_once_with("/alerting/v1/alerts", json=expected_body) - + client._post.assert_called_once_with( + "/alerting/v1/alerts", json=expected_body) + # Verify result assert alert["id"] == "a1b2c3" assert alert["name"] == "Test Alert" @@ -78,12 +77,11 @@ def test_create_usage_alert(usage_alerts_client): assert alert["data"]["alert_at_percent"] == 85 - def test_get_usage_alert(usage_alerts_client): """Test retrieving a usage alert""" client = usage_alerts_client alert_id = "a1b2c3" - + # Create a mock for the _get method client._get = mock.MagicMock() client._get.return_value = { @@ -95,26 +93,25 @@ def test_get_usage_alert(usage_alerts_client): "notifier_list_ids": ["n1"], "zone_names": [] } - + # Patch the client reference with mock.patch.object(client.alerting().usage, "_c", client): alert = client.alerting().usage.get(alert_id) - + # Verify _get was called with correct URL client._get.assert_called_once_with(f"/alerting/v1/alerts/{alert_id}") - + # Verify result assert alert["id"] == alert_id assert alert["name"] == "Test Alert" assert alert["data"]["alert_at_percent"] == 85 - def test_patch_usage_alert(usage_alerts_client): """Test patching a usage alert - verify type/subtype are not sent""" client = usage_alerts_client alert_id = "a1b2c3" - + # Create a mock for the _patch method client._patch = mock.MagicMock() client._patch.return_value = { @@ -126,7 +123,7 @@ def test_patch_usage_alert(usage_alerts_client): "notifier_list_ids": ["n1"], "zone_names": [] } - + # Patch the client reference with mock.patch.object(client.alerting().usage, "_c", client): alert = client.alerting().usage.patch( @@ -134,47 +131,46 @@ def test_patch_usage_alert(usage_alerts_client): name="Updated Alert", alert_at_percent=90 ) - + # Verify _patch was called with correct arguments expected_body = { "name": "Updated Alert", "data": {"alert_at_percent": 90} } - client._patch.assert_called_once_with(f"/alerting/v1/alerts/{alert_id}", json=expected_body) - + client._patch.assert_called_once_with( + f"/alerting/v1/alerts/{alert_id}", json=expected_body) + # Verify type/subtype are not in the arguments call_args = client._patch.call_args[1]["json"] assert "type" not in call_args assert "subtype" not in call_args - + # Verify result assert alert["id"] == alert_id assert alert["name"] == "Updated Alert" assert alert["data"]["alert_at_percent"] == 90 - def test_delete_usage_alert(usage_alerts_client): """Test deleting a usage alert""" client = usage_alerts_client alert_id = "a1b2c3" - + # Create a mock for the _delete method client._delete = mock.MagicMock() - + # Patch the client reference with mock.patch.object(client.alerting().usage, "_c", client): client.alerting().usage.delete(alert_id) - + # Verify _delete was called with correct URL client._delete.assert_called_once_with(f"/alerting/v1/alerts/{alert_id}") - def test_list_usage_alerts(usage_alerts_client): """Test listing usage alerts with pagination params""" client = usage_alerts_client - + # Create a mock for the _get method client._get = mock.MagicMock() client._get.return_value = { @@ -191,21 +187,22 @@ def test_list_usage_alerts(usage_alerts_client): } ] } - + # Patch the client reference with mock.patch.object(client.alerting().usage, "_c", client): response = client.alerting().usage.list( limit=1, order_descending=True ) - + # Verify _get was called with correct URL and params expected_params = { "limit": 1, "order_descending": "true" } - client._get.assert_called_once_with("/alerting/v1/alerts", params=expected_params) - + client._get.assert_called_once_with( + "/alerting/v1/alerts", params=expected_params) + # Verify result assert "results" in response assert "next" in response @@ -215,11 +212,10 @@ def test_list_usage_alerts(usage_alerts_client): assert response["results"][0]["id"] == "a1" - def test_validation_threshold_bounds(usage_alerts_client): """Test validation of alert_at_percent bounds""" client = usage_alerts_client - + # Test below minimum with pytest.raises(ValueError) as excinfo: client.alerting().usage.create( @@ -228,7 +224,7 @@ def test_validation_threshold_bounds(usage_alerts_client): alert_at_percent=0 ) assert "alert_at_percent must be int in 1..100" in str(excinfo.value) - + # Test above maximum with pytest.raises(ValueError) as excinfo: client.alerting().usage.create( @@ -237,7 +233,7 @@ def test_validation_threshold_bounds(usage_alerts_client): alert_at_percent=101 ) assert "alert_at_percent must be int in 1..100" in str(excinfo.value) - + # Test same validation in patch with pytest.raises(ValueError) as excinfo: client.alerting().usage.patch( @@ -247,19 +243,18 @@ def test_validation_threshold_bounds(usage_alerts_client): assert "alert_at_percent must be int in 1..100" in str(excinfo.value) - def test_validation_subtype(): """Test validation of subtype values""" from ns1.alerting import USAGE_SUBTYPES from ns1.alerting.usage_alerts import _validate - + # Valid subtypes should pass validation for subtype in USAGE_SUBTYPES: try: _validate("Test Alert", subtype, 85) except ValueError: pytest.fail(f"Valid subtype '{subtype}' was rejected") - + # Invalid subtype should fail validation with pytest.raises(ValueError) as excinfo: _validate("Test Alert", "invalid_subtype", 85) From cc24900e7abaf631f688fc2944d84f350c7784db Mon Sep 17 00:00:00 2001 From: haydencarroll-NS1 Date: Wed, 20 Aug 2025 10:23:17 +0100 Subject: [PATCH 5/6] reformat --- examples/usage_alerts.py | 48 +++++++++++++--------------- ns1/alerting/usage_alerts.py | 54 ++++++++++++++++++++----------- tests/unit/test_usage_alerts.py | 56 +++++++++++++-------------------- 3 files changed, 77 insertions(+), 81 deletions(-) diff --git a/examples/usage_alerts.py b/examples/usage_alerts.py index 8548b20..1494971 100644 --- a/examples/usage_alerts.py +++ b/examples/usage_alerts.py @@ -10,19 +10,20 @@ from ns1.config import Config # Path hackery to ensure we import the local ns1 module -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), - '..'))) +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +) # Create NS1 client config = { - 'endpoint': 'https://api.nsone.net', - 'default_key': 'test1', - 'keys': { - 'test1': { - 'key': os.environ.get('NS1_APIKEY', 'test1'), - 'desc': 'test key' + "endpoint": "https://api.nsone.net", + "default_key": "test1", + "keys": { + "test1": { + "key": os.environ.get("NS1_APIKEY", "test1"), + "desc": "test key", } - } + }, } # Create a config from dictionary and create the client @@ -40,7 +41,7 @@ def usage_alerts_example(): try: alerts = client.alerting().usage.list(limit=10) print(f"Total alerts: {alerts.get('total_results', 0)}") - for i, alert in enumerate(alerts.get('results', [])): + for i, alert in enumerate(alerts.get("results", [])): print(f" {i+1}. {alert.get('name')} (id: {alert.get('id')})") except Exception as e: print(f"Error listing alerts: {e}") @@ -53,9 +54,9 @@ def usage_alerts_example(): subtype="query_usage", alert_at_percent=85, notifier_list_ids=[], - zone_names=[] + zone_names=[], ) - alert_id = alert['id'] + alert_id = alert["id"] print(f"Created alert: {alert['name']} (id: {alert_id})") print(f"Alert details: {json.dumps(alert, indent=2)}") except Exception as e: @@ -65,10 +66,7 @@ def usage_alerts_example(): # Update the alert print("\nUpdating the alert threshold to 90%:") try: - updated = client.alerting().usage.patch( - alert_id, - alert_at_percent=90 - ) + updated = client.alerting().usage.patch(alert_id, alert_at_percent=90) print(f"Updated alert: {updated['name']}") print(f"New threshold: {updated['data']['alert_at_percent']}%") except Exception as e: @@ -99,9 +97,7 @@ def test_validation(): print("Testing invalid subtype:") try: client.alerting().usage.create( - name="Test alert", - subtype="invalid_subtype", - alert_at_percent=85 + name="Test alert", subtype="invalid_subtype", alert_at_percent=85 ) except ValueError as e: print(f"Validation error (expected): {e}") @@ -110,9 +106,7 @@ def test_validation(): print("\nTesting threshold too low (0):") try: client.alerting().usage.create( - name="Test alert", - subtype="query_usage", - alert_at_percent=0 + name="Test alert", subtype="query_usage", alert_at_percent=0 ) except ValueError as e: print(f"Validation error (expected): {e}") @@ -121,18 +115,18 @@ def test_validation(): print("\nTesting threshold too high (101):") try: client.alerting().usage.create( - name="Test alert", - subtype="query_usage", - alert_at_percent=101 + name="Test alert", subtype="query_usage", alert_at_percent=101 ) except ValueError as e: print(f"Validation error (expected): {e}") -if __name__ == '__main__': +if __name__ == "__main__": print("Usage Alerts API Examples") print("-" * 30) - print("Note: To run against the actual API, set the NS1_APIKEY environment variable") + print( + "Note: To run against the actual API, set the NS1_APIKEY environment variable" + ) print("Otherwise, this will run against a mock API endpoint") # Run examples diff --git a/ns1/alerting/usage_alerts.py b/ns1/alerting/usage_alerts.py index fc18ce8..b07c8b5 100644 --- a/ns1/alerting/usage_alerts.py +++ b/ns1/alerting/usage_alerts.py @@ -1,8 +1,12 @@ from typing import List, Optional, Dict, Any USAGE_SUBTYPES = { - "query_usage", "record_usage", "china_query_usage", - "rum_decision_usage", "filter_chain_usage", "monitor_usage", + "query_usage", + "record_usage", + "china_query_usage", + "rum_decision_usage", + "filter_chain_usage", + "monitor_usage", } @@ -12,7 +16,8 @@ def _validate(name: str, subtype: str, alert_at_percent: int) -> None: if subtype not in USAGE_SUBTYPES: raise ValueError("invalid subtype") if not isinstance(alert_at_percent, int) or not ( - 1 <= alert_at_percent <= 100): + 1 <= alert_at_percent <= 100 + ): raise ValueError("data.alert_at_percent must be int in 1..100") @@ -26,12 +31,15 @@ class UsageAlertsAPI: def __init__(self, client) -> None: self._c = client - def create(self, *, - name: str, - subtype: str, - alert_at_percent: int, - notifier_list_ids: Optional[List[str]] = None, - zone_names: Optional[List[str]] = None) -> Dict[str, Any]: + def create( + self, + *, + name: str, + subtype: str, + alert_at_percent: int, + notifier_list_ids: Optional[List[str]] = None, + zone_names: Optional[List[str]] = None, + ) -> Dict[str, Any]: _validate(name, subtype, alert_at_percent) body = { "name": name, @@ -46,17 +54,22 @@ def create(self, *, def get(self, alert_id: str) -> Dict[str, Any]: return self._c._get(f"/alerting/v1/alerts/{alert_id}") - def patch(self, alert_id: str, *, - name: Optional[str] = None, - alert_at_percent: Optional[int] = None, - notifier_list_ids: Optional[List[str]] = None, - zone_names: Optional[List[str]] = None) -> Dict[str, Any]: + def patch( + self, + alert_id: str, + *, + name: Optional[str] = None, + alert_at_percent: Optional[int] = None, + notifier_list_ids: Optional[List[str]] = None, + zone_names: Optional[List[str]] = None, + ) -> Dict[str, Any]: patch: Dict[str, Any] = {} if name is not None: patch["name"] = name if alert_at_percent is not None: if not isinstance(alert_at_percent, int) or not ( - 1 <= alert_at_percent <= 100): + 1 <= alert_at_percent <= 100 + ): raise ValueError("data.alert_at_percent must be int in 1..100") patch["data"] = {"alert_at_percent": int(alert_at_percent)} if notifier_list_ids is not None: @@ -68,10 +81,13 @@ def patch(self, alert_id: str, *, def delete(self, alert_id: str) -> None: self._c._delete(f"/alerting/v1/alerts/{alert_id}") - def list(self, *, - limit: int = 50, - next: Optional[str] = None, - order_descending: bool = False) -> Dict[str, Any]: + def list( + self, + *, + limit: int = 50, + next: Optional[str] = None, + order_descending: bool = False, + ) -> Dict[str, Any]: params: Dict[str, Any] = {"limit": limit} if next: params["next"] = next diff --git a/tests/unit/test_usage_alerts.py b/tests/unit/test_usage_alerts.py index 820ada6..c6da0aa 100644 --- a/tests/unit/test_usage_alerts.py +++ b/tests/unit/test_usage_alerts.py @@ -26,6 +26,7 @@ def usage_alerts_client(config): } ) from ns1 import NS1 + client = NS1(config=config) return client @@ -45,7 +46,7 @@ def test_create_usage_alert(usage_alerts_client): "notifier_list_ids": ["n1"], "zone_names": [], "created_at": 1597937213, - "updated_at": 1597937213 + "updated_at": 1597937213, } # Patch the client reference @@ -54,7 +55,7 @@ def test_create_usage_alert(usage_alerts_client): name="Test Alert", subtype="query_usage", alert_at_percent=85, - notifier_list_ids=["n1"] + notifier_list_ids=["n1"], ) # Verify _post was called with correct arguments @@ -64,10 +65,11 @@ def test_create_usage_alert(usage_alerts_client): "subtype": "query_usage", "data": {"alert_at_percent": 85}, "notifier_list_ids": ["n1"], - "zone_names": [] + "zone_names": [], } client._post.assert_called_once_with( - "/alerting/v1/alerts", json=expected_body) + "/alerting/v1/alerts", json=expected_body + ) # Verify result assert alert["id"] == "a1b2c3" @@ -91,7 +93,7 @@ def test_get_usage_alert(usage_alerts_client): "subtype": "query_usage", "data": {"alert_at_percent": 85}, "notifier_list_ids": ["n1"], - "zone_names": [] + "zone_names": [], } # Patch the client reference @@ -121,24 +123,20 @@ def test_patch_usage_alert(usage_alerts_client): "subtype": "query_usage", "data": {"alert_at_percent": 90}, "notifier_list_ids": ["n1"], - "zone_names": [] + "zone_names": [], } # Patch the client reference with mock.patch.object(client.alerting().usage, "_c", client): alert = client.alerting().usage.patch( - alert_id, - name="Updated Alert", - alert_at_percent=90 + alert_id, name="Updated Alert", alert_at_percent=90 ) # Verify _patch was called with correct arguments - expected_body = { - "name": "Updated Alert", - "data": {"alert_at_percent": 90} - } + expected_body = {"name": "Updated Alert", "data": {"alert_at_percent": 90}} client._patch.assert_called_once_with( - f"/alerting/v1/alerts/{alert_id}", json=expected_body) + f"/alerting/v1/alerts/{alert_id}", json=expected_body + ) # Verify type/subtype are not in the arguments call_args = client._patch.call_args[1]["json"] @@ -183,25 +181,20 @@ def test_list_usage_alerts(usage_alerts_client): "name": "Alert 1", "type": "account", "subtype": "query_usage", - "data": {"alert_at_percent": 80} + "data": {"alert_at_percent": 80}, } - ] + ], } # Patch the client reference with mock.patch.object(client.alerting().usage, "_c", client): - response = client.alerting().usage.list( - limit=1, - order_descending=True - ) + response = client.alerting().usage.list(limit=1, order_descending=True) # Verify _get was called with correct URL and params - expected_params = { - "limit": 1, - "order_descending": "true" - } + expected_params = {"limit": 1, "order_descending": "true"} client._get.assert_called_once_with( - "/alerting/v1/alerts", params=expected_params) + "/alerting/v1/alerts", params=expected_params + ) # Verify result assert "results" in response @@ -219,27 +212,20 @@ def test_validation_threshold_bounds(usage_alerts_client): # Test below minimum with pytest.raises(ValueError) as excinfo: client.alerting().usage.create( - name="Test Alert", - subtype="query_usage", - alert_at_percent=0 + name="Test Alert", subtype="query_usage", alert_at_percent=0 ) assert "alert_at_percent must be int in 1..100" in str(excinfo.value) # Test above maximum with pytest.raises(ValueError) as excinfo: client.alerting().usage.create( - name="Test Alert", - subtype="query_usage", - alert_at_percent=101 + name="Test Alert", subtype="query_usage", alert_at_percent=101 ) assert "alert_at_percent must be int in 1..100" in str(excinfo.value) # Test same validation in patch with pytest.raises(ValueError) as excinfo: - client.alerting().usage.patch( - "a1", - alert_at_percent=101 - ) + client.alerting().usage.patch("a1", alert_at_percent=101) assert "alert_at_percent must be int in 1..100" in str(excinfo.value) From 65f9c751ae8dbcc1951ad4d352404fadf678f25d Mon Sep 17 00:00:00 2001 From: haydencarroll-NS1 Date: Wed, 27 Aug 2025 10:24:13 +0100 Subject: [PATCH 6/6] changes --- CHANGELOG.md | 2 +- examples/usage_alerts.py | 27 ++++++++-------- ns1/__init__.py | 15 --------- ns1/alerting/usage_alerts.py | 11 +++++-- ns1/rest/alerts.py | 57 +++++++++++++++++++++++++++++++++ tests/unit/test_usage_alerts.py | 57 +++++++++++++++++++++------------ 6 files changed, 115 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93a011f..34a2f97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## 0.25.0 (August 19th, 2025) ENHANCEMENTS: -* Add Usage Alerts (account): create/get/patch/delete/list; client-side validation; examples. +* Add Usage Alerts (account): create/get/patch/delete/list; client-side validation; examples. Now accessible via alerts().usage. ## 0.24.0 (March 20th, 2025) diff --git a/examples/usage_alerts.py b/examples/usage_alerts.py index 1494971..729490a 100644 --- a/examples/usage_alerts.py +++ b/examples/usage_alerts.py @@ -4,16 +4,10 @@ # import os -import sys import json from ns1 import NS1 from ns1.config import Config -# Path hackery to ensure we import the local ns1 module -sys.path.insert( - 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -) - # Create NS1 client config = { "endpoint": "https://api.nsone.net", @@ -31,6 +25,11 @@ c.loadFromDict(config) client = NS1(config=c) +# If no real API key is set, we'll get appropriate errors +# This is just an example to show the usage pattern +if not os.environ.get("NS1_APIKEY"): + print("Using a mock endpoint - for real usage, set the NS1_APIKEY environment variable") + # Usage Alerts API Examples def usage_alerts_example(): @@ -39,7 +38,7 @@ def usage_alerts_example(): # List all usage alerts print("Listing usage alerts:") try: - alerts = client.alerting().usage.list(limit=10) + alerts = client.alerts().usage.list(limit=10) print(f"Total alerts: {alerts.get('total_results', 0)}") for i, alert in enumerate(alerts.get("results", [])): print(f" {i+1}. {alert.get('name')} (id: {alert.get('id')})") @@ -49,7 +48,7 @@ def usage_alerts_example(): # Create a usage alert print("\nCreating a usage alert:") try: - alert = client.alerting().usage.create( + alert = client.alerts().usage.create( name="Example query usage alert", subtype="query_usage", alert_at_percent=85, @@ -66,7 +65,7 @@ def usage_alerts_example(): # Update the alert print("\nUpdating the alert threshold to 90%:") try: - updated = client.alerting().usage.patch(alert_id, alert_at_percent=90) + updated = client.alerts().usage.patch(alert_id, alert_at_percent=90) print(f"Updated alert: {updated['name']}") print(f"New threshold: {updated['data']['alert_at_percent']}%") except Exception as e: @@ -75,7 +74,7 @@ def usage_alerts_example(): # Get alert details print("\nGetting alert details:") try: - details = client.alerting().usage.get(alert_id) + details = client.alerts().usage.get(alert_id) print(f"Alert details: {json.dumps(details, indent=2)}") except Exception as e: print(f"Error getting alert: {e}") @@ -83,7 +82,7 @@ def usage_alerts_example(): # Delete the alert print("\nDeleting the alert:") try: - client.alerting().usage.delete(alert_id) + client.alerts().usage.delete(alert_id) print(f"Alert {alert_id} deleted successfully") except Exception as e: print(f"Error deleting alert: {e}") @@ -96,7 +95,7 @@ def test_validation(): # Test invalid subtype print("Testing invalid subtype:") try: - client.alerting().usage.create( + client.alerts().usage.create( name="Test alert", subtype="invalid_subtype", alert_at_percent=85 ) except ValueError as e: @@ -105,7 +104,7 @@ def test_validation(): # Test threshold too low print("\nTesting threshold too low (0):") try: - client.alerting().usage.create( + client.alerts().usage.create( name="Test alert", subtype="query_usage", alert_at_percent=0 ) except ValueError as e: @@ -114,7 +113,7 @@ def test_validation(): # Test threshold too high print("\nTesting threshold too high (101):") try: - client.alerting().usage.create( + client.alerts().usage.create( name="Test alert", subtype="query_usage", alert_at_percent=101 ) except ValueError as e: diff --git a/ns1/__init__.py b/ns1/__init__.py index 78a0466..0d00cc0 100644 --- a/ns1/__init__.py +++ b/ns1/__init__.py @@ -242,21 +242,6 @@ def alerts(self): return ns1.rest.alerts.Alerts(self.config) - def alerting(self): - """ - Return the alerting namespace for accessing alerting features - - :return: Alerting namespace - """ - from ns1.alerting import UsageAlertsAPI - - # Create or reuse the alerting namespace - ns = getattr(self, "_alerting_ns", None) - if ns is None: - ns = type("AlertingNS", (), {})() - ns.usage = UsageAlertsAPI(self) - setattr(self, "_alerting_ns", ns) - return ns def billing_usage(self): """ diff --git a/ns1/alerting/usage_alerts.py b/ns1/alerting/usage_alerts.py index b07c8b5..debb93f 100644 --- a/ns1/alerting/usage_alerts.py +++ b/ns1/alerting/usage_alerts.py @@ -23,9 +23,14 @@ def _validate(name: str, subtype: str, alert_at_percent: int) -> None: class UsageAlertsAPI: """ - Account-scoped usage alerts. - Server rules: type='account'; data.alert_at_percent in 1..100; - PATCH must not include type/subtype; zone_names/notifier_list_ids may be []. + Account-scoped usage alerts. Triggers when usage ≥ alert_at_percent. + + Server rules: + - Always type='account' + - data.alert_at_percent must be in 1..100 + - PATCH must not include type/subtype + - zone_names/notifier_list_ids may be empty ([]) + - Server ignores datafeed notifiers for usage alerts """ def __init__(self, client) -> None: diff --git a/ns1/rest/alerts.py b/ns1/rest/alerts.py index d3966f0..07c00d2 100644 --- a/ns1/rest/alerts.py +++ b/ns1/rest/alerts.py @@ -4,6 +4,7 @@ # License under The MIT License (MIT). See LICENSE in project root. # from . import resource +from ns1.alerting import UsageAlertsAPI class Alerts(resource.BaseResource): @@ -15,6 +16,62 @@ class Alerts(resource.BaseResource): "record_ids", "zone_names", ] + + # Forward HTTP methods needed by UsageAlertsAPI + def _get(self, path, params=None): + """Forward GET requests to make_request""" + # Fix path to start with /alerting/v1/ if needed + if path.startswith('/'): + path = path[1:] # Remove leading slash + if not path.startswith("alerting/v1/"): + # Alerting endpoints should have this prefix + path = f"{self.ROOT}/{path.split('/')[-1]}" + return self._make_request("GET", path, params=params) + + def _post(self, path, json=None): + """Forward POST requests to make_request""" + if path.startswith('/'): + path = path[1:] # Remove leading slash + if not path.startswith("alerting/v1/"): + path = f"{self.ROOT}" + return self._make_request("POST", path, body=json) + + def _patch(self, path, json=None): + """Forward PATCH requests to make_request""" + if path.startswith('/'): + path = path[1:] # Remove leading slash + if not path.startswith("alerting/v1/"): + parts = path.split('/') + path = f"{self.ROOT}/{parts[-1]}" + return self._make_request("PATCH", path, body=json) + + def _delete(self, path): + """Forward DELETE requests to make_request""" + if path.startswith('/'): + path = path[1:] # Remove leading slash + if not path.startswith("alerting/v1/"): + parts = path.split('/') + path = f"{self.ROOT}/{parts[-1]}" + return self._make_request("DELETE", path) + + def __init__(self, config): + super(Alerts, self).__init__(config) + self._usage_api = None + + @property + def usage(self): + """ + Return interface to usage alerts operations + + :return: :py:class:`ns1.alerting.UsageAlertsAPI` + """ + if self._usage_api is None: + # The UsageAlertsAPI expects a client with HTTP methods (_get, _post, etc.) + # Since the NS1 object is not directly accessible here, we'll use self as the client + # The UsageAlertsAPI only needs HTTP methods (_get, _post, etc.) + # For tests, we'll later patch the _c attribute on the UsageAlertsAPI instance + self._usage_api = UsageAlertsAPI(self) + return self._usage_api def _buildBody(self, alid, **kwargs): body = {} diff --git a/tests/unit/test_usage_alerts.py b/tests/unit/test_usage_alerts.py index c6da0aa..40bdf3a 100644 --- a/tests/unit/test_usage_alerts.py +++ b/tests/unit/test_usage_alerts.py @@ -35,7 +35,7 @@ def test_create_usage_alert(usage_alerts_client): """Test creating a usage alert""" client = usage_alerts_client - # Create a mock for the _post method in alerting().usage + # Create a mock for the _post method client._post = mock.MagicMock() client._post.return_value = { "id": "a1b2c3", @@ -49,9 +49,12 @@ def test_create_usage_alert(usage_alerts_client): "updated_at": 1597937213, } - # Patch the client reference - with mock.patch.object(client.alerting().usage, "_c", client): - alert = client.alerting().usage.create( + # Get the usage API and directly set its client + usage_api = client.alerts().usage + usage_api._c = client + + # Make the API call + alert = usage_api.create( name="Test Alert", subtype="query_usage", alert_at_percent=85, @@ -96,9 +99,12 @@ def test_get_usage_alert(usage_alerts_client): "zone_names": [], } - # Patch the client reference - with mock.patch.object(client.alerting().usage, "_c", client): - alert = client.alerting().usage.get(alert_id) + # Get the usage API and directly set its client + usage_api = client.alerts().usage + usage_api._c = client + + # Make the API call + alert = usage_api.get(alert_id) # Verify _get was called with correct URL client._get.assert_called_once_with(f"/alerting/v1/alerts/{alert_id}") @@ -126,11 +132,14 @@ def test_patch_usage_alert(usage_alerts_client): "zone_names": [], } - # Patch the client reference - with mock.patch.object(client.alerting().usage, "_c", client): - alert = client.alerting().usage.patch( - alert_id, name="Updated Alert", alert_at_percent=90 - ) + # Get the usage API and directly set its client + usage_api = client.alerts().usage + usage_api._c = client + + # Make the API call + alert = usage_api.patch( + alert_id, name="Updated Alert", alert_at_percent=90 + ) # Verify _patch was called with correct arguments expected_body = {"name": "Updated Alert", "data": {"alert_at_percent": 90}} @@ -157,9 +166,12 @@ def test_delete_usage_alert(usage_alerts_client): # Create a mock for the _delete method client._delete = mock.MagicMock() - # Patch the client reference - with mock.patch.object(client.alerting().usage, "_c", client): - client.alerting().usage.delete(alert_id) + # Get the usage API and directly set its client + usage_api = client.alerts().usage + usage_api._c = client + + # Make the API call + usage_api.delete(alert_id) # Verify _delete was called with correct URL client._delete.assert_called_once_with(f"/alerting/v1/alerts/{alert_id}") @@ -186,9 +198,12 @@ def test_list_usage_alerts(usage_alerts_client): ], } - # Patch the client reference - with mock.patch.object(client.alerting().usage, "_c", client): - response = client.alerting().usage.list(limit=1, order_descending=True) + # Get the usage API and directly set its client + usage_api = client.alerts().usage + usage_api._c = client + + # Make the API call + response = usage_api.list(limit=1, order_descending=True) # Verify _get was called with correct URL and params expected_params = {"limit": 1, "order_descending": "true"} @@ -211,21 +226,21 @@ def test_validation_threshold_bounds(usage_alerts_client): # Test below minimum with pytest.raises(ValueError) as excinfo: - client.alerting().usage.create( + client.alerts().usage.create( name="Test Alert", subtype="query_usage", alert_at_percent=0 ) assert "alert_at_percent must be int in 1..100" in str(excinfo.value) # Test above maximum with pytest.raises(ValueError) as excinfo: - client.alerting().usage.create( + client.alerts().usage.create( name="Test Alert", subtype="query_usage", alert_at_percent=101 ) assert "alert_at_percent must be int in 1..100" in str(excinfo.value) # Test same validation in patch with pytest.raises(ValueError) as excinfo: - client.alerting().usage.patch("a1", alert_at_percent=101) + client.alerts().usage.patch("a1", alert_at_percent=101) assert "alert_at_percent must be int in 1..100" in str(excinfo.value)