diff --git a/pagerduty.py b/pagerduty.py index 9bb3712..d8380d6 100644 --- a/pagerduty.py +++ b/pagerduty.py @@ -68,12 +68,22 @@ '/abilities/{id}', '/addons', '/addons/{id}', + '/alert_grouping_settings', + '/alert_grouping_settings/{id}', '/analytics/metrics/incidents/all', + '/analytics/metrics/incidents/escalation_policies', + '/analytics/metrics/incidents/escalation_policies/all', '/analytics/metrics/incidents/services', + '/analytics/metrics/incidents/services/all', '/analytics/metrics/incidents/teams', + '/analytics/metrics/incidents/teams/all', + '/analytics/metrics/pd_advance_usage/features', + '/analytics/metrics/responders/all', + '/analytics/metrics/responders/teams', '/analytics/raw/incidents', '/analytics/raw/incidents/{id}', '/analytics/raw/incidents/{id}/responses', + '/analytics/raw/responders/{responder_id}/incidents', '/audit/records', '/automation_actions/actions', '/automation_actions/actions/{id}', @@ -99,26 +109,23 @@ '/business_services/priority_thresholds', '/change_events', '/change_events/{id}', - '/customfields/fields', - '/customfields/fields/{field_id}', - '/customfields/fields/{field_id}/field_options', - '/customfields/fields/{field_id}/field_options/{field_option_id}', - '/customfields/fields/{field_id}/schemas', - '/customfields/schema_assignments', - '/customfields/schema_assignments/{id}', - '/customfields/schemas', - '/customfields/schemas/{schema_id}', - '/customfields/schemas/{schema_id}/field_configurations', - '/customfields/schemas/{schema_id}/field_configurations/{field_configuration_id}', '/escalation_policies', '/escalation_policies/{id}', '/escalation_policies/{id}/audit/records', '/event_orchestrations', '/event_orchestrations/{id}', + '/event_orchestrations/{id}/integrations', + '/event_orchestrations/{id}/integrations/{integration_id}', + '/event_orchestrations/{id}/integrations/migration', + '/event_orchestrations/{id}/global', '/event_orchestrations/{id}/router', '/event_orchestrations/{id}/unrouted', - '/event_orchestrations/services/{id}', - '/event_orchestrations/services/{id}/active', + '/event_orchestrations/services/{service_id}', + '/event_orchestrations/services/{service_id}/active', + '/event_orchestrations/{id}/cache_variables', + '/event_orchestrations/{id}/cache_variables/{cache_variable_id}', + '/event_orchestrations/services/{service_id}/cache_variables', + '/event_orchestrations/services/{service_id}/cache_variables/{cache_variable_id}', '/extension_schemas', '/extension_schemas/{id}', '/extensions', @@ -139,8 +146,7 @@ '/incidents/{id}/alerts/{alert_id}', '/incidents/{id}/business_services/{business_service_id}/impacts', '/incidents/{id}/business_services/impacts', - '/incidents/{id}/field_values', - '/incidents/{id}/field_values/schema', + '/incidents/{id}/custom_fields/values', '/incidents/{id}/log_entries', '/incidents/{id}/merge', '/incidents/{id}/notes', @@ -154,6 +160,10 @@ '/incidents/{id}/status_updates/subscribers', '/incidents/{id}/status_updates/unsubscribe', '/incidents/count', + '/incidents/custom_fields', + '/incidents/custom_fields/{field_id}', + '/incidents/custom_fields/{field_id}/field_options', + '/incidents/custom_fields/{field_id}/field_options/{field_option_id}', '/license_allocations', '/licenses', '/log_entries', @@ -162,6 +172,8 @@ '/maintenance_windows', '/maintenance_windows/{id}', '/notifications', + '/oauth_delegations', + '/oauth_delegations/revocation_requests/status', '/oncalls', '/paused_incident_reports/alerts', '/paused_incident_reports/counts', @@ -191,12 +203,33 @@ '/services/{id}/integrations', '/services/{id}/integrations/{integration_id}', '/services/{id}/rules', + '/services/{id}/rules/convert', '/services/{id}/rules/{rule_id}', + '/standards', + '/standards/{id}', + '/standards/scores/{resource_type}', + '/standards/scores/{resource_type}/{id}', '/status_dashboards', '/status_dashboards/{id}', '/status_dashboards/{id}/service_impacts', '/status_dashboards/url_slugs/{url_slug}', '/status_dashboards/url_slugs/{url_slug}/service_impacts', + '/status_pages', + '/status_pages/{id}/impacts', + '/status_pages/{id}/impacts/{impact_id}', + '/status_pages/{id}/services', + '/status_pages/{id}/services/{service_id}', + '/status_pages/{id}/severities', + '/status_pages/{id}/severities/{severity_id}', + '/status_pages/{id}/statuses', + '/status_pages/{id}/statuses/{status_id}', + '/status_pages/{id}/posts', + '/status_pages/{id}/posts/{post_id}', + '/status_pages/{id}/posts/{post_id}/post_updates', + '/status_pages/{id}/posts/{post_id}/post_updates/{post_update_id}', + '/status_pages/{id}/posts/{post_id}/postmortem', + '/status_pages/{id}/subscriptions', + '/status_pages/{id}/subscriptions/{subscription_id}', '/tags', '/tags/{id}', '/tags/{id}/users', @@ -213,6 +246,7 @@ '/templates', '/templates/{id}', '/templates/{id}/render', + '/templates/fields', '/users', '/users/{id}', '/users/{id}/audit/records', @@ -236,6 +270,11 @@ '/webhook_subscriptions/{id}', '/webhook_subscriptions/{id}/enable', '/webhook_subscriptions/{id}/ping', + '/workflows/integrations', + '/workflows/integrations/{id}', + '/workflows/integrations/connections', + '/workflows/integrations/{integration_id}/connections', + '/workflows/integrations/{integration_id}/connections/{id}', ] """ Explicit list of supported canonical REST API v2 paths @@ -254,6 +293,9 @@ '/services/{id}/audit/records', '/teams/{id}/audit/records', '/users/{id}/audit/records', + '/workflows/integrations', + '/workflows/integrations/connections', + '/workflows/integrations/{integration_id}/connections', ] """ Explicit list of paths that support cursor-based pagination @@ -264,8 +306,15 @@ ENTITY_WRAPPER_CONFIG = { # Analytics '* /analytics/metrics/incidents/all': None, + '* /analytics/metrics/incidents/escalation_policies': None, + '* /analytics/metrics/incidents/escalation_policies/all': None, '* /analytics/metrics/incidents/services': None, + '* /analytics/metrics/incidents/services/all': None, '* /analytics/metrics/incidents/teams': None, + '* /analytics/metrics/incidents/teams/all': None, + '* /analytics/metrics/pd_advance_usage/features': None, + '* /analytics/metrics/responders/all': None, + '* /analytics/metrics/responders/teams': None, '* /analytics/raw/incidents': None, '* /analytics/raw/incidents/{id}': None, '* /analytics/raw/incidents/{id}/responses': None, @@ -291,11 +340,13 @@ # Event Orchestrations '* /event_orchestrations': 'orchestrations', + '* /event_orchestrations/services/{id}': 'orchestration_path', + '* /event_orchestrations/services/{id}/active': None, '* /event_orchestrations/{id}': 'orchestration', + '* /event_orchestrations/{id}/global': 'orchestration_path', + '* /event_orchestrations/{id}/integrations/migration': None, '* /event_orchestrations/{id}/router': 'orchestration_path', '* /event_orchestrations/{id}/unrouted': 'orchestration_path', - '* /event_orchestrations/services/{id}': 'orchestration_path', - '* /event_orchestrations/services/{id}/active': None, # Extensions 'POST /extensions/{id}/enable': (None, 'extension'), @@ -310,6 +361,16 @@ 'POST /incidents/{id}/status_updates/unsubscribe': ('subscribers', None), 'GET /incidents/{id}/business_services/impacts': 'services', 'PUT /incidents/{id}/business_services/{business_service_id}/impacts': None, + '* /incidents/{id}/custom_fields/values': 'custom_fields', + 'POST /incidents/{id}/responder_requests': None, + + # Incident Custom Fields + '* /incidents/custom_fields': ('field', 'fields'), + '* /incidents/custom_fields/{field_id}': 'field', + + # Incident Types + # TODO: Update after this is GA and no longer early-access (for now we are manually + # excluding the canonical paths in the update) # Incident Workflows 'POST /incident_workflows/{id}/instances': 'incident_workflow_instance', @@ -333,6 +394,9 @@ 'GET /status_dashboards/url_slugs/{url_slug}': 'status_dashboard', 'GET /status_dashboards/url_slugs/{url_slug}/service_impacts': 'services', + # Status Pages + # Adheres to orthodox API conventions / fully supported via inference from path + # Tags 'POST /{entity_type}/{id}/change_tags': None, @@ -352,6 +416,12 @@ 'GET /users/{id}/sessions': 'user_sessions', 'GET /users/{id}/sessions/{type}/{session_id}': 'user_session', 'GET /users/me': 'user', + + # Workflow Integrations + # Adheres to orthodox API conventions / fully supported via inference from path + + # OAuth Delegations + 'GET /oauth_delegations/revocation_requests/status': None } #: :meta hide-value: """ Wrapped entities antipattern handling configuration. @@ -654,10 +724,19 @@ def resource_url(method): need to re-construct the resource URL or hold it in a temporary variable. """ doc = method.__doc__ + name = method.__name__ def call(self, resource, **kw): url = resource - if type(resource) is dict and 'self' in resource: # passing an object - url = resource['self'] + if type(resource) is dict: + if 'self' in resource: # passing an object + url = resource['self'] + else: + # Unsupported APIs for this feature: + raise UrlError(f"The dict object passed to {name} in place of a URL " + "has no 'self' key and cannot be used in place of an API resource " + "URL. The schema of the API endpoint in use probably does not " + "include this property, in which case this feature does not " + "support that endpoint.") elif type(resource) is not str: name = method.__name__ raise UrlError(f"Value passed to {name} is not a str or dict with " @@ -2054,6 +2133,19 @@ def rget(self, resource, **kw) -> Union[dict, list]: """ return self.get(resource, **kw) + @wrapped_entities + def rpatch(self, path, **kw) -> dict: + """ + Wrapped-entity-aware PATCH function. + + Currently the only API endpoint that uses or supports this method is "Update + Workflow Integration Connection": ``PATCH + /workflows/integrations/{integration_id}/connections/{id}`` + + It cannot use the :attr:`resource_url` decorator because the schema in that case has no + ``self`` property, and so the URL or path must be supplied. + """ + @wrapped_entities def rpost(self, path, **kw) -> Union[dict, list]: """ diff --git a/test_pagerduty.py b/test_pagerduty.py index 93fa1b1..82f8bcb 100755 --- a/test_pagerduty.py +++ b/test_pagerduty.py @@ -126,7 +126,7 @@ def test_normalize_url(self): ) ] for args in invalid_input: - self.assertRaises(pagerduty.URLError, pagerduty.normalize_url, *args) + self.assertRaises(pagerduty.UrlError, pagerduty.normalize_url, *args) class EntityWrappingTest(unittest.TestCase): @@ -177,10 +177,10 @@ def test_infer_entity_wrapper(self): def test_unwrap(self): # Response has unexpected type, raise: r = Response(200, json.dumps([])) - self.assertRaises(pagerduty.PDServerError, pagerduty.unwrap, r, 'foo') + self.assertRaises(pagerduty.ServerHttpError, pagerduty.unwrap, r, 'foo') # Response has unexpected structure, raise: r = Response(200, json.dumps({'foo_1': {'bar':1}, 'foo_2': 'bar2'})) - self.assertRaises(pagerduty.PDServerError, pagerduty.unwrap, r, 'foo') + self.assertRaises(pagerduty.ServerHttpError, pagerduty.unwrap, r, 'foo') # Response has the expected structure, return the wrapped entity: foo_entity = {'type':'foo_reference', 'id': 'PFOOBAR'} r = Response(200, json.dumps({'foo': foo_entity})) @@ -232,7 +232,7 @@ def reset_mocks(): response.ok = True do_http_things.__name__ = 'rput' # just for instance response.json.side_effect = [ValueError('Bad JSON!')] - self.assertRaises(pagerduty.PDClientError, + self.assertRaises(pagerduty.Error, pagerduty.wrapped_entities(do_http_things), session, '/services') reset_mocks() @@ -244,7 +244,7 @@ def reset_mocks(): do_http_things.return_value = response do_http_things.__name__ = 'rput' # just for instance response.json.return_value = {'nope': 'nopenope'} - self.assertRaises(pagerduty.PDHTTPError, + self.assertRaises(pagerduty.HttpError, pagerduty.wrapped_entities(do_http_things), session, '/services') reset_mocks() @@ -252,7 +252,7 @@ def reset_mocks(): response.reset_mock() response.ok = False do_http_things.__name__ = 'rput' # just for instance - self.assertRaises(pagerduty.PDClientError, + self.assertRaises(pagerduty.Error, pagerduty.wrapped_entities(do_http_things), session, '/services') reset_mocks() @@ -273,7 +273,7 @@ def reset_mocks(): do_http_things.__name__ = 'rpost' user_payload = {'email':'user@example.com', 'name':'User McUserson'} self.assertRaises( - pagerduty.PDClientError, + pagerduty.Error, pagerduty.wrapped_entities(do_http_things), dummy_session, '/users', json=user_payload ) @@ -330,9 +330,9 @@ def test_plural_deplural(self): ) def test_successful_response(self): - self.assertRaises(pagerduty.PDClientError, pagerduty.successful_response, + self.assertRaises(pagerduty.Error, pagerduty.successful_response, Response(400, json.dumps({}))) - self.assertRaises(pagerduty.PDServerError, pagerduty.successful_response, + self.assertRaises(pagerduty.ServerHttpError, pagerduty.successful_response, Response(500, json.dumps({}))) class EventsApiV2ClientTest(SessionTest): @@ -420,15 +420,16 @@ def test_submit_change_event(self): sess = pagerduty.EventsApiV2Client('routingkey') parent = MagicMock() parent.request = MagicMock() - parent.request.side_effect = [ Response(202, '{"id":"abc123"}') ] + # The dedup key for change events is unused so we don't care about the response + # schema, only that it is valid JSON: + parent.request.side_effect = [ Response(202, '{}') ] with patch.object(sess, 'parent', new=parent): - ddk = sess.submit( - 'testing 123', - 'triggered.from.pagerduty', - custom_details={"this":"that"}, - links=[{'href':'https://http.cat/502.jpg'}], - ) - self.assertEqual('abc123', ddk) + sess.submit( + 'testing 123', + 'triggered.from.pagerduty', + custom_details={"this":"that"}, + links=[{'href':'https://http.cat/502.jpg'}], + ) self.assertEqual( 'POST', parent.request.call_args[0][0]) @@ -457,10 +458,10 @@ def test_submit_change_event(self): sess = pagerduty.EventsApiV2Client('routingkey') parent = MagicMock() parent.request = MagicMock() - parent.request.side_effect = [ Response(202, '{"id":"abc123"}') ] + parent.request.side_effect = [ Response(202, '{}') ] with patch.object(sess, 'parent', new=parent): custom_timestamp = '2023-06-26T00:00:00Z' - ddk = sess.submit( + sess.submit( 'testing 123', 'triggered.from.pagerduty', custom_details={"this":"that"}, @@ -478,10 +479,9 @@ def test_submit_lite_change_event(self): sess = pagerduty.EventsApiV2Client('routingkey') parent = MagicMock() parent.request = MagicMock() - parent.request.side_effect = [ Response(202, '{"id":"abc123"}') ] + parent.request.side_effect = [ Response(202, '{}') ] with patch.object(sess, 'parent', new=parent): - ddk = sess.submit('testing 123') - self.assertEqual('abc123', ddk) + sess.submit('testing 123') self.assertEqual( 'POST', parent.request.call_args[0][0]) @@ -501,6 +501,7 @@ def test_submit_lite_change_event(self): 'summary': 'testing 123', 'timestamp': '2020-03-25T00:00:00Z', }, + 'links': [] }, parent.request.call_args[1]['json']) @@ -586,14 +587,14 @@ def test_iter_all(self, get, iter_cursor): # Test: user tries to use iter_all on a singular resource, raise error: self.assertRaises( - pagerduty.URLError, + pagerduty.UrlError, lambda p: list(sess.iter_all(p)), 'users/PABC123' ) # Test: user tries to use iter_all on an endpoint that doesn't actually # support pagination, raise error: self.assertRaises( - pagerduty.URLError, + pagerduty.UrlError, lambda p: list(sess.iter_all(p)), '/analytics/raw/incidents/Q3R8ZN19Z8K083/responses' ) @@ -641,7 +642,7 @@ def test_iter_all(self, get, iter_cursor): Response(200, json.dumps(page(4, 50, 10))), ] get.side_effect = copy.deepcopy(error_encountered) - self.assertRaises(pagerduty.PDClientError, list, sess.iter_all(weirdurl)) + self.assertRaises(pagerduty.Error, list, sess.iter_all(weirdurl)) # Test reaching the iteration limit: get.reset_mock() @@ -678,7 +679,7 @@ def test_iter_cursor(self, get): }) # Test: user tries to use iter_cursor where it won't work, raise: self.assertRaises( - pagerduty.URLError, + pagerduty.UrlError, lambda p: list(sess.iter_cursor(p)), 'incidents', # Maybe some glorious day, but not as of this writing ) @@ -800,7 +801,7 @@ def test_request(self, postprocess): parent.request = request # Test bad request method self.assertRaises( - pagerduty.PDClientError, + pagerduty.Error, sess.request, *['poke', '/something'] ) @@ -889,7 +890,7 @@ def test_request(self, postprocess): 'message': "You shall not pass.", } })) - self.assertRaises(pagerduty.PDClientError, sess.request, 'get', + self.assertRaises(pagerduty.Error, sess.request, 'get', '/services') request.reset_mock() @@ -922,7 +923,7 @@ def test_request(self, postprocess): sess.get('/users') self.assertTrue(False, msg='Exception not raised after ' \ 'retry maximum count reached') - except pagerduty.PDClientError as e: + except pagerduty.Error as e: self.assertEqual(e.__cause__, raises[-1]) except Exception as e: self.assertTrue(False, msg='Raised exception not of the ' \ @@ -969,7 +970,7 @@ def test_rget(self, get): response404 = Response(404, '{"user": {"email": "user@example.com"}}') get.reset_mock() get.return_value = response404 - self.assertRaises(pagerduty.PDClientError, s.rget, '/users/P123ABC') + self.assertRaises(pagerduty.Error, s.rget, '/users/P123ABC') @patch.object(pagerduty.RestApiV2Client, 'rget') def test_subdomain(self, rget):