From 3c2bea4fdcae5e5d29c5d1eff3285904d1e83e0f Mon Sep 17 00:00:00 2001 From: Ye Chen Date: Thu, 10 Jul 2025 16:21:00 -0400 Subject: [PATCH 1/8] init --- linode_api4/__init__.py | 2 +- linode_api4/groups/__init__.py | 1 + linode_api4/groups/monitor_api.py | 47 ++ linode_api4/linode_client.py | 439 +++++++++++------- linode_api4/objects/monitor_api.py | 34 ++ .../monitor_services_dbaas_metrics.json | 47 ++ test/unit/base.py | 28 +- test/unit/groups/monitor_api_test.py | 65 +++ 8 files changed, 500 insertions(+), 163 deletions(-) create mode 100644 linode_api4/groups/monitor_api.py create mode 100644 linode_api4/objects/monitor_api.py create mode 100644 test/fixtures/monitor_services_dbaas_metrics.json create mode 100644 test/unit/groups/monitor_api_test.py diff --git a/linode_api4/__init__.py b/linode_api4/__init__.py index b347b607d..69fa1111c 100644 --- a/linode_api4/__init__.py +++ b/linode_api4/__init__.py @@ -1,7 +1,7 @@ # isort: skip_file from linode_api4.objects import * from linode_api4.errors import ApiError, UnexpectedResponseError -from linode_api4.linode_client import LinodeClient +from linode_api4.linode_client import LinodeClient, MonitorClient from linode_api4.login_client import LinodeLoginClient, OAuthScopes from linode_api4.paginated_list import PaginatedList from linode_api4.polling import EventPoller diff --git a/linode_api4/groups/__init__.py b/linode_api4/groups/__init__.py index 3842042ad..4096cd21c 100644 --- a/linode_api4/groups/__init__.py +++ b/linode_api4/groups/__init__.py @@ -11,6 +11,7 @@ from .lke_tier import * from .longview import * from .monitor import * +from .monitor_api import * from .networking import * from .nodebalancer import * from .object_storage import * diff --git a/linode_api4/groups/monitor_api.py b/linode_api4/groups/monitor_api.py new file mode 100644 index 000000000..859afa146 --- /dev/null +++ b/linode_api4/groups/monitor_api.py @@ -0,0 +1,47 @@ +from typing import Optional + +from linode_api4.groups import Group +from linode_api4.objects.monitor_api import EntityMetrics + + +class MetricsGroup(Group): + """ + Encapsulates Monitor-related methods of the :any:`MonitorClient`. + + This group contains all features related to metrics in the API monitor-api. + """ + def fetch_metrics( + self, + service_type: str, + entity_ids: list, + **kwargs, + ) -> Optional[EntityMetrics]: + """ + Returns metrics information for the individual entities within a specific service type. + + API documentation: https://techdocs.akamai.com/linode-api/reference/post-read-metric + + :param service_type: The service being monitored. + Currently, only the Managed Databases (dbaas) service type is supported. + :type service_type: str + + :param entity_ids: The id for each individual entity from a service_type. + :type entity_ids: list + + :param kwargs: Any other arguments accepted by the api. Please refer to the API documentation for full info. + + :returns: Service metrics requested. + :rtype: EntityMetrics or None + """ + + params = { + "entity_ids": entity_ids + } + + params.update(kwargs) + + result = self.client.post( + f"/monitor/services/{service_type}/metrics", data=params + ) + + return EntityMetrics.from_json(result) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index e71f1563e..44faf8f97 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -19,6 +19,7 @@ LinodeGroup, LKEGroup, LongviewGroup, + MetricsGroup, MonitorGroup, NetworkingGroup, NodeBalancerGroup, @@ -51,55 +52,19 @@ def get_backoff_time(self): return self.backoff_factor -class LinodeClient: +class BaseClient: def __init__( - self, - token, - base_url="https://api.linode.com/v4", - user_agent=None, - page_size=None, - retry=True, - retry_rate_limit_interval=1.0, - retry_max=5, - retry_statuses=None, - ca_path=None, + self, + token, + base_url, + user_agent=None, + page_size=None, + retry=True, + retry_rate_limit_interval=1.0, + retry_max=5, + retry_statuses=None, + ca_path=None, ): - """ - The main interface to the Linode API. - - :param token: The authentication token to use for communication with the - API. Can be either a Personal Access Token or an OAuth Token. - :type token: str - :param base_url: The base URL for API requests. Generally, you shouldn't - change this. - :type base_url: str - :param user_agent: What to append to the User Agent of all requests made - by this client. Setting this allows Linode's internal - monitoring applications to track the usage of your - application. Setting this is not necessary, but some - applications may desire this behavior. - :type user_agent: str - :param page_size: The default size to request pages at. If not given, - the API's default page size is used. Valid values - can be found in the API docs, but at time of writing - are between 25 and 500. - :type page_size: int - :param retry: Whether API requests should automatically be retries on known - intermittent responses. - :type retry: bool - :param retry_rate_limit_interval: The amount of time to wait between HTTP request - retries. - :type retry_rate_limit_interval: Union[float, int] - :param retry_max: The number of request retries that should be attempted before - raising an API error. - :type retry_max: int - :type retry_statuses: List of int - :param retry_statuses: Additional HTTP response statuses to retry on. - By default, the client will retry on 408, 429, and 502 - responses. - :param ca_path: The path to a CA file to use for API requests in this client. - :type ca_path: str - """ self.base_url = base_url self._add_user_agent = user_agent self.token = token @@ -138,72 +103,6 @@ def __init__( self.session.mount("http://", retry_adapter) self.session.mount("https://", retry_adapter) - #: Access methods related to Linodes - see :any:`LinodeGroup` for - #: more information - self.linode = LinodeGroup(self) - - #: Access methods related to your user - see :any:`ProfileGroup` for - #: more information - self.profile = ProfileGroup(self) - - #: Access methods related to your account - see :any:`AccountGroup` for - #: more information - self.account = AccountGroup(self) - - #: Access methods related to networking on your account - see - #: :any:`NetworkingGroup` for more information - self.networking = NetworkingGroup(self) - - #: Access methods related to support - see :any:`SupportGroup` for more - #: information - self.support = SupportGroup(self) - - #: Access information related to the Longview service - see - #: :any:`LongviewGroup` for more information - self.longview = LongviewGroup(self) - - #: Access methods related to Object Storage - see :any:`ObjectStorageGroup` - #: for more information - self.object_storage = ObjectStorageGroup(self) - - #: Access methods related to LKE - see :any:`LKEGroup` for more information. - self.lke = LKEGroup(self) - - #: Access methods related to Managed Databases - see :any:`DatabaseGroup` for more information. - self.database = DatabaseGroup(self) - - #: Access methods related to NodeBalancers - see :any:`NodeBalancerGroup` for more information. - self.nodebalancers = NodeBalancerGroup(self) - - #: Access methods related to Domains - see :any:`DomainGroup` for more information. - self.domains = DomainGroup(self) - - #: Access methods related to Tags - See :any:`TagGroup` for more information. - self.tags = TagGroup(self) - - #: Access methods related to Volumes - See :any:`VolumeGroup` for more information. - self.volumes = VolumeGroup(self) - - #: Access methods related to Regions - See :any:`RegionGroup` for more information. - self.regions = RegionGroup(self) - - #: Access methods related to Images - See :any:`ImageGroup` for more information. - self.images = ImageGroup(self) - - #: Access methods related to VPCs - See :any:`VPCGroup` for more information. - self.vpcs = VPCGroup(self) - - #: Access methods related to Event polling - See :any:`PollingGroup` for more information. - self.polling = PollingGroup(self) - - #: Access methods related to Beta Program - See :any:`BetaProgramGroup` for more information. - self.beta = BetaProgramGroup(self) - - #: Access methods related to VM placement - See :any:`PlacementAPIGroup` for more information. - self.placement = PlacementAPIGroup(self) - - self.monitor = MonitorGroup(self) - @property def _user_agent(self): return "{}python-linode_api4/{} {}".format( @@ -248,7 +147,7 @@ def load(self, target_type, target_id, target_parent_id=None): return result def _api_call( - self, endpoint, model=None, method=None, data=None, filters=None + self, endpoint, model=None, method=None, data=None, filters=None ): """ Makes a call to the linode api. Data should only be given if the method is @@ -302,7 +201,7 @@ def _api_call( return j def _get_objects( - self, endpoint, cls, model=None, parent_id=None, filters=None + self, endpoint, cls, model=None, parent_id=None, filters=None ): # handle non-default page sizes call_endpoint = endpoint @@ -367,6 +266,196 @@ def __setattr__(self, key, value): super().__setattr__(key, value) + # helper functions + def _get_and_filter( + self, + obj_type, + *filters, + endpoint=None, + parent_id=None, + ): + parsed_filters = None + if filters: + if len(filters) > 1: + parsed_filters = and_( + *filters + ).dct # pylint: disable=no-value-for-parameter + else: + parsed_filters = filters[0].dct + + # Use sepcified endpoint + if endpoint: + return self._get_objects( + endpoint, obj_type, parent_id=parent_id, filters=parsed_filters + ) + else: + return self._get_objects( + obj_type.api_list(), + obj_type, + parent_id=parent_id, + filters=parsed_filters, + ) + + +class LinodeClient(BaseClient): + def __init__( + self, + token, + base_url="https://api.linode.com/v4", + user_agent=None, + page_size=None, + retry=True, + retry_rate_limit_interval=1.0, + retry_max=5, + retry_statuses=None, + ca_path=None, + ): + """ + The main interface to the Linode API. + + :param token: The authentication token to use for communication with the + API. Can be either a Personal Access Token or an OAuth Token. + :type token: str + :param base_url: The base URL for API requests. Generally, you shouldn't + change this. + :type base_url: str + :param user_agent: What to append to the User Agent of all requests made + by this client. Setting this allows Linode's internal + monitoring applications to track the usage of your + application. Setting this is not necessary, but some + applications may desire this behavior. + :type user_agent: str + :param page_size: The default size to request pages at. If not given, + the API's default page size is used. Valid values + can be found in the API docs, but at time of writing + are between 25 and 500. + :type page_size: int + :param retry: Whether API requests should automatically be retries on known + intermittent responses. + :type retry: bool + :param retry_rate_limit_interval: The amount of time to wait between HTTP request + retries. + :type retry_rate_limit_interval: Union[float, int] + :param retry_max: The number of request retries that should be attempted before + raising an API error. + :type retry_max: int + :type retry_statuses: List of int + :param retry_statuses: Additional HTTP response statuses to retry on. + By default, the client will retry on 408, 429, and 502 + responses. + :param ca_path: The path to a CA file to use for API requests in this client. + :type ca_path: str + """ + # retry_forcelist = [408, 429, 502] + # + # if retry_statuses is not None: + # retry_forcelist.extend(retry_statuses) + # + # # Ensure the max retries value is valid + # if not isinstance(retry_max, int): + # raise ValueError("retry_max must be an int") + # + # self.retry = retry + # self.retry_rate_limit_interval = float(retry_rate_limit_interval) + # self.retry_max = retry_max + # self.retry_statuses = retry_forcelist + # + # # Initialize the HTTP client session + # self.session = requests.Session() + # + # self._retry_config = LinearRetry( + # total=retry_max if retry else 0, + # status_forcelist=self.retry_statuses, + # respect_retry_after_header=True, + # backoff_factor=self.retry_rate_limit_interval, + # raise_on_status=False, + # # By default, POST is not an allowed method. + # # We should explicitly include it. + # allowed_methods={"DELETE", "GET", "POST", "PUT"}, + # ) + # retry_adapter = HTTPAdapter(max_retries=self._retry_config) + # + # self.session.mount("http://", retry_adapter) + # self.session.mount("https://", retry_adapter) + + #: Access methods related to Linodes - see :any:`LinodeGroup` for + #: more information + self.linode = LinodeGroup(self) + + #: Access methods related to your user - see :any:`ProfileGroup` for + #: more information + self.profile = ProfileGroup(self) + + #: Access methods related to your account - see :any:`AccountGroup` for + #: more information + self.account = AccountGroup(self) + + #: Access methods related to networking on your account - see + #: :any:`NetworkingGroup` for more information + self.networking = NetworkingGroup(self) + + #: Access methods related to support - see :any:`SupportGroup` for more + #: information + self.support = SupportGroup(self) + + #: Access information related to the Longview service - see + #: :any:`LongviewGroup` for more information + self.longview = LongviewGroup(self) + + #: Access methods related to Object Storage - see :any:`ObjectStorageGroup` + #: for more information + self.object_storage = ObjectStorageGroup(self) + + #: Access methods related to LKE - see :any:`LKEGroup` for more information. + self.lke = LKEGroup(self) + + #: Access methods related to Managed Databases - see :any:`DatabaseGroup` for more information. + self.database = DatabaseGroup(self) + + #: Access methods related to NodeBalancers - see :any:`NodeBalancerGroup` for more information. + self.nodebalancers = NodeBalancerGroup(self) + + #: Access methods related to Domains - see :any:`DomainGroup` for more information. + self.domains = DomainGroup(self) + + #: Access methods related to Tags - See :any:`TagGroup` for more information. + self.tags = TagGroup(self) + + #: Access methods related to Volumes - See :any:`VolumeGroup` for more information. + self.volumes = VolumeGroup(self) + + #: Access methods related to Regions - See :any:`RegionGroup` for more information. + self.regions = RegionGroup(self) + + #: Access methods related to Images - See :any:`ImageGroup` for more information. + self.images = ImageGroup(self) + + #: Access methods related to VPCs - See :any:`VPCGroup` for more information. + self.vpcs = VPCGroup(self) + + #: Access methods related to Event polling - See :any:`PollingGroup` for more information. + self.polling = PollingGroup(self) + + #: Access methods related to Beta Program - See :any:`BetaProgramGroup` for more information. + self.beta = BetaProgramGroup(self) + + #: Access methods related to VM placement - See :any:`PlacementAPIGroup` for more information. + self.placement = PlacementAPIGroup(self) + + self.monitor = MonitorGroup(self) + + super(LinodeClient, self).__init__( + token=token, + base_url=base_url, + user_agent=user_agent, + page_size=page_size, + retry=retry, + retry_rate_limit_interval=retry_rate_limit_interval, + retry_max=retry_max, + retry_statuses=retry_statuses, + ca_path=ca_path, + ) + def image_create(self, disk, label=None, description=None, tags=None): """ .. note:: This method is an alias to maintain backwards compatibility. @@ -377,11 +466,11 @@ def image_create(self, disk, label=None, description=None, tags=None): ) def image_create_upload( - self, - label: str, - region: str, - description: Optional[str] = None, - tags: Optional[List[str]] = None, + self, + label: str, + region: str, + description: Optional[str] = None, + tags: Optional[List[str]] = None, ) -> Tuple[Image, str]: """ .. note:: This method is an alias to maintain backwards compatibility. @@ -394,12 +483,12 @@ def image_create_upload( ) def image_upload( - self, - label: str, - region: str, - file: BinaryIO, - description: Optional[str] = None, - tags: Optional[List[str]] = None, + self, + label: str, + region: str, + file: BinaryIO, + description: Optional[str] = None, + tags: Optional[List[str]] = None, ) -> Image: """ .. note:: This method is an alias to maintain backwards compatibility. @@ -427,13 +516,13 @@ def domain_create(self, domain, master=True, **kwargs): return self.domains.create(domain, master=master, **kwargs) def tag_create( - self, - label, - instances=None, - domains=None, - nodebalancers=None, - volumes=None, - entities=[], + self, + label, + instances=None, + domains=None, + nodebalancers=None, + volumes=None, + entities=[], ): """ .. note:: This method is an alias to maintain backwards compatibility. @@ -457,32 +546,60 @@ def volume_create(self, label, region=None, linode=None, size=20, **kwargs): label, region=region, linode=linode, size=size, **kwargs ) - # helper functions - def _get_and_filter( - self, - obj_type, - *filters, - endpoint=None, - parent_id=None, - ): - parsed_filters = None - if filters: - if len(filters) > 1: - parsed_filters = and_( - *filters - ).dct # pylint: disable=no-value-for-parameter - else: - parsed_filters = filters[0].dct - # Use sepcified endpoint - if endpoint: - return self._get_objects( - endpoint, obj_type, parent_id=parent_id, filters=parsed_filters - ) - else: - return self._get_objects( - obj_type.api_list(), - obj_type, - parent_id=parent_id, - filters=parsed_filters, - ) +class MonitorClient(BaseClient): + """ + The main interface to the Monitor API. + + :param token: The authentication Personal Access Token token to use for + communication with the API. You may want to generate one using + Linode Client. For example: + linode_client.monitor.create_token( + service_type="dbaas", entity_ids=[entity_id] + ) + :type token: str + :param base_url: The base URL for monitor API requests. Generally, you shouldn't + change this. + :type base_url: str + :param user_agent: What to append to the User Agent of all requests made + by this client. Setting this allows Linode's internal + monitoring applications to track the usage of your + application. Setting this is not necessary, but some + applications may desire this behavior. + :type user_agent: str + :param page_size: The default size to request pages at. If not given, + the API's default page size is used. Valid values + can be found in the API docs. + :type page_size: int + :param ca_path: The path to a CA file to use for API requests in this client. + :type ca_path: str + """ + + def __init__( + self, + token, + base_url="https://monitor-api.linode.com/v2beta", + user_agent=None, + page_size=None, + ca_path=None, + retry=True, + retry_rate_limit_interval=1.0, + retry_max=5, + retry_statuses=None, + ): + # define properties and modules + + # define a metric group and add access to it through MonitorClient + self.metrics = MetricsGroup(self) + + super(MonitorClient, self).__init__( + token=token, + base_url=base_url, + user_agent=user_agent, + page_size=page_size, + retry=retry, + retry_rate_limit_interval=retry_rate_limit_interval, + retry_max=retry_max, + retry_statuses=retry_statuses, + ca_path=ca_path + ) diff --git a/linode_api4/objects/monitor_api.py b/linode_api4/objects/monitor_api.py new file mode 100644 index 000000000..ffe4c6d3b --- /dev/null +++ b/linode_api4/objects/monitor_api.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from linode_api4 import JSONObject, Base, Property + + +@dataclass +class EntityMetricsStats(JSONObject): + executionTimeMsec: int = 0 + seriesFetched: str = "" + + +@dataclass +class EntityMetricsDataResult(JSONObject): + metric: dict = field( + default_factory=dict + ) + values: list = field( + default_factory=list + ) + + +@dataclass +class EntityMetricsData(JSONObject): + result: Optional[List[EntityMetricsDataResult]] = None + resultType: str = "" + + +@dataclass +class EntityMetrics(JSONObject): + data: Optional[EntityMetricsData] = None + isPartial: bool = False + stats: Optional[EntityMetricsStats] = None + status: str = "" diff --git a/test/fixtures/monitor_services_dbaas_metrics.json b/test/fixtures/monitor_services_dbaas_metrics.json new file mode 100644 index 000000000..67657cb78 --- /dev/null +++ b/test/fixtures/monitor_services_dbaas_metrics.json @@ -0,0 +1,47 @@ +{ + "data": { + "result": [ + { + "metric": { + "entity_id": 13316, + "metric_name": "avg_read_iops", + "node_id": "primary-9" + }, + "values": [ + [ + 1728996500, + "90.55555555555556" + ], + [ + 1729043400, + "14890.583333333334" + ] + ] + }, + { + "metric": { + "entity_id": 13217, + "metric_name": "avg_cpu_usage", + "node_id": "primary-0" + }, + "values": [ + [ + 1728996500, + "12.45" + ], + [ + 1729043400, + "18.67" + ] + ] + } + ], + "resultType": "matrix" + }, + "isPartial": false, + "stats": { + "executionTimeMsec": 21, + "seriesFetched": "2" + }, + "status": "success" +} \ No newline at end of file diff --git a/test/unit/base.py b/test/unit/base.py index e143f8f64..bc0ec2f08 100644 --- a/test/unit/base.py +++ b/test/unit/base.py @@ -4,7 +4,7 @@ from mock import patch -from linode_api4 import LinodeClient +from linode_api4 import LinodeClient, MonitorClient FIXTURES = TestFixtures() @@ -202,3 +202,29 @@ def mock_delete(self): mocked requests """ return MethodMock("delete", {}) + + +class MonitorClientBaseCase(TestCase): + def setUp(self): + self.client = MonitorClient("testing", base_url="/") + + self.get_patch = patch( + "linode_api4.linode_client.requests.Session.get", + side_effect=mock_get, + ) + self.get_patch.start() + + def tearDown(self): + self.get_patch.stop() + + def mock_post(self, return_dct): + """ + Returns a MethodMock mocking a POST. This should be used in a with + statement. + + :param return_dct: The JSON that should be returned from this POST + + :returns: A MethodMock object who will capture the parameters of the + mocked requests + """ + return MethodMock("post", return_dct) diff --git a/test/unit/groups/monitor_api_test.py b/test/unit/groups/monitor_api_test.py new file mode 100644 index 000000000..1ae3d1b3a --- /dev/null +++ b/test/unit/groups/monitor_api_test.py @@ -0,0 +1,65 @@ +from test.unit.base import MonitorClientBaseCase, load_json + + +class MonitorAPITest(MonitorClientBaseCase): + """ + Tests methods of the Monitor API group + """ + + def test_fetch_metrics(self): + service_type = "dbaas" + url = f"/monitor/services/{service_type}/metrics" + with self.mock_post(url) as m: + metrics = self.client.metrics.fetch_metrics( + service_type, + entity_ids=[13217, 13316], + metrics=[ + { + "name": "avg_read_iops", + "aggregate": "avg" + }, + { + "name": "avg_cpu_usage", + "aggregate": "avg" + } + ], + relative_time_duration={ + "unit": "hr", + "value": 1 + } + ) + + # assert call data + assert m.call_url == url + assert m.call_data == { + "entity_ids": [13217, 13316], + "metrics": [ + { + "name": "avg_read_iops", + "aggregate": "avg" + }, + { + "name": "avg_cpu_usage", + "aggregate": "avg" + } + ], + "relative_time_duration": { + "unit": "hr", + "value": 1 + } + } + + # assert the metrics data + metric_data = metrics.data.result[0] + + assert metrics.data.resultType == "matrix" + assert metric_data.metric["entity_id"] == 13316 + assert metric_data.metric["metric_name"] == "avg_read_iops" + assert metric_data.metric["node_id"] == "primary-9" + assert metric_data.values[0][0] == 1728996500 + assert metric_data.values[0][1] == "90.55555555555556" + + assert metrics.status == "success" + assert metrics.stats.executionTimeMsec == 21 + assert metrics.stats.seriesFetched == "2" + assert not metrics.isPartial From 4b0dfffa76a72c16b38040ed69401bf2cc885505 Mon Sep 17 00:00:00 2001 From: Ye Chen Date: Thu, 10 Jul 2025 16:35:12 -0400 Subject: [PATCH 2/8] lint --- linode_api4/groups/group.py | 4 +- linode_api4/groups/monitor.py | 4 +- linode_api4/linode_client.py | 149 +++++++++++---------------- linode_api4/objects/monitor_api.py | 10 +- test/unit/groups/monitor_api_test.py | 36 ++----- 5 files changed, 73 insertions(+), 130 deletions(-) diff --git a/linode_api4/groups/group.py b/linode_api4/groups/group.py index c591b7fda..b7c0e1eeb 100644 --- a/linode_api4/groups/group.py +++ b/linode_api4/groups/group.py @@ -3,9 +3,9 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from linode_api4 import LinodeClient + from linode_api4.linode_client import BaseClient class Group: - def __init__(self, client: LinodeClient): + def __init__(self, client: BaseClient): self.client = client diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index 908b4e819..7164a6e5c 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -3,9 +3,7 @@ ] from typing import Any, Optional -from linode_api4 import ( - PaginatedList, -) +from linode_api4 import PaginatedList from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 44faf8f97..b229391fa 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -54,16 +54,16 @@ def get_backoff_time(self): class BaseClient: def __init__( - self, - token, - base_url, - user_agent=None, - page_size=None, - retry=True, - retry_rate_limit_interval=1.0, - retry_max=5, - retry_statuses=None, - ca_path=None, + self, + token, + base_url, + user_agent=None, + page_size=None, + retry=True, + retry_rate_limit_interval=1.0, + retry_max=5, + retry_statuses=None, + ca_path=None, ): self.base_url = base_url self._add_user_agent = user_agent @@ -147,7 +147,7 @@ def load(self, target_type, target_id, target_parent_id=None): return result def _api_call( - self, endpoint, model=None, method=None, data=None, filters=None + self, endpoint, model=None, method=None, data=None, filters=None ): """ Makes a call to the linode api. Data should only be given if the method is @@ -201,7 +201,7 @@ def _api_call( return j def _get_objects( - self, endpoint, cls, model=None, parent_id=None, filters=None + self, endpoint, cls, model=None, parent_id=None, filters=None ): # handle non-default page sizes call_endpoint = endpoint @@ -268,11 +268,11 @@ def __setattr__(self, key, value): # helper functions def _get_and_filter( - self, - obj_type, - *filters, - endpoint=None, - parent_id=None, + self, + obj_type, + *filters, + endpoint=None, + parent_id=None, ): parsed_filters = None if filters: @@ -299,16 +299,16 @@ def _get_and_filter( class LinodeClient(BaseClient): def __init__( - self, - token, - base_url="https://api.linode.com/v4", - user_agent=None, - page_size=None, - retry=True, - retry_rate_limit_interval=1.0, - retry_max=5, - retry_statuses=None, - ca_path=None, + self, + token, + base_url="https://api.linode.com/v4", + user_agent=None, + page_size=None, + retry=True, + retry_rate_limit_interval=1.0, + retry_max=5, + retry_statuses=None, + ca_path=None, ): """ The main interface to the Linode API. @@ -346,38 +346,6 @@ def __init__( :param ca_path: The path to a CA file to use for API requests in this client. :type ca_path: str """ - # retry_forcelist = [408, 429, 502] - # - # if retry_statuses is not None: - # retry_forcelist.extend(retry_statuses) - # - # # Ensure the max retries value is valid - # if not isinstance(retry_max, int): - # raise ValueError("retry_max must be an int") - # - # self.retry = retry - # self.retry_rate_limit_interval = float(retry_rate_limit_interval) - # self.retry_max = retry_max - # self.retry_statuses = retry_forcelist - # - # # Initialize the HTTP client session - # self.session = requests.Session() - # - # self._retry_config = LinearRetry( - # total=retry_max if retry else 0, - # status_forcelist=self.retry_statuses, - # respect_retry_after_header=True, - # backoff_factor=self.retry_rate_limit_interval, - # raise_on_status=False, - # # By default, POST is not an allowed method. - # # We should explicitly include it. - # allowed_methods={"DELETE", "GET", "POST", "PUT"}, - # ) - # retry_adapter = HTTPAdapter(max_retries=self._retry_config) - # - # self.session.mount("http://", retry_adapter) - # self.session.mount("https://", retry_adapter) - #: Access methods related to Linodes - see :any:`LinodeGroup` for #: more information self.linode = LinodeGroup(self) @@ -466,11 +434,11 @@ def image_create(self, disk, label=None, description=None, tags=None): ) def image_create_upload( - self, - label: str, - region: str, - description: Optional[str] = None, - tags: Optional[List[str]] = None, + self, + label: str, + region: str, + description: Optional[str] = None, + tags: Optional[List[str]] = None, ) -> Tuple[Image, str]: """ .. note:: This method is an alias to maintain backwards compatibility. @@ -483,12 +451,12 @@ def image_create_upload( ) def image_upload( - self, - label: str, - region: str, - file: BinaryIO, - description: Optional[str] = None, - tags: Optional[List[str]] = None, + self, + label: str, + region: str, + file: BinaryIO, + description: Optional[str] = None, + tags: Optional[List[str]] = None, ) -> Image: """ .. note:: This method is an alias to maintain backwards compatibility. @@ -516,13 +484,13 @@ def domain_create(self, domain, master=True, **kwargs): return self.domains.create(domain, master=master, **kwargs) def tag_create( - self, - label, - instances=None, - domains=None, - nodebalancers=None, - volumes=None, - entities=[], + self, + label, + instances=None, + domains=None, + nodebalancers=None, + volumes=None, + entities=[], ): """ .. note:: This method is an alias to maintain backwards compatibility. @@ -576,20 +544,19 @@ class MonitorClient(BaseClient): """ def __init__( - self, - token, - base_url="https://monitor-api.linode.com/v2beta", - user_agent=None, - page_size=None, - ca_path=None, - retry=True, - retry_rate_limit_interval=1.0, - retry_max=5, - retry_statuses=None, + self, + token, + base_url="https://monitor-api.linode.com/v2beta", + user_agent=None, + page_size=None, + ca_path=None, + retry=True, + retry_rate_limit_interval=1.0, + retry_max=5, + retry_statuses=None, ): - # define properties and modules - - # define a metric group and add access to it through MonitorClient + #: Access methods related to your monitor metrics - see :any:`MetricsGroup` for + #: more information self.metrics = MetricsGroup(self) super(MonitorClient, self).__init__( @@ -601,5 +568,5 @@ def __init__( retry_rate_limit_interval=retry_rate_limit_interval, retry_max=retry_max, retry_statuses=retry_statuses, - ca_path=ca_path + ca_path=ca_path, ) diff --git a/linode_api4/objects/monitor_api.py b/linode_api4/objects/monitor_api.py index ffe4c6d3b..c42e15998 100644 --- a/linode_api4/objects/monitor_api.py +++ b/linode_api4/objects/monitor_api.py @@ -1,7 +1,7 @@ from dataclasses import dataclass, field from typing import List, Optional -from linode_api4 import JSONObject, Base, Property +from linode_api4 import Base, JSONObject, Property @dataclass @@ -12,12 +12,8 @@ class EntityMetricsStats(JSONObject): @dataclass class EntityMetricsDataResult(JSONObject): - metric: dict = field( - default_factory=dict - ) - values: list = field( - default_factory=list - ) + metric: dict = field(default_factory=dict) + values: list = field(default_factory=list) @dataclass diff --git a/test/unit/groups/monitor_api_test.py b/test/unit/groups/monitor_api_test.py index 1ae3d1b3a..e0c6d4405 100644 --- a/test/unit/groups/monitor_api_test.py +++ b/test/unit/groups/monitor_api_test.py @@ -14,39 +14,21 @@ def test_fetch_metrics(self): service_type, entity_ids=[13217, 13316], metrics=[ - { - "name": "avg_read_iops", - "aggregate": "avg" - }, - { - "name": "avg_cpu_usage", - "aggregate": "avg" - } + {"name": "avg_read_iops", "aggregate": "avg"}, + {"name": "avg_cpu_usage", "aggregate": "avg"}, ], - relative_time_duration={ - "unit": "hr", - "value": 1 - } + relative_time_duration={"unit": "hr", "value": 1}, ) # assert call data assert m.call_url == url assert m.call_data == { - "entity_ids": [13217, 13316], - "metrics": [ - { - "name": "avg_read_iops", - "aggregate": "avg" - }, - { - "name": "avg_cpu_usage", - "aggregate": "avg" - } - ], - "relative_time_duration": { - "unit": "hr", - "value": 1 - } + "entity_ids": [13217, 13316], + "metrics": [ + {"name": "avg_read_iops", "aggregate": "avg"}, + {"name": "avg_cpu_usage", "aggregate": "avg"}, + ], + "relative_time_duration": {"unit": "hr", "value": 1}, } # assert the metrics data From 017d530cadacb1e3d1713294b3dda8e3c2dfdbee Mon Sep 17 00:00:00 2001 From: Ye Chen Date: Thu, 10 Jul 2025 16:38:16 -0400 Subject: [PATCH 3/8] define all --- linode_api4/objects/monitor_api.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/linode_api4/objects/monitor_api.py b/linode_api4/objects/monitor_api.py index c42e15998..e159458ef 100644 --- a/linode_api4/objects/monitor_api.py +++ b/linode_api4/objects/monitor_api.py @@ -1,7 +1,13 @@ +__all__ = [ + "EntityMetrics", + "EntityMetricsData", + "EntityMetricsDataResult", + "EntityMetricsStats", +] from dataclasses import dataclass, field from typing import List, Optional -from linode_api4 import Base, JSONObject, Property +from linode_api4 import JSONObject @dataclass From 098c6226a51fee49cacbf4b5e27d8e7fc482fbd8 Mon Sep 17 00:00:00 2001 From: Ye Chen Date: Thu, 10 Jul 2025 16:43:37 -0400 Subject: [PATCH 4/8] clean up --- linode_api4/groups/monitor_api.py | 4 ++++ linode_api4/objects/__init__.py | 1 + test/unit/groups/monitor_api_test.py | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/linode_api4/groups/monitor_api.py b/linode_api4/groups/monitor_api.py index 859afa146..27700bda1 100644 --- a/linode_api4/groups/monitor_api.py +++ b/linode_api4/groups/monitor_api.py @@ -1,3 +1,7 @@ +__all__ = [ + "MetricsGroup", +] + from typing import Optional from linode_api4.groups import Group diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index 7f1542d2a..30f95b969 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -22,3 +22,4 @@ from .beta import * from .placement import * from .monitor import * +from .monitor_api import * \ No newline at end of file diff --git a/test/unit/groups/monitor_api_test.py b/test/unit/groups/monitor_api_test.py index e0c6d4405..eb28fdde7 100644 --- a/test/unit/groups/monitor_api_test.py +++ b/test/unit/groups/monitor_api_test.py @@ -1,4 +1,4 @@ -from test.unit.base import MonitorClientBaseCase, load_json +from test.unit.base import MonitorClientBaseCase class MonitorAPITest(MonitorClientBaseCase): From 56d8846075bd5dffec49ab5d538d102817102d5a Mon Sep 17 00:00:00 2001 From: Ye Chen Date: Thu, 10 Jul 2025 17:00:09 -0400 Subject: [PATCH 5/8] lint --- linode_api4/groups/monitor_api.py | 5 ++--- linode_api4/linode_client.py | 4 ++-- linode_api4/objects/__init__.py | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/linode_api4/groups/monitor_api.py b/linode_api4/groups/monitor_api.py index 27700bda1..6eeb2bcb2 100644 --- a/linode_api4/groups/monitor_api.py +++ b/linode_api4/groups/monitor_api.py @@ -14,6 +14,7 @@ class MetricsGroup(Group): This group contains all features related to metrics in the API monitor-api. """ + def fetch_metrics( self, service_type: str, @@ -38,9 +39,7 @@ def fetch_metrics( :rtype: EntityMetrics or None """ - params = { - "entity_ids": entity_ids - } + params = {"entity_ids": entity_ids} params.update(kwargs) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index b229391fa..5390e7ed3 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -412,7 +412,7 @@ def __init__( self.monitor = MonitorGroup(self) - super(LinodeClient, self).__init__( + super().__init__( token=token, base_url=base_url, user_agent=user_agent, @@ -559,7 +559,7 @@ def __init__( #: more information self.metrics = MetricsGroup(self) - super(MonitorClient, self).__init__( + super().__init__( token=token, base_url=base_url, user_agent=user_agent, diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index 30f95b969..c847024d8 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -22,4 +22,4 @@ from .beta import * from .placement import * from .monitor import * -from .monitor_api import * \ No newline at end of file +from .monitor_api import * From c7245229ffcb0fc181b294c936f2a223511a8fcf Mon Sep 17 00:00:00 2001 From: Ye Chen Date: Mon, 14 Jul 2025 11:06:33 -0400 Subject: [PATCH 6/8] fix import --- linode_api4/objects/monitor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index f518e641d..80d2d92ae 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -7,7 +7,8 @@ from dataclasses import dataclass, field from typing import List, Optional -from linode_api4.objects import Base, JSONObject, Property, StrEnum +from linode_api4.objects.base import Base, Property +from linode_api4.objects.serializable import JSONObject, StrEnum class AggregateFunction(StrEnum): From c9e1b723d24f7ea85bc154ee63fe668cb7a95544 Mon Sep 17 00:00:00 2001 From: Ye Chen Date: Tue, 15 Jul 2025 09:28:26 -0400 Subject: [PATCH 7/8] fix import --- linode_api4/objects/monitor_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linode_api4/objects/monitor_api.py b/linode_api4/objects/monitor_api.py index e159458ef..347aa3d30 100644 --- a/linode_api4/objects/monitor_api.py +++ b/linode_api4/objects/monitor_api.py @@ -7,7 +7,7 @@ from dataclasses import dataclass, field from typing import List, Optional -from linode_api4 import JSONObject +from linode_api4.objects.serializable import JSONObject @dataclass From d13d94e370c92f81981811fbe3427bfe71dc0650 Mon Sep 17 00:00:00 2001 From: Ye Chen Date: Wed, 23 Jul 2025 15:39:00 -0400 Subject: [PATCH 8/8] add int test --- linode_api4/groups/monitor_api.py | 19 +++-- linode_api4/linode_client.py | 37 +++++++++ linode_api4/objects/monitor.py | 1 + linode_api4/objects/monitor_api.py | 8 ++ test/integration/conftest.py | 75 ++++++++++++++++++- .../models/monitor_api/test_monitor_api.py | 12 +++ test/unit/groups/monitor_api_test.py | 13 +++- 7 files changed, 154 insertions(+), 11 deletions(-) create mode 100644 test/integration/models/monitor_api/test_monitor_api.py diff --git a/linode_api4/groups/monitor_api.py b/linode_api4/groups/monitor_api.py index 6eeb2bcb2..48e2b2c30 100644 --- a/linode_api4/groups/monitor_api.py +++ b/linode_api4/groups/monitor_api.py @@ -2,10 +2,12 @@ "MetricsGroup", ] -from typing import Optional +from typing import Any, Dict, List, Optional, Union +from linode_api4 import drop_null_keys from linode_api4.groups import Group -from linode_api4.objects.monitor_api import EntityMetrics +from linode_api4.objects.base import _flatten_request_body_recursive +from linode_api4.objects.monitor_api import EntityMetricOptions, EntityMetrics class MetricsGroup(Group): @@ -19,6 +21,7 @@ def fetch_metrics( self, service_type: str, entity_ids: list, + metrics: List[Union[EntityMetricOptions, Dict[str, Any]]], **kwargs, ) -> Optional[EntityMetrics]: """ @@ -33,18 +36,24 @@ def fetch_metrics( :param entity_ids: The id for each individual entity from a service_type. :type entity_ids: list + :param metrics: A list of metric objects, each specifying a metric name and its corresponding aggregation function. + :type metrics: list of EntityMetricOptions or Dict[str, Any] + :param kwargs: Any other arguments accepted by the api. Please refer to the API documentation for full info. :returns: Service metrics requested. :rtype: EntityMetrics or None """ - - params = {"entity_ids": entity_ids} + params = { + "entity_ids": entity_ids, + "metrics": metrics, + } params.update(kwargs) result = self.client.post( - f"/monitor/services/{service_type}/metrics", data=params + f"/monitor/services/{service_type}/metrics", + data=drop_null_keys(_flatten_request_body_recursive(params)), ) return EntityMetrics.from_json(result) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 5390e7ed3..d1e35761e 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -53,6 +53,43 @@ def get_backoff_time(self): class BaseClient: + """ + The base class for a client. + + :param token: The authentication token to use for communication with the + API. Can be either a Personal Access Token or an OAuth Token. + :type token: str + :param base_url: The base URL for API requests. Generally, you shouldn't + change this. + :type base_url: str + :param user_agent: What to append to the User Agent of all requests made + by this client. Setting this allows Linode's internal + monitoring applications to track the usage of your + application. Setting this is not necessary, but some + applications may desire this behavior. + :type user_agent: str + :param page_size: The default size to request pages at. If not given, + the API's default page size is used. Valid values + can be found in the API docs, but at time of writing + are between 25 and 500. + :type page_size: int + :param retry: Whether API requests should automatically be retries on known + intermittent responses. + :type retry: bool + :param retry_rate_limit_interval: The amount of time to wait between HTTP request + retries. + :type retry_rate_limit_interval: Union[float, int] + :param retry_max: The number of request retries that should be attempted before + raising an API error. + :type retry_max: int + :type retry_statuses: List of int + :param retry_statuses: Additional HTTP response statuses to retry on. + By default, the client will retry on 408, 429, and 502 + responses. + :param ca_path: The path to a CA file to use for API requests in this client. + :type ca_path: str + """ + def __init__( self, token, diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index 80d2d92ae..7d5471fbd 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -3,6 +3,7 @@ "MonitorMetricsDefinition", "MonitorService", "MonitorServiceToken", + "AggregateFunction", ] from dataclasses import dataclass, field from typing import List, Optional diff --git a/linode_api4/objects/monitor_api.py b/linode_api4/objects/monitor_api.py index 347aa3d30..c3496668c 100644 --- a/linode_api4/objects/monitor_api.py +++ b/linode_api4/objects/monitor_api.py @@ -3,10 +3,12 @@ "EntityMetricsData", "EntityMetricsDataResult", "EntityMetricsStats", + "EntityMetricOptions", ] from dataclasses import dataclass, field from typing import List, Optional +from linode_api4.objects.monitor import AggregateFunction from linode_api4.objects.serializable import JSONObject @@ -34,3 +36,9 @@ class EntityMetrics(JSONObject): isPartial: bool = False stats: Optional[EntityMetricsStats] = None status: str = "" + + +@dataclass +class EntityMetricOptions(JSONObject): + name: str = "" + aggregate_function: AggregateFunction = "" diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 8c7d44a57..0a0566775 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -5,15 +5,21 @@ from test.integration.helpers import ( get_test_label, send_request_when_resource_available, + wait_for_condition, ) +from test.integration.models.database.helpers import get_db_engine_id from typing import Optional, Set import pytest import requests from requests.exceptions import ConnectionError, RequestException -from linode_api4 import PlacementGroupPolicy, PlacementGroupType -from linode_api4.linode_client import LinodeClient +from linode_api4 import ( + PlacementGroupPolicy, + PlacementGroupType, + PostgreSQLDatabase, +) +from linode_api4.linode_client import LinodeClient, MonitorClient from linode_api4.objects import Region ENV_TOKEN_NAME = "LINODE_TOKEN" @@ -521,3 +527,68 @@ def linode_for_vlan_tests(test_linode_client, e2e_test_firewall): yield linode_instance linode_instance.delete() + + +@pytest.fixture(scope="session") +def test_create_postgres_db(test_linode_client): + client = test_linode_client + label = get_test_label() + "-postgresqldb" + region = "us-ord" + engine_id = get_db_engine_id(client, "postgresql") + dbtype = "g6-standard-1" + + db = client.database.postgresql_create( + label=label, + region=region, + engine=engine_id, + ltype=dbtype, + cluster_size=None, + ) + + def get_db_status(): + return db.status == "active" + + # TAKES 15-30 MINUTES TO FULLY PROVISION DB + wait_for_condition(60, 2000, get_db_status) + + yield db + + send_request_when_resource_available(300, db.delete) + + +@pytest.fixture(scope="session") +def get_monitor_token_for_db_entities(test_linode_client): + client = test_linode_client + + dbs = client.database.postgresql_instances() + + if len(dbs) < 1: + db_id = test_create_postgres_db.id + else: + db_id = dbs[0].id + + region = client.load(PostgreSQLDatabase, db_id).region + dbs = client.database.instances() + + # only collect entity_ids in the same region + entity_ids = [db.id for db in dbs if db.region == region] + + # create token for the particular service + token = client.monitor.create_token( + service_type="dbaas", entity_ids=entity_ids + ) + + yield token, entity_ids + + +@pytest.fixture(scope="session") +def test_monitor_client(get_monitor_token_for_db_entities): + api_ca_file = get_api_ca_file() + token, entity_ids = get_monitor_token_for_db_entities + + client = MonitorClient( + token.token, + ca_path=api_ca_file, + ) + + return client, entity_ids diff --git a/test/integration/models/monitor_api/test_monitor_api.py b/test/integration/models/monitor_api/test_monitor_api.py new file mode 100644 index 000000000..842a8c420 --- /dev/null +++ b/test/integration/models/monitor_api/test_monitor_api.py @@ -0,0 +1,12 @@ +def test_monitor_api_fetch_dbaas_metrics(test_monitor_client): + client, entity_ids = test_monitor_client + + metrics = client.metrics.fetch_metrics( + "dbaas", + entity_ids=entity_ids, + metrics=[{"name": "read_iops", "aggregate_function": "avg"}], + relative_time_duration={"unit": "hr", "value": 1}, + ) + + assert metrics.status == "success" + assert len(metrics.data.result) > 0 diff --git a/test/unit/groups/monitor_api_test.py b/test/unit/groups/monitor_api_test.py index eb28fdde7..c34db068f 100644 --- a/test/unit/groups/monitor_api_test.py +++ b/test/unit/groups/monitor_api_test.py @@ -1,5 +1,7 @@ from test.unit.base import MonitorClientBaseCase +from linode_api4.objects import AggregateFunction, EntityMetricOptions + class MonitorAPITest(MonitorClientBaseCase): """ @@ -14,8 +16,11 @@ def test_fetch_metrics(self): service_type, entity_ids=[13217, 13316], metrics=[ - {"name": "avg_read_iops", "aggregate": "avg"}, - {"name": "avg_cpu_usage", "aggregate": "avg"}, + EntityMetricOptions( + name="avg_read_iops", + aggregate_function=AggregateFunction("avg"), + ), + {"name": "avg_cpu_usage", "aggregate_function": "avg"}, ], relative_time_duration={"unit": "hr", "value": 1}, ) @@ -25,8 +30,8 @@ def test_fetch_metrics(self): assert m.call_data == { "entity_ids": [13217, 13316], "metrics": [ - {"name": "avg_read_iops", "aggregate": "avg"}, - {"name": "avg_cpu_usage", "aggregate": "avg"}, + {"name": "avg_read_iops", "aggregate_function": "avg"}, + {"name": "avg_cpu_usage", "aggregate_function": "avg"}, ], "relative_time_duration": {"unit": "hr", "value": 1}, }