-
Notifications
You must be signed in to change notification settings - Fork 0
/
pagerduty.py
2288 lines (2004 loc) · 87.2 KB
/
pagerduty.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Copyright (c) PagerDuty.
# See LICENSE for details.
# Standard libraries
import logging
import sys
import time
from copy import deepcopy
from datetime import datetime
from random import random
from typing import Iterator, Union
from warnings import warn
# Upstream components on which this client is based:
from requests import Response, Session
from requests import __version__ as REQUESTS_VERSION
# HTTP client exceptions:
from urllib3.exceptions import HTTPError, PoolError
from requests.exceptions import RequestException
__version__ = '0.9.0'
#######################
### CLIENT DEFAULTS ###
#######################
ITERATION_LIMIT = 1e4
"""
The maximum position of a result in classic pagination.
The offset plus limit parameter may not exceed this number. This is enforced
server-side and is not something the client may override. Rather, this value is
used to short-circuit pagination in order to avoid a HTTP 400 error.
See: `Pagination
<https://developer.pagerduty.com/docs/ZG9jOjExMDI5NTU4-pagination>`_.
"""
TIMEOUT = 60
"""
The default timeout in seconds for any given HTTP request.
Modifying this value will not affect any preexisting API session instances.
Rather, it will only affect new instances. It is recommended to use
:attr:`ApiClient.timeout` to configure the timeout for a given session.
"""
TEXT_LEN_LIMIT = 100
"""
The longest permissible length of API content to include in error messages.
"""
# List of canonical API paths
#
# Supporting a new API for entity wrapping will require adding its patterns to
# this list. If it doesn't follow standard naming conventions, it will also
# require one or more new entries in ENTITY_WRAPPER_CONFIG.
#
# To generate new definitions for CANONICAL_PATHS and
# CURSOR_BASED_PAGINATION_PATHS based on the API documentation's source code,
# use scripts/get_path_list/get_path_list.py
CANONICAL_PATHS = [
'/{entity_type}/{id}/change_tags',
'/{entity_type}/{id}/tags',
'/abilities',
'/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}',
'/automation_actions/actions/{id}/invocations',
'/automation_actions/actions/{id}/services',
'/automation_actions/actions/{id}/services/{service_id}',
'/automation_actions/actions/{id}/teams',
'/automation_actions/actions/{id}/teams/{team_id}',
'/automation_actions/invocations',
'/automation_actions/invocations/{id}',
'/automation_actions/runners',
'/automation_actions/runners/{id}',
'/automation_actions/runners/{id}/teams',
'/automation_actions/runners/{id}/teams/{team_id}',
'/business_services',
'/business_services/{id}',
'/business_services/{id}/account_subscription',
'/business_services/{id}/subscribers',
'/business_services/{id}/supporting_services/impacts',
'/business_services/{id}/unsubscribe',
'/business_services/impactors',
'/business_services/impacts',
'/business_services/priority_thresholds',
'/change_events',
'/change_events/{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/{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',
'/extensions/{id}',
'/extensions/{id}/enable',
'/incident_workflows',
'/incident_workflows/{id}',
'/incident_workflows/{id}/instances',
'/incident_workflows/actions',
'/incident_workflows/actions/{id}',
'/incident_workflows/triggers',
'/incident_workflows/triggers/{id}',
'/incident_workflows/triggers/{id}/services',
'/incident_workflows/triggers/{trigger_id}/services/{service_id}',
'/incidents',
'/incidents/{id}',
'/incidents/{id}/alerts',
'/incidents/{id}/alerts/{alert_id}',
'/incidents/{id}/business_services/{business_service_id}/impacts',
'/incidents/{id}/business_services/impacts',
'/incidents/{id}/custom_fields/values',
'/incidents/{id}/log_entries',
'/incidents/{id}/merge',
'/incidents/{id}/notes',
'/incidents/{id}/outlier_incident',
'/incidents/{id}/past_incidents',
'/incidents/{id}/related_change_events',
'/incidents/{id}/related_incidents',
'/incidents/{id}/responder_requests',
'/incidents/{id}/snooze',
'/incidents/{id}/status_updates',
'/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',
'/log_entries/{id}',
'/log_entries/{id}/channel',
'/maintenance_windows',
'/maintenance_windows/{id}',
'/notifications',
'/oauth_delegations',
'/oauth_delegations/revocation_requests/status',
'/oncalls',
'/paused_incident_reports/alerts',
'/paused_incident_reports/counts',
'/priorities',
'/response_plays',
'/response_plays/{id}',
'/response_plays/{response_play_id}/run',
'/rulesets',
'/rulesets/{id}',
'/rulesets/{id}/rules',
'/rulesets/{id}/rules/{rule_id}',
'/schedules',
'/schedules/{id}',
'/schedules/{id}/audit/records',
'/schedules/{id}/overrides',
'/schedules/{id}/overrides/{override_id}',
'/schedules/{id}/users',
'/schedules/preview',
'/service_dependencies/associate',
'/service_dependencies/business_services/{id}',
'/service_dependencies/disassociate',
'/service_dependencies/technical_services/{id}',
'/services',
'/services/{id}',
'/services/{id}/audit/records',
'/services/{id}/change_events',
'/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',
'/tags/{id}/teams',
'/tags/{id}/escalation_policies',
'/teams',
'/teams/{id}',
'/teams/{id}/audit/records',
'/teams/{id}/escalation_policies/{escalation_policy_id}',
'/teams/{id}/members',
'/teams/{id}/notification_subscriptions',
'/teams/{id}/notification_subscriptions/unsubscribe',
'/teams/{id}/users/{user_id}',
'/templates',
'/templates/{id}',
'/templates/{id}/render',
'/templates/fields',
'/users',
'/users/{id}',
'/users/{id}/audit/records',
'/users/{id}/contact_methods',
'/users/{id}/contact_methods/{contact_method_id}',
'/users/{id}/license',
'/users/{id}/notification_rules',
'/users/{id}/notification_rules/{notification_rule_id}',
'/users/{id}/notification_subscriptions',
'/users/{id}/notification_subscriptions/unsubscribe',
'/users/{id}/oncall_handoff_notification_rules',
'/users/{id}/oncall_handoff_notification_rules/{oncall_handoff_notification_rule_id}',
'/users/{id}/sessions',
'/users/{id}/sessions/{type}/{session_id}',
'/users/{id}/status_update_notification_rules',
'/users/{id}/status_update_notification_rules/{status_update_notification_rule_id}',
'/users/me',
'/vendors',
'/vendors/{id}',
'/webhook_subscriptions',
'/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
:meta hide-value:
"""
CURSOR_BASED_PAGINATION_PATHS = [
'/audit/records',
'/automation_actions/actions',
'/automation_actions/runners',
'/escalation_policies/{id}/audit/records',
'/incident_workflows/actions',
'/incident_workflows/triggers',
'/schedules/{id}/audit/records',
'/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
:meta hide-value:
"""
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,
# Automation Actions
'POST /automation_actions/actions/{id}/invocations': (None,'invocation'),
# Paused Incident Reports
'GET /paused_incident_reports/alerts': 'paused_incident_reporting_counts',
'GET /paused_incident_reports/counts': 'paused_incident_reporting_counts',
# Business Services
'* /business_services/{id}/account_subscription': None,
'POST /business_services/{id}/subscribers': ('subscribers', 'subscriptions'),
'POST /business_services/{id}/unsubscribe': ('subscribers', None),
'* /business_services/priority_thresholds': None,
'GET /business_services/impacts': 'services',
'GET /business_services/{id}/supporting_services/impacts': 'services',
# Change Events
'POST /change_events': None, # why not just use EventsApiV2Client?
'GET /incidents/{id}/related_change_events': 'change_events',
# 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',
# Extensions
'POST /extensions/{id}/enable': (None, 'extension'),
# Incidents
'PUT /incidents/{id}/merge': ('source_incidents', 'incident'),
'POST /incidents/{id}/responder_requests': (None, 'responder_request'),
'POST /incidents/{id}/snooze': (None, 'incident'),
'POST /incidents/{id}/status_updates': (None, 'status_update'),
'POST /incidents/{id}/status_updates/subscribers': ('subscribers', 'subscriptions'),
'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',
'POST /incident_workflows/triggers/{id}/services': ('service', 'trigger'),
# Response Plays
'POST /response_plays/{response_play_id}/run': None, # (deprecated)
# Schedules
'POST /schedules/{id}/overrides': ('overrides', None),
# Service Dependencies
'POST /service_dependencies/associate': 'relationships',
# Webhooks
'POST /webhook_subscriptions/{id}/enable': (None, 'webhook_subscription'),
'POST /webhook_subscriptions/{id}/ping': None,
# Status Dashboards
'GET /status_dashboards/{id}/service_impacts': 'services',
'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,
# Teams
'PUT /teams/{id}/escalation_policies/{escalation_policy_id}': None,
'POST /teams/{id}/notification_subscriptions': ('subscribables', 'subscriptions'),
'POST /teams/{id}/notification_subscriptions/unsubscribe': ('subscribables', None),
'PUT /teams/{id}/users/{user_id}': None,
'GET /teams/{id}/notification_subscriptions': 'subscriptions',
# Templates
'POST /templates/{id}/render': None,
# Users
'* /users/{id}/notification_subscriptions': ('subscribables', 'subscriptions'),
'POST /users/{id}/notification_subscriptions/unsubscribe': ('subscribables', None),
'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.
When trying to determine the entity wrapper name, this dictionary is first
checked for keys that apply to a given request method and canonical API path
based on a matching logic. If no keys are found that match, it is assumed that
the API endpoint follows classic entity wrapping conventions, and the wrapper
name can be inferred based on those conventions (see
:attr:`infer_entity_wrapper`). Any new API that does not follow these
conventions should therefore be given an entry in this dictionary in order to
properly support it for entity wrapping.
Each of the keys should be a capitalized HTTP method (or ``*`` to match any
method), followed by a space, followed by a canonical path i.e. as returned by
:attr:`canonical_path` and included in :attr:`CANONICAL_PATHS`. Each value
is either a tuple with request and response body wrappers (if they differ), a
string (if they are the same for both cases) or ``None`` (if wrapping is
disabled and the data is to be marshaled or unmarshaled as-is). Values in tuples
can also be None to denote that either the request or response is unwrapped.
An endpoint, under the design logic of this client, is said to have entity
wrapping if the body (request or response) has only one property containing
the content requested or transmitted, apart from properties used for
pagination. If there are any secondary content-bearing properties (other than
those used for pagination), entity wrapping should be disabled to avoid
discarding those properties from responses or preventing the use of those
properties in request bodies.
:meta hide-value:
"""
####################
### URL HANDLING ###
####################
def canonical_path(base_url: str, url: str) -> str:
"""
The canonical path from the API documentation corresponding to a URL
This is used to identify and classify URLs according to which particular API
within REST API v2 it belongs to.
Explicitly supported canonical paths are defined in the list
:attr:`CANONICAL_PATHS` and are the path part of any given API's URL. The
path for a given API is what is shown at the top of its reference page, i.e.
``/users/{id}/contact_methods`` for retrieving a user's contact methods
(GET) or creating a new one (POST).
:param base_url: The base URL of the API
:param url: A non-normalized URL (a path or full URL)
:returns:
The canonical REST API v2 path corresponding to a URL.
"""
full_url = normalize_url(base_url, url)
# Starting with / after hostname before the query string:
url_path = full_url.replace(base_url.rstrip('/'), '').split('?')[0]
# Root node (blank) counts so we include it:
n_nodes = url_path.count('/')
# First winnow the list down to paths with the same number of nodes:
patterns = list(filter(
lambda p: p.count('/') == n_nodes,
CANONICAL_PATHS
))
# Match against each node, skipping index zero because the root node always
# matches, and using the adjusted index "j":
for i, node in enumerate(url_path.split('/')[1:]):
j = i+1
patterns = list(filter(
lambda p: p.split('/')[j] == node or is_path_param(p.split('/')[j]),
patterns
))
# Don't break early if len(patterns) == 1, but require an exact match...
if len(patterns) == 0:
raise UrlError(f"URL {url} does not match any canonical API path " \
'supported by this client.')
elif len(patterns) > 1:
# If there's multiple matches but one matches exactly, return that.
if url_path in patterns:
return url_path
# ...otherwise this is ambiguous.
raise Exception(f"Ambiguous URL {url} matches more than one " \
"canonical path pattern: "+', '.join(patterns)+'; this is likely ' \
'a bug.')
else:
return patterns[0]
def endpoint_matches(endpoint_pattern: str, method: str, path: str) -> bool:
"""
Whether an endpoint (method and canonical path) matches a given pattern
This is the filtering logic used for finding the appropriate entry in
:attr:`ENTITY_WRAPPER_CONFIG` to use for a given method and API path.
:param endpoint_pattern:
The endpoint pattern in the form ``METHOD PATH`` where ``METHOD`` is the
HTTP method in uppercase or ``*`` to match all methods, and ``PATH`` is
a canonical API path.
:param method:
The HTTP method.
:param path:
The canonical API path (i.e. as returned by :func:`canonical_path`)
:returns:
True or False based on whether the pattern matches the endpoint
"""
return (
endpoint_pattern.startswith(method.upper()) \
or endpoint_pattern.startswith('*')
) and endpoint_pattern.endswith(f" {path}")
def is_path_param(path_node: str) -> bool:
"""
Whether a part of a canonical path represents a variable parameter
:param path_node: The node (value between slashes) in the path
:returns:
True if the node is an arbitrary variable, False if it is a fixed value
"""
return path_node.startswith('{') and path_node.endswith('}')
def normalize_url(base_url: str, url: str) -> str:
"""
Normalize a URL or path to be a complete API URL before query parameters.
The ``url`` argument may be a path relative to the base URL or a full URL.
:param url:
The URL or path to normalize to a full URL.
:param base_url:
The base API URL, excluding any trailing slash, i.e.
"https://api.pagerduty.com"
:returns:
The full API URL.
"""
if url.startswith(base_url):
return url
elif not (url.startswith('http://') or url.startswith('https://')):
return base_url.rstrip('/') + "/" + url.lstrip('/')
else:
raise UrlError(
f"URL {url} does not start with the API base URL {base_url}"
)
#######################
### ENTITY WRAPPING ###
#######################
def entity_wrappers(method: str, path: str) -> tuple:
"""
Obtains entity wrapping information for a given endpoint (path and method)
:param method: The HTTP method
:param path: A canonical API path i.e. as returned by ``canonical_path``
:returns:
A 2-tuple. The first element is the wrapper name that should be used for
the request body, and the second is the wrapper name to be used for the
response body. For either elements, if ``None`` is returned, that
signals to disable wrapping and pass the user-supplied request body or
API response body object unmodified.
"""
m = method.upper()
endpoint = "%s %s"%(m, path)
match = list(filter(
lambda k: endpoint_matches(k, m, path),
ENTITY_WRAPPER_CONFIG.keys()
))
if len(match) == 1:
# Look up entity wrapping info from the global dictionary and validate:
wrapper = ENTITY_WRAPPER_CONFIG[match[0]]
invalid_config_error = 'Invalid entity wrapping configuration for ' \
f"{endpoint}: {wrapper}; this is most likely a bug."
if wrapper is not None and type(wrapper) not in (tuple, str):
raise Exception(invalid_config_error)
elif wrapper is None or type(wrapper) is str:
# Both request and response have the same wrapping at this endpoint.
return (wrapper, wrapper)
elif type(wrapper) is tuple and len(wrapper) == 2:
# Endpoint uses different wrapping for request and response bodies.
#
# Both elements must be either str or None. The first element is the
# request body wrapper and the second is the response body wrapper.
# If a value is None, that indicates that the request or response
# value should be encoded and decoded as-is without modifications.
if False in [w is None or type(w) is str for w in wrapper]:
raise Exception(invalid_config_error)
return wrapper
elif len(match) == 0:
# Nothing in entity wrapper config matches. In this case it is assumed
# that the endpoint follows classic API patterns and the wrapper name
# can be inferred from the URL and request method:
wrapper = infer_entity_wrapper(method, path)
return (wrapper, wrapper)
else:
matches_str = ', '.join(match)
raise Exception(f"{endpoint} matches more than one pattern:" + \
f"{matches_str}; this is most likely a bug.")
def infer_entity_wrapper(method: str, path: str) -> str:
"""
Infer the entity wrapper name from the endpoint using orthodox patterns.
This is based on patterns that are broadly applicable but not universal in
the v2 REST API, where the wrapper name is predictable from the path and
method. This is the default logic applied to determine the wrapper name
based on the path if there is no explicit entity wrapping defined for the
given path in :attr:`ENTITY_WRAPPER_CONFIG`.
:param method: The HTTP method
:param path: A canonical API path i.e. from :attr:`CANONICAL_PATHS`
"""
m = method.upper()
path_nodes = path.split('/')
if is_path_param(path_nodes[-1]):
# Singular if it's an individual resource's URL for read/update/delete
# (named similarly to the second to last node, as the last is its ID and
# the second to last denotes the API resource collection it is part of):
return singular_name(path_nodes[-2])
elif m == 'POST':
# Singular if creating a new resource by POSTing to the index containing
# similar resources (named simiarly to the last path node):
return singular_name(path_nodes[-1])
else:
# Plural if listing via GET to the index endpoint, or doing a multi-put:
return path_nodes[-1]
def unwrap(response: Response, wrapper) -> Union[dict, list]:
"""
Unwraps a wrapped entity.
:param response: The response object
:param wrapper: The entity wrapper
:type wrapper: str or None
:returns:
The value associated with the wrapper key in the JSON-decoded body of
the response, which is expected to be a dictionary (map).
"""
body = try_decoding(response)
endpoint = "%s %s"%(response.request.method.upper(), response.request.url)
if wrapper is not None:
# There is a wrapped entity to unpack:
bod_type = type(body)
error_msg = f"Expected response body from {endpoint} after JSON-" \
f"decoding to be a dictionary with a key \"{wrapper}\", but "
if bod_type is dict:
if wrapper in body:
return body[wrapper]
else:
keys = truncate_text(', '.join(body.keys()))
raise ServerHttpError(
error_msg + f"its keys are: {keys}",
response
)
else:
raise ServerHttpError(
error_msg + f"its type is {bod_type}.",
response
)
else:
# Wrapping is disabled for responses:
return body
###########################
### FUNCTION DECORATORS ###
###########################
def auto_json(method):
"""
Makes methods return the full response body object after decoding from JSON.
Intended for use on functions that take a URL positional argument followed
by keyword arguments and return a `requests.Response`_ object.
"""
doc = method.__doc__
def call(self, url, **kw):
return try_decoding(successful_response(method(self, url, **kw)))
call.__doc__ = doc
return call
def requires_success(method):
"""
Decorator that validates HTTP responses.
"""
doc = method.__doc__
def call(self, url, **kw):
return successful_response(method(self, url, **kw))
call.__doc__ = doc
return call
def resource_url(method):
"""
API call decorator that allows passing a resource dict as the path/URL
Most resources returned by the API will contain a ``self`` attribute that is
the URL of the resource itself.
Using this decorator allows the implementer to pass either a URL/path or
such a resource dictionary as the ``path`` argument, thus eliminating the
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:
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 "
"path/URL.")
elif type(resource) is not str:
name = method.__name__
raise UrlError(f"Value passed to {name} is not a str or dict with "
"key 'self'")
return method(self, url, **kw)
call.__doc__ = doc
return call
def wrapped_entities(method):
"""
Automatically wrap request entities and unwrap response entities.
Used for methods :attr:`RestApiV2Client.rget`, :attr:`RestApiV2Client.rpost` and
:attr:`RestApiV2Client.rput`. It makes them always return an object representing
the resource entity in the response (whether wrapped in a root-level
property or not) rather than the full response body. When making a post /
put request, and passing the ``json`` keyword argument to specify the
content to be JSON-encoded as the body, that keyword argument can be either
the to-be-wrapped content or the full body including the entity wrapper, and
the ``json`` keyword argument will be normalized to include the wrapper.
Methods using this decorator will raise a :class:`HttpError` with its
``response`` property being being the `requests.Response`_ object in the
case of any error (as of version 4.2 this is subclassed as
:class:`HttpError`), so that the implementer can access it by catching the
exception, and thus design their own custom logic around different types of
error responses.
:param method: Method being decorated. Must take one positional argument
after ``self`` that is the URL/path to the resource, followed by keyword
any number of keyword arguments, and must return an object of class
`requests.Response`_, and be named after the HTTP method but with "r"
prepended.
:returns: A callable object; the reformed method
"""
http_method = method.__name__.lstrip('r')
doc = method.__doc__
def call(self, url, **kw):
pass_kw = deepcopy(kw) # Make a copy for modification
path = canonical_path(self.url, url)
endpoint = "%s %s"%(http_method.upper(), path)
req_w, res_w = entity_wrappers(http_method, path)
# Validate the abbreviated (or full) request payload, and automatically
# wrap the request entity for the implementer if necessary:
if req_w is not None and http_method in ('post', 'put') \
and 'json' in pass_kw and req_w not in pass_kw['json']:
pass_kw['json'] = {req_w: pass_kw['json']}
# Make the request:
r = successful_response(method(self, url, **pass_kw))
# Unpack the response:
return unwrap(r, res_w)
call.__doc__ = doc
return call
########################
### HELPER FUNCTIONS ###
########################
def deprecated_kwarg(deprecated_name: str, details=None, method=None):
"""
Raises a warning if a deprecated keyword argument is used.
:param deprecated_name: The name of the deprecated function
:param details: An optional message to append to the deprecation message
:param method: An optional method name
"""
details_msg = ''
method_msg = ''
if method is not None:
method_msg = f" of {method}"
if details is not None:
details_msg = f" {details}"
warn(
f"Keyword argument \"{deprecated_name}\"{method_msg} is deprecated."+details_msg
)
def http_error_message(r: Response, context=None) -> str:
"""
Formats a message describing a HTTP error.
:param r:
The response object.
:param context:
A description of when the error was received, or None to not include it
:returns:
The message to include in the HTTP error
"""
received_http_response = bool(r.status_code)
endpoint = "%s %s"%(r.request.method.upper(), r.request.url)
context_msg = ""
if type(context) is str:
context_msg=f" in {context}"
if received_http_response and not r.ok:
err_type = 'unknown'
if r.status_code / 100 == 4:
err_type = 'client'
elif r.status_code / 100 == 5:
err_type = 'server'
tr_bod = truncate_text(r.text)
return f"{endpoint}: API responded with {err_type} error (status " \
f"{r.status_code}){context_msg}: {tr_bod}"
elif not received_http_response:
return f"{endpoint}: Network or other unknown error{context_msg}"
else:
return f"{endpoint}: Success (status {r.status_code}) but an " \
f"expectation still failed{context_msg}"
def last_4(secret: str) -> str:
"""
Truncate a sensitive value to its last 4 characters
:param secret: text to truncate
:returns:
The truncated text
"""
return '*'+str(secret)[-4:]
def plural_name(obj_type: str) -> str:
"""
Pluralizes a name, i.e. the API name from the ``type`` property
:param obj_type:
The object type, i.e. ``user`` or ``user_reference``
:returns:
The name of the resource, i.e. the last part of the URL for the
resource's index URL
"""
if obj_type.endswith('_reference'):
# Strip down to basic type if it's a reference
obj_type = obj_type[:obj_type.index('_reference')]
if obj_type.endswith('y'):
# Because English
return obj_type[:-1]+'ies'
else:
return obj_type+'s'
def singular_name(r_name: str) -> str:
"""
Singularizes a name, i.e. for the entity wrapper in a POST request
:para r_name:
The "resource" name, i.e. "escalation_policies", a plural noun that
forms the part of the canonical path identifying what kind of resource
lives in the collection there, for an API that follows classic wrapped
entity naming patterns.
:returns:
The singularized name
"""
if r_name.endswith('ies'):
# Because English
return r_name[:-3]+'y'
else:
return r_name.rstrip('s')
def successful_response(r: Response, context=None) -> Response:
"""Validates the response as successful.
Returns the response if it was successful; otherwise, raises an exception.
:param r:
Response object corresponding to the response received.
:param context:
A description of when the HTTP request is happening, for error reporting
:returns:
The response object, if it was successful
"""
if r.ok and bool(r.status_code):
return r
elif r.status_code / 100 == 5:
raise ServerHttpError(http_error_message(r, context=context), r)
elif bool(r.status_code):
raise HttpError(http_error_message(r, context=context), r)
else:
raise Error(http_error_message(r, context=context))
def truncate_text(text: str) -> str:
"""Truncates a string longer than :attr:`TEXT_LEN_LIMIT`
:param text: The string to truncate if longer than the limit.
"""
if len(text) > TEXT_LEN_LIMIT:
return text[:TEXT_LEN_LIMIT-1]+'...'
else:
return text
def try_decoding(r: Response) -> Union[dict, list, str]:
"""
JSON-decode a response body
Returns the decoded body if successful; raises :class:`ServerHttpError`
otherwise.
:param r:
The response object
"""
try:
return r.json()
except ValueError as e:
raise ServerHttpError(
"API responded with invalid JSON: " + truncate_text(r.text),
r,
)
###############
### CLASSES ###
###############
class ApiClient(Session):
"""
Base class for making HTTP requests to PagerDuty APIs
This is an opinionated wrapper of `requests.Session`_, with a few additional
features:
- The client will reattempt the request with auto-increasing cooldown/retry
intervals, with attempt limits configurable through the :attr:`retry`
attribute.
- When making requests, headers specified ad-hoc in calls to HTTP verb
functions will not replace, but will be merged into, default headers.
- The request URL, if it doesn't already start with the REST API base URL,
will be prepended with the default REST API base URL.
- It will only perform requests with methods as given in the
:attr:`permitted_methods` list, and will raise :class:`Error` for
any other HTTP methods.
:param api_key:
REST API access token to use for HTTP requests
:param debug:
Sets :attr:`print_debug`. Set to True to enable verbose command line
output.
:type token: str
:type debug: bool
"""
log = None
"""
A ``logging.Logger`` object for logging messages. By default it is
configured without any handlers and so no messages will be emitted. See:
`Logger Objects
<https://docs.python.org/3/library/logging.html#logger-objects>`_.
"""
max_http_attempts = 10
"""
The number of times that the client will retry after error statuses, for any
that are defined greater than zero in :attr:`retry`.
"""
max_network_attempts = 3
"""
The number of times that connecting to the API will be attempted before
treating the failure as non-transient; a :class:`Error` exception
will be raised if this happens.
"""
parent = None
"""The ``super`` object (`requests.Session`_)"""
permitted_methods = ()